Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CI

on:
push:
branches: [main]
pull_request:

Comment thread
mcalthrop marked this conversation as resolved.
permissions:
contents: read

jobs:
validate:
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

- 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

- name: Install API dev dependencies
working-directory: apps/api
run: .venv/bin/python -m pip install -e ".[dev]"

- name: Lint
run: pnpm lint

- name: Test
run: pnpm test

- name: Generate OpenAPI code
run: pnpm openapi:generate

- name: Validate generated OpenAPI code matches the repo
run: pnpm openapi:validate
27 changes: 0 additions & 27 deletions .github/workflows/validate-openapi.yml

This file was deleted.

2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
12 changes: 4 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -65,10 +61,10 @@ pnpm build
pnpm dev
pnpm lint
pnpm test
pnpm openapi:generate
pnpm openapi:validate
```

(`pnpm run <script>` is equivalent if you prefer the explicit form.)

To run a script in a single workspace, use **pnpm**’s filter (examples):

```bash
Expand Down
30 changes: 27 additions & 3 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ FastAPI ASGI service: **`GET /health`**, **`GET /recipes`**, and **`GET /recipes

## Setup

From `apps/api`:
From the **repository root**, **`pnpm install`** runs this package’s **`postinstall`**: create **`apps/api/.venv`** if it is missing, then **`pip install -e .`** inside it so **`datamodel-code-generator`** and the rest of the core dependencies are available to **`pnpm openapi:generate`**.

For tests and optional dev tools, from **`apps/api`** with the venv activated:

```bash
source .venv/bin/activate
pip install -e ".[dev]"
```

Alternatively, create the venv and install everything in one go manually:

```bash
python3 -m venv .venv
Expand All @@ -20,14 +29,29 @@ pip install -e ".[dev]"
## Run (development)

```bash
python3 -m uvicorn recipes_api.main:app --reload --host 127.0.0.1 --port 8000
pnpm dev
```

