Skip to content

fix(api): describe the binary endpoints truthfully and make book downloads resumable - #41

Merged
AshDevFr merged 9 commits into
mainfrom
fix/openapi-description-defects
Aug 22, 2026
Merged

fix(api): describe the binary endpoints truthfully and make book downloads resumable#41
AshDevFr merged 9 commits into
mainfrom
fix/openapi-description-defects

Conversation

@AshDevFr

Copy link
Copy Markdown
Owner

Summary

Two things, both driven by codex-reader-ios hitting them on a generated Swift client.

Describe the media types the binary endpoints actually send. Six operations declared one concrete content type and set a different Content-Type at runtime. The document was internally consistent and generated without warning, so nothing flagged it, but a strict generated client validates the response content type before decoding and throws on a mismatch — a hard failure on a response the server considers successful.

Operation Declared Actually sent Now
get_book_file application/octet-stream zip / rar / epub / pdf, from books.format all four, plus the octet-stream its catch-all writes
get_page_image image/jpeg whatever the archive holds image/*
opds_book_page_image image/jpeg same — it delegates to get_page_image image/*
get_book_thumbnail image/jpeg image/svg+xml until generation finishes image/*
get_series_thumbnail image/jpeg same image/*
download_export application/octet-stream text/csv / text/markdown / application/json all three

The thumbnail pair is the sharp one: the placeholder fires on timing, not content, so every cover in a freshly scanned library took a branch the document said could not happen. The last two rows are outside the original report — same defect, found while reading, closed rather than left half-open.

Make GET /books/{book_id}/file range-capable and conditional. An interrupted download of a 200 MB volume had to restart from zero, and the only way to read one page without the whole archive was GET /books/{id}/pages/{n}, which reopens and re-extracts every time. It now advertises Accept-Ranges: bytes and a strong ETag, and answers 206 to bytes=a-b, bytes=a- and bytes=-n, 416 to an unsatisfiable range, and 304 to a current If-None-Match. A request with no Range is byte-for-byte what it was.

Notes for review

The ETag is books.file_hash. A non-null column the scanner already computes, so the validator costs no I/O, survives a rescan, and survives the file moving on disk. It happens to be the file's SHA-256, so it is strong in the RFC sense and a client can verify a resumed download against it.

If-Range is honoured. A stale validator yields the full 200 rather than a 206, because splicing fresh bytes into a partially downloaded old file produces something that is neither version.

Suffix ranges are the point, not a nicety. bytes=-n is how a client reads a ZIP central directory, and a CBZ is a ZIP. Handling only bytes=a-b would satisfy resume and none of the partial-read case.

Access control runs before the file is opened, so a 206 is never a way around a check a 200 has to pass.

A latent perf defect fell out of the measurement. ReaderStream's 4 KiB default turns a 700 KiB page into ~170 chunks and a 40 MiB volume into ~10,000. The first measurement showed a ranged read slower than the extract endpoint on identical bytes. At 64 KiB, on a 41 MB 60-page CBZ over keep-alive:

4 KiB 64 KiB GET /pages/{n}
one page 21.1 ms 4.7 ms 10.2 ms
all 60 pages 1940.8 ms 294.9 ms 552.0 ms
whole file 143.2 ms (~290 MB/s) n/a

This also sped up the plain whole-file download, which is pre-existing behaviour.

A filename-encoding bug, fixed in both routes. Folding RFC 6266 encoding into the v1 route surfaced that the Komga copy — cited as the working example to follow — put the raw filename into the quoted filename parameter. A header value may only carry visible ASCII, so a non-ASCII name went onto the wire as raw UTF-8 and was read as latin-1, which is exactly the mangling filename* prevents. The encoder is now shared, and transliterates the quoted parameter.

Verification

  • Full integration suite: 1883 passed, 0 failed. cargo clippy --all-targets -- -D warnings clean. tsc -b clean. pre-commit run green, including the openapi-sync hook.
  • 30 new tests: 21 unit tests over range parsing and filename encoding, 19 integration tests over the endpoint, 11 content-type conformance tests. The range parser was perturbed three ways to confirm the table actually catches regressions.
  • Against a running server: a 42,005,902-byte download interrupted at 16,830,464 bytes resumed with 206, transferred exactly the remaining 25,175,438, and the reassembled file matched the source SHA-256. Reading one page out of 60 via the central directory transferred 765,566 bytes — 1.82% of the archive.
  • Against the real Swift generator: the regenerated document runs through swift-openapi-generator 1.13 with the iOS client's own config — clean, zero warnings — and compile-time probes confirm each fix, including that a filtered series list decodes as series and that image/* accepts the SVG placeholder.

Third commit: document the library-jobs routes

Seven paths and eight operations were absent from the document entirely — per-library job CRUD, run-now, dry-run, and the field-group catalog its editor is built from. None of the handlers in handlers/library_jobs.rs carried a #[utoipa::path], and the module was never listed in docs.rs paths().

Half the work had been done, which is what made it hard to notice: all fourteen DTOs were registered in schemas(), so they shipped as components no operation could reach. Reading the component list, the API looked described. Generating a client, it did not exist.

The annotations also record three behaviours the signatures do not show: patch_job's timezone is tri-state (absent leaves it, null clears it to the server default, a value sets it), run_job_now answers 409 rather than double-queueing when a run is already in flight, and dry_run_job's configOverride plans against a config the job does not have yet, which is what lets an editor preview an edit before saving.

353 paths → 358.

Also added

An orphan-component invariant. A schema no operation can reach transitively is the signature the last four defects shared, so the set is now pinned against an explicit allowlist grouped by why each name is acceptable. It fails both ways — a new orphan needs a decision, and an allowlisted name that becomes reachable has to be removed — and both directions were verified by perturbation.

It found the library-jobs gap, and then verified the fix. Annotating those paths made the fourteen components reachable, so the check's stale-entry half failed and named every one of them, and the allowlist entry recording the gap was deleted rather than edited. A finding it records is a finding it later insists you close — that property is worth more than the initial detection.

Remaining entries include several registered DTOs referenced by no handler (TokenResponse, SharingTagListResponse, ReleaseLedgerListResponse), each with the reason recorded inline.

Deliberately not in scope

  • full=true's undocumented alternate shape. Needs an API decision about whether one operation may return two shapes, not an annotation fix.
  • The Komga compat routes' copy of the media-type defect. That layer exists to serve Komic, which this client replaces.
  • Paginated wrappers inlining their element type instead of $ref-ing it, found during the generator check. Every PaginatedResponse_* inlines, so a series from a list is nominally a different generated type from a series from getSeries, despite being identical. Not blocking; filed separately.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 21, 2026

Copy link
Copy Markdown

Deploying codex with  Cloudflare Pages  Cloudflare Pages

Latest commit: db0d6da
Status: ✅  Deploy successful!
Preview URL: https://b135ce3c.codex-asm.pages.dev
Branch Preview URL: https://fix-openapi-description-defe.codex-asm.pages.dev

View logs

…grammar correctly

Three OpenAPI description defects, reported by a strict generated client. None
changes the wire format; all three make the document match what the handlers
already do.

Series listings were documented as pages of books. `PaginatedResponse<T>` named
through a `pub type` alias loses its type argument and collapses to the base
component, which held whichever instantiation the registry rendered into that
slot: `data: [BookDto]`. Four series operations therefore claimed to return
books, and a strict client threw on the first item of the first page. All
alias-named bodies now name the generic inline. The nine book listings were
correct only by accident of that slot's contents, so they are fixed too, which
is what stops them becoming a silent regression later.

Four library book endpoints documented an `IntoParams` struct they never
extracted. utoipa infers a parameter's location from the handler's extractor,
and with nothing to infer from it falls back to `Path`, so `page` and `pageSize`
rendered as path parameters of a path with no such segments: an operation no
client could construct. They now name a struct describing exactly what those
routes honour, with an explicit `parameter_in`, which also exposes `sort` and
`full` for the first time. The struct that existed only to be named incorrectly
is deleted.

The filter grammar was erased to an untyped object by a `value_type` override,
so the primary query interface reached generated clients as a freeform
dictionary and was discoverable only by reading the source. The override was not
load-bearing: the recursive schemas render correctly and the web build is clean
without it.

Adds document invariants covering all three, each a whole-document walk so it
holds for operations added later: no operation may reference a generic wrapper
with its argument discarded, path parameters and path templates must agree in
both directions, and the filter conditions must reference their grammar.
Six operations annotated a single concrete content type and then set a
different Content-Type at runtime. The document was internally consistent,
generated without warning, and described a response the server never writes.

A strict generated client validates the response content type before
decoding and throws on a mismatch, so these were runtime failures on
responses the server considers successful:

  get_book_file        declared application/octet-stream, sends zip, rar,
                       epub or pdf chosen from books.format
  get_page_image       declared image/jpeg, sends whatever the archive holds
  opds_book_page_image same, it delegates to get_page_image
  get_book_thumbnail   declared image/jpeg, sends image/svg+xml for any
  get_series_thumbnail thumbnail whose generation task has not finished
  download_export      declared application/octet-stream, sends text/csv,
                       text/markdown or application/json

The thumbnail pair is the sharp one: the placeholder fires on timing rather
than content, so every cover in a freshly scanned library took a branch the
document said could not happen.

Enumerate where the set is bounded and the distinction is information the
caller wants (get_book_file, download_export), and use a wildcard where it
is open-ended and the caller cannot act on it (the three image routes),
following the image/* already used by komga_get_page. get_book_file lists
application/octet-stream alongside the four concrete types so the annotation
covers its catch-all arm rather than only the formats the scanner writes.

The server was not changed to match the document. Forcing get_book_file to
octet-stream would discard the format hint, and rendering the placeholder as
JPEG would replace a small vector with a raster to satisfy an annotation.

This invariant cannot be checked from inside the document, since nothing in
it is inconsistent, so it is asserted from the other side: a test per
operation makes the request and compares the response Content-Type against
the set ApiDoc declares for it, covering the placeholder and non-JPEG paths
rather than only the happy one. Each case also pins which branch it
exercises, so one that stops reaching the placeholder fails instead of
passing on nothing.

Also adds an orphan-component invariant. A schema no operation can reach
transitively is the signature the last four defects shared, so the set is
now pinned against an explicit allowlist grouped by why each name is
acceptable. It fails both ways: a new orphan needs a decision, and an
allowlisted name that becomes reachable has to be removed. Two entries are
findings rather than exemptions, recorded rather than fixed: the library
jobs routes carry no utoipa::path at all, and several registered DTOs are
referenced by no handler.
An interrupted download of a 200 MB volume had to start again from zero, and
the only way to read one page without fetching the whole archive was
GET /books/{id}/pages/{n}, which reopens and re-extracts on every request.
So the endpoint that costs least to serve was the one clients could not use
efficiently, and the one that costs most was the only one offering random
access. Range support inverts that.

GET /books/{book_id}/file now advertises Accept-Ranges: bytes and a strong
ETag on every response, and answers 206 to bytes=a-b, bytes=a- and bytes=-n,
416 to a range that names no byte, and 304 to a current If-None-Match. A
request without a Range is byte-for-byte what it was.

The suffix form is the one that is easy to skip and should not be: bytes=-n
is how a client reads a ZIP central directory, and a CBZ is a ZIP. Handling
only bytes=a-b would satisfy resume and none of the partial-read case.

The ETag is books.file_hash, a non-null column the scanner already computes,
so the validator costs no I/O, survives a rescan, and survives the file being
moved on disk. An mtime validator would do none of those and would invalidate
every client's cache on any rescan that touches timestamps.

If-Range is honoured against it: a stale validator yields the full 200 rather
than a 206, because splicing fresh bytes into a partially downloaded old file
produces something that is neither version. A multi-range request would need
a multipart/byteranges body no client here asks for, and RFC 9110 permits
ignoring a range the server declines, so it gets the whole file. A malformed
Range is ignored rather than rejected, for the same reason.

Authorisation, the permission check and the content filter all still run
before the file is opened, so a 206 is never a way around a check a 200 has
to pass.

Streaming uses 64 KiB chunks rather than ReaderStream's 4 KiB default. That
default was found by the measurement this work called for: it turns a 700 KiB
page into ~170 chunks and a 40 MiB volume into ~10,000, and it was the whole
of a 2-3x gap against the page endpoint on identical bytes. On a 41 MB
60-page CBZ over a keep-alive connection, one page by range went from 21.1 ms
to 4.7 ms and reading all 60 went from 1941 ms to 295 ms, against 10.2 ms and
552 ms for the page endpoint. The whole-file download runs at ~290 MB/s.

Folds in RFC 6266 filename encoding, which this route lacked. The encoder is
now shared with the Komga download rather than duplicated, and it emits an
ASCII-transliterated fallback for the quoted parameter: a header value may
only hold visible ASCII, so the previous code put raw UTF-8 on the wire and
produced exactly the latin-1 mangling that filename* exists to prevent. Both
routes were affected.

Verified against a running server as well as in tests. A 42 MB download
interrupted at 16,830,464 bytes resumed with 206 and transferred 25,175,438
more, exactly the remainder, and the reassembled file matched the source
SHA-256. Reading one page out of 60 by way of the central directory
transferred 765,566 bytes, 1.82% of the archive.

Range, If-Range and If-None-Match are declared as header parameters and 206,
304 and 416 as responses, so a generated client can construct a resumable
download without dropping to a raw request.
… at all

Seven routes and eight operations were absent from the OpenAPI document
entirely: the per-library job CRUD, run-now, dry-run, and the field-group
catalog its editor is built from. None of the handlers in
handlers/library_jobs.rs carried a #[utoipa::path], and the module was never
listed in docs.rs paths().

Half the work had been done already, which is what made it hard to notice:
all fourteen DTOs were registered in docs.rs schemas(), so they shipped in
the document as components that no operation could reach. To anyone reading
the component list the API looked described; to anyone generating a client it
did not exist.

The orphan-component invariant added on this branch is what surfaced it, and
it is also what verified the fix. Annotating the paths made those fourteen
components reachable, so the check's stale-entry half failed and named every
one of them, and the allowlist entry that recorded the gap could be deleted
rather than edited. That is the shape this check was meant to have: a finding
it records is a finding it later insists you close.

The utoipa parameter-location trap does not apply here. Every handler
extracts its parameters as Path<Uuid> or Path<(Uuid, Uuid)> and none take a
Query, so the derive has an extractor to infer from and no explicit
parameter_in is needed.

Beyond the mechanical transcription, the annotations record three things that
are true of the handlers but not evident from their signatures: patch_job's
timezone is tri-state, where absent leaves it, null clears it to the server
default, and a value sets it; run_job_now answers 409 rather than queueing
twice when a run for the job is already in flight; and dry_run_job's
configOverride plans against a config the job does not have yet, which is
what lets an editor preview an edit before saving it.

353 paths to 358.
…ilds again

The docs site has failed to build since the filter grammar stopped being an
untyped object. Making SeriesListRequest.condition and BookListRequest.condition
`$ref`s exposed the grammar to the docs generator for the first time, and
docusaurus-theme-openapi-docs 4.7.1 cannot render it:

  Can't render static file for pathname "/docs/api/schemas/serieslistrequest"
    TypeError: schema[key]?.map is not a function   at AnyOneOf

Two things trip it, and the document is valid OpenAPI on both counts. The
plugin dereferences `$ref`s eagerly and replaces the recursion in
SeriesCondition with the literal string "circular()", so `schema.oneOf` is a
string rather than an array. And the grammar uses `allOf` and `anyOf` as
property names, since that is how the Rust enum variants serialize, so a
`properties` map reads as a schema carrying an `anyOf` keyword whose value is
an object. `AnyOneOf` picks its key with `schema.oneOf ? "oneOf" : "anyOf"` and
calls `.map` on the result without checking it is an array.

Nothing caught this because the docs build runs on Cloudflare Pages against
pull requests, and this branch had no PR until now.

Renaming the wire fields was not an option: it addresses the keyword collision
but not the recursion, and it would break the filter grammar for both the web
app and the iOS client.

Upgrading fixes it. 5.x still contains the same unguarded line but no longer
feeds it a string, so both schema pages render. The upgrade cascades: the
plugin's 5.x peers require @docusaurus/* ^3.10, and 3.10 requires an explicit
@docusaurus/faster dependency for the `future.v4` flag this site already sets.
docusaurus-plugin-sass is a new peer of the theme.

Verified by running the Cloudflare build command, `npm run build`, against a
tree with no generated API docs, matching a fresh checkout. Both previously
failing pages now render.
@AshDevFr
AshDevFr force-pushed the fix/openapi-description-defects branch from d1ac3d7 to c8b7618 Compare August 21, 2026 18:46
utoipa renders a concrete instantiation of a generic by expanding its type
argument inline, even when that argument is registered as its own component.
Every generic wrapper in the document did it: all ten PaginatedResponse_*
components and all four KomgaPage_* ones.

The document stayed correct, so nothing flagged it, but any generator that has
to name an inline schema invents a fresh type for it. swift-openapi-generator
produced PaginatedResponseSeriesDto.DataPayloadPayload where getSeries produced
SeriesDto: structurally identical, nominally distinct, and not interchangeable.
A client could not pass a row from a list into anything typed on the detail DTO
without a hand-written conversion, once per paginated endpoint.

GenericArgumentReferencer walks each Base_Argument component and replaces any
subschema byte-identical to Argument's own component with a $ref to it.
Requiring an exact match is what makes the rewrite safe: it can only collapse a
copy of a schema the document already contains under that name, so nothing
about the described wire format changes. It joins the existing Modify pipeline
alongside NullableRefFlattener, so it needs no handler or DTO changes and
covers every generic rather than only pagination.

Verified against the generator that found the problem: a probe taking a series
from a paginated list and one from getSeries as the same type now compiles.

SeriesExternalIndexDto was an orphan only because its wrapper inlined it, so it
becomes reachable and leaves the accepted-orphan list. The invariant added here
fails if any generic wrapper starts inlining again.
`?full=true` switches the response to a different schema. OpenAPI cannot
express a body whose shape depends on a parameter *value*, so the alternate
shape was invisible to every generated client, and FullBookResponse and
FullSeriesResponse sat in the document as components no operation referenced.

The two shapes are not lean versus rich, which rules out the tidy fixes. BookDto
carries chapter, summary and volume that FullBookResponse lacks; SeriesDto
carries title, titleSort, publisher, summary and year that FullSeriesResponse
lacks, because the full shape moves them inside `metadata`. They are different
projections that partially overlap, so they cannot be merged into one schema
with optional fields, and a oneOf union would force callers to switch between
two arms that put the same field at different paths.

Adds three routes, each with one concrete response schema:

  GET /api/v1/books/{book_id}/full     -> FullBookResponse
  GET /api/v1/series/{series_id}/full  -> FullSeriesResponse
  GET /api/v1/series/full              -> PaginatedResponse<FullSeriesResponse>

The listing is there because the parameter has a live external consumer, not on
principle: shisho pages GET /api/v1/series?full=true across a whole library for
its candidate pools, and calls the series detail form too. Both now have a
describable route to move to. Only these three were added — the other nineteen
operations accept `full` and nobody sends it, and splitting all twenty-two
would oblige every future list endpoint to be added twice.

`/series/full` shares a path slot with `/series/{series_id}`, which is the same
shape as the thirteen static siblings already there, `/series/external-index`
among them. A test pins that the literal route wins.

The listing shares one implementation with the plain one rather than
duplicating it, and deliberately does not echo `full=true` into its pagination
links: the route already means full.

`full` is now marked deprecated on all twenty-two operations. Nothing breaks;
removal is planned for 3.0, once shisho has moved.

The web app is migrated off it. `booksApi.getFull` and `seriesApi.getFull`
replace the conditional-return-type `getDetail<T>` / `getById<T>` pair, which
existed only to model in TypeScript what the document could not express.

Each new route is tested against the `?full=true` form it replaces and asserted
byte-identical, so removing the parameter later cannot silently change a
payload. The book comparison ignores the metadata row's own timestamps, since a
book with no metadata gets one created on read and two requests produce two
different values.

All four `full=true` orphans become reachable and leave the accepted-orphan
list.
The frontend suite failed a different test on every full run while every one of
them passed in isolation: three consecutive runs failed BulkMetadataEditModal,
then AddLibraryModal twice, then InstallNudgeModal and MediaCard.

That signature is a budget problem, not broken tests. Vitest's 5s default is
sized for fast unit tests. 74 of the 221 files drive the UI through userEvent,
which awaits a React render per keystroke, and the runner gives each of the
machine's cores its own jsdom environment. Under that contention the heavier
interaction tests genuinely exceed 5s — the worst offender types thirteen
characters, walks a file-browser flow with three async lookups, switches tabs
and sets four selects.

Raises testTimeout and hookTimeout to 20s. Individual findBy* calls keep their
own shorter timeouts, so a missing element still fails fast; what this costs is
that a truly hung test now takes 20s to report rather than 5s.

Three consecutive full runs are green at 3627/3627, against three consecutive
red ones before.
…full plumbing

BookDetail and SeriesDetail were migrated onto GET /books/{id}/full and
GET /series/{id}/full, but the MSW handlers still only knew the deprecated
`?full=true` form, so `make frontend-mock` would have 404'd both pages. Nothing
caught it: MSW is wired through setupWorker, which only runs in a browser, so
vitest never loads the handlers — and neither page has a test.

Adds handlers for all three new routes and a coverage test that asserts they
exist. The test also pins that the literal /series/full is declared before the
/series/:id pattern, since MSW resolves in array order and would otherwise read
"full" as a series id — the same ordering constraint the real router has.

Writing that assertion caught a flaw in the assertion rather than the code: an
early `indexOf` matched the PATCH handler for /series/:id and reported an order
violation that cannot exist, because MSW matches on method and path together.
It now compares within GET handlers only.

Also removes the `full` plumbing from six series API wrappers — getByLibrary,
getInProgress, getRecentlyAdded, getRecentlyUpdated, getBooks and search. Each
accepted `full?: T` and returned a conditional type, the TypeScript workaround
for a response shape the document could not express, and no caller passed it.
They collapse to their plain return types, so series.ts no longer references a
parameter that goes away in 3.0.

Caps the vitest worker pool at 50% of cores in the same change. Raising the
per-test timeout earlier was not enough: the suite kept failing one test per run
— InstallNudgeModal, TemplateSelector, MediaCard, AddLibraryModal — never the
same one twice, and each passed in isolation. Vitest defaults to one worker per
logical core, and each runs a full jsdom + React + Mantine pipeline, so twelve of
them starve each other. Failures landing on whichever test lost the race is the
signature of contention rather than a broken test.

The cap halves aggregate test time, 369-403s against 660-681s, because the tests
get real CPU instead of fighting for it; wall clock costs about 13s. Seven
consecutive green runs, against a rate that had been failing roughly four in ten.
@AshDevFr
AshDevFr merged commit 9325486 into main Aug 22, 2026
23 checks passed
@AshDevFr
AshDevFr deleted the fix/openapi-description-defects branch August 22, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant