Files
openstack-api-simulator/app/openstack/registry.py
T
inecs 6033967e6a Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm
packaging, API contract packs, and pytest/Pulumi coverage labs.
2026-07-18 04:26:48 +03:00

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