Initial commit.
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Route registry, module-scoped wrapper, and dispatcher.
|
||||
|
||||
Internal infrastructure for managing HTTP route registration and dispatch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import DynamicResource
|
||||
|
||||
from ..api.routes import RouteHandler, RouteInfo, RouteMark
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
|
||||
from ..api.context import ModuleContext
|
||||
|
||||
logger = logging.getLogger("owlbot.web")
|
||||
|
||||
|
||||
class RouteRegistry:
|
||||
"""
|
||||
Holds all registered routes for a bot instance.
|
||||
|
||||
Routes are namespaced by module to prevent conflicts. Supports path
|
||||
patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via
|
||||
``DynamicResource`` for pattern compilation and matching.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty route registry."""
|
||||
# Ordered list of (DynamicResource, RouteInfo) for pattern matching.
|
||||
# DynamicResource handles both plain and parameterized paths uniformly.
|
||||
self._routes: list[tuple[DynamicResource, RouteInfo]] = []
|
||||
# Maps module_name -> list of full_paths (for cleanup).
|
||||
self._module_routes: dict[str, list[str]] = {}
|
||||
logger.debug("RouteRegistry initialized.")
|
||||
|
||||
def register(
|
||||
self,
|
||||
path: str,
|
||||
handler: RouteHandler,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
module_name: str,
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Register a route handler.
|
||||
|
||||
:param path: URL path relative to module namespace. Supports
|
||||
``{name}`` and ``{name:regex}`` patterns.
|
||||
:param handler: Async function to handle the route.
|
||||
:param methods: List of HTTP methods. Default: ["GET"].
|
||||
:param module_name: Name of the module registering this route.
|
||||
:return: RouteInfo for the registered route.
|
||||
:raises ValueError: If route conflicts with existing route.
|
||||
"""
|
||||
if methods is None:
|
||||
methods = ["GET"]
|
||||
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
full_path = f"/owlbot/{module_name}{path}"
|
||||
|
||||
if any(info.full_path == full_path for _, info in self._routes):
|
||||
raise ValueError(f"Route '{full_path}' is already registered")
|
||||
|
||||
info = RouteInfo(
|
||||
path=path,
|
||||
full_path=full_path,
|
||||
methods=frozenset(m.upper() for m in methods),
|
||||
handler=handler,
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
resource = DynamicResource(full_path)
|
||||
self._routes.append((resource, info))
|
||||
|
||||
if module_name not in self._module_routes:
|
||||
self._module_routes[module_name] = []
|
||||
self._module_routes[module_name].append(full_path)
|
||||
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
module_logger.debug(
|
||||
f"Registered route '{full_path}' [{', '.join(info.methods)}]."
|
||||
)
|
||||
return info
|
||||
|
||||
def unregister(self, full_path: str) -> bool:
|
||||
"""
|
||||
Unregister a route by its full path.
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: True if route was found and removed, False otherwise.
|
||||
"""
|
||||
for i, (_, info) in enumerate(self._routes):
|
||||
if info.full_path == full_path:
|
||||
self._routes.pop(i)
|
||||
|
||||
if info.module_name in self._module_routes:
|
||||
self._module_routes[info.module_name] = [
|
||||
p
|
||||
for p in self._module_routes[info.module_name]
|
||||
if p != full_path
|
||||
]
|
||||
|
||||
module_logger = logging.getLogger(
|
||||
f"owlbot.modules.{info.module_name}.routes"
|
||||
)
|
||||
module_logger.debug(f"Unregistered route '{full_path}'.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get(self, full_path: str) -> RouteInfo | None:
|
||||
"""
|
||||
Look up a route by its full path (exact match on registered pattern).
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: RouteInfo if found, None otherwise.
|
||||
"""
|
||||
for _, info in self._routes:
|
||||
if info.full_path == full_path:
|
||||
return info
|
||||
return None
|
||||
|
||||
def match(self, full_path: str) -> tuple[RouteInfo, dict[str, str]] | None:
|
||||
"""
|
||||
Match a request path against registered routes.
|
||||
|
||||
Scans routes in registration order (first match wins). Uses
|
||||
``DynamicResource._match()`` for both plain and parameterized paths.
|
||||
|
||||
:param full_path: The request path to match.
|
||||
:return: Tuple of (RouteInfo, match_info dict) if matched, None otherwise.
|
||||
"""
|
||||
for resource, info in self._routes:
|
||||
match_dict = resource._match(full_path) # no public API alternative
|
||||
if match_dict is not None:
|
||||
return info, match_dict
|
||||
return None
|
||||
|
||||
def get_all(self) -> dict[str, RouteInfo]:
|
||||
"""
|
||||
Get all registered routes.
|
||||
|
||||
:return: Dict mapping full paths to RouteInfo.
|
||||
"""
|
||||
return {info.full_path: info for _, info in self._routes}
|
||||
|
||||
def get_by_module(self, module_name: str) -> list[RouteInfo]:
|
||||
"""
|
||||
Get all routes registered by a specific module.
|
||||
|
||||
:param module_name: The module name.
|
||||
:return: List of RouteInfo for that module.
|
||||
"""
|
||||
paths = set(self._module_routes.get(module_name, []))
|
||||
return [info for _, info in self._routes if info.full_path in paths]
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all routes registered by a specific module.
|
||||
|
||||
:param module_name: The module whose routes should be removed.
|
||||
:return: Number of routes removed.
|
||||
"""
|
||||
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
paths = list(self._module_routes.get(module_name, []))
|
||||
|
||||
if not paths:
|
||||
return 0
|
||||
|
||||
module_logger.debug(f"Unregistering all routes ({len(paths)} total).")
|
||||
count = 0
|
||||
for path in paths:
|
||||
if self.unregister(path):
|
||||
count += 1
|
||||
|
||||
self._module_routes.pop(module_name, None)
|
||||
return count
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_route-decorated functions and register them.
|
||||
|
||||
Looks for functions with the ``_owlbot_route`` attribute set by
|
||||
the ``@on_route`` decorator and registers each one.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
for obj in vars(module).values():
|
||||
if callable(obj):
|
||||
route_info = getattr(obj, "_owlbot_route", None)
|
||||
if route_info is not None:
|
||||
mark = cast("RouteMark", route_info)
|
||||
self.register(
|
||||
path=mark["path"],
|
||||
handler=obj,
|
||||
methods=mark["methods"],
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
|
||||
class RouteDispatcher:
|
||||
"""
|
||||
Dispatches HTTP requests to registered module route handlers.
|
||||
|
||||
Looks up routes in the RouteRegistry, validates methods, creates
|
||||
RouteContext, and calls the handler with timeout and transaction management.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_module_context: Callable[[str], ModuleContext],
|
||||
handler_timeout: float,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the route dispatcher.
|
||||
|
||||
Creates and owns a :class:`RouteRegistry` internally.
|
||||
|
||||
:param get_module_context: Callable that looks up a
|
||||
ModuleContext by module name.
|
||||
:param handler_timeout: Timeout for route handlers in seconds.
|
||||
"""
|
||||
self._route_registry = RouteRegistry()
|
||||
self._get_module_context = get_module_context
|
||||
self._handler_timeout = handler_timeout
|
||||
|
||||
def register(
|
||||
self,
|
||||
path: str,
|
||||
handler: RouteHandler,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
module_name: str,
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Register a route handler.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param path: URL path relative to module namespace.
|
||||
:param handler: Async function to handle the route.
|
||||
:param methods: List of HTTP methods. Default: ["GET"].
|
||||
:param module_name: Name of the module registering this route.
|
||||
:return: RouteInfo for the registered route.
|
||||
:raises ValueError: If route conflicts with existing route.
|
||||
"""
|
||||
return self._route_registry.register(
|
||||
path=path,
|
||||
handler=handler,
|
||||
methods=methods,
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
def unregister(self, full_path: str) -> bool:
|
||||
"""
|
||||
Unregister a route by its full path.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: True if route was found and removed, False otherwise.
|
||||
"""
|
||||
return self._route_registry.unregister(full_path)
|
||||
|
||||
def get(self, full_path: str) -> RouteInfo | None:
|
||||
"""
|
||||
Look up a route by its full path.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param full_path: The full route path including namespace.
|
||||
:return: RouteInfo if found, None otherwise.
|
||||
"""
|
||||
return self._route_registry.get(full_path)
|
||||
|
||||
def get_by_module(self, module_name: str) -> list[RouteInfo]:
|
||||
"""
|
||||
Get all routes registered by a specific module.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param module_name: The module name.
|
||||
:return: List of RouteInfo for that module.
|
||||
"""
|
||||
return self._route_registry.get_by_module(module_name)
|
||||
|
||||
def register_from_module(self, module: ModuleType, module_name: str) -> None:
|
||||
"""
|
||||
Scan a Python module for @on_route-decorated functions and register them.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param module: The loaded Python module to scan.
|
||||
:param module_name: Name of the module (for ownership tracking).
|
||||
"""
|
||||
self._route_registry.register_from_module(module, module_name)
|
||||
|
||||
def unregister_by_module(self, module_name: str) -> int:
|
||||
"""
|
||||
Remove all routes registered by a specific module.
|
||||
|
||||
Delegates to the internal RouteRegistry.
|
||||
|
||||
:param module_name: The module whose routes should be removed.
|
||||
:return: Number of routes removed.
|
||||
"""
|
||||
return self._route_registry.unregister_by_module(module_name)
|
||||
|
||||
async def dispatch(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
Dispatch an HTTP request to the appropriate module route handler.
|
||||
|
||||
Extracts module_name and path from the URL, matches it against
|
||||
registered routes (supporting path patterns), validates the HTTP
|
||||
method, and calls the handler.
|
||||
|
||||
:param request: The aiohttp request object.
|
||||
:return: HTTP response.
|
||||
"""
|
||||
module_name = request.match_info["module_name"]
|
||||
path = request.match_info.get("path", "")
|
||||
|
||||
relative_path = f"/{path}" if path else "/"
|
||||
|
||||
full_path = f"/owlbot/{module_name}{relative_path}"
|
||||
|
||||
result = self._route_registry.match(full_path)
|
||||
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
if result is None:
|
||||
mod_logger.debug(f"No route registered for '{full_path}'.")
|
||||
return web.Response(status=404)
|
||||
|
||||
route_info, match_info = result
|
||||
|
||||
if request.method not in route_info.methods:
|
||||
allowed = ", ".join(sorted(route_info.methods))
|
||||
mod_logger.debug(
|
||||
f"Method {request.method} not allowed for '{full_path}' "
|
||||
f"(allowed: {allowed})"
|
||||
)
|
||||
return web.Response(status=405, headers={"Allow": allowed})
|
||||
|
||||
return await self._handle_module_route(request, route_info, match_info)
|
||||
|
||||
async def _handle_module_route(
|
||||
self,
|
||||
request: web.Request,
|
||||
route_info: RouteInfo,
|
||||
match_info: dict[str, str] | None = None,
|
||||
) -> web.Response:
|
||||
"""
|
||||
Handle an HTTP request to a module-registered route.
|
||||
|
||||
:param request: The aiohttp request object.
|
||||
:param route_info: Information about the registered route.
|
||||
:param match_info: Captured path parameters from pattern matching.
|
||||
:return: HTTP response.
|
||||
"""
|
||||
# Import here to avoid circular imports at module load time.
|
||||
from ..api.context import RouteContext
|
||||
|
||||
module_name = route_info.module_name
|
||||
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
|
||||
|
||||
module_ctx = self._get_module_context(module_name)
|
||||
|
||||
ctx = RouteContext(
|
||||
request=request,
|
||||
module=module_ctx,
|
||||
match_info=match_info if match_info is not None else {},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Calling route handler: {route_info.full_path} from module: {module_name}"
|
||||
)
|
||||
|
||||
# The checkout acquires a pooled connection for the
|
||||
# duration of this route invocation.
|
||||
async with module_ctx.storage._checkout():
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
result = await asyncio.wait_for(
|
||||
route_info.handler(ctx), timeout=self._handler_timeout
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
# Handler succeeded, commit any database changes.
|
||||
await module_ctx.storage._commit()
|
||||
|
||||
mod_logger.debug(
|
||||
f"Route handler '{route_info.full_path}' "
|
||||
f"completed in {elapsed:.1f}ms."
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return web.Response(status=204) # No Content.
|
||||
elif isinstance(result, web.Response):
|
||||
return result
|
||||
elif isinstance(result, dict):
|
||||
return web.json_response(result)
|
||||
else: # pragma: no branch — defensive against untyped handlers
|
||||
mod_logger.error( # type: ignore[unreachable]
|
||||
f"Route handler '{route_info.full_path}' returned "
|
||||
f"unsupported type: {type(result).__name__}"
|
||||
)
|
||||
return web.Response(status=500)
|
||||
|
||||
except TimeoutError:
|
||||
await module_ctx.storage._rollback()
|
||||
mod_logger.warning(
|
||||
f"Route handler '{route_info.full_path}' timed out "
|
||||
f"after {self._handler_timeout}s."
|
||||
)
|
||||
return web.Response(status=500)
|
||||
except Exception as e:
|
||||
await module_ctx.storage._rollback()
|
||||
mod_logger.exception(
|
||||
f"Route handler '{route_info.full_path}' raised exception: {e}"
|
||||
)
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
class ModuleRoutes:
|
||||
"""
|
||||
Module-scoped wrapper around RouteDispatcher.
|
||||
|
||||
This wrapper auto-supplies the module name for route operations,
|
||||
so modules don't need to know the internal routing namespace.
|
||||
Follows the same pattern as ModuleCommands.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, dispatcher: RouteDispatcher, module_name: str, public_base_url: str
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the module-scoped routes wrapper.
|
||||
|
||||
:param dispatcher: The RouteDispatcher that owns the route registry.
|
||||
:param module_name: The name of the module using this wrapper.
|
||||
:param public_base_url: The public base URL for building route URLs.
|
||||
"""
|
||||
self._dispatcher = dispatcher
|
||||
self._module_name = module_name
|
||||
self._public_base_url = public_base_url
|
||||
|
||||
@property
|
||||
def module_routes(self) -> list[RouteInfo]:
|
||||
"""Routes registered by this module."""
|
||||
return self._dispatcher.get_by_module(self._module_name)
|
||||
|
||||
def url_for(self, path: str) -> str:
|
||||
"""
|
||||
Build a public URL for a route registered by this module.
|
||||
|
||||
:param path: The route path (e.g., "/list").
|
||||
:return: Full public URL (e.g., "http://host/owlbot/quotes/list").
|
||||
"""
|
||||
return f"{self._public_base_url}{self._full_path(path)}"
|
||||
|
||||
def register(
|
||||
self,
|
||||
path: str,
|
||||
handler: RouteHandler,
|
||||
*,
|
||||
methods: list[str] | None = None,
|
||||
) -> RouteInfo:
|
||||
"""
|
||||
Register a route handler for this module.
|
||||
|
||||
The module name is automatically supplied.
|
||||
|
||||
:param path: URL path relative to module namespace (e.g., "/stats").
|
||||
:param handler: Async function to handle the route.
|
||||
:param methods: List of HTTP methods (e.g., ["GET"]). Default: ["GET"].
|
||||
:return: RouteInfo for the registered route.
|
||||
:raises ValueError: If route conflicts with existing route.
|
||||
"""
|
||||
return self._dispatcher.register(
|
||||
path=path,
|
||||
handler=handler,
|
||||
methods=methods,
|
||||
module_name=self._module_name,
|
||||
)
|
||||
|
||||
def unregister(self, path: str) -> bool:
|
||||
"""
|
||||
Unregister a route by its relative path.
|
||||
|
||||
:param path: Relative route path (e.g., "/stats").
|
||||
:return: True if route was found and removed, False otherwise.
|
||||
"""
|
||||
return self._dispatcher.unregister(self._full_path(path))
|
||||
|
||||
def get(self, path: str) -> RouteInfo | None:
|
||||
"""
|
||||
Look up a route by its relative path.
|
||||
|
||||
:param path: Relative route path (e.g., "/stats").
|
||||
:return: RouteInfo if found, None otherwise.
|
||||
"""
|
||||
return self._dispatcher.get(self._full_path(path))
|
||||
|
||||
def exists(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a route is registered at the given relative path.
|
||||
|
||||
:param path: Relative route path (e.g., "/stats").
|
||||
:return: True if the route exists, False otherwise.
|
||||
"""
|
||||
return self.get(path) is not None
|
||||
|
||||
def _full_path(self, path: str) -> str:
|
||||
"""
|
||||
Normalize a relative path into the full namespaced path.
|
||||
|
||||
:param path: Relative route path (e.g., "/list" or "list").
|
||||
:return: Full path (e.g., "/owlbot/quotes/list").
|
||||
"""
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"/owlbot/{self._module_name}{path}"
|
||||
Reference in New Issue
Block a user