#!/usr/bin/env python3 """Compare the Rust website/API with the FastAPI app, route by route. Phase 3 of docs/RUST_REWRITE.md: before the Rust service takes over port 8000, both answer the same requests against the same database and every response is compared: status, content type, Cache-Control and the JSON body (floats exactly; lists of database rows without ORDER BY as sets). Static files and the dashboard byte for byte. # the FastAPI app on :8000, `ping-collector serve --listen 127.0.0.1:8001` uv run python scripts/api_parity.py \\ --python http://127.0.0.1:8000 --rust http://127.0.0.1:8001 Query parameters vary per run (limits, hours) so the FastAPI app's response caches (up to 15 min) answer with fresh data, not an older copy. Data the collector writes during the run can still make a route differ once: a mismatch is retried after a pause before it counts. Intended differences (the Rust side may answer differently): /health a lagging RID report with current ThaiWater telemetry for the same gauges is healthy (as the notifier, phase 2) /scraping/status extra fields (collector version, forecast run) /metrics process counters: compared by shape only /sensors/thaiwater live upstream: compared, a difference only warns /api/hii/rainfall/catchment correlation within 1e-9 (numpy uses BLAS) /api/stats newer counts on the Rust side are accepted (the FastAPI app caches this one for 5 minutes) *.geojson content type application/geo+json (FastAPI: octet-stream) Exit status 0 when every hard check passed. """ import argparse import random import sys import time from typing import Any, Callable, List, Optional, Tuple import requests SOFT = "soft" # a difference warns, does not fail def get(base: str, path: str, method: str = "GET", **kw) -> requests.Response: last = None for _ in range(3): try: return requests.request(method, base + path, timeout=120, **kw) except requests.RequestException as e: # connection reset etc. last = e time.sleep(1) raise last def first_diff(a: Any, b: Any, path: str = "$", rel: float = 0.0) -> Optional[str]: """Where two decoded JSON values differ (None if equal).""" if isinstance(a, bool) or isinstance(b, bool): return None if a is b else f"{path}: {a!r} != {b!r}" if isinstance(a, (int, float)) and isinstance(b, (int, float)): if a == b: return None if rel and abs(a - b) <= rel * max(abs(a), abs(b)): return None return f"{path}: {a!r} != {b!r}" if type(a) is not type(b): return f"{path}: {type(a).__name__} {a!r} != {type(b).__name__} {b!r}" if isinstance(a, dict): if list(a.keys()) != list(b.keys()): if set(a.keys()) != set(b.keys()): return f"{path}: keys {sorted(set(a) ^ set(b))} only on one side" for k in a: d = first_diff(a[k], b[k], f"{path}.{k}", rel) if d: return d return None if isinstance(a, list): if len(a) != len(b): return f"{path}: {len(a)} items != {len(b)} items" for i, (x, y) in enumerate(zip(a, b)): d = first_diff(x, y, f"{path}[{i}]", rel) if d: return d return None return None if a == b else f"{path}: {a!r} != {b!r}" def media_type(r: requests.Response) -> str: return r.headers.get("content-type", "").split(";")[0].strip() class Case: def __init__( self, path: str, method: str = "GET", compare: Optional[Callable] = None, sort_key: Optional[str] = None, soft: bool = False, body: Any = None, headers: Optional[dict] = None, ): self.path, self.method, self.compare = path, method, compare self.sort_key, self.soft, self.body = sort_key, soft, body self.headers = headers or {} def run(self, py: str, rs: str) -> Tuple[Optional[str], Optional[str]]: """(hard difference, warning)""" kw = {"headers": self.headers} if self.body is not None: kw["json"] = self.body a = get(py, self.path, self.method, **kw) b = get(rs, self.path, self.method, **kw) if a.status_code != b.status_code: return self._fail( f"status {a.status_code} != {b.status_code} " f"({a.text[:120]!r} / {b.text[:120]!r})" ) if media_type(a) != media_type(b): warn = f"content-type {media_type(a)!r} != {media_type(b)!r}" if media_type(a).startswith("application/json") or media_type(b).startswith( "application/json" ): return self._fail(warn) return None, warn if self.method == "GET" and a.status_code == 200: ca, cb = a.headers.get("cache-control"), b.headers.get("cache-control") if ca != cb: return self._fail(f"Cache-Control {ca!r} != {cb!r}") if self.compare is not None: d = self.compare(a, b) elif media_type(a) == "application/json": ja, jb = a.json(), b.json() if a.status_code == 422: # pydantic's messages are approximated; type and location must agree ja = [(e["type"], e["loc"]) for e in ja["detail"]] jb = [(e["type"], e["loc"]) for e in jb["detail"]] if any(t.startswith("date") for t, _ in ja): ja, jb = [loc for _, loc in ja], [loc for _, loc in jb] elif self.sort_key and isinstance(ja, list): ja = sorted(ja, key=lambda r: str(r.get(self.sort_key))) jb = sorted(jb, key=lambda r: str(r.get(self.sort_key))) d = first_diff(ja, jb) else: d = ( None if a.content == b.content else f"bodies differ ({len(a.content)} / {len(b.content)} bytes)" ) return self._fail(d) if d else (None, None) def _fail(self, d: str) -> Tuple[Optional[str], Optional[str]]: return (None, d) if self.soft else (d, None) def compare_health(a, b) -> Optional[str]: ja, jb = a.json(), b.json() if set(ja["checks"]) != set(jb["checks"]): return f"checks {sorted(ja['checks'])} != {sorted(jb['checks'])}" for name, ca in ja["checks"].items(): cb = jb["checks"][name] if ca["status"] == cb["status"]: continue # the intended change: RID lagging, ThaiWater current if ( name == "database" and ca["status"] == "degraded" and cb["status"] == "healthy" and "ThaiWater telemetry current" in cb["message"] ): continue if name in ("api", "memory"): continue # probes of the RID API / own process, compared loosely return f"{name}: {ca} != {cb}" return None def compare_metrics(a, b) -> Optional[str]: ja, jb = a.json(), b.json() return None if set(ja) == set(jb) else f"keys {sorted(ja)} != {sorted(jb)}" def compare_status(a, b) -> Optional[str]: ja, jb = a.json(), b.json() for k, v in ja.items(): if jb.get(k) != v: return f"{k}: {v!r} != {jb.get(k)!r}" return None def compare_skill(a, b) -> Optional[str]: ja, jb = a.json(), b.json() va, vb = ja.pop("verified_until"), jb.pop("verified_until") gap = abs( time.mktime(time.strptime(va[:19], "%Y-%m-%dT%H:%M:%S")) - time.mktime(time.strptime(vb[:19], "%Y-%m-%dT%H:%M:%S")) ) if gap > 300: return f"verified_until {va} != {vb}" return first_diff(ja, jb) def compare_stats(a, b) -> Optional[str]: """Exact, except that the FastAPI app may answer from its 5-minute cache while the collector has written newer rows since.""" ja, jb = a.json(), b.json() if ja == jb: return None for k, v in ja.items(): w = jb.get(k) if k.endswith("_measurements") or k == "days_spanned": if not isinstance(w, int) or w < v: return f"{k}: {v!r} > {w!r}" elif k == "last_timestamp": if not isinstance(w, str) or w < v: return f"{k}: {v!r} > {w!r}" elif k == "coverage_percent": if not isinstance(w, (int, float)) or abs(w - v) > 0.2: return f"{k}: {v!r} != {w!r}" elif v != w: return f"{k}: {v!r} != {w!r}" return None def compare_catchment(a, b) -> Optional[str]: return first_diff(a.json(), b.json(), rel=1e-9) def compare_openapi(a, b) -> Optional[str]: ja, jb = a.json(), b.json() if ja["info"]["title"] != jb["info"]["title"]: return "title differs" ops = lambda spec: { # noqa: E731 (m, p): op for p, item in spec["paths"].items() for m, op in item.items() } oa, ob = ops(ja), ops(jb) if set(oa) != set(ob): return f"operations only on one side: {sorted(set(oa) ^ set(ob))}" for k, op in oa.items(): other = ob[k] for field in ("operationId", "summary", "parameters", "requestBody"): d = first_diff(op.get(field), other.get(field), f"{k}.{field}") if d: return d if set(op["responses"]) != set(other["responses"]): return f"{k}: responses {sorted(op['responses'])} != {sorted(other['responses'])}" for name in ja["components"]["schemas"]: if name not in jb["components"]["schemas"]: return f"schema {name} missing" return None def compare_html_docs(a, b) -> Optional[str]: return ( None if "swagger-ui" in b.text and "/openapi.json" in b.text else "no Swagger UI" ) def cases(admin_key: Optional[str]) -> List[Case]: r = random.Random(time.time()) hours = r.randint(150, 190) # fresh keys for the Python app's caches limit = r.randint(40000, 49999) feed_hours = r.randint(20, 30) skill_station = r.choice( ["P.67", "P.103", "P.20", "P.75", "P.92", "P.21", "P.5", "P.82"] ) day = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400 * 3)) day2 = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400)) out = [ Case("/"), Case("/robots.txt"), Case("/llms.txt"), Case("/sitemap.xml"), Case("/static/ping-river-network.geojson", soft=True), Case("/static/custom-markers.json"), Case("/static/flood-zones-p1.geojson", soft=True), Case("/static/flood-2024-all.json"), Case("/static/nope.json"), Case("/openapi.json", compare=compare_openapi), Case("/docs", compare=compare_html_docs), Case("/health", compare=compare_health), Case("/metrics", compare=compare_metrics), Case("/stations"), Case("/stations/8"), Case("/stations/1"), Case("/stations/999"), Case("/stations/abc"), Case(f"/measurements/latest?limit={r.randint(200, 499)}"), Case(f"/measurements/latest?limit={r.randint(1, 15)}"), Case(f"/measurements/history/P.1?hours={hours}&limit={limit}"), Case(f"/measurements/history/P.67?hours={hours + 1}"), Case(f"/measurements/history/P.1?start={day}&end={day2}&limit={limit - 1}"), Case(f"/measurements/history/P.1?start={day}&limit={r.randint(5, 50)}"), Case(f"/measurements/history/P.1?start={day2}&end={day}"), Case(f"/measurements/history/NOPE?hours={hours}"), Case("/measurements/history/P.1?hours=0"), Case("/measurements/history/P.1?limit=100001"), Case("/measurements/history/P.1?start=yesterday"), Case(f"/measurements/station/P.1?hours={r.randint(20, 48)}"), Case("/measurements/station/P.103?hours=6&limit=2"), Case(f"/api/hii/rainfall/latest?hours={feed_hours}", sort_key="station_id"), Case( f"/api/hii/waterlevel/latest?hours={feed_hours + 1}", sort_key="station_id" ), Case("/api/hii/waterlevel/latest?hours=500"), Case( f"/api/hii/rainfall/catchment?days={r.randint(15, 30)}", compare=compare_catchment, ), Case("/sensors/thaiwater", sort_key="id", soft=True), Case("/forecast"), Case(f"/api/forecast/history/P.1?hours={hours}&horizon=24"), Case(f"/api/forecast/history/P.103?hours={feed_hours}"), Case(f"/api/forecast/history/P.1?start={day}&end={day2}&horizon=6"), Case("/api/forecast/history/P.1?horizon=99"), Case("/api/notifications"), # a station/horizon pair the dashboard does not ask for: not in the # FastAPI app's 15-minute cache; the dashboard's own one may be Case( f"/api/forecast/skill?station_code={skill_station}" f"&horizon={r.randint(1, 48)}", compare=compare_skill, ), Case( "/api/forecast/skill?station_code=P.1&horizon=24", compare=compare_skill, soft=True, ), Case("/api/forecast/skill?horizon=0"), Case("/api/stats", compare=compare_stats), Case("/scraping/status", compare=compare_status), Case("/nope"), Case("/forecast", method="DELETE"), Case("/config"), Case("/scrape/trigger", method="POST"), Case("/stations", method="POST", body={"station_code": "X"}), Case("/stations/8", method="DELETE"), ] if admin_key: key = {"X-API-Key": admin_key} out += [ Case("/config", headers={"X-API-Key": "wrong"}), Case("/stations", method="POST", body={"thai_name": 1}, headers=key), Case("/stations/999", method="PUT", body={}, headers=key), ] return out def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--python", default="http://127.0.0.1:8000") ap.add_argument("--rust", default="http://127.0.0.1:8001") ap.add_argument( "--admin-key", help="ADMIN_API_KEY, to compare the admin errors too" ) ap.add_argument("--retries", type=int, default=2) ap.add_argument("--pause", type=float, default=30.0, help="seconds before a retry") args = ap.parse_args() failed, warned, passed = [], [], 0 for case in cases(args.admin_key): label = f"{case.method} {case.path}" for attempt in range(args.retries + 1): try: hard, warn = case.run(args.python, args.rust) except Exception as e: # noqa: BLE001 - report and go on hard, warn = f"{type(e).__name__}: {e}", None if not hard or attempt == args.retries: break print(f" retry {label}: {hard}", flush=True) time.sleep(args.pause) if hard: failed.append((label, hard)) print(f"FAIL {label}: {hard}", flush=True) elif warn: warned.append((label, warn)) print(f"warn {label}: {warn}", flush=True) else: passed += 1 print( f"{passed} identical, {len(warned)} warnings, {len(failed)} different " f"({args.python} vs {args.rust})" ) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())