79 lines
2.5 KiB
Python
79 lines
2.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Literal, cast
|
|
|
|
from backend.bootstrap.config import APP_DIR
|
|
|
|
|
|
AccessRole = Literal["public", "authenticated", "member", "admin"]
|
|
MatchType = Literal["exact", "regex"]
|
|
|
|
|
|
class RouteRegistryError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApiRoute:
|
|
method: str
|
|
path: str
|
|
match: MatchType
|
|
feature: str
|
|
access: AccessRole
|
|
|
|
def matches(self, method: str, path: str) -> bool:
|
|
if self.method != method.upper():
|
|
return False
|
|
return self.path == path if self.match == "exact" else re.fullmatch(self.path, path) is not None
|
|
|
|
|
|
class ApiRouteRegistry:
|
|
def __init__(self, routes: tuple[ApiRoute, ...]) -> None:
|
|
self.routes = routes
|
|
self._exact: dict[tuple[str, str], ApiRoute] = {}
|
|
regex_routes: list[ApiRoute] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
for route in routes:
|
|
key = (route.method, route.path)
|
|
if key in seen:
|
|
raise RouteRegistryError(f"Duplicate API route: {route.method} {route.path}")
|
|
seen.add(key)
|
|
if route.match == "exact":
|
|
self._exact[key] = route
|
|
else:
|
|
try:
|
|
re.compile(route.path)
|
|
except re.error as exc:
|
|
raise RouteRegistryError(f"Invalid API route regex: {route.path}") from exc
|
|
regex_routes.append(route)
|
|
self._regex = tuple(regex_routes)
|
|
|
|
@classmethod
|
|
def load(cls, path: Path | None = None) -> "ApiRouteRegistry":
|
|
config_path = path or APP_DIR / "config" / "api.config.json"
|
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
routes = tuple(
|
|
ApiRoute(
|
|
method=str(item["method"]).upper(),
|
|
path=str(item["path"]),
|
|
match=cast(MatchType, str(item["match"])),
|
|
feature=str(item["feature"]),
|
|
access=cast(AccessRole, str(item["access"])),
|
|
)
|
|
for item in payload.get("routes") or []
|
|
)
|
|
if not routes:
|
|
raise RouteRegistryError("API route registry is empty")
|
|
return cls(routes)
|
|
|
|
def resolve(self, method: str, path: str) -> ApiRoute | None:
|
|
normalized = method.upper()
|
|
exact = self._exact.get((normalized, path))
|
|
if exact:
|
|
return exact
|
|
return next((route for route in self._regex if route.matches(normalized, path)), None)
|