House-move inventory: A REST API whose docs can't go stale - Part 4
~7 min read
With the core in place (post 2), the REST API was the easy bit. FastAPI handlers are about six lines each. The interesting problem, and the one that took more commits than the endpoints themselves, was keeping the documentation true.
The thin layer
inventory.api maps HTTP to service calls:
- Pydantic request and response models in
api/schemas.py, distinct from the core's dataclasses, so the wire format can evolve without touching the domain. Request bodies areextra="forbid": an unknown field is a 422, not silently ignored. - Domain errors become 404 / 409 / 422 through one mapping.
- Serializers in
api/serializers.pydo thestr(Decimal)conversion, so the money rule from post 3 lives in one place. - Everything lives under
/api, except/health, which is the only public route.
A plain-text contents sheet (GET /boxes/{code}/sheet) came along in Phase 2 as a bonus. It was meant to be the first printable thing; the PDF version (post 8) came later.
Three kinds of documentation, one source
FastAPI generates an OpenAPI schema for free, and /docs (Swagger UI) is useful out of the box. But a generated schema is only as good as what you feed it, so Phase 3.22 was about feeding it properly:
- an expanded top-level description,
_OPENAPI_TAGS- one sentence per resource group,- a
summary=and a short docstring on every route, exampleson the create request bodies.
Then a small script, scripts/dump_openapi.py, writes the schema to docs/openapi.json without starting a server. The file is committed, so API changes show up in code review as a diff of the contract. hatch run docs-api also builds a self-contained three-panel HTML reference with @redocly/cli, and /redoc serves the same thing live.
Making staleness a build failure
Generating a file doesn't help if nobody regenerates it. Two guards close that gap:
- A pre-commit hook (
.githooks/pre-commit, opt-in withgit config core.hooksPath .githooks) regeneratesdocs/openapi.jsonwhen you commit. - A CI job called api docs regenerates it and fails if the committed copy differs. It never pushes - it only fails. That asymmetry is deliberate: a bot silently committing to your branch is a surprise; a red check with a one-line fix (
hatch run openapi) is not.
I've watched this catch me every time I touch a route and forget. The first time was within a day of adding it, which is the best argument for it.
CI also builds the HTML reference on every run and keeps it as an api-docs artifact. There's a GitHub Pages publish job in ci.yml, commented out - Pages on a private repo needs a paid plan, so for now the artifact is the published form.
Prose docs are the other half
The OpenAPI file is the contract, but docs/api.md holds the explanations: what ?invoices=true appends, what each filter means, which endpoints are synchronous and which are jobs. The project rule is to keep it in sync with the code, and it's checked by the dullest mechanism possible - I read the diff and ask "did the doc table change?".
This post's own example is the last feature I shipped. The all-rooms export (post 12) touched one route and one table row in api.md. It's a good test of whether the habit holds: if documenting a feature feels heavy, the feature is probably too big.
What I'd do differently
Pagination: there isn't any. Every list endpoint returns everything. For two people and a few hundred boxes that is the correct amount of engineering; the docs say "no pagination" in so many words so nobody is surprised. If the box count ever reached the tens of thousands I'd revisit it - until then, the absence is a feature.
Next: the web app that sits on top of it.
Previous: ← Money, currencies and an approximate euro · Next: The SPA: small pieces, no UI library →
