Skip to content

Commit 6db76ef

Browse files
authored
feat(api): OpenAPI info from spec (PLAN 3.3) (#8)
* feat(api): load OpenAPI info from packages/openapi spec (PLAN 3.3) Wire FastAPI title, version, and description from openapi.yaml via load_openapi_info(); add PyYAML and tests. Mark PLAN 3.3 complete. Made-with: Cursor * refactor(api): extract openapi path helper and lean on mocks in tests Move resolve_openapi_spec_path to openapi_paths.py; require Path in load_openapi_info; use unittest.mock in openapi_spec and main tests; add openapi_paths_test. Made-with: Cursor * feat(api): validate OpenAPI info with Pydantic Replace manual dict checks with small Pydantic models; wrap ValidationError as ValueError. Extend openapi_spec tests for extra keys, invalid payloads, and exception chaining. Made-with: Cursor * docs(api): clarify monorepo .parent chain in openapi_paths Made-with: Cursor
1 parent 1fc4180 commit 6db76ef

8 files changed

Lines changed: 196 additions & 7 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O
1717

1818
- [x] **3.1** Create the Python project layout, dependency files, and a minimal ASGI app (e.g. FastAPI) wired for local dev.
1919
- [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.
20-
- [ ] **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`).
20+
- [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`).
2121
- [ ] **3.4** Implement REST handlers to match the OpenAPI spec (response shapes and status codes); keep behaviour aligned with the spec.
2222
- [ ] **3.5** Tests with 100% coverage and a coverage gate in CI for the API package; add `README.md` for install, run, and test commands.
2323

apps/api/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.12"
1111
dependencies = [
1212
"fastapi==0.115.12",
13+
"pyyaml==6.0.2",
1314
"uvicorn[standard]==0.34.3",
1415
]
1516

apps/api/recipes_api/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@
44

55
from fastapi import FastAPI
66

7+
from recipes_api.openapi_paths import resolve_openapi_spec_path
8+
from recipes_api.openapi_spec import load_openapi_info
9+
710

811
class HealthPayload(TypedDict):
912
"""Response body for the health check."""
1013

1114
status: str
1215

1316

17+
_openapi_info = load_openapi_info(resolve_openapi_spec_path())
18+
1419
app = FastAPI(
15-
title="Bread Recipes API",
16-
version="0.1.0",
17-
description="REST API for browsing bread recipes (list overview and full detail).",
20+
title=_openapi_info["title"],
21+
version=_openapi_info["version"],
22+
description=_openapi_info["description"],
1823
)
1924

2025

apps/api/recipes_api/main_test.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
"""Smoke tests for the minimal ASGI app (``main``)."""
22

3-
from fastapi.testclient import TestClient
3+
import importlib
4+
from unittest.mock import patch
45

5-
from recipes_api.main import app
6+
from fastapi.testclient import TestClient
67

7-
client = TestClient(app)
8+
import recipes_api.main as main_module
89

910

1011
def test_health_returns_ok_status() -> None:
12+
client = TestClient(main_module.app)
1113
response = client.get("/health")
1214
assert response.status_code == 200
1315
assert response.json() == {"status": "ok"}
16+
17+
18+
def test_app_metadata_comes_from_load_openapi_info() -> None:
19+
fake = {
20+
"title": "Mock API",
21+
"version": "2.0.0",
22+
"description": "Loaded via mock",
23+
}
24+
with patch("recipes_api.openapi_spec.load_openapi_info", return_value=fake):
25+
importlib.reload(main_module)
26+
assert main_module.app.title == fake["title"]
27+
assert main_module.app.version == fake["version"]
28+
assert main_module.app.description == fake["description"]
29+
importlib.reload(main_module)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Monorepo paths for OpenAPI artefacts."""
2+
3+
from pathlib import Path
4+
5+
6+
def resolve_openapi_spec_path() -> Path:
7+
"""Resolve the path to ``packages/openapi/openapi.yaml`` in the monorepo checkout."""
8+
# .parent chain from this file: recipes_api/ → apps/api/ → apps/ → monorepo root (four times).
9+
root = Path(__file__).resolve().parent.parent.parent.parent
10+
return root / "packages" / "openapi" / "openapi.yaml"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Tests for ``openapi_paths``."""
2+
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
from recipes_api import openapi_paths
7+
8+
9+
def test_resolve_openapi_spec_path() -> None:
10+
with patch.object(
11+
openapi_paths,
12+
"__file__",
13+
"/workspace/apps/api/recipes_api/openapi_paths.py",
14+
):
15+
resolved = openapi_paths.resolve_openapi_spec_path()
16+
assert resolved == Path("/workspace/packages/openapi/openapi.yaml")
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Load OpenAPI document metadata from a YAML spec file."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import TypedDict
7+
8+
import yaml
9+
from pydantic import BaseModel, ConfigDict, ValidationError
10+
11+
12+
class OpenApiInfo(TypedDict):
13+
"""Subset of OpenAPI ``info`` used to configure FastAPI."""
14+
15+
title: str
16+
version: str
17+
description: str
18+
19+
20+
class _OpenApiInfoBlock(BaseModel):
21+
"""``info`` object: only the fields we need; other OpenAPI keys are ignored."""
22+
23+
model_config = ConfigDict(extra="ignore")
24+
25+
title: str
26+
version: str
27+
description: str
28+
29+
30+
class _OpenApiRootForInfo(BaseModel):
31+
"""Minimal root shape: must include ``info``; other top-level keys are ignored."""
32+
33+
model_config = ConfigDict(extra="ignore")
34+
35+
info: _OpenApiInfoBlock
36+
37+
38+
def load_openapi_info(path: Path) -> OpenApiInfo:
39+
"""Read ``info.title``, ``info.version``, and ``info.description`` from the YAML spec."""
40+
if not path.is_file():
41+
msg = (
42+
f"OpenAPI spec not found at {path}. "
43+
"Run the API from the monorepo checkout so packages/openapi is available."
44+
)
45+
raise FileNotFoundError(msg)
46+
47+
parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
48+
try:
49+
root = _OpenApiRootForInfo.model_validate(parsed)
50+
except ValidationError as exc:
51+
raise ValueError("OpenAPI document is invalid or missing required info fields") from exc
52+
53+
return OpenApiInfo(
54+
title=root.info.title,
55+
version=root.info.version,
56+
description=root.info.description,
57+
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tests for ``openapi_spec`` (load ``info`` from YAML)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
from pydantic import ValidationError
10+
11+
from recipes_api.openapi_spec import load_openapi_info
12+
13+
14+
@patch("recipes_api.openapi_spec.yaml.safe_load")
15+
def test_load_openapi_info_returns_info_from_yaml(mock_safe_load: MagicMock, tmp_path: Path) -> None:
16+
mock_safe_load.return_value = {
17+
"info": {
18+
"title": "Bread Recipes API",
19+
"version": "0.1.0",
20+
"description": "REST API for browsing bread recipes (list overview and full detail).",
21+
}
22+
}
23+
path = tmp_path / "spec.yaml"
24+
path.write_text("ignored: true\n", encoding="utf-8")
25+
info = load_openapi_info(path)
26+
assert info == {
27+
"title": "Bread Recipes API",
28+
"version": "0.1.0",
29+
"description": "REST API for browsing bread recipes (list overview and full detail).",
30+
}
31+
mock_safe_load.assert_called_once()
32+
33+
34+
@patch("recipes_api.openapi_spec.yaml.safe_load")
35+
def test_load_openapi_info_ignores_extra_openapi_keys(mock_safe_load: MagicMock, tmp_path: Path) -> None:
36+
mock_safe_load.return_value = {
37+
"openapi": "3.1.0",
38+
"info": {
39+
"title": "T",
40+
"version": "1.0.0",
41+
"description": "D",
42+
"license": {"name": "MIT"},
43+
},
44+
"paths": {},
45+
}
46+
path = tmp_path / "spec.yaml"
47+
path.write_text("x", encoding="utf-8")
48+
assert load_openapi_info(path) == {
49+
"title": "T",
50+
"version": "1.0.0",
51+
"description": "D",
52+
}
53+
54+
55+
@patch("recipes_api.openapi_spec.yaml.safe_load")
56+
@pytest.mark.parametrize(
57+
"parsed",
58+
[
59+
None,
60+
[],
61+
{},
62+
{"openapi": "3.1.0"},
63+
{"info": {"title": "only title"}},
64+
{"info": {"title": "t", "version": "v", "description": 123}},
65+
],
66+
)
67+
def test_load_openapi_info_invalid_document_raises_value_error(
68+
mock_safe_load: MagicMock,
69+
tmp_path: Path,
70+
parsed: object,
71+
) -> None:
72+
mock_safe_load.return_value = parsed
73+
path = tmp_path / "spec.yaml"
74+
path.write_text("x", encoding="utf-8")
75+
with pytest.raises(ValueError, match="invalid or missing required info") as exc_info:
76+
load_openapi_info(path)
77+
assert isinstance(exc_info.value.__cause__, ValidationError)
78+
79+
80+
def test_load_openapi_info_missing_file_raises() -> None:
81+
path = MagicMock(spec=Path)
82+
path.is_file.return_value = False
83+
with pytest.raises(FileNotFoundError, match="OpenAPI spec not found"):
84+
load_openapi_info(path)

0 commit comments

Comments
 (0)