231 lines
7.0 KiB
Python
231 lines
7.0 KiB
Python
"""Deterministic inventory of HTTP and model-dispatched mutation surfaces."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
from dataclasses import dataclass, replace
|
||
|
|
from enum import Enum
|
||
|
|
from typing import Callable, TypeVar
|
||
|
|
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from fastapi.routing import APIRoute
|
||
|
|
|
||
|
|
|
||
|
|
_SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||
|
|
_ROUTE_POLICY_ATTRIBUTE = "__plantai_route_policy__"
|
||
|
|
_Endpoint = TypeVar("_Endpoint", bound=Callable)
|
||
|
|
|
||
|
|
|
||
|
|
class MutationSurfaceError(ValueError):
|
||
|
|
"""Raised when a state-changing surface lacks explicit policy metadata."""
|
||
|
|
|
||
|
|
|
||
|
|
class RoutePolicy(str, Enum):
|
||
|
|
READ_ONLY = "read_only"
|
||
|
|
MUTATION = "mutation"
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class RouteSurfaceEntry:
|
||
|
|
path: str
|
||
|
|
methods: tuple[str, ...]
|
||
|
|
name: str
|
||
|
|
endpoint: str
|
||
|
|
required_policy: RoutePolicy
|
||
|
|
declared_policy: RoutePolicy | None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def covered(self) -> bool:
|
||
|
|
if self.declared_policy is None:
|
||
|
|
return self.required_policy is RoutePolicy.READ_ONLY
|
||
|
|
return self.declared_policy is self.required_policy
|
||
|
|
|
||
|
|
@property
|
||
|
|
def label(self) -> str:
|
||
|
|
return f"{','.join(self.methods)} {self.path}"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def identity(self) -> tuple[tuple[str, ...], str, str, str]:
|
||
|
|
return (self.methods, self.path, self.name, self.endpoint)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class RoutePolicyDeclaration:
|
||
|
|
path: str
|
||
|
|
methods: tuple[str, ...]
|
||
|
|
name: str
|
||
|
|
endpoint: str
|
||
|
|
policy: RoutePolicy
|
||
|
|
|
||
|
|
@property
|
||
|
|
def identity(self) -> tuple[tuple[str, ...], str, str, str]:
|
||
|
|
return (self.methods, self.path, self.name, self.endpoint)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class ToolSurfaceEntry:
|
||
|
|
name: str
|
||
|
|
permission: str
|
||
|
|
handler: str
|
||
|
|
|
||
|
|
|
||
|
|
def declare_route_policy(policy: RoutePolicy) -> Callable[[_Endpoint], _Endpoint]:
|
||
|
|
if not isinstance(policy, RoutePolicy):
|
||
|
|
raise TypeError("route policy must be a RoutePolicy")
|
||
|
|
|
||
|
|
def decorate(endpoint: _Endpoint) -> _Endpoint:
|
||
|
|
existing = getattr(endpoint, _ROUTE_POLICY_ATTRIBUTE, None)
|
||
|
|
if existing is not None:
|
||
|
|
raise MutationSurfaceError("route policy is already declared")
|
||
|
|
setattr(endpoint, _ROUTE_POLICY_ATTRIBUTE, policy)
|
||
|
|
return endpoint
|
||
|
|
|
||
|
|
return decorate
|
||
|
|
|
||
|
|
|
||
|
|
def inventory_fastapi_routes(app: FastAPI) -> tuple[RouteSurfaceEntry, ...]:
|
||
|
|
entries: list[tuple[int, RouteSurfaceEntry]] = []
|
||
|
|
for index, route in enumerate(app.routes):
|
||
|
|
if not isinstance(route, APIRoute):
|
||
|
|
continue
|
||
|
|
methods = tuple(sorted(route.methods))
|
||
|
|
required_policy = (
|
||
|
|
RoutePolicy.READ_ONLY
|
||
|
|
if set(methods) <= _SAFE_HTTP_METHODS
|
||
|
|
else RoutePolicy.MUTATION
|
||
|
|
)
|
||
|
|
raw_policy = getattr(route.endpoint, _ROUTE_POLICY_ATTRIBUTE, None)
|
||
|
|
if raw_policy is not None and not isinstance(raw_policy, RoutePolicy):
|
||
|
|
raise MutationSurfaceError(
|
||
|
|
f"{','.join(methods)} {route.path} has invalid route policy metadata"
|
||
|
|
)
|
||
|
|
endpoint_name = (
|
||
|
|
f"{route.endpoint.__module__}.{route.endpoint.__qualname__}"
|
||
|
|
)
|
||
|
|
entries.append(
|
||
|
|
(
|
||
|
|
index,
|
||
|
|
RouteSurfaceEntry(
|
||
|
|
path=route.path,
|
||
|
|
methods=methods,
|
||
|
|
name=route.name,
|
||
|
|
endpoint=endpoint_name,
|
||
|
|
required_policy=required_policy,
|
||
|
|
declared_policy=raw_policy,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
entries.sort(
|
||
|
|
key=lambda item: (
|
||
|
|
item[1].path,
|
||
|
|
item[1].methods,
|
||
|
|
item[1].endpoint,
|
||
|
|
item[1].name,
|
||
|
|
item[0],
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return tuple(entry for _index, entry in entries)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_route_policy_coverage(
|
||
|
|
entries: tuple[RouteSurfaceEntry, ...],
|
||
|
|
) -> None:
|
||
|
|
uncovered = tuple(item for item in entries if not item.covered)
|
||
|
|
if uncovered:
|
||
|
|
labels = ", ".join(item.label for item in uncovered)
|
||
|
|
raise MutationSurfaceError(
|
||
|
|
f"{len(uncovered)} route(s) lack matching route policy: {labels}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def apply_route_policy_manifest(
|
||
|
|
entries: tuple[RouteSurfaceEntry, ...],
|
||
|
|
declarations: tuple[RoutePolicyDeclaration, ...],
|
||
|
|
) -> tuple[RouteSurfaceEntry, ...]:
|
||
|
|
mutation_entries = tuple(
|
||
|
|
item for item in entries if item.required_policy is RoutePolicy.MUTATION
|
||
|
|
)
|
||
|
|
entry_identities = [item.identity for item in mutation_entries]
|
||
|
|
if len(entry_identities) != len(set(entry_identities)):
|
||
|
|
raise MutationSurfaceError("duplicate mutation route identity")
|
||
|
|
declaration_identities = [item.identity for item in declarations]
|
||
|
|
if len(declaration_identities) != len(set(declaration_identities)):
|
||
|
|
raise MutationSurfaceError("duplicate route policy declaration")
|
||
|
|
if any(item.policy is not RoutePolicy.MUTATION for item in declarations):
|
||
|
|
raise MutationSurfaceError("mutation route manifest may only declare MUTATION")
|
||
|
|
entry_keys = set(entry_identities)
|
||
|
|
declaration_keys = set(declaration_identities)
|
||
|
|
missing = entry_keys - declaration_keys
|
||
|
|
extra = declaration_keys - entry_keys
|
||
|
|
if missing or extra:
|
||
|
|
details: list[str] = []
|
||
|
|
if missing:
|
||
|
|
details.append(f"missing={len(missing)}")
|
||
|
|
if extra:
|
||
|
|
details.append(f"extra={len(extra)}")
|
||
|
|
raise MutationSurfaceError(
|
||
|
|
"route policy manifest disagrees with runtime surface: "
|
||
|
|
+ ", ".join(details)
|
||
|
|
)
|
||
|
|
declarations_by_identity = {
|
||
|
|
item.identity: item.policy for item in declarations
|
||
|
|
}
|
||
|
|
conflicting = tuple(
|
||
|
|
item
|
||
|
|
for item in mutation_entries
|
||
|
|
if item.declared_policy is not None
|
||
|
|
and item.declared_policy
|
||
|
|
is not declarations_by_identity[item.identity]
|
||
|
|
)
|
||
|
|
if conflicting:
|
||
|
|
labels = ", ".join(item.label for item in conflicting)
|
||
|
|
raise MutationSurfaceError(
|
||
|
|
f"conflicting endpoint and manifest route policy: {labels}"
|
||
|
|
)
|
||
|
|
return tuple(
|
||
|
|
replace(
|
||
|
|
item,
|
||
|
|
declared_policy=declarations_by_identity[item.identity],
|
||
|
|
)
|
||
|
|
if item.identity in declarations_by_identity
|
||
|
|
else item
|
||
|
|
for item in entries
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def route_inventory_digest(entries: tuple[RouteSurfaceEntry, ...]) -> str:
|
||
|
|
payload = [
|
||
|
|
{
|
||
|
|
"path": item.path,
|
||
|
|
"methods": item.methods,
|
||
|
|
"name": item.name,
|
||
|
|
"endpoint": item.endpoint,
|
||
|
|
"required_policy": item.required_policy.value,
|
||
|
|
"declared_policy": (
|
||
|
|
item.declared_policy.value if item.declared_policy else ""
|
||
|
|
),
|
||
|
|
}
|
||
|
|
for item in entries
|
||
|
|
]
|
||
|
|
canonical = json.dumps(
|
||
|
|
payload,
|
||
|
|
sort_keys=True,
|
||
|
|
separators=(",", ":"),
|
||
|
|
).encode("utf-8")
|
||
|
|
return hashlib.sha256(canonical).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
__all__ = [
|
||
|
|
"MutationSurfaceError",
|
||
|
|
"RoutePolicy",
|
||
|
|
"RoutePolicyDeclaration",
|
||
|
|
"RouteSurfaceEntry",
|
||
|
|
"ToolSurfaceEntry",
|
||
|
|
"apply_route_policy_manifest",
|
||
|
|
"declare_route_policy",
|
||
|
|
"inventory_fastapi_routes",
|
||
|
|
"route_inventory_digest",
|
||
|
|
"validate_route_policy_coverage",
|
||
|
|
]
|