-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_route_versioning.py
More file actions
105 lines (85 loc) · 4.35 KB
/
test_route_versioning.py
File metadata and controls
105 lines (85 loc) · 4.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
"""Audit every FastAPI route is `/api/v*/...` or in the documented allowlist.
Backstops invariant 7 in `docs/INVARIANTS.md` (was *aspirational on test*).
A future contributor adding `@app.get("/foo")` instead of registering the
endpoint on the versioned `APIRouter(prefix="/api/v1")` would silently
introduce an un-versioned route — breaking the API-versioning contract from
`docs/DEVELOPMENT.md` without any gate firing. This test is the gate.
"""
from __future__ import annotations
import re
from src.api.main import app
VERSIONED_PREFIX = re.compile(r"^/api/v\d+/")
# Routes deliberately NOT under `/api/v*/`. Add an entry here only with a
# comment naming the structural reason — never just "we needed an exception".
UNVERSIONED_ALLOWLIST: frozenset[str] = frozenset(
{
# FastAPI auto-generated documentation surface. Disabled by passing
# docs_url=None / redoc_url=None to FastAPI() if needed; the
# scaffold keeps them enabled at the unversioned root.
"/openapi.json",
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}
)
def test_every_route_is_versioned_or_allowlisted() -> None:
"""Every routable path is `/api/v*/` or in the explicit allowlist."""
offenders: list[str] = []
for route in app.routes:
path = getattr(route, "path", None)
if path is None:
# Not an APIRoute (e.g. Mount). Skip cleanly — `path` is the
# discriminator we care about.
continue
if path in UNVERSIONED_ALLOWLIST:
continue
if not VERSIONED_PREFIX.match(path):
offenders.append(path)
assert not offenders, (
f"un-versioned route(s) detected in app.routes: {offenders!r}. Per "
"docs/DEVELOPMENT.md API Versioning, every routable path must match "
"`/api/v*/<…>` or be added to UNVERSIONED_ALLOWLIST in this test "
"with a comment naming the structural reason. Common entry points "
'for an un-versioned path: a router wired without `prefix="/api/vN"`, '
"an `@app.<verb>` decorator on the FastAPI app instead of a versioned "
"APIRouter, a manual `app.add_api_route(...)` call, or a Mount that "
"exposes paths at the unversioned root. Fix at the wiring site or, "
"if the path is genuinely unversioned by design (cf. `/api/health`), "
"add it to the allowlist."
)
def test_allowlist_has_no_dead_entries() -> None:
"""Every UNVERSIONED_ALLOWLIST entry must correspond to a real route.
Without this assertion, removing a docs surface (e.g. constructing
FastAPI with `docs_url=None`) leaves `/docs` in the allowlist as cruft;
over time the allowlist becomes a graveyard that masks what is
*actually* deliberately unversioned. Any drop-out fails fast and forces
a deletion in the same PR.
"""
actual_paths = {getattr(r, "path", None) for r in app.routes}
actual_paths.discard(None)
dead = sorted(UNVERSIONED_ALLOWLIST - actual_paths)
assert not dead, (
f"UNVERSIONED_ALLOWLIST entries no longer present in app.routes: "
f"{dead}. Remove the dead entry — keeping it is allowlist cruft. "
"If the path was intentionally removed (e.g. FastAPI docs disabled "
"via `docs_url=None`), drop the corresponding allowlist line in "
"this same PR so the allowlist tracks reality."
)
# ---------- Regex sanity checks ----------
#
# Belt-and-braces against the allowlist swallowing a typo that the route
# walk wouldn't catch (e.g. someone mutating the regex to be too permissive).
def test_regex_accepts_v1_routes() -> None:
assert VERSIONED_PREFIX.match("/api/v1/chat")
assert VERSIONED_PREFIX.match("/api/v1/sessions")
assert VERSIONED_PREFIX.match("/api/v1/sessions/{session_id}")
def test_regex_accepts_future_major_versions() -> None:
"""`/api/v2/...` and beyond are accepted; the rule isn't v1-only."""
assert VERSIONED_PREFIX.match("/api/v2/chat")
assert VERSIONED_PREFIX.match("/api/v10/some-endpoint")
def test_regex_rejects_unversioned_paths() -> None:
assert not VERSIONED_PREFIX.match("/foo")
assert not VERSIONED_PREFIX.match("/api/foo")
assert not VERSIONED_PREFIX.match("/api/health") # in allowlist, not regex
assert not VERSIONED_PREFIX.match("/api/v/chat") # missing version digit
assert not VERSIONED_PREFIX.match("/api/version1/chat") # wrong prefix shape