Skip to content

Commit 9efa82d

Browse files
committed
Add custom HTTP API route ambiguity check
1 parent f66d869 commit 9efa82d

File tree

3 files changed

+111
-3
lines changed

3 files changed

+111
-3
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
/.gen
33
/.vscode
44
/.stamp
5-
*~
5+
*~
6+
__pycache__/

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ SHELL=bash -o pipefail
22

33
$(VERBOSE).SILENT:
44
############################# Main targets #############################
5-
ci-build: install proto http-api-docs
5+
ci-build: install proto http-api-docs test
66

77
# Install dependencies.
88
install: grpc-install api-linter-install buf-install
@@ -73,6 +73,10 @@ http-api-docs:
7373
yq e -i '(.paths[] | .[] | .operationId) |= sub("\w+_(.*)", "$$1")' $(OAPI_OUT)/openapi.yaml
7474
mv -f $(OAPI_OUT)/openapi.yaml $(OAPI_OUT)/openapiv3.yaml
7575

76+
test:
77+
./test-http-api-ambiguity
78+
79+
7680
##### Plugins & tools #####
7781
grpc-install:
7882
@printf $(COLOR) "Install/update protoc and plugins..."
@@ -113,7 +117,7 @@ buf-lint: $(STAMPDIR)/buf-mod-prune
113117
(cd $(PROTO_ROOT) && buf lint)
114118

115119
buf-breaking:
116-
@printf $(COLOR) "Run buf breaking changes check against master branch..."
120+
@printf $(COLOR) "Run buf breaking changes check against master branch..."
117121
@(cd $(PROTO_ROOT) && buf breaking --against 'https://github.com/temporalio/api.git#branch=master')
118122

119123
##### Clean #####

test-http-api-ambiguity

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env -S uv run --script
2+
#
3+
# /// script
4+
# requires-python = ">=3.12"
5+
# dependencies = ["pytest", "networkx"]
6+
# ///
7+
#
8+
# Checks that every possible URL matches at most one HTTP API handler.
9+
# Parses openapi/openapiv2.json and reports any pair of routes (same HTTP
10+
# method) where a concrete URL could match both patterns.
11+
12+
import json
13+
import sys
14+
from collections import defaultdict
15+
from itertools import combinations
16+
from pathlib import Path
17+
18+
import networkx as nx
19+
import pytest
20+
21+
SPEC_PATH = Path(__file__).parent / "openapi" / "openapiv2.json"
22+
HTTP_METHODS = {"get", "put", "post", "delete", "patch"}
23+
24+
25+
def test_no_ambiguous_routes() -> None:
26+
g = find_conflicts(load_routes())
27+
if g.number_of_edges():
28+
msg = (
29+
f"Found {len(list(nx.connected_components(g)))} conflict group(s):\n\n"
30+
+ format_conflicts(g)
31+
)
32+
if __name__ == "__main__":
33+
print(msg, file=sys.stderr)
34+
sys.exit(1)
35+
pytest.fail(msg)
36+
37+
38+
# --- helpers ---
39+
40+
41+
def load_routes() -> defaultdict[str, list[tuple[str, str]]]:
42+
spec = json.loads(SPEC_PATH.read_text())
43+
routes: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
44+
for path, methods in spec["paths"].items():
45+
segments = path.strip("/").split("/")
46+
validate_segments(path, segments)
47+
for method in methods:
48+
if method not in HTTP_METHODS:
49+
continue
50+
op_id = methods[method].get("operationId", "?")
51+
routes[method].append((path, op_id))
52+
return routes
53+
54+
55+
def find_conflicts(routes: defaultdict[str, list[tuple[str, str]]]) -> nx.Graph:
56+
g: nx.Graph = nx.Graph()
57+
for method, entries in sorted(routes.items()):
58+
for (path_a, op_a), (path_b, op_b) in combinations(entries, 2):
59+
if ambiguous(path_a, path_b):
60+
g.add_edge((method, path_a, op_a), (method, path_b, op_b))
61+
return g
62+
63+
64+
def format_conflicts(g: nx.Graph) -> str:
65+
groups: list[str] = []
66+
for comp in nx.connected_components(g):
67+
hub = max(comp, key=lambda n: g.degree(n)) # type: ignore[type-var]
68+
others = sorted(comp - {hub})
69+
method, path, op = hub
70+
lines = f"{method.upper()} {path} ({op}) ambiguous with:"
71+
for _, p, o in others:
72+
lines += f"\n {p} ({o})"
73+
groups.append(lines)
74+
return "\n\n".join(sorted(groups))
75+
76+
77+
def ambiguous(a: str, b: str) -> bool:
78+
sa = a.strip("/").split("/")
79+
sb = b.strip("/").split("/")
80+
if len(sa) != len(sb):
81+
return False
82+
return all(x == y or is_variable(x) or is_variable(y) for x, y in zip(sa, sb))
83+
84+
85+
def is_variable(segment: str) -> bool:
86+
return segment.startswith("{") and segment.endswith("}")
87+
88+
89+
def validate_segments(path: str, segments: list[str]) -> None:
90+
"""Fail loudly if any segment uses multi-segment wildcards."""
91+
for seg in segments:
92+
if seg in ("*", "**"):
93+
raise ValueError(
94+
f"Unsupported wildcard segment '{seg}' in {path}; extend this check."
95+
)
96+
if seg.startswith("{") and "=" in seg:
97+
raise ValueError(
98+
f"Unsupported pattern variable '{seg}' in {path}; extend this check."
99+
)
100+
101+
102+
if __name__ == "__main__":
103+
test_no_ambiguous_routes()

0 commit comments

Comments
 (0)