House-move inventory: A REST API whose docs can't go stale - Part 4

House-move inventory: A REST API whose docs can't go stale - Part 4

Building Inventory System

~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 are extra="forbid": an unknown field is a 422, not silently ignored.
  • Domain errors become 404 / 409 / 422 through one mapping.
  • Serializers in api/serializers.py do the str(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,
  • examples on 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:

  1. A pre-commit hook (.githooks/pre-commit, opt-in with git config core.hooksPath .githooks) regenerates docs/openapi.json when you commit.
  2. 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 →