6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""Contract-driven OpenStack route registry (Proxmox-style per-path registration)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, Request, Response
|
|
from fastapi.routing import APIRoute, APIRouter
|
|
|
|
from app.api.openapi import service_openapi_tag
|
|
from app.openstack.opspec import OperationSpec, ServicePack
|
|
|
|
Handler = Callable[[Request], Awaitable[Response]]
|
|
|
|
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
|
_ROUTE_NAME_PREFIX = "os-contract:"
|
|
|
|
|
|
class RouteCollisionError(ValueError):
|
|
pass
|
|
|
|
|
|
def normalize_path_template(path: str) -> str:
|
|
"""Collapse `{param}` names so `/servers/{id}` matches `/servers/{server_id}`."""
|
|
|
|
return _PATH_PARAM.sub("{}", path if path.startswith("/") else f"/{path}")
|
|
|
|
|
|
def _param_names(path: str) -> list[str]:
|
|
"""Path param names without FastAPI converters (``{object_name:path}`` → ``object_name``)."""
|
|
|
|
return [name.split(":", 1)[0] for name in _PATH_PARAM.findall(path)]
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HandlerRegistry:
|
|
"""Semantic handlers keyed by (service, path, verb)."""
|
|
|
|
_handlers: dict[tuple[str, str, str], Handler] = field(default_factory=dict)
|
|
_normalized: dict[tuple[str, str, str], tuple[str, Handler]] = field(default_factory=dict)
|
|
|
|
def register(self, service: str, path: str, verb: str, handler: Handler) -> None:
|
|
key = (service, path, verb.upper())
|
|
if key in self._handlers:
|
|
raise RouteCollisionError(f"duplicate semantic handler: {verb} {service} {path}")
|
|
self._handlers[key] = handler
|
|
norm_key = (service, normalize_path_template(path), verb.upper())
|
|
# First registration wins for structural lookup (prefer exact contract names).
|
|
self._normalized.setdefault(norm_key, (path, handler))
|
|
|
|
def get(self, service: str, path: str, verb: str) -> Handler | None:
|
|
verb_u = verb.upper()
|
|
exact = self._handlers.get((service, path, verb_u))
|
|
if exact is not None:
|
|
return exact
|
|
hit = self._normalized.get((service, normalize_path_template(path), verb_u))
|
|
return hit[1] if hit else None
|
|
|
|
def get_specialized_path(self, service: str, path: str, verb: str) -> str | None:
|
|
"""Return the path template the handler was registered under (for param remap)."""
|
|
|
|
verb_u = verb.upper()
|
|
if (service, path, verb_u) in self._handlers:
|
|
return path
|
|
hit = self._normalized.get((service, normalize_path_template(path), verb_u))
|
|
return hit[0] if hit else None
|
|
|
|
def keys(self) -> frozenset[tuple[str, str, str]]:
|
|
return frozenset(self._handlers)
|
|
|
|
|
|
def _fastapi_path(path: str) -> str:
|
|
return path if path.startswith("/") else f"/{path}"
|
|
|
|
|
|
def _route_priority(op: OperationSpec) -> tuple[int, int, str]:
|
|
"""Static paths before templated ones so /detail is not captured by /{id}."""
|
|
|
|
path = op.path
|
|
braces = path.count("{")
|
|
detail_bias = 0 if path.rstrip("/").endswith("/detail") else 1
|
|
return (braces, detail_bias, path)
|
|
|
|
|
|
def _remap_path_params(request: Request, contract_path: str, specialized_path: str) -> None:
|
|
"""Align request.path_params names with the specialized route template."""
|
|
|
|
contract_names = _param_names(contract_path)
|
|
specialized_names = _param_names(specialized_path)
|
|
if not specialized_names:
|
|
return
|
|
current = dict(request.path_params)
|
|
if set(specialized_names) <= set(current):
|
|
return
|
|
values: list[str] = []
|
|
for name in contract_names:
|
|
if name in current:
|
|
values.append(str(current[name]))
|
|
if len(values) != len(specialized_names):
|
|
# Fall back to positional values already present.
|
|
values = [str(v) for v in current.values()]
|
|
if len(values) != len(specialized_names):
|
|
return
|
|
remapped = dict(zip(specialized_names, values, strict=True))
|
|
# Keep any non-path extras (unlikely) under original keys.
|
|
for key, value in current.items():
|
|
if key not in remapped and key not in contract_names:
|
|
remapped[key] = value
|
|
request.scope["path_params"] = remapped
|
|
|
|
|
|
def _bridge_route_handler(specialized_path: str, route_handler: Handler) -> Handler:
|
|
async def handler(request: Request) -> Response:
|
|
contract_path = getattr(request.state, "os_contract_path", specialized_path)
|
|
_remap_path_params(request, contract_path, specialized_path)
|
|
return await route_handler(request)
|
|
|
|
return handler
|
|
|
|
|
|
def register_specialized_handlers(
|
|
registry: HandlerRegistry,
|
|
service: str,
|
|
router: APIRouter,
|
|
) -> int:
|
|
"""Import FastAPI router endpoints into the semantic handler registry."""
|
|
|
|
count = 0
|
|
for route in router.routes:
|
|
if not isinstance(route, APIRoute):
|
|
continue
|
|
methods = route.methods or set()
|
|
route_handler = route.get_route_handler()
|
|
for method in methods:
|
|
if method in {"HEAD", "OPTIONS"}:
|
|
continue
|
|
path = route.path
|
|
key = (service, path, method.upper())
|
|
if key in registry._handlers:
|
|
continue
|
|
registry.register(
|
|
service,
|
|
path,
|
|
method,
|
|
_bridge_route_handler(path, route_handler),
|
|
)
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def clear_os_contract_routes(app: FastAPI) -> None:
|
|
"""Drop previously registered ``os-contract:`` routes for rebuild / hot-swap."""
|
|
|
|
app.router.routes = [
|
|
route
|
|
for route in app.router.routes
|
|
if not (
|
|
isinstance(getattr(route, "name", None), str)
|
|
and str(route.name).startswith(_ROUTE_NAME_PREFIX)
|
|
)
|
|
]
|
|
app.openapi_schema = None
|
|
|
|
|
|
def register_openstack_contract_routes(
|
|
app: FastAPI,
|
|
packs: dict[str, ServicePack],
|
|
handlers: HandlerRegistry,
|
|
*,
|
|
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
|
|
) -> int:
|
|
"""Register one FastAPI route per unique (service, method, path) from packs.
|
|
|
|
Endpoint looks up a semantic handler first; otherwise falls back to ``dispatch_fn``
|
|
(schema engine generic CRUD/action behaviour).
|
|
"""
|
|
|
|
registered = 0
|
|
for pack in packs.values():
|
|
seen: set[tuple[str, str]] = set()
|
|
for op in sorted(pack.operations, key=_route_priority):
|
|
key = (op.method, op.path)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
path = _fastapi_path(op.path)
|
|
full_path = f"/_os/{pack.name}{path}"
|
|
name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}"
|
|
endpoint = _make_contract_endpoint(pack, op, handlers, dispatch_fn)
|
|
|
|
app.add_api_route(
|
|
full_path,
|
|
endpoint,
|
|
methods=[op.method],
|
|
name=name,
|
|
include_in_schema=True,
|
|
tags=[service_openapi_tag(pack.name)],
|
|
)
|
|
registered += 1
|
|
return registered
|
|
|
|
|
|
def _make_contract_endpoint(
|
|
pack: ServicePack,
|
|
op: OperationSpec,
|
|
handlers: HandlerRegistry,
|
|
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
|
|
) -> Handler:
|
|
async def endpoint(request: Request) -> Response:
|
|
request.state.os_contract_path = op.path
|
|
request.state.os_contract_op = op
|
|
handler = handlers.get(pack.name, op.path, op.method)
|
|
if handler is not None:
|
|
return await handler(request)
|
|
return await dispatch_fn(request, pack, op)
|
|
|
|
return endpoint
|
|
|
|
|
|
def register_specialized_orphan_routes(
|
|
app: FastAPI,
|
|
packs: dict[str, ServicePack],
|
|
handlers: HandlerRegistry,
|
|
) -> int:
|
|
"""Register specialized handler paths that are not declared in the contract pack.
|
|
|
|
Keeps trailing-slash version roots, PUT collection aliases, etc. that exist on
|
|
stateful routers but are missing from generated ``api.json`` packs.
|
|
"""
|
|
|
|
declared: set[tuple[str, str, str]] = set()
|
|
declared_norm: set[tuple[str, str, str]] = set()
|
|
for pack in packs.values():
|
|
for op in pack.operations:
|
|
declared.add((pack.name, op.method.upper(), op.path))
|
|
declared_norm.add((pack.name, op.method.upper(), normalize_path_template(op.path)))
|
|
|
|
# Paths already mounted by the contract loop.
|
|
mounted: set[tuple[str, str, str]] = set()
|
|
for route in app.router.routes:
|
|
name = getattr(route, "name", None)
|
|
if not isinstance(name, str) or not name.startswith(_ROUTE_NAME_PREFIX):
|
|
continue
|
|
# os-contract:{service}:{METHOD}:{path}
|
|
rest = name[len(_ROUTE_NAME_PREFIX) :]
|
|
service, _, remainder = rest.partition(":")
|
|
method, _, path = remainder.partition(":")
|
|
mounted.add((service, method.upper(), path))
|
|
|
|
registered = 0
|
|
for service, path, verb in sorted(handlers.keys()):
|
|
verb_u = verb.upper()
|
|
if (service, verb_u, path) in declared or (service, verb_u, path) in mounted:
|
|
continue
|
|
if (service, verb_u, normalize_path_template(path)) in declared_norm:
|
|
continue
|
|
handler = handlers.get(service, path, verb_u)
|
|
if handler is None:
|
|
continue
|
|
full_path = f"/_os/{service}{_fastapi_path(path)}"
|
|
name = f"{_ROUTE_NAME_PREFIX}{service}:{verb_u}:{path}"
|
|
endpoint = _make_handler_only_endpoint(path, handler)
|
|
app.add_api_route(
|
|
full_path,
|
|
endpoint,
|
|
methods=[verb_u],
|
|
name=name,
|
|
include_in_schema=True,
|
|
tags=[service_openapi_tag(service)],
|
|
)
|
|
registered += 1
|
|
return registered
|
|
|
|
|
|
def _make_handler_only_endpoint(specialized_path: str, handler: Handler) -> Handler:
|
|
async def endpoint(request: Request) -> Response:
|
|
request.state.os_contract_path = specialized_path
|
|
return await handler(request)
|
|
|
|
return endpoint
|
|
|
|
|
|
def mount_contract_services(
|
|
app: FastAPI,
|
|
*,
|
|
packs: dict[str, ServicePack],
|
|
handlers: HandlerRegistry,
|
|
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
|
|
) -> int:
|
|
"""Clear previous contract routes and register from packs. Returns route count."""
|
|
|
|
# Preserve non-contract routes; insert contract routes before gen-* so static
|
|
# schema paths are not stolen by generic /{item_id}.
|
|
non_gen: list[Any] = []
|
|
gen: list[Any] = []
|
|
for route in app.router.routes:
|
|
name = getattr(route, "name", "") or ""
|
|
if isinstance(name, str) and name.startswith(_ROUTE_NAME_PREFIX):
|
|
continue
|
|
if isinstance(name, str) and name.startswith("schema-"):
|
|
# Legacy schema-* routes from older mounts — drop on rebuild.
|
|
continue
|
|
if isinstance(name, str) and name.startswith("gen-"):
|
|
gen.append(route)
|
|
else:
|
|
non_gen.append(route)
|
|
app.router.routes = non_gen
|
|
app.openapi_schema = None
|
|
count = register_openstack_contract_routes(app, packs, handlers, dispatch_fn=dispatch_fn)
|
|
count += register_specialized_orphan_routes(app, packs, handlers)
|
|
app.router.routes.extend(gen)
|
|
return count
|