Skip to content

Commit ed140d5

Browse files
authored
test(contract): freeze Python API routes (#4)
1 parent 9619e74 commit ed140d5

3 files changed

Lines changed: 1915 additions & 12 deletions

File tree

‎tests/_surface.py‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Introspection over the public client surface.
22
3-
Every resource method declares its HTTP verb and path template on the first line
4-
of its docstring, and its inputs in its signature. These helpers read both so the
5-
suite can exercise all endpoints without checked-in snapshots.
3+
HTTP verbs, paths, required inputs, and response kinds come from a frozen fixture.
4+
Method signatures and docstrings are checked against that independent contract so
5+
an implementation and its documentation cannot drift together unnoticed.
66
"""
77

88
from __future__ import annotations
@@ -13,6 +13,7 @@
1313
from dataclasses import dataclass
1414
from functools import cached_property
1515
from importlib import import_module
16+
from pathlib import Path
1617
from typing import Any, Callable, Iterator
1718

1819
import httpx
@@ -25,6 +26,10 @@
2526
DECLARATION = re.compile(r"^(GET|POST|PUT|PATCH|DELETE) (/[^\s.]*)\.")
2627
CONTROL_PARAMS = ("extra_headers", "extra_query", "extra_body", "timeout")
2728
HANDWRITTEN_RESOURCE_METHODS = {"sessions.events.resumable_stream"}
29+
CONTRACT_FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "api-contracts.json").read_text())
30+
CONTRACT_ROWS = CONTRACT_FIXTURE["contracts"]
31+
CONTRACTS = {row["id"]: row for row in CONTRACT_ROWS}
32+
assert len(CONTRACTS) == len(CONTRACT_ROWS), "duplicate API contract fixture ID"
2833
CLIENTS = {
2934
("forward", False): Forward,
3035
("forward", True): AsyncForward,
@@ -129,28 +134,30 @@ def method_names(client: Any) -> set[str]:
129134

130135

131136
def _endpoint(mode: str, attribute: str, function: Any) -> Endpoint:
132-
summary = (function.__doc__ or "").strip()
133-
declaration = DECLARATION.match(summary)
134-
assert declaration, f"{mode}.{attribute} must document its route on the first docstring line: {summary!r}"
135-
verb, template = declaration.groups()
137+
endpoint_id = f"{mode}.{attribute}"
138+
contract = CONTRACTS.get(endpoint_id)
139+
assert contract, f"{endpoint_id} is missing from tests/fixtures/api-contracts.json"
136140
signature = inspect.signature(function)
137141
arguments = {
138142
name: _value(name, str(parameter.annotation))
139143
for name, parameter in signature.parameters.items()
140144
if name not in ("self", *CONTROL_PARAMS) and parameter.default is inspect.Parameter.empty
141145
}
142-
placeholders = tuple(re.findall(r"\{(\w+)\}", template))
146+
assert sorted(arguments) == contract["required"], f"{endpoint_id} required parameters drifted"
147+
placeholders = tuple(re.findall(r"\{(\w+)\}", contract["path"]))
143148
missing = [name for name in placeholders if name not in arguments]
144-
assert not missing, f"{mode}.{attribute} does not accept path parameters {missing}"
145-
return Endpoint(
149+
assert not missing, f"{endpoint_id} does not accept path parameters {missing}"
150+
endpoint = Endpoint(
146151
mode=mode,
147152
attribute=attribute,
148-
verb=verb,
149-
template=template,
153+
verb=contract["method"],
154+
template=contract["path"],
150155
returns=str(signature.return_annotation),
151156
arguments=arguments,
152157
path_params=placeholders,
153158
)
159+
assert endpoint.kind == contract["kind"], f"{endpoint_id} response kind drifted"
160+
return endpoint
154161

155162

156163
def endpoints() -> list[Endpoint]:
@@ -163,6 +170,11 @@ def endpoints() -> list[Endpoint]:
163170
if attribute not in HANDWRITTEN_RESOURCE_METHODS
164171
)
165172
client.close()
173+
actual_ids = {endpoint.id for endpoint in found}
174+
assert actual_ids == set(CONTRACTS), (
175+
f"API contract fixture drift: missing={sorted(actual_ids - set(CONTRACTS))}, "
176+
f"orphaned={sorted(set(CONTRACTS) - actual_ids)}"
177+
)
166178
return sorted(found, key=lambda endpoint: endpoint.id)
167179

168180

0 commit comments

Comments
 (0)