from __future__ import annotations import argparse import hashlib import http.cookiejar import json import urllib.error import urllib.request from pathlib import Path from typing import Any def request_json( opener: urllib.request.OpenerDirector, url: str, payload: dict[str, Any] | None = None, method: str = "GET", ) -> tuple[int, Any]: data = None headers = {"Accept": "application/json"} if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with opener.open(request, timeout=90) as response: return response.status, json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: return exc.code, json.loads(exc.read().decode("utf-8")) def session(base_url: str, username: str, password: str) -> urllib.request.OpenerDirector: opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()) ) status, body = request_json( opener, f"{base_url.rstrip('/')}/api/auth/login", {"username": username, "password": password}, "POST", ) if status != 200 or not body.get("ok"): raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}") csrf_token = str(body.get("csrf_token") or "") if csrf_token: opener.addheaders.append(("X-CSRF-Token", csrf_token)) return opener def digest(value: Any) -> str: content = json.dumps( value, ensure_ascii=False, sort_keys=True, separators=(",", ":") ).encode("utf-8") return hashlib.sha256(content).hexdigest() def comparable( value: Any, excluded_paths: set[str] | None = None, sorted_lists: dict[str, str] | None = None, path: str = "$", ) -> Any: excluded_paths = excluded_paths or set() sorted_lists = sorted_lists or {} if isinstance(value, dict): return { key: comparable( item, excluded_paths, sorted_lists, f"{path}.{key}", ) for key, item in value.items() if key != "request_id" and f"{path}.{key}" not in excluded_paths } if isinstance(value, list): normalized = [ comparable(item, excluded_paths, sorted_lists, f"{path}[]") for item in value ] sort_key = sorted_lists.get(path) if sort_key: normalized.sort( key=lambda item: ( str(item.get(sort_key) or "") if isinstance(item, dict) else json.dumps(item, ensure_ascii=False, sort_keys=True, default=str) ) ) return normalized return value def first_difference(original: Any, migrated: Any, path: str = "$") -> dict[str, Any] | None: if type(original) is not type(migrated): return {"path": path, "original": original, "migrated": migrated} if isinstance(original, dict): for key in sorted(set(original) | set(migrated)): if key not in original or key not in migrated: return { "path": f"{path}.{key}", "original": original.get(key, ""), "migrated": migrated.get(key, ""), } difference = first_difference(original[key], migrated[key], f"{path}.{key}") if difference: return difference return None if isinstance(original, list): if len(original) != len(migrated): return {"path": f"{path}.length", "original": len(original), "migrated": len(migrated)} for index, (original_item, migrated_item) in enumerate(zip(original, migrated)): difference = first_difference( original_item, migrated_item, f"{path}[{index}]" ) if difference: return difference return None if original != migrated: return {"path": path, "original": original, "migrated": migrated} return None def main() -> None: parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs") parser.add_argument("--original", required=True) parser.add_argument("--migrated", required=True) parser.add_argument("--username", required=True) parser.add_argument("--password", required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--requests-file", type=Path) parser.add_argument("endpoints", nargs="*") args = parser.parse_args() original = session(args.original, args.username, args.password) migrated = session(args.migrated, args.username, args.password) rows = [] all_equal = True requests = [ {"name": endpoint, "method": "GET", "endpoint": endpoint, "payload": None} for endpoint in args.endpoints ] if args.requests_file: requests.extend(json.loads(args.requests_file.read_text(encoding="utf-8"))) if not requests: parser.error("provide at least one endpoint or --requests-file") for item in requests: endpoint = str(item["endpoint"]) method = str(item.get("method") or "GET").upper() payload = item.get("payload") excluded_paths = {str(path) for path in item.get("exclude_paths") or []} sorted_lists = { str(path): str(key) for path, key in (item.get("sort_lists") or {}).items() } original_status, original_body = request_json( original, f"{args.original.rstrip('/')}{endpoint}", payload, method ) migrated_status, migrated_body = request_json( migrated, f"{args.migrated.rstrip('/')}{endpoint}", payload, method ) original_comparable = comparable( original_body, excluded_paths, sorted_lists ) migrated_comparable = comparable( migrated_body, excluded_paths, sorted_lists ) equal = original_status == migrated_status and original_comparable == migrated_comparable all_equal = all_equal and equal rows.append( { "name": str(item.get("name") or endpoint), "method": method, "endpoint": endpoint, "original_status": original_status, "migrated_status": migrated_status, "original_sha256": digest(original_comparable), "migrated_sha256": digest(migrated_comparable), "equal": equal, "first_difference": ( None if equal else first_difference(original_comparable, migrated_comparable) ), } ) result = {"all_equal": all_equal, "endpoints": rows} args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text( json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) print(json.dumps(result, ensure_ascii=False, indent=2)) if not all_equal: raise SystemExit(1) if __name__ == "__main__": main()