From e8565fd4fcec2ac1095ba3ed977b48c39a5378da Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Wed, 1 Apr 2026 16:48:18 +0100 Subject: [PATCH 1/8] feat(api): OpenAPI Pydantic codegen + CI drift check (PLAN 3.5) Add datamodel-code-generator, scripts/generate_openapi_models.py, and committed recipes_api/generated/openapi_models.py from packages/openapi. New workflow regenerates models and fails on git diff under generated/. Document pnpm generate:openapi-models in apps/api README; mark PLAN 3.5 done. Made-with: Cursor --- .../validate-openapi-python-codegen.yml | 29 ++++++++++ PLAN.md | 2 +- apps/api/README.md | 10 ++++ apps/api/package.json | 1 + apps/api/pyproject.toml | 1 + apps/api/recipes_api/generated/__init__.py | 1 + .../recipes_api/generated/openapi_models.py | 44 +++++++++++++++ apps/api/scripts/generate_openapi_models.py | 53 +++++++++++++++++++ 8 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/validate-openapi-python-codegen.yml create mode 100644 apps/api/recipes_api/generated/__init__.py create mode 100644 apps/api/recipes_api/generated/openapi_models.py create mode 100644 apps/api/scripts/generate_openapi_models.py diff --git a/.github/workflows/validate-openapi-python-codegen.yml b/.github/workflows/validate-openapi-python-codegen.yml new file mode 100644 index 0000000..2f6b539 --- /dev/null +++ b/.github/workflows/validate-openapi-python-codegen.yml @@ -0,0 +1,29 @@ +name: Validate OpenAPI Python codegen + +on: + push: + branches: [main] + pull_request: + +jobs: + openapi-python-codegen: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-python@v6.3.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: apps/api/pyproject.toml + + - name: Install API dev dependencies + working-directory: apps/api + run: python -m pip install -e ".[dev]" + + - name: Regenerate OpenAPI Pydantic models + working-directory: apps/api + run: python scripts/generate_openapi_models.py + + - name: Fail if generated models are out of sync with the spec + run: git diff --exit-code -- apps/api/recipes_api/generated/ diff --git a/PLAN.md b/PLAN.md index 97d8471..85c6da8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -19,7 +19,7 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O - [x] **3.2** Implement a data-access abstraction and a static implementation (files under repo) so swapping to DB/CMS later does not reshape route handlers. - [x] **3.3** Wire FastAPI/OpenAPI **`info`** (title, version, description) from **`packages/openapi/openapi.yaml`** so the running app matches the committed spec and those values are not duplicated in code (e.g. `main.py`). - [x] **3.4** Implement REST handlers to match the OpenAPI spec (response shapes and status codes); keep behaviour aligned with the spec. -- [ ] **3.5** Generate Pydantic models from **`packages/openapi/openapi.yaml`** (e.g. **datamodel-code-generator**), commit generated output, and add CI that fails when the spec changes without regenerating (drift check). +- [x] **3.5** Generate Pydantic models from **`packages/openapi/openapi.yaml`** (e.g. **datamodel-code-generator**), commit generated output, and add CI that fails when the spec changes without regenerating (drift check). - [ ] **3.6** Tests with 100% coverage and a coverage gate in CI for the API package; add `README.md` for install, run, and test commands. - [ ] **3.7** Select and configure a Python import-ordering tool (PEP 8–aligned; e.g. **Ruff**’s isort rules or **isort**), apply it across **`apps/api`**, and document how to run it (CI enforcement can align with §3.6 / §6.1). diff --git a/apps/api/README.md b/apps/api/README.md index 97ae4ba..ed5e8a4 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -25,6 +25,16 @@ python3 -m uvicorn recipes_api.main:app --reload --host 127.0.0.1 --port 8000 Open `http://127.0.0.1:8000/docs` for interactive OpenAPI UI, or `GET /health` for a simple JSON response. +## OpenAPI → Pydantic (generated) + +**`recipes_api/generated/openapi_models.py`** is produced from **`packages/openapi/openapi.yaml`** using **`datamodel-code-generator`**. After you change the spec, regenerate and commit the output (from the repository root): + +```bash +pnpm --filter api run generate:openapi-models +``` + +CI fails if the committed generated files do not match the spec (**Validate OpenAPI Python codegen** workflow). Runtime handlers continue to use **`TypedDict`** types in **`models.py`**; the generated Pydantic models are the checked mirror of the spec. + ## Data layer Recipe payloads match **`packages/openapi/openapi.yaml`**. The **`RecipeRepository`** protocol and **`StaticRecipeRepository`** implementation live under **`recipes_api/`**; static content is **`recipes_api/data/recipes.json`** (a JSON array of full recipe objects). HTTP handlers use **`RecipeRepository`** for **`GET /recipes`** and **`GET /recipes/{recipe_id}`**; swapping to a database or CMS means providing another implementation without changing route signatures. diff --git a/apps/api/package.json b/apps/api/package.json index 0edb7ab..ef68299 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "python3 -m compileall -q recipes_api", "dev": "python3 -m uvicorn recipes_api.main:app --reload --host 127.0.0.1 --port 8000", + "generate:openapi-models": "python3 scripts/generate_openapi_models.py", "lint": "python3 -m compileall -q recipes_api", "test": "python3 -m pytest" } diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index e6472bc..32d5a31 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "datamodel-code-generator==0.26.5", "httpx==0.28.1", "pytest==8.3.5", ] diff --git a/apps/api/recipes_api/generated/__init__.py b/apps/api/recipes_api/generated/__init__.py new file mode 100644 index 0000000..cc81111 --- /dev/null +++ b/apps/api/recipes_api/generated/__init__.py @@ -0,0 +1 @@ +"""Code-generated artefacts (do not edit by hand).""" diff --git a/apps/api/recipes_api/generated/openapi_models.py b/apps/api/recipes_api/generated/openapi_models.py new file mode 100644 index 0000000..a55b1af --- /dev/null +++ b/apps/api/recipes_api/generated/openapi_models.py @@ -0,0 +1,44 @@ +# generated by datamodel-codegen: +# filename: openapi.yaml + +from __future__ import annotations + +from typing import List, Optional + +from pydantic import AnyUrl, BaseModel, Field, conint + + +class ErrorMessage(BaseModel): + message: str = Field(..., description='Human-readable error description.') + + +class RecipeSummary(BaseModel): + id: str = Field( + ..., description='Unique recipe identifier (used in `/recipes/{recipe_id}`).' + ) + title: str = Field(..., examples=['Seeded sourdough']) + summary: str = Field(..., description='Short teaser for the recipe card.') + imageUrl: AnyUrl = Field(..., description='Thumbnail image for the list view.') + + +class RecipeDetail(BaseModel): + id: str + title: str + summary: str + imageUrl: AnyUrl = Field( + ..., + description='Same logical photo as the list; suitable for smaller placements.', + ) + imageUrlLarge: AnyUrl = Field( + ..., description='Larger version of the same photo for the recipe page.' + ) + ingredients: List[str] = Field( + ..., description='Ingredient lines as displayed to the baker.' + ) + steps: List[str] = Field(..., description='Ordered steps to make the bread.') + prepTimeMinutes: Optional[conint(ge=0)] = Field( + None, description='Optional active preparation time in minutes.' + ) + bakeTimeMinutes: Optional[conint(ge=0)] = Field( + None, description='Optional bake time in minutes.' + ) diff --git a/apps/api/scripts/generate_openapi_models.py b/apps/api/scripts/generate_openapi_models.py new file mode 100644 index 0000000..e3833f8 --- /dev/null +++ b/apps/api/scripts/generate_openapi_models.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Regenerate ``recipes_api/generated/openapi_models.py`` from ``packages/openapi/openapi.yaml``.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +OPENAPI_YAML = REPO_ROOT / "packages" / "openapi" / "openapi.yaml" +OUTPUT = REPO_ROOT / "apps" / "api" / "recipes_api" / "generated" / "openapi_models.py" + + +def _datamodel_codegen() -> str: + next_to_python = Path(sys.executable).parent / "datamodel-codegen" + if next_to_python.is_file(): + return str(next_to_python) + found = shutil.which("datamodel-codegen") + if found: + return found + print( + "datamodel-codegen not found (install apps/api with dev extras: pip install -e '.[dev]').", + file=sys.stderr, + ) + sys.exit(1) + + +def main() -> None: + if not OPENAPI_YAML.is_file(): + print(f"OpenAPI spec not found: {OPENAPI_YAML}", file=sys.stderr) + sys.exit(1) + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + _datamodel_codegen(), + "--input", + str(OPENAPI_YAML), + "--input-file-type", + "openapi", + "--output", + str(OUTPUT), + "--output-model-type", + "pydantic_v2.BaseModel", + "--disable-timestamp", + "--target-python-version", + "3.12", + ] + subprocess.run(cmd, check=True) + + +if __name__ == "__main__": + main() From f71340892b0ac70b9fe9a468480406d9aa9050c6 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Wed, 1 Apr 2026 16:54:32 +0100 Subject: [PATCH 2/8] fix(ci): Copilot review for OpenAPI codegen workflow - Pin actions/setup-python@v6.2.0; set permissions.contents: read. - Fail on untracked/uncommitted files under recipes_api/generated/. - generate_openapi_models: reuse resolve_openapi_spec_path(); derive output path from script location. Made-with: Cursor --- .../validate-openapi-python-codegen.yml | 13 +++++++++++-- apps/api/scripts/generate_openapi_models.py | 16 ++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/validate-openapi-python-codegen.yml b/.github/workflows/validate-openapi-python-codegen.yml index 2f6b539..4d76c40 100644 --- a/.github/workflows/validate-openapi-python-codegen.yml +++ b/.github/workflows/validate-openapi-python-codegen.yml @@ -5,13 +5,16 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: openapi-python-codegen: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6.3.0 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.12" cache: pip @@ -26,4 +29,10 @@ jobs: run: python scripts/generate_openapi_models.py - name: Fail if generated models are out of sync with the spec - run: git diff --exit-code -- apps/api/recipes_api/generated/ + run: | + git diff --exit-code -- apps/api/recipes_api/generated/ + if [ -n "$(git status --porcelain -- apps/api/recipes_api/generated/)" ]; then + echo "Untracked or uncommitted changes under apps/api/recipes_api/generated/. Regenerate and commit." + git status --porcelain -- apps/api/recipes_api/generated/ + exit 1 + fi diff --git a/apps/api/scripts/generate_openapi_models.py b/apps/api/scripts/generate_openapi_models.py index e3833f8..30d7a6b 100644 --- a/apps/api/scripts/generate_openapi_models.py +++ b/apps/api/scripts/generate_openapi_models.py @@ -8,9 +8,12 @@ import sys from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[3] -OPENAPI_YAML = REPO_ROOT / "packages" / "openapi" / "openapi.yaml" -OUTPUT = REPO_ROOT / "apps" / "api" / "recipes_api" / "generated" / "openapi_models.py" +# Run with ``apps/api`` on the path (``pip install -e .`` or ``PYTHONPATH=.``). +from recipes_api.openapi_paths import resolve_openapi_spec_path + +_SCRIPTS_DIR = Path(__file__).resolve().parent +_API_ROOT = _SCRIPTS_DIR.parent +OUTPUT = _API_ROOT / "recipes_api" / "generated" / "openapi_models.py" def _datamodel_codegen() -> str: @@ -28,14 +31,15 @@ def _datamodel_codegen() -> str: def main() -> None: - if not OPENAPI_YAML.is_file(): - print(f"OpenAPI spec not found: {OPENAPI_YAML}", file=sys.stderr) + openapi_yaml = resolve_openapi_spec_path() + if not openapi_yaml.is_file(): + print(f"OpenAPI spec not found: {openapi_yaml}", file=sys.stderr) sys.exit(1) OUTPUT.parent.mkdir(parents=True, exist_ok=True) cmd = [ _datamodel_codegen(), "--input", - str(OPENAPI_YAML), + str(openapi_yaml), "--input-file-type", "openapi", "--output", From 6cd5ef1b3466fedcc2e4dfdc3b0398a1d0654527 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Wed, 1 Apr 2026 16:55:55 +0100 Subject: [PATCH 3/8] ci: use [[ for bash test in OpenAPI codegen workflow Made-with: Cursor --- .github/workflows/validate-openapi-python-codegen.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-openapi-python-codegen.yml b/.github/workflows/validate-openapi-python-codegen.yml index 4d76c40..947822b 100644 --- a/.github/workflows/validate-openapi-python-codegen.yml +++ b/.github/workflows/validate-openapi-python-codegen.yml @@ -31,7 +31,7 @@ jobs: - name: Fail if generated models are out of sync with the spec run: | git diff --exit-code -- apps/api/recipes_api/generated/ - if [ -n "$(git status --porcelain -- apps/api/recipes_api/generated/)" ]; then + if [[ -n "$(git status --porcelain -- apps/api/recipes_api/generated/)" ]]; then echo "Untracked or uncommitted changes under apps/api/recipes_api/generated/. Regenerate and commit." git status --porcelain -- apps/api/recipes_api/generated/ exit 1 From 3e8fcbcbb8626d5b9b79bbbccbb1a34ab1c3a9a6 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Wed, 1 Apr 2026 16:58:34 +0100 Subject: [PATCH 4/8] ci: use GENERATED_PATH in OpenAPI codegen drift check Made-with: Cursor --- .github/workflows/validate-openapi-python-codegen.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/validate-openapi-python-codegen.yml b/.github/workflows/validate-openapi-python-codegen.yml index 947822b..926ba69 100644 --- a/.github/workflows/validate-openapi-python-codegen.yml +++ b/.github/workflows/validate-openapi-python-codegen.yml @@ -30,9 +30,12 @@ jobs: - name: Fail if generated models are out of sync with the spec run: | - git diff --exit-code -- apps/api/recipes_api/generated/ - if [[ -n "$(git status --porcelain -- apps/api/recipes_api/generated/)" ]]; then - echo "Untracked or uncommitted changes under apps/api/recipes_api/generated/. Regenerate and commit." - git status --porcelain -- apps/api/recipes_api/generated/ + set -ux + + GENERATED_PATH="apps/api/recipes_api/generated" + git diff --exit-code -- "$GENERATED_PATH" + if [[ -n "$(git status --porcelain -- "$GENERATED_PATH")" ]]; then + echo "Untracked or uncommitted changes under ${GENERATED_PATH}. Regenerate and commit." + git status --porcelain -- "$GENERATED_PATH" exit 1 fi From 0ef4973cfd18b95f38f51c1b5b7c45ae169ee5c5 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Wed, 1 Apr 2026 18:57:48 +0100 Subject: [PATCH 5/8] ci: unify workflow, add openapi:validate, root pnpm scripts - Replace separate OpenAPI workflows with single ci.yml job - Add validate_openapi_generated.sh and turbo/root openapi:validate - Document pnpm lint, openapi:generate, openapi:validate in README Made-with: Cursor --- .github/workflows/ci.yml | 47 +++++++++++++++++++ .../validate-openapi-python-codegen.yml | 41 ---------------- .github/workflows/validate-openapi.yml | 27 ----------- README.md | 12 ++--- apps/api/README.md | 5 +- apps/api/package.json | 3 +- .../api/scripts/validate_openapi_generated.sh | 18 +++++++ package.json | 2 + turbo.json | 6 +++ 9 files changed, 82 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/validate-openapi-python-codegen.yml delete mode 100644 .github/workflows/validate-openapi.yml create mode 100644 apps/api/scripts/validate_openapi_generated.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1d16db2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: pnpm/action-setup@v5.0.0 + + - uses: actions/setup-node@v6.3.0 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: apps/api/pyproject.toml + + - name: Install API dev dependencies + working-directory: apps/api + run: python -m pip install -e ".[dev]" + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Generate OpenAPI Pydantic models + run: pnpm openapi:generate + + - name: Validate generated OpenAPI models match the repo + run: pnpm openapi:validate diff --git a/.github/workflows/validate-openapi-python-codegen.yml b/.github/workflows/validate-openapi-python-codegen.yml deleted file mode 100644 index 926ba69..0000000 --- a/.github/workflows/validate-openapi-python-codegen.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Validate OpenAPI Python codegen - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -jobs: - openapi-python-codegen: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - - - uses: actions/setup-python@v6.2.0 - with: - python-version: "3.12" - cache: pip - cache-dependency-path: apps/api/pyproject.toml - - - name: Install API dev dependencies - working-directory: apps/api - run: python -m pip install -e ".[dev]" - - - name: Regenerate OpenAPI Pydantic models - working-directory: apps/api - run: python scripts/generate_openapi_models.py - - - name: Fail if generated models are out of sync with the spec - run: | - set -ux - - GENERATED_PATH="apps/api/recipes_api/generated" - git diff --exit-code -- "$GENERATED_PATH" - if [[ -n "$(git status --porcelain -- "$GENERATED_PATH")" ]]; then - echo "Untracked or uncommitted changes under ${GENERATED_PATH}. Regenerate and commit." - git status --porcelain -- "$GENERATED_PATH" - exit 1 - fi diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml deleted file mode 100644 index 8c78159..0000000 --- a/.github/workflows/validate-openapi.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Validate OpenAPI - -on: - push: - branches: [main] - pull_request: - -jobs: - openapi: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - - - uses: pnpm/action-setup@v5.0.0 - with: - version: 10.18.1 - - - uses: actions/setup-node@v6.3.0 - with: - node-version-file: ".nvmrc" - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint OpenAPI spec - run: pnpm --filter @solid-pact/openapi run lint diff --git a/README.md b/README.md index 019ea0f..d3a4f65 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,9 @@ You can work in other editors, but Cursor is the intended environment. ## OpenAPI contract -The REST API is defined in **`packages/openapi/openapi.yaml`** (shared by the Python API and the front end). Lint it with **[Redocly](https://redocly.com/docs/cli/)** from the repo root: +The REST API is defined in **`packages/openapi/openapi.yaml`** (shared by the Python API and the front end). From the repository root, **`pnpm lint`** runs **[Redocly](https://redocly.com/docs/cli/)** on that spec (via **`@solid-pact/openapi`**) together with the other workspace lint tasks. -```bash -pnpm --filter @solid-pact/openapi run lint -``` - -The **Validate OpenAPI** workflow (`.github/workflows/validate-openapi.yml`) runs that command on pushes to `main` and on every pull request. +The **CI** workflow (`.github/workflows/ci.yml`) runs **`pnpm lint`**, **`pnpm test`**, **`pnpm openapi:generate`**, and **`pnpm openapi:validate`** (among the other setup steps) on pushes to `main` and on every pull request. When you add or upgrade Node dependencies, run **`pnpm install`** with the **pnpm** version pinned in **`packageManager`** (via Corepack). Using a different pnpm release can rewrite **`pnpm-lock.yaml`** in an incompatible way (for example changing the lockfile format). @@ -65,10 +61,10 @@ pnpm build pnpm dev pnpm lint pnpm test +pnpm openapi:generate +pnpm openapi:validate ``` -(`pnpm run