Skip to content

Derive the frontend's API types from the backend's OpenAPI schema - #16

Open
albertotb wants to merge 2 commits into
mainfrom
claude/react-template-modernize-7q6egv
Open

Derive the frontend's API types from the backend's OpenAPI schema#16
albertotb wants to merge 2 commits into
mainfrom
claude/react-template-modernize-7q6egv

Conversation

@albertotb

Copy link
Copy Markdown
Member

The "anything better to automate the contract" question, implemented. FastAPI already knows the whole contract, so this stops restating it by hand.

How it works

make generate-types dumps the schema without booting a server and runs openapi-typescript over it:

cd backend && uv run python -c "import json; from app.api import app; print(json.dumps(app.openapi()))" > ../frontend/openapi.json
npm --prefix frontend run generate-types   # -> src/api/schema.d.ts (committed)

src/api/backend.ts then reads its types out of that file instead of declaring them:

export type PredictBody = Body<paths['/predict']['post']>
export type PredictResponse = Ok<paths['/predict']['post']>

The hand-mirrored interface PredictResponse { output: number } is gone — that type now comes from the backend's Pydantic model, keyed by the endpoint path, so renaming the route breaks compilation too.

The gate

Contract regenerates and fails if the committed types differ. It checks git status, not git diff — the latter ignores an untracked or deleted file and passes vacuously, which is exactly what happened on my first attempt (the check said "no drift" while tsc was already failing).

Proof it earns its keep

I simulated a sync renaming Response.output.result:

Layer Before this PR After
Contract drift check n/a schema.d.ts is stale + diff
tsc / build passed silently Property 'output' does not exist on type '{ result: number; }'
runtime curl assertion ❌ (only because we hardcoded .output == 5) ❌ unchanged

The difference: the curl assertion only catches changes to the three endpoints someone remembered to assert; the types catch any endpoint, parameter or field the frontend touches.

Scope, deliberately

Types-only rather than a generated client (tiangolo's template generates a full SDK with @hey-api/openapi-ts). One generated file, and the hand-written wrappers stay readable — a template should show the pattern, not hide it. If you'd rather have the full SDK later, hey-api also has a TanStack Query plugin that would fit this stack.

Existing checks untouched: types prove shape, not that the server answers (Contract's runtime assertions) and not that nginx strips /api (Docker). Structural / behavioural / infrastructural, one job each. Slimming the runtime assertions is possible now but I kept them so this PR only adds coverage.

Costs, honestly: one generated file committed (inherited by projects made from the template), a make generate-types step to remember locally (the gate catches forgetting), and Contract now installs node as well as uv (~15s).

🤖 Generated with Claude Code

https://claude.ai/code/session_011texLkDBELWbXsBf6San3M


Generated by Claude Code

claude added 2 commits August 10, 2026 13:41
FastAPI already knows the whole contract, so stop restating it by hand:
`make generate-types` dumps the schema (offline, no server needed) and
openapi-typescript writes frontend/src/api/schema.d.ts. The wrappers in
api/backend.ts now read their request and response types out of that
file instead of declaring them, so an endpoint that is renamed or
reshaped upstream breaks compilation instead of failing at runtime.

The Contract workflow gains the matching gate: regenerate and fail if the
committed types differ. It checks `git status`, not `git diff` — the
latter ignores an untracked or deleted file and passes vacuously, which
it did on the first attempt here.

Verified by simulating a sync that renames Response.output to .result:
the drift check reports the diff and tsc fails with "Property 'output'
does not exist on type '{ result: number; }'".

Existing runtime assertions are untouched: types prove shape, not that
the server answers, and nothing here covers the nginx prefix (Docker's
job).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011texLkDBELWbXsBf6San3M
`cd backend && uv sync` on the first line of a run block left the shell
in backend/, so `npm --prefix frontend ci` resolved to backend/frontend
and failed with "can only install with an existing package-lock.json".
Split into two steps with working-directory, which cannot leak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011texLkDBELWbXsBf6San3M
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.

2 participants