diff --git a/apiforgepy/__init__.py b/apiforgepy/__init__.py index 0249eba..f7e2a20 100644 --- a/apiforgepy/__init__.py +++ b/apiforgepy/__init__.py @@ -24,7 +24,7 @@ from .transport import LocalTransport from .cloud_transport import CloudTransport -__version__ = "2.1.0" +__version__ = "2.1.1" __all__ = ["ApiForgeMiddleware"] @@ -82,9 +82,11 @@ def __init__( if is_cloud: transport = CloudTransport(cloud_url, api_key, service) + config["store_routes"] = transport.write_routes else: self._db = ApiForgeDatabase(db_path) transport = LocalTransport(self._db) + config["store_routes"] = self._db.upsert_known_routes aggregator = Aggregator(transport, flush_interval) aggregator.start() diff --git a/apiforgepy/cloud_transport.py b/apiforgepy/cloud_transport.py index 210f753..b4d8455 100644 --- a/apiforgepy/cloud_transport.py +++ b/apiforgepy/cloud_transport.py @@ -20,6 +20,27 @@ def __init__(self, cloud_url: str, api_key: str, service: str): self._open_until = 0.0 self._lock = threading.Lock() + def write_routes(self, routes: list[dict]) -> None: + if not routes: + return + payload = json.dumps({ + "routes": [ + {"route": r["route"], "method": r["method"], "service": self._service} + for r in routes + ] + }).encode() + req = urllib.request.Request( + self._url + "/routes", + data=payload, + headers={"Content-Type": "application/json", "X-API-Key": self._api_key}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10): + pass + except (urllib.error.URLError, OSError) as exc: + print(f"[apiforgepy] Failed to sync route registry: {exc}") + def write(self, rows: list[dict]) -> None: if not rows: return diff --git a/apiforgepy/middleware.py b/apiforgepy/middleware.py index 52ba080..8a1738d 100644 --- a/apiforgepy/middleware.py +++ b/apiforgepy/middleware.py @@ -1,6 +1,7 @@ import re import time import os +import threading from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request @@ -15,19 +16,52 @@ def _normalize_path(path: str) -> str: return path +def _extract_routes(app) -> list[dict]: + """Walk a FastAPI/Starlette app's router and return all declared routes.""" + from starlette.routing import Route, Mount + routes = [] + + def walk(route_list, prefix: str = ""): + for r in route_list: + if isinstance(r, Route) and r.methods: + for method in r.methods: + # HEAD is auto-added by Starlette for every GET route — skip it + if method != "HEAD": + routes.append({"route": prefix + r.path, "method": method}) + elif isinstance(r, Mount) and hasattr(r, "routes"): + walk(r.routes, prefix + (r.path or "")) + + try: + walk(getattr(app, "routes", [])) + except Exception: + pass + + return routes + + class ApiForgeMiddleware(BaseHTTPMiddleware): def __init__(self, app, *, aggregator, config: dict): super().__init__(app) - self._aggregator = aggregator - self._env = config["env"] - self._release = config.get("release") - self._service = config["service"] - self._sampling = config["sampling"] - self._ignore = set(config["ignore_paths"]) + self._aggregator = aggregator + self._env = config["env"] + self._release = config.get("release") + self._service = config["service"] + self._sampling = config["sampling"] + self._ignore = set(config["ignore_paths"]) + self._store_routes = config.get("store_routes") + self._routes_scanned = False async def dispatch(self, request: Request, call_next): path = request.url.path + if not self._routes_scanned and self._store_routes: + self._routes_scanned = True + routes = _extract_routes(request.app) + if routes: + threading.Thread( + target=self._store_routes, args=(routes,), daemon=True + ).start() + if path in self._ignore: return await call_next(request) diff --git a/pyproject.toml b/pyproject.toml index aa4c7ce..8047f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "2.1.0" +version = "2.1.1" description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md"