Add Pydantic response models and generate frontend types from OpenAPI #76

Closed
opened 2026-07-28 06:00:24 +00:00 by claude-bot · 1 comment

Severity: HIGH

The problem

Every route returns a hand-built dict annotated -> dict (_photo_to_dict, _evidence_to_dict,
_decision_to_dict, _comment_to_dict, _job_to_dict, and siblings). So FastAPI's generated
OpenAPI document describes every response as an untyped object — the framework's main contract
feature is switched off.

frontend/src/types/index.ts is maintained by hand with no tether to the backend. The drift has
already shipped:
the Dashboard reads meta.total, a field the API never returns, using an
as { total?: number } cast that deliberately bypasses the type system. All four stat cards
show "—" permanently.

Decision

Generate the frontend types from the OpenAPI schema.

Scope

  • Define Pydantic response models for every endpoint. These also replace the six _x_to_dict
    serializers and their repetitive isoformat() calls.
  • Model the {data, meta} envelope as generic ApiList[T] / ApiSingle[T] wrappers, turning the
    convention into schema.
  • Generate frontend/src/types with openapi-typescript, wired into CI (#13) so a diff fails the
    build.
  • Emit timezone-aware ISO timestamps with a trailing Z while defining the models — currently
    naive datetime.utcnow() values serialize with no offset, and the frontend's new Date(...)
    interprets them as local time, shifting every displayed timestamp by the viewer's UTC offset.
  • While defining models, tidy two verb-ish endpoints: POST /evidence/{id}/supersede, and
    /evidence/ai-rerun which returns a job, not evidence, so belongs under /jobs.

Why now

Phase 2 adds constraint explanations, duplicate pairs, album ordering, and OCR regions — several
times the current API surface. Locking the contract now means all of it lands typed on both sides;
retrofitting later means re-serializing roughly thirty endpoints.

Done when

  • Every endpoint declares a Pydantic response model
  • openapi.json describes real response schemas
  • Frontend types are generated, not hand-written
  • CI fails when generated types drift from the committed ones
  • Timestamps are timezone-aware and render correctly in the UI
  • The meta.total bug class is structurally impossible

References

  • backend/app/api/routes/photos.py:89-113 and the other _x_to_dict serializers
  • frontend/src/types/index.ts
  • frontend/src/pages/DashboardPage.tsx:40

Related: #8 (contract tests), #13 (CI).

## Severity: HIGH ## The problem Every route returns a hand-built dict annotated `-> dict` (`_photo_to_dict`, `_evidence_to_dict`, `_decision_to_dict`, `_comment_to_dict`, `_job_to_dict`, and siblings). So FastAPI's generated OpenAPI document describes every response as an untyped object — the framework's main contract feature is switched off. `frontend/src/types/index.ts` is maintained by hand with no tether to the backend. **The drift has already shipped:** the Dashboard reads `meta.total`, a field the API never returns, using an `as { total?: number }` cast that deliberately bypasses the type system. All four stat cards show "—" permanently. ## Decision **Generate the frontend types from the OpenAPI schema.** ## Scope - Define Pydantic response models for every endpoint. These also replace the six `_x_to_dict` serializers and their repetitive `isoformat()` calls. - Model the `{data, meta}` envelope as generic `ApiList[T]` / `ApiSingle[T]` wrappers, turning the convention into schema. - Generate `frontend/src/types` with `openapi-typescript`, wired into CI (#13) so a diff fails the build. - Emit timezone-aware ISO timestamps with a trailing `Z` while defining the models — currently naive `datetime.utcnow()` values serialize with no offset, and the frontend's `new Date(...)` interprets them as **local** time, shifting every displayed timestamp by the viewer's UTC offset. - While defining models, tidy two verb-ish endpoints: `POST /evidence/{id}/supersede`, and `/evidence/ai-rerun` which returns a **job**, not evidence, so belongs under `/jobs`. ## Why now Phase 2 adds constraint explanations, duplicate pairs, album ordering, and OCR regions — several times the current API surface. Locking the contract now means all of it lands typed on both sides; retrofitting later means re-serializing roughly thirty endpoints. ## Done when - [ ] Every endpoint declares a Pydantic response model - [ ] `openapi.json` describes real response schemas - [ ] Frontend types are generated, not hand-written - [ ] CI fails when generated types drift from the committed ones - [ ] Timestamps are timezone-aware and render correctly in the UI - [ ] The `meta.total` bug class is structurally impossible ## References - `backend/app/api/routes/photos.py:89-113` and the other `_x_to_dict` serializers - `frontend/src/types/index.ts` - `frontend/src/pages/DashboardPage.tsx:40` Related: #8 (contract tests), #13 (CI).
claude-bot added this to the v0.2.0 milestone 2026-07-28 06:00:24 +00:00
Author

Done in 2189e4e (+ 40c2c85, 37dd237). All six "done when" items covered — 22 contract tests in test_openapi_contract.py, 495 backend tests total.

Pydantic response models in app/api/schemas.py, generic ApiSingle[T] / ApiList[T] envelopes, openapi.json exported by python -m app.cli.export_openapi, and frontend/src/types/generated.ts from openapi-typescript. CI regenerates both and fails on either diff — catching "the API changed and nobody exported" and "someone hand-edited the generated file", the second being how this drifted before.

The generated types were a drop-in — tsc passed first try. That's the uncomfortable part: the hand-written types were nearly right, which is precisely why both bugs survived. A type that's 95% correct gets believed. ListMeta having no total is now load-bearing.

Models are written out rather than derived from the ORM. from_attributes would be less code, but a response model mirroring a table puts every new column on the wire the moment someone adds one — and this schema carries provider_sub, storage keys and session rows. Tests assert those stay off.

Timestamps go through a UtcDateTime type that serializes with a trailing Z, so no serializer can forget and reintroduce #91's local-time shift. Calendar dates deliberately don't get one — a date on a photograph is not an instant.

Two mismatches surfaced while doing this:

  • Five list endpoints returned bare {"data": [...]} while the frontend's ApiList<T> already declared meta on all of them — a live mismatch nobody had hit. Now uniform.
  • /auth/login, /auth/callback and the two media routes declared application/json with empty schemas (they return RedirectResponse/FileResponse). They now name their response classes, so the "no untyped endpoint" test stays strict instead of carrying an allowlist.

Not done: the endpoint tidy. You suggested moving /photos/{id}/evidence/ai-rerun under /jobs, since it returns a job rather than evidence. I've left it — it's a client-visible path change with no test coverage on the frontend side yet, and #11/#12 are about to add that. Better done with a net under it. Say if you'd rather I do it now; supersede I left alone deliberately, as it now takes a body and reads as a proper resource operation.

One CI failure worth recording. The first schema-freshness step used git diff --exit-code, and git isn't installed in node:22-bookworm-slim — so it failed with "command not found" and my || fallback reported "frontend/openapi.json is stale". The schema was fine. That is the same bug class as #78: an error rendered as a confident negative answer, because || cannot tell a missing tool from a real diff. The check now lives in export_openapi --check, needs only Python, and can only report what it actually determined.

Done in 2189e4e (+ 40c2c85, 37dd237). All six "done when" items covered — 22 contract tests in `test_openapi_contract.py`, 495 backend tests total. Pydantic response models in `app/api/schemas.py`, generic `ApiSingle[T]` / `ApiList[T]` envelopes, `openapi.json` exported by `python -m app.cli.export_openapi`, and `frontend/src/types/generated.ts` from `openapi-typescript`. CI regenerates both and fails on either diff — catching "the API changed and nobody exported" *and* "someone hand-edited the generated file", the second being how this drifted before. **The generated types were a drop-in — `tsc` passed first try.** That's the uncomfortable part: the hand-written types were *nearly* right, which is precisely why both bugs survived. A type that's 95% correct gets believed. `ListMeta` having no `total` is now load-bearing. **Models are written out rather than derived from the ORM.** `from_attributes` would be less code, but a response model mirroring a table puts every new column on the wire the moment someone adds one — and this schema carries `provider_sub`, storage keys and session rows. Tests assert those stay off. Timestamps go through a `UtcDateTime` type that serializes with a trailing `Z`, so no serializer can forget and reintroduce #91's local-time shift. Calendar dates deliberately don't get one — a date on a photograph is not an instant. **Two mismatches surfaced while doing this:** - Five list endpoints returned bare `{"data": [...]}` while the frontend's `ApiList<T>` already declared `meta` on all of them — a live mismatch nobody had hit. Now uniform. - `/auth/login`, `/auth/callback` and the two media routes declared `application/json` with empty schemas (they return `RedirectResponse`/`FileResponse`). They now name their response classes, so the "no untyped endpoint" test stays strict instead of carrying an allowlist. **Not done: the endpoint tidy.** You suggested moving `/photos/{id}/evidence/ai-rerun` under `/jobs`, since it returns a job rather than evidence. I've left it — it's a client-visible path change with no test coverage on the frontend side yet, and #11/#12 are about to add that. Better done with a net under it. Say if you'd rather I do it now; `supersede` I left alone deliberately, as it now takes a body and reads as a proper resource operation. **One CI failure worth recording.** The first schema-freshness step used `git diff --exit-code`, and `git` isn't installed in `node:22-bookworm-slim` — so it failed with "command not found" and my `||` fallback reported *"frontend/openapi.json is stale"*. The schema was fine. That is the same bug class as #78: an error rendered as a confident negative answer, because `||` cannot tell a missing tool from a real diff. The check now lives in `export_openapi --check`, needs only Python, and can only report what it actually determined.
Sign in to join this conversation.
No description provided.