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.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user