Skip to content

Commit 866e055

Browse files
committed
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
1 parent 876f4c4 commit 866e055

2 files changed

Lines changed: 77 additions & 21 deletions

File tree

apps/api/recipes_api/openapi_spec.py

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from __future__ import annotations
44

55
from pathlib import Path
6-
from typing import Any, TypedDict, cast
6+
from typing import TypedDict
77

88
import yaml
9+
from pydantic import BaseModel, ConfigDict, ValidationError
910

1011

1112
class OpenApiInfo(TypedDict):
@@ -16,6 +17,24 @@ class OpenApiInfo(TypedDict):
1617
description: str
1718

1819

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+
1938
def load_openapi_info(path: Path) -> OpenApiInfo:
2039
"""Read ``info.title``, ``info.version``, and ``info.description`` from the YAML spec."""
2140
if not path.is_file():
@@ -26,23 +45,13 @@ def load_openapi_info(path: Path) -> OpenApiInfo:
2645
raise FileNotFoundError(msg)
2746

2847
parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
29-
if not isinstance(parsed, dict):
30-
raise ValueError("OpenAPI document root must be a mapping")
31-
root = cast(dict[str, Any], parsed)
32-
info_raw = root.get("info")
33-
if not isinstance(info_raw, dict):
34-
raise ValueError("OpenAPI document must contain an info object")
35-
info = cast(dict[str, Any], info_raw)
36-
37-
title = info.get("title")
38-
version = info.get("version")
39-
description = info.get("description")
40-
if not isinstance(title, str) or not isinstance(version, str) or not isinstance(
41-
description,
42-
str,
43-
):
44-
raise ValueError(
45-
"OpenAPI info.title, info.version, and info.description must be strings",
46-
)
47-
48-
return OpenApiInfo(title=title, version=version, description=description)
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+
)

apps/api/recipes_api/openapi_spec_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import MagicMock, patch
77

88
import pytest
9+
from pydantic import ValidationError
910

1011
from recipes_api.openapi_spec import load_openapi_info
1112

@@ -30,6 +31,52 @@ def test_load_openapi_info_returns_info_from_yaml(mock_safe_load: MagicMock, tmp
3031
mock_safe_load.assert_called_once()
3132

3233

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+
3380
def test_load_openapi_info_missing_file_raises() -> None:
3481
path = MagicMock(spec=Path)
3582
path.is_file.return_value = False

0 commit comments

Comments
 (0)