Skip to content

Commit 9c984ac

Browse files
Hasan KhanCopilot
andcommitted
Add CI check that Flask routes and OpenAPI spec stay in sync
Adds scripts/validate_spec.py which parses @app.route decorators in main.py and paths in spec.v1.yml, normalizes path params positionally, and fails if either side has an endpoint the other lacks. Adds .github/workflows/spec-check.yml which runs the validator on every PR and push to master that touches main.py or spec.v1.yml. This makes it impossible to merge a new/changed API endpoint without updating the spec that Stoplight publishes at https://sunnah.stoplight.io/docs/api/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 45ae657 commit 9c984ac

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

.github/workflows/spec-check.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Spec drift check
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- main.py
7+
- spec.v1.yml
8+
- scripts/validate_spec.py
9+
- .github/workflows/spec-check.yml
10+
push:
11+
branches: [master]
12+
paths:
13+
- main.py
14+
- spec.v1.yml
15+
- scripts/validate_spec.py
16+
- .github/workflows/spec-check.yml
17+
18+
jobs:
19+
validate:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-python@v5
24+
with:
25+
python-version: "3.12"
26+
- name: Install PyYAML
27+
run: pip install pyyaml
28+
- name: Validate spec matches Flask routes
29+
run: python scripts/validate_spec.py

scripts/validate_spec.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env python3
2+
"""Validate that OpenAPI spec paths match the Flask routes declared in main.py.
3+
4+
Fails (exit 1) if any Flask route is missing from the spec, or vice versa.
5+
This prevents `spec.v1.yml` from drifting out of sync with the actual API.
6+
"""
7+
from __future__ import annotations
8+
9+
import re
10+
import sys
11+
from pathlib import Path
12+
13+
import yaml
14+
15+
REPO_ROOT = Path(__file__).resolve().parent.parent
16+
MAIN_PY = REPO_ROOT / "main.py"
17+
SPEC_YML = REPO_ROOT / "spec.v1.yml"
18+
19+
ROUTE_RE = re.compile(r"""@app\.route\(\s*["']([^"']+)["']""")
20+
FLASK_PARAM_RE = re.compile(r"<(?:[^:>]+:)?[^>]+>")
21+
SPEC_PARAM_RE = re.compile(r"\{[^}]+\}")
22+
23+
API_PREFIX = "/v1"
24+
PARAM_PLACEHOLDER = "{}"
25+
26+
27+
def _normalize(route: str, param_re: re.Pattern[str]) -> str:
28+
"""Strip the API prefix and replace path params with a common placeholder.
29+
30+
Route params are compared positionally so that Flask's `<string:name>` and
31+
the spec's `{collectionName}` are treated as equal.
32+
"""
33+
if route.startswith(API_PREFIX):
34+
route = route[len(API_PREFIX):] or "/"
35+
return param_re.sub(PARAM_PLACEHOLDER, route)
36+
37+
38+
def flask_routes(path: Path) -> set[str]:
39+
return {
40+
_normalize(r, FLASK_PARAM_RE)
41+
for r in ROUTE_RE.findall(path.read_text())
42+
}
43+
44+
45+
def spec_paths(path: Path) -> set[str]:
46+
spec = yaml.safe_load(path.read_text())
47+
return {
48+
_normalize(p, SPEC_PARAM_RE)
49+
for p in spec.get("paths", {}).keys()
50+
}
51+
52+
53+
def main() -> int:
54+
flask = flask_routes(MAIN_PY)
55+
spec = spec_paths(SPEC_YML)
56+
57+
missing_in_spec = flask - spec
58+
missing_in_code = spec - flask
59+
60+
if not missing_in_spec and not missing_in_code:
61+
print(f"OK: {len(flask)} routes match between main.py and spec.v1.yml.")
62+
return 0
63+
64+
if missing_in_spec:
65+
print("ERROR: Flask routes missing from spec.v1.yml:", file=sys.stderr)
66+
for r in sorted(missing_in_spec):
67+
print(f" - {r}", file=sys.stderr)
68+
69+
if missing_in_code:
70+
print("ERROR: Spec paths with no matching Flask route:", file=sys.stderr)
71+
for r in sorted(missing_in_code):
72+
print(f" - {r}", file=sys.stderr)
73+
74+
print(
75+
"\nUpdate spec.v1.yml (or the route) so they match. "
76+
"See https://sunnah.stoplight.io/docs/api/ for the published docs.",
77+
file=sys.stderr,
78+
)
79+
return 1
80+
81+
82+
if __name__ == "__main__":
83+
sys.exit(main())

0 commit comments

Comments
 (0)