Skip to content

Commit 8ebaf02

Browse files
committed
add a script to validate the openapi spec yml file
1 parent b48aeeb commit 8ebaf02

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

scripts/run_tests.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ display_result $? 1 "Code format check"
3535
mypy .
3636
display_result $? 1 "Type check"
3737

38+
python scripts/validate_openapi_specs.py
39+
display_result $? 1 "OpenAPI spec check"
40+
3841
# Run tests that need serial execution.
3942
if ! docker info > /dev/null 2>&1; then
4043
echo "This test uses docker, and it isn't running - please start docker and try again."

scripts/validate_openapi_specs.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
"""Validate the OpenAPI spec YAML files.
3+
4+
Runs as part of ``make test`` to catch malformed OpenAPI specs before they are
5+
shipped. The checks are intentionally dependency-free (PyYAML only) so they can
6+
run without pulling in an OpenAPI validator that conflicts with the pinned
7+
``jsonschema`` version.
8+
9+
Checks performed for each spec file:
10+
* the file is syntactically valid YAML
11+
* no mapping contains duplicate keys (PyYAML silently keeps the last one)
12+
* the required top-level OpenAPI sections are present
13+
* ``info`` declares a ``title`` and ``version``
14+
* every local ``$ref`` (``#/...``) resolves to an existing node
15+
"""
16+
17+
import os
18+
import sys
19+
20+
import yaml # type: ignore
21+
22+
SPEC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "openapi")
23+
SPEC_FILES = [
24+
"v2-notifications-api-en.yaml",
25+
"v2-notifications-api-fr.yaml",
26+
]
27+
28+
REQUIRED_TOP_LEVEL_KEYS = ["openapi", "info", "paths"]
29+
REQUIRED_INFO_KEYS = ["title", "version"]
30+
31+
32+
class _DuplicateKeyError(Exception):
33+
pass
34+
35+
36+
class _UniqueKeyLoader(yaml.SafeLoader):
37+
"""A SafeLoader that raises on duplicate mapping keys instead of silently
38+
keeping the last value."""
39+
40+
41+
def _construct_mapping(loader: _UniqueKeyLoader, node: yaml.MappingNode) -> dict:
42+
mapping: dict = {}
43+
for key_node, value_node in node.value:
44+
key = loader.construct_object(key_node, deep=True)
45+
if key in mapping:
46+
raise _DuplicateKeyError(f"duplicate key {key!r} at line {key_node.start_mark.line + 1}")
47+
mapping[key] = loader.construct_object(value_node, deep=True)
48+
return mapping
49+
50+
51+
_UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
52+
53+
54+
def _resolve_ref(spec: dict, ref: str) -> bool:
55+
"""Return True if a local ``#/a/b/c`` reference resolves within the spec."""
56+
node = spec
57+
for part in ref.lstrip("#/").split("/"):
58+
part = part.replace("~1", "/").replace("~0", "~")
59+
if isinstance(node, dict) and part in node:
60+
node = node[part]
61+
else:
62+
return False
63+
return True
64+
65+
66+
def _collect_broken_refs(node, spec: dict, broken: list) -> None:
67+
if isinstance(node, dict):
68+
for key, value in node.items():
69+
if key == "$ref" and isinstance(value, str) and value.startswith("#/"):
70+
if not _resolve_ref(spec, value):
71+
broken.append(value)
72+
else:
73+
_collect_broken_refs(value, spec, broken)
74+
elif isinstance(node, list):
75+
for item in node:
76+
_collect_broken_refs(item, spec, broken)
77+
78+
79+
def validate_spec(path: str) -> list:
80+
"""Validate a single spec file and return a list of error messages."""
81+
errors: list = []
82+
with open(path, "r") as f:
83+
try:
84+
spec = yaml.load(f, Loader=_UniqueKeyLoader)
85+
except (yaml.YAMLError, _DuplicateKeyError) as exc:
86+
return [f"invalid YAML: {exc}"]
87+
88+
if not isinstance(spec, dict):
89+
return ["top-level document is not a mapping"]
90+
91+
for key in REQUIRED_TOP_LEVEL_KEYS:
92+
if key not in spec:
93+
errors.append(f"missing required top-level key '{key}'")
94+
95+
info = spec.get("info")
96+
if isinstance(info, dict):
97+
for key in REQUIRED_INFO_KEYS:
98+
if key not in info:
99+
errors.append(f"missing required 'info.{key}'")
100+
elif "info" in spec:
101+
errors.append("'info' is not a mapping")
102+
103+
broken_refs: list = []
104+
_collect_broken_refs(spec, spec, broken_refs)
105+
for ref in sorted(set(broken_refs)):
106+
errors.append(f"unresolved $ref '{ref}'")
107+
108+
return errors
109+
110+
111+
def main() -> int:
112+
exit_code = 0
113+
for filename in SPEC_FILES:
114+
path = os.path.join(SPEC_DIR, filename)
115+
if not os.path.exists(path):
116+
print(f"\033[31m{filename}: file not found\033[0m")
117+
exit_code = 1
118+
continue
119+
120+
errors = validate_spec(path)
121+
if errors:
122+
exit_code = 1
123+
print(f"\033[31m{filename}: {len(errors)} error(s)\033[0m")
124+
for error in errors:
125+
print(f" - {error}")
126+
else:
127+
print(f"\033[32m{filename}: OK\033[0m")
128+
129+
return exit_code
130+
131+
132+
if __name__ == "__main__":
133+
sys.exit(main())

0 commit comments

Comments
 (0)