feat: register contract-driven API routes

This commit is contained in:
Sergey Antropoff
2026-07-12 23:38:14 +03:00
parent 637d6dc3f2
commit d0ef2eca30
5 changed files with 229 additions and 3 deletions
+115
View File
@@ -0,0 +1,115 @@
"""Contract-driven dynamic route and semantic handler registry."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Literal
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.contracts.model import Method, Schema, Snapshot
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
FallbackMode = Literal["error", "schema-default", "fixture"]
class RouteCollisionError(ValueError):
pass
@dataclass(slots=True)
class HandlerRegistry:
_handlers: dict[tuple[str, str], Handler] = field(default_factory=dict)
def register(self, path: str, verb: str, handler: Handler) -> None:
key = (path, verb.upper())
if key in self._handlers:
raise RouteCollisionError(f"duplicate semantic handler: {verb} {path}")
self._handlers[key] = handler
def get(self, path: str, verb: str) -> Handler | None:
return self._handlers.get((path, verb.upper()))
def register_contract_routes(
app: FastAPI,
snapshot: Snapshot,
handlers: HandlerRegistry,
fallback: FallbackMode = "error",
) -> None:
seen: set[tuple[str, str, str]] = set()
for contract_path in snapshot.paths:
for contract_method in contract_path.methods:
for renderer in ("json", "extjs"):
route = f"/api2/{renderer}{contract_path.path}"
key = (route, contract_method.verb, renderer)
if key in seen:
raise RouteCollisionError(
f"duplicate contract route: {contract_method.verb} {route}"
)
seen.add(key)
endpoint = _endpoint(
contract_path.path,
contract_method,
renderer,
handlers,
fallback,
)
app.add_api_route(
route,
endpoint,
methods=[contract_method.verb],
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
openapi_extra={"x-proxmox-method-checksum": contract_method.checksum},
)
def _endpoint(
semantic_path: str,
method: Method,
renderer: str,
handlers: HandlerRegistry,
fallback: FallbackMode,
) -> Callable[[Request], Awaitable[JSONResponse]]:
async def dispatch(request: Request) -> JSONResponse:
handler = handlers.get(semantic_path, method.verb)
inputs = {
"path": dict(request.path_params),
"query": dict(request.query_params),
}
if handler is not None:
data = await handler(request, inputs)
elif fallback == "schema-default":
data = _schema_default(method.returns)
elif fallback == "fixture" and "fixture" in method.extra:
data = method.extra["fixture"]
else:
return JSONResponse(
status_code=501,
content={"data": None, "errors": "method semantics are not implemented"},
)
if renderer == "extjs":
return JSONResponse({"data": data, "success": True})
return JSONResponse({"data": data})
return dispatch
def _schema_default(schema: Schema) -> Any:
if schema.default is not None:
return schema.default
if schema.type == "array":
return []
if schema.type == "object":
return {
name: _schema_default(definition)
for name, definition in schema.properties.items()
if not definition.optional
}
if schema.type == "boolean":
return False
if schema.type in {"integer", "number"}:
return 0
return None
+4
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -30,6 +32,8 @@ class Settings(BaseSettings):
db_command_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
log_level: str = "INFO"
request_id_header: str = "X-Request-ID"
contract_snapshot: Path | None = None
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
@lru_cache(maxsize=1)
+11
View File
@@ -6,7 +6,9 @@ from fastapi import FastAPI
from app.api.errors import unhandled_exception_handler
from app.api.middleware import RequestContextMiddleware
from app.api.registry import HandlerRegistry, register_contract_routes
from app.config import Settings, get_settings
from app.contracts.model import Snapshot
from app.lifespan import DatabaseFactory, create_lifespan, default_database_factory
from app.logging import configure_logging
from app.observability.health import router as health_router
@@ -15,6 +17,7 @@ from app.observability.health import router as health_router
def create_app(
settings: Settings | None = None,
database_factory: DatabaseFactory = default_database_factory,
handlers: HandlerRegistry | None = None,
) -> FastAPI:
"""Create an isolated application instance with explicit resource factories."""
@@ -28,6 +31,14 @@ def create_app(
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.include_router(health_router)
if resolved.contract_snapshot is not None:
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
register_contract_routes(
app,
snapshot,
handlers or HandlerRegistry(),
resolved.contract_fallback,
)
return app