"""The shape of the API, written down. An app is built against a contract, and a contract nobody checks is a wish. The web app in this repository is deployed with the server, so a route that quietly changes shape is caught by the next `docker compose build`; an app on somebody's phone is not, and finds out in the field. So the surface is a checked-in file. Adding a route or a field is a diff to review and accept; removing or renaming one is a diff that says, in the review, exactly which client is about to break. Regenerate with: python -m tests.test_api_contract --write There is no assertion here about internals — only what a caller can see: the address, the method, the parameters it takes and the codes it answers with. """ import json import pathlib import sys import unittest SNAPSHOT = pathlib.Path(__file__).with_name("api-contract.json") def surface() -> dict: """Every public address, reduced to what a client depends on.""" from app.main import app schema = app.openapi() routes: dict[str, dict] = {} for path, methods in sorted(schema.get("paths", {}).items()): for method, operation in sorted(methods.items()): if method.upper() not in ("GET", "POST", "PUT", "PATCH", "DELETE"): continue params = sorted( f"{p.get('in')}:{p.get('name')}{'' if p.get('required') else '?'}" for p in operation.get("parameters", []) ) routes[f"{method.upper()} {path}"] = { "params": params, "body": bool(operation.get("requestBody")), "responses": sorted(operation.get("responses", {})), } return {"version": schema.get("info", {}).get("version"), "routes": routes} class ApiContractTests(unittest.TestCase): maxDiff = None def test_the_published_surface_matches_the_snapshot(self): self.assertTrue( SNAPSHOT.exists(), "No contract snapshot. Write one with: python -m tests.test_api_contract --write", ) expected = json.loads(SNAPSHOT.read_text()) actual = surface() gone = sorted(set(expected["routes"]) - set(actual["routes"])) added = sorted(set(actual["routes"]) - set(expected["routes"])) self.assertEqual(gone, [], f"Routes removed from the API: {gone}. " "Every one of these is a client that stops working. " "If it is deliberate, regenerate the snapshot.") self.assertEqual(added, [], f"New routes: {added}. Regenerate the snapshot to accept them.") for name in sorted(expected["routes"]): self.assertEqual(actual["routes"][name], expected["routes"][name], f"{name} changed shape") def test_every_route_is_reachable_by_both_addresses(self): """`/api/...` is the old address and stays working, for ever. Not a second mount — a rewrite — so this checks the rewrite rather than a duplicate set of paths, which would be its own kind of wrong. """ from app.api.versioning import VERSIONED_ROOT, VersionAlias, is_versioned actual = surface()["routes"] api_paths = [name.split(" ", 1)[1] for name in actual if name.split(" ", 1)[1].startswith("/api/")] self.assertTrue(api_paths, "No API routes found at all") # Everything under /api is versioned. A path that is not would be # reachable at one address only, which is the drift this prevents. from app.api.versioning import PASSTHROUGH unversioned = [path for path in api_paths if not is_versioned(path) and path not in PASSTHROUGH] self.assertEqual(unversioned, [], f"Not under {VERSIONED_ROOT}: {unversioned}") rewritten = [] class Sink: async def __call__(self, scope, receive, send): rewritten.append(scope["path"]) import asyncio alias = VersionAlias(Sink()) for path, expected in [ ("/api/auth/me", "/api/v1/auth/me"), ("/api/v1/auth/me", "/api/v1/auth/me"), # already versioned, untouched ("/api/docs", "/api/docs"), # the docs are not a route ("/api/openapi.json", "/api/openapi.json"), ("/api/health", "/api/health"), # monitoring points here ("/uploads/questions/1.png", "/uploads/questions/1.png"), ]: rewritten.clear() asyncio.run(alias({"type": "http", "path": path}, None, None)) self.assertEqual(rewritten, [expected], path) if __name__ == "__main__": if "--write" in sys.argv: SNAPSHOT.write_text(json.dumps(surface(), indent=2, sort_keys=True) + "\n") print(f"Wrote {SNAPSHOT} — {len(surface()['routes'])} routes") else: unittest.main()