From a64895a3d4807120c1085d6f90bacdd3ac54f454 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:41:40 +0000 Subject: [PATCH 1/2] Derive the frontend's API types from the backend's OpenAPI schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011texLkDBELWbXsBf6San3M --- .github/workflows/contract.yml | 32 +++- Makefile | 9 +- frontend/.gitignore | 3 + frontend/.prettierignore | 1 + frontend/package-lock.json | 284 +++++++++++++++++++++++++++++++++ frontend/package.json | 4 +- frontend/src/api/backend.ts | 30 +++- frontend/src/api/schema.d.ts | 194 ++++++++++++++++++++++ 8 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 frontend/src/api/schema.d.ts diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 7e578c3..415fc12 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -1,5 +1,9 @@ -# Verifies the API contract between frontend and backend by booting the real -# backend and hitting the endpoints the frontend uses (frontend/src/api/backend.ts). +# Verifies the contract between frontend and backend, two ways: +# 1. structurally — the committed API types (frontend/src/api/schema.d.ts) +# must match the OpenAPI schema FastAPI generates today, so any change to +# an endpoint, parameter or response model shows up here; +# 2. behaviourally — the real backend answers the endpoints the frontend calls +# (frontend/src/api/backend.ts). # This is what makes automated backend syncs from python-copier-template safe: # a template change that breaks the contract turns this check red. name: Contract @@ -22,10 +26,32 @@ jobs: with: working-directory: backend + - uses: actions/setup-node@v7 + with: + node-version-file: frontend/.nvmrc + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: | + cd backend && uv sync + npm --prefix frontend ci + + # Fails if backend/app/api.py changed without running `make generate-types`. + # Uses git status, not git diff: the latter ignores an untracked or deleted + # file and would pass vacuously. + - name: API types match the backend schema + run: | + make generate-types + if [ -n "$(git status --porcelain -- frontend/src/api/schema.d.ts)" ]; then + echo "::error::frontend/src/api/schema.d.ts is stale or missing — run 'make generate-types' and commit the result" + git diff -- frontend/src/api/schema.d.ts + exit 1 + fi + - name: Start backend working-directory: backend run: | - uv sync uv run uvicorn app.api:app --port 7000 & timeout 30 bash -c 'until curl -sf http://localhost:7000/health > /dev/null; do sleep 1; done' diff --git a/Makefile b/Makefile index bc93beb..a0ba712 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev-frontend dev-backend test lint build up down +.PHONY: install dev-frontend dev-backend test lint generate-types build up down # Convenience targets delegating to the two projects. See frontend/README.md # and backend/README.md for the full command reference of each side. @@ -23,6 +23,13 @@ lint: npm --prefix frontend run format:check cd backend && uv run ruff check . && uv run ruff format --check . && uv run ty check app tests +# Regenerate the frontend's API types from the backend's OpenAPI schema. +# Run this after changing backend/app/api.py; CI fails if the committed types +# are stale (see .github/workflows/contract.yml). +generate-types: + 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 + build: npm --prefix frontend run build diff --git a/frontend/.gitignore b/frontend/.gitignore index 0368abb..796b5cd 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -26,3 +26,6 @@ npm-debug.log* # TypeScript / tooling caches *.tsbuildinfo .eslintcache + +# Generated on demand by `make generate-types` +openapi.json diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 9eb4595..bc79d27 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,3 +1,4 @@ dist coverage package-lock.json +src/api/schema.d.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 521ca33..134504a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.9.0", "jsdom": "^30.0.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.9.6", "typescript": "^5.9.3", "typescript-eslint": "^8.66.0", @@ -1168,6 +1169,89 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.18", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz", + "integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", @@ -2048,6 +2132,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2065,6 +2159,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2090,6 +2194,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2245,6 +2356,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2254,6 +2372,13 @@ "node": ">=6" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2939,6 +3064,20 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2985,6 +3124,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3043,12 +3195,45 @@ "dev": true, "license": "ISC" }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", @@ -3600,6 +3785,45 @@ "node": ">=12.20.0" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3754,6 +3978,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -4145,6 +4379,19 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -4280,6 +4527,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4376,6 +4636,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", @@ -4677,6 +4944,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a1fcf0f..94c28ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,8 @@ "test:watch": "vitest", "lint": "eslint .", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "generate-types": "openapi-typescript openapi.json -o src/api/schema.d.ts" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -42,6 +43,7 @@ "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.9.0", "jsdom": "^30.0.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.9.6", "typescript": "^5.9.3", "typescript-eslint": "^8.66.0", diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 20941a9..6833029 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -1,23 +1,39 @@ import { api } from './client' +import type { paths } from './schema' -// Typed wrappers for the example backend endpoints (see backend/app/api.py). -// Replace these with your real API as it grows. +// Typed wrappers for the example backend endpoints. The types are not written +// by hand: they are read out of schema.d.ts, which `make generate-types` +// derives from the backend's OpenAPI schema. Rename or reshape an endpoint in +// backend/app/api.py and these lines stop compiling — that is the point. +// +// Replace these wrappers with your real API as it grows. + +type Json = T extends { content: { 'application/json': infer B } } ? B : never +type Ok

= P extends { responses: { 200: infer R } } ? Json : never +type Body

= P extends { requestBody: infer B } ? Json : never + +export type ApiInfo = Ok +export type Health = Ok +export type PredictBody = Body +export type PredictResponse = Ok /** GET / returns `{"-api": "version "}`. */ export async function getApiInfo(): Promise { - const info = await api>('/') + const info = await api('/') return Object.values(info)[0] ?? 'unknown' } -export interface PredictResponse { - output: number +/** GET /health — used by the compose healthcheck and CI. */ +export function getHealth(): Promise { + return api('/health') } /** POST /predict echoes the input back — a stand-in for a real model. */ -export function predict(input: number): Promise { +export function predict(input: PredictBody['input']): Promise { + const body: PredictBody = { input } return api('/predict', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input }), + body: JSON.stringify(body), }) } diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts new file mode 100644 index 0000000..6c09787 --- /dev/null +++ b/frontend/src/api/schema.d.ts @@ -0,0 +1,194 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read Root + * @description Check API version. + */ + get: operations["read_root__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health + * @description Health check for container orchestration and monitoring. + * + * Stable endpoint: keep it when replacing the example endpoints below, + * since Docker healthchecks and CI smoke tests rely on it. + */ + get: operations["health_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/predict": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Predict + * @description Mock prediction endpoint. + */ + post: operations["predict_predict_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * Request + * @description Data model for the request body. + */ + Request: { + /** Input */ + input: number; + }; + /** + * Response + * @description Data model for the response body. + */ + Response: { + /** Output */ + output: number; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + read_root__get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + health_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + predict_predict_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Request"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Response"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} From 44f2f238a7239307144a0f3eac6a072b6e831f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:53:06 +0000 Subject: [PATCH 2/2] Fix the Contract job's dependency install `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 Claude-Session: https://claude.ai/code/session_011texLkDBELWbXsBf6San3M --- .github/workflows/contract.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 415fc12..8309e02 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -32,10 +32,13 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - - name: Install dependencies - run: | - cd backend && uv sync - npm --prefix frontend ci + - name: Install backend dependencies + working-directory: backend + run: uv sync + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci # Fails if backend/app/api.py changed without running `make generate-types`. # Uses git status, not git diff: the latter ignores an untracked or deleted