Skip to content

Commit 53e43aa

Browse files
committed
Add custom HTTP API route ambiguity check
1 parent f66d869 commit 53e43aa

4 files changed

Lines changed: 124 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jobs:
1515
- uses: arduino/setup-protoc@v2
1616
- name: 'Setup jq'
1717
uses: dcarbone/install-jq-action@v2
18+
- uses: astral-sh/setup-uv@v7
1819
- run: make ci-build
1920
- name: Fail if the repo is dirty
2021
run: |

.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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
# Known ambiguities that have not yet been resolved.
25+
KNOWN_CONFLICTS = {
26+
("post", "/namespaces/{namespace}/workflows/{workflowId}"),
27+
("post", "/api/v1/namespaces/{namespace}/workflows/{workflowId}"),
28+
}
29+
30+
31+
def test_no_ambiguous_routes() -> None:
32+
g = find_conflicts(load_routes())
33+
unknown = nx.Graph(
34+
(u, v)
35+
for u, v in g.edges()
36+
if u[:2] not in KNOWN_CONFLICTS and v[:2] not in KNOWN_CONFLICTS
37+
)
38+
if unknown.number_of_edges():
39+
pytest.fail(
40+
f"Found {len(list(nx.connected_components(unknown)))} conflict group(s):\n\n"
41+
+ format_conflicts(unknown)
42+
)
43+
44+
45+
# --- helpers ---
46+
47+
48+
def load_routes() -> defaultdict[str, list[tuple[str, str]]]:
49+
spec = json.loads(SPEC_PATH.read_text())
50+
routes: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
51+
for path, methods in spec["paths"].items():
52+
segments = path.strip("/").split("/")
53+
validate_segments(path, segments)
54+
for method in methods:
55+
if method not in HTTP_METHODS:
56+
continue
57+
op_id = methods[method].get("operationId", "?")
58+
routes[method].append((path, op_id))
59+
return routes
60+
61+
62+
def find_conflicts(routes: defaultdict[str, list[tuple[str, str]]]) -> nx.Graph:
63+
g: nx.Graph = nx.Graph()
64+
for method, entries in sorted(routes.items()):
65+
for (path_a, op_a), (path_b, op_b) in combinations(entries, 2):
66+
if ambiguous(path_a, path_b):
67+
g.add_edge((method, path_a, op_a), (method, path_b, op_b))
68+
return g
69+
70+
71+
def format_conflicts(g: nx.Graph) -> str:
72+
groups: list[str] = []
73+
for comp in nx.connected_components(g):
74+
hub = max(comp, key=lambda n: g.degree(n)) # type: ignore[type-var]
75+
others = sorted(comp - {hub})
76+
method, path, op = hub
77+
lines = f"{method.upper()} {path} ({op}) ambiguous with:"
78+
for _, p, o in others:
79+
lines += f"\n {p} ({o})"
80+
groups.append(lines)
81+
return "\n\n".join(sorted(groups))
82+
83+
84+
def ambiguous(a: str, b: str) -> bool:
85+
sa = a.strip("/").split("/")
86+
sb = b.strip("/").split("/")
87+
if len(sa) != len(sb):
88+
return False
89+
return all(x == y or is_variable(x) or is_variable(y) for x, y in zip(sa, sb))
90+
91+
92+
def is_variable(segment: str) -> bool:
93+
return segment.startswith("{") and segment.endswith("}")
94+
95+
96+
def validate_segments(path: str, segments: list[str]) -> None:
97+
"""Fail loudly if any segment uses multi-segment wildcards."""
98+
for seg in segments:
99+
if seg in ("*", "**"):
100+
raise ValueError(
101+
f"Unsupported wildcard segment '{seg}' in {path}; extend this check."
102+
)
103+
if seg.startswith("{") and "=" in seg:
104+
raise ValueError(
105+
f"Unsupported pattern variable '{seg}' in {path}; extend this check."
106+
)
107+
108+
109+
if __name__ == "__main__":
110+
try:
111+
test_no_ambiguous_routes()
112+
except pytest.fail.Exception as e:
113+
print(f"FAILED: {e}", file=sys.stderr)
114+
sys.exit(1)
115+
print("PASSED: test_no_ambiguous_routes")

0 commit comments

Comments
 (0)