(or **`.venv/bin/python -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 openapi:generate
pnpm openapi:validate
```

**`pnpm openapi:generate`** runs **`.venv/bin/python -m recipes_api.openapi_codegen`** (after **`pnpm install`**, which creates **`apps/api/.venv`** via **`postinstall`**). **`datamodel-code-generator`** is a normal dependency in **`pyproject.toml`**.

CI runs **`pnpm openapi:validate`** after **`pnpm openapi:generate`** (see `.github/workflows/ci.yml`). 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.
Recipe payloads match **`packages/openapi/openapi.yaml`**. The **`RecipeRepository`** protocol and **`StaticRecipeRepository`** implementation live under **`recipes_api/`**; static content is **`apps/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.

## Monorepo scripts

Expand Down
File renamed without changes.
11 changes: 7 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "python3 -m compileall -q recipes_api",
"dev": "python3 -m uvicorn recipes_api.main:app --reload --host 127.0.0.1 --port 8000",
"lint": "python3 -m compileall -q recipes_api",
"test": "python3 -m pytest"
"build": ".venv/bin/python -m compileall -q recipes_api",
"dev": ".venv/bin/python -m uvicorn recipes_api.main:app --reload --host 127.0.0.1 --port 8000",
"openapi:generate": ".venv/bin/python -m recipes_api.openapi_codegen",
"openapi:validate": "bash scripts/validate_openapi_generated.sh",
"lint": ".venv/bin/python -m compileall -q recipes_api",
"test": ".venv/bin/python -m pytest -v",
"postinstall": "bash -e -c 'test -d .venv || python3 -m venv .venv; .venv/bin/python -m pip install -e .'"
}
}
3 changes: 2 additions & 1 deletion apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description = "Bread Recipes REST API (FastAPI)"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"datamodel-code-generator==0.26.5",
"fastapi==0.115.12",
"pyyaml==6.0.2",
"uvicorn[standard]==0.34.3",
Expand All @@ -25,7 +26,7 @@ packages = ["recipes_api"]
exclude = ["**/recipes_api/**/*_test.py"]

[tool.hatch.build.targets.wheel.force-include]
"recipes_api/data/recipes.json" = "recipes_api/data/recipes.json"
"data/recipes.json" = "recipes_api/data/recipes.json"

[tool.pytest.ini_options]
testpaths = ["recipes_api"]
Expand Down
2 changes: 1 addition & 1 deletion apps/api/pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"include": ["recipes_api", "tests"],
"include": ["recipes_api"],
"exclude": [".venv", "**/__pycache__"],
"pythonVersion": "3.12",
"venvPath": ".",
Expand Down
13 changes: 13 additions & 0 deletions apps/api/recipes_api/data_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Paths to bundled recipe data (editable install vs wheel)."""

from pathlib import Path


def resolve_recipes_json_path() -> Path:
"""Return ``apps/api/data/recipes.json`` in the repo; ``recipes_api/data/recipes.json`` in an installed wheel."""
here = Path(__file__).resolve().parent
peer = here.parent / "data" / "recipes.json"
packaged = here / "data" / "recipes.json"
if peer.is_file():
return peer
return packaged
1 change: 1 addition & 0 deletions apps/api/recipes_api/generated/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Code-generated artefacts (do not edit by hand)."""
44 changes: 44 additions & 0 deletions apps/api/recipes_api/generated/openapi_models.py
Original file line number Diff line number Diff line change
@@ -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.'
)
36 changes: 36 additions & 0 deletions apps/api/recipes_api/openapi_codegen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Regenerate ``recipes_api/generated/openapi_models.py`` from ``packages/openapi/openapi.yaml``."""

import sys
from pathlib import Path

from datamodel_code_generator import (
DataModelType,
InputFileType,
PythonVersion,
generate,
)
from recipes_api.openapi_paths import resolve_openapi_spec_path

_RECIPES_PKG = Path(__file__).resolve().parent
OUTPUT = _RECIPES_PKG / "generated" / "openapi_models.py"


def main() -> None:
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)
generate(
openapi_yaml,
input_filename=openapi_yaml.name,
input_file_type=InputFileType.OpenAPI,
output=OUTPUT,
output_model_type=DataModelType.PydanticV2BaseModel,
disable_timestamp=True,
target_python_version=PythonVersion.PY_312,
)


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion apps/api/recipes_api/recipes/recipes_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from fastapi import APIRouter, Depends, Path, status
from pydantic import BaseModel, ConfigDict

from recipes_api.data_paths import resolve_recipes_json_path
from recipes_api.models import RecipeDetail, RecipeSummary
from recipes_api.recipes.exceptions import RecipeNotFoundError
from recipes_api.repository import RecipeRepository, StaticRecipeRepository

_repository = StaticRecipeRepository()
_repository = StaticRecipeRepository(data_path=resolve_recipes_json_path())


def get_recipe_repository() -> RecipeRepository:
Expand Down
6 changes: 3 additions & 3 deletions apps/api/recipes_api/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ def get_detail(self, recipe_id: str) -> RecipeDetail | None:


class StaticRecipeRepository:
"""Load recipes from a JSON file committed with the package."""
"""Load recipes from a JSON file."""

def __init__(self, data_path: Path | None = None) -> None:
self._path = data_path or (Path(__file__).resolve().parent / "data" / "recipes.json")
def __init__(self, data_path: Path) -> None:
self._path = data_path
self._by_id: dict[str, RecipeDetail] = {}
self._order: list[str] = []
self._load()
Expand Down
7 changes: 4 additions & 3 deletions apps/api/recipes_api/repository_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pytest

from recipes_api.data_paths import resolve_recipes_json_path
from recipes_api.repository import StaticRecipeRepository


Expand Down Expand Up @@ -116,9 +117,9 @@ def test_duplicate_ids_raise(tmp_path: Path) -> None:
StaticRecipeRepository(data_path=path)


def test_bundled_recipes_json_loads() -> None:
"""Packaged ``recipes.json`` loads (wheel/sdist layout)."""
repo = StaticRecipeRepository()
def test_resolve_recipes_json_path_loads_recipes() -> None:
"""``recipes.json`` resolved via ``resolve_recipes_json_path`` loads successfully."""
repo = StaticRecipeRepository(data_path=resolve_recipes_json_path())
summaries = repo.list_summaries()
ids = [summary["id"] for summary in summaries]
assert "country-loaf" in ids
Expand Down
25 changes: 25 additions & 0 deletions apps/api/scripts/validate_openapi_generated.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Fail if recipes_api/generated does not match the working tree (run from repo root via git).
set -euo pipefail

ROOT="$(git rev-parse --show-toplevel 2>/dev/null || :)"
if [[ -z "${ROOT}" ]]; then
echo "validate_openapi_generated.sh: not inside a git repository" >&2
exit 1
fi
cd "${ROOT}"

GENERATED_PATH="apps/api/recipes_api/generated"
if ! git diff --quiet -- "${GENERATED_PATH}"; then
echo "Generated OpenAPI files under ${GENERATED_PATH} are out of date." >&2
echo "Please run 'pnpm openapi:generate' and commit the updated files." >&2
git diff -- "${GENERATED_PATH}" >&2
exit 1
fi

PORCELAIN_CHANGES=$(git status --porcelain -- "${GENERATED_PATH}") || exit $?
if [[ -n "${PORCELAIN_CHANGES}" ]]; then
echo "Untracked or uncommitted changes under ${GENERATED_PATH}. Regenerate with 'pnpm openapi:generate' and commit." >&2
printf '%s\n' "${PORCELAIN_CHANGES}" >&2
exit 1
fi
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"build": "node -e \"console.log('web: build placeholder')\"",
"dev": "node -e \"console.log('web: dev placeholder')\"",
"lint": "node -e \"console.log('web: lint placeholder')\"",
"openapi:generate": "node -e \"console.log('web: openapi:generate placeholder')\"",
"openapi:validate": "node -e \"console.log('web: openapi:validate placeholder')\"",
"test": "node -e \"console.log('web: test placeholder')\""
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"openapi:generate": "turbo run openapi:generate",
"openapi:validate": "turbo run openapi:validate",
"test": "turbo run test"
},
"devDependencies": {
Expand Down
Loading
Loading