Add Pydantic response models and generate frontend types from OpenAPI #76
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 generatedOpenAPI document describes every response as an untyped object — the framework's main contract
feature is switched off.
frontend/src/types/index.tsis maintained by hand with no tether to the backend. The drift hasalready shipped: the Dashboard reads
meta.total, a field the API never returns, using anas { total?: number }cast that deliberately bypasses the type system. All four stat cardsshow "—" permanently.
Decision
Generate the frontend types from the OpenAPI schema.
Scope
_x_to_dictserializers and their repetitive
isoformat()calls.{data, meta}envelope as genericApiList[T]/ApiSingle[T]wrappers, turning theconvention into schema.
frontend/src/typeswithopenapi-typescript, wired into CI (#13) so a diff fails thebuild.
Zwhile defining the models — currentlynaive
datetime.utcnow()values serialize with no offset, and the frontend'snew Date(...)interprets them as local time, shifting every displayed timestamp by the viewer's UTC offset.
POST /evidence/{id}/supersede, and/evidence/ai-rerunwhich 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
openapi.jsondescribes real response schemasmeta.totalbug class is structurally impossibleReferences
backend/app/api/routes/photos.py:89-113and the other_x_to_dictserializersfrontend/src/types/index.tsfrontend/src/pages/DashboardPage.tsx:40Related: #8 (contract tests), #13 (CI).
Done in
2189e4e(+40c2c85,37dd237). All six "done when" items covered — 22 contract tests intest_openapi_contract.py, 495 backend tests total.Pydantic response models in
app/api/schemas.py, genericApiSingle[T]/ApiList[T]envelopes,openapi.jsonexported bypython -m app.cli.export_openapi, andfrontend/src/types/generated.tsfromopenapi-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 —
tscpassed 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.ListMetahaving nototalis now load-bearing.Models are written out rather than derived from the ORM.
from_attributeswould 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 carriesprovider_sub, storage keys and session rows. Tests assert those stay off.Timestamps go through a
UtcDateTimetype that serializes with a trailingZ, 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:
{"data": [...]}while the frontend'sApiList<T>already declaredmetaon all of them — a live mismatch nobody had hit. Now uniform./auth/login,/auth/callbackand the two media routes declaredapplication/jsonwith empty schemas (they returnRedirectResponse/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-rerununder/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;supersedeI 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, andgitisn't installed innode: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 inexport_openapi --check, needs only Python, and can only report what it actually determined.