Merge pull request #454 from willmiao/codex/migrate-recipe-http-layer-architecture

Refactor recipe routes to use registrar scaffolding
This commit is contained in:
pixelpaws
2025-09-22 12:42:37 +08:00
committed by GitHub
5 changed files with 504 additions and 130 deletions

View File

@@ -0,0 +1,50 @@
# Recipe route scaffolding
The recipe HTTP stack is being migrated to mirror the shared model routing
architecture. The first phase extracts the registrar/controller scaffolding so
future handler sets can plug into a stable surface area. The stack now mirrors
the same separation of concerns described in
`docs/architecture/model_routes.md`:
```mermaid
graph TD
subgraph HTTP
A[RecipeRouteRegistrar] -->|binds| B[BaseRecipeRoutes handler owner]
end
subgraph Application
B --> C[Recipe handler set]
C --> D[Async handlers]
D --> E[Services / scanners]
end
```
## Responsibilities
| Layer | Module(s) | Responsibility |
| --- | --- | --- |
| Registrar | `py/routes/recipe_route_registrar.py` | Declarative list of every recipe endpoint and helper that binds them to an `aiohttp` application. |
| Base controller | `py/routes/base_recipe_routes.py` | Lazily resolves shared services, registers the server-side i18n filter exactly once, pre-warms caches on startup, and exposes a `{handler_name: coroutine}` mapping used by the registrar. |
| Handler set (upcoming) | `py/routes/handlers/recipe_handlers.py` (planned) | Will group HTTP handlers by concern (page rendering, listings, mutations, queries, sharing) and surface them to `BaseRecipeRoutes.get_handler_owner()`. |
`RecipeRoutes` subclasses the base controller to keep compatibility with the
existing monolithic handlers. Once the handler set is extracted the subclass
will simply provide the concrete owner returned by `get_handler_owner()`.
## High-level test baseline
The new smoke suite in `tests/routes/test_recipe_route_scaffolding.py`
guarantees the registrar/controller contract remains intact:
* `BaseRecipeRoutes.attach_dependencies` resolves registry services only once
and protects the i18n filter from duplicate registration.
* Startup hooks are appended exactly once so cache pre-warming and dependency
resolution run during application boot.
* `BaseRecipeRoutes.to_route_mapping()` uses the handler owner as the source of
callables, enabling the upcoming handler set without touching the registrar.
* `RecipeRouteRegistrar` binds every declarative route to the aiohttp router.
* `RecipeRoutes.setup_routes` wires the registrar and startup hooks together so
future refactors can swap in the handler set without editing callers.
These guardrails mirror the expectations in the model route architecture and
provide confidence that future refactors can focus on handlers and use cases
without breaking HTTP wiring.

View File

@@ -0,0 +1,109 @@
"""Base infrastructure shared across recipe routes."""
from __future__ import annotations
import logging
from typing import Callable, Mapping
import jinja2
from aiohttp import web
from ..config import config
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings
from .recipe_route_registrar import ROUTE_DEFINITIONS
logger = logging.getLogger(__name__)
class BaseRecipeRoutes:
"""Common dependency and startup wiring for recipe routes."""
_HANDLER_NAMES: tuple[str, ...] = tuple(
definition.handler_name for definition in ROUTE_DEFINITIONS
)
def __init__(self) -> None:
self.recipe_scanner = None
self.lora_scanner = None
self.civitai_client = None
self.settings = settings
self.server_i18n = server_i18n
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True,
)
self._i18n_registered = False
self._startup_hooks_registered = False
self._handler_mapping: dict[str, Callable] | None = None
async def attach_dependencies(self, app: web.Application | None = None) -> None:
"""Resolve shared services from the registry."""
await self._ensure_services()
self._ensure_i18n_filter()
async def ensure_dependencies_ready(self) -> None:
"""Ensure dependencies are available for request handlers."""
if self.recipe_scanner is None or self.civitai_client is None:
await self.attach_dependencies()
def register_startup_hooks(self, app: web.Application) -> None:
"""Register startup hooks once for dependency wiring."""
if self._startup_hooks_registered:
return
app.on_startup.append(self.attach_dependencies)
app.on_startup.append(self.prewarm_cache)
self._startup_hooks_registered = True
async def prewarm_cache(self, app: web.Application | None = None) -> None:
"""Pre-load recipe and LoRA caches on startup."""
try:
await self.attach_dependencies(app)
if self.lora_scanner is not None:
await self.lora_scanner.get_cached_data()
hash_index = getattr(self.lora_scanner, "_hash_index", None)
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
_ = len(hash_index._hash_to_path)
if self.recipe_scanner is not None:
await self.recipe_scanner.get_cached_data(force_refresh=True)
except Exception as exc:
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
def to_route_mapping(self) -> Mapping[str, Callable]:
"""Return a mapping of handler name to coroutine for registrar binding."""
if self._handler_mapping is None:
owner = self.get_handler_owner()
self._handler_mapping = {
name: getattr(owner, name) for name in self._HANDLER_NAMES
}
return self._handler_mapping
# Internal helpers -------------------------------------------------
async def _ensure_services(self) -> None:
if self.recipe_scanner is None:
self.recipe_scanner = await ServiceRegistry.get_recipe_scanner()
self.lora_scanner = getattr(self.recipe_scanner, "_lora_scanner", None)
if self.civitai_client is None:
self.civitai_client = await ServiceRegistry.get_civitai_client()
def _ensure_i18n_filter(self) -> None:
if not self._i18n_registered:
self.template_env.filters["t"] = self.server_i18n.create_template_filter()
self._i18n_registered = True
def get_handler_owner(self):
"""Return the object supplying bound handler coroutines."""
return self

View File

@@ -0,0 +1,64 @@
"""Route registrar for recipe endpoints."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Mapping
from aiohttp import web
@dataclass(frozen=True)
class RouteDefinition:
"""Declarative definition for a recipe HTTP route."""
method: str
path: str
handler_name: str
ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/loras/recipes", "render_page"),
RouteDefinition("GET", "/api/lm/recipes", "list_recipes"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"),
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
)
class RecipeRouteRegistrar:
"""Bind declarative recipe definitions to an aiohttp router."""
_METHOD_MAP = {
"GET": "add_get",
"POST": "add_post",
"PUT": "add_put",
"DELETE": "add_delete",
}
def __init__(self, app: web.Application) -> None:
self._app = app
def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None:
for definition in ROUTE_DEFINITIONS:
handler = handler_lookup[definition.handler_name]
self._bind_route(definition.method, definition.path, handler)
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
add_method_name = self._METHOD_MAP[method.upper()]
add_method = getattr(self._app.router, add_method_name)
add_method(path, handler)

View File

@@ -1,7 +1,6 @@
import os
import time
import base64
import jinja2
import numpy as np
from PIL import Image
import io
@@ -12,20 +11,18 @@ import tempfile
import json
import asyncio
import sys
from .base_recipe_routes import BaseRecipeRoutes
from .recipe_route_registrar import RecipeRouteRegistrar
from ..utils.exif_utils import ExifUtils
from ..recipes import RecipeParserFactory
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..config import config
from ..services.downloader import get_downloader
# Check if running in standalone mode
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
from ..services.downloader import get_downloader
# Only import MetadataRegistry in non-standalone mode
if not standalone_mode:
# Import metadata_collector functions and classes conditionally
@@ -35,111 +32,30 @@ if not standalone_mode:
logger = logging.getLogger(__name__)
class RecipeRoutes:
"""API route handlers for Recipe management"""
def __init__(self):
# Initialize service references as None, will be set during async init
self.recipe_scanner = None
self.civitai_client = None
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True
)
# Pre-warm the cache
self._init_cache_task = None
async def init_services(self):
"""Initialize services from ServiceRegistry"""
self.recipe_scanner = await ServiceRegistry.get_recipe_scanner()
self.civitai_client = await ServiceRegistry.get_civitai_client()
class RecipeRoutes(BaseRecipeRoutes):
"""API route handlers for Recipe management."""
@classmethod
def setup_routes(cls, app: web.Application):
"""Register API routes"""
"""Register API routes using the declarative registrar."""
routes = cls()
app.router.add_get('/loras/recipes', routes.handle_recipes_page)
registrar = RecipeRouteRegistrar(app)
registrar.register_routes(routes.to_route_mapping())
routes.register_startup_hooks(app)
app.router.add_get('/api/lm/recipes', routes.get_recipes)
app.router.add_get('/api/lm/recipe/{recipe_id}', routes.get_recipe_detail)
app.router.add_post('/api/lm/recipes/analyze-image', routes.analyze_recipe_image)
app.router.add_post('/api/lm/recipes/analyze-local-image', routes.analyze_local_image)
app.router.add_post('/api/lm/recipes/save', routes.save_recipe)
app.router.add_delete('/api/lm/recipe/{recipe_id}', routes.delete_recipe)
# Add new filter-related endpoints
app.router.add_get('/api/lm/recipes/top-tags', routes.get_top_tags)
app.router.add_get('/api/lm/recipes/base-models', routes.get_base_models)
# Add new sharing endpoints
app.router.add_get('/api/lm/recipe/{recipe_id}/share', routes.share_recipe)
app.router.add_get('/api/lm/recipe/{recipe_id}/share/download', routes.download_shared_recipe)
# Add new endpoint for getting recipe syntax
app.router.add_get('/api/lm/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
# Add new endpoint for updating recipe metadata (name, tags and source_path)
app.router.add_put('/api/lm/recipe/{recipe_id}/update', routes.update_recipe)
# Add new endpoint for reconnecting deleted LoRAs
app.router.add_post('/api/lm/recipe/lora/reconnect', routes.reconnect_lora)
# Add new endpoint for finding duplicate recipes
app.router.add_get('/api/lm/recipes/find-duplicates', routes.find_duplicates)
# Add new endpoint for bulk deletion of recipes
app.router.add_post('/api/lm/recipes/bulk-delete', routes.bulk_delete)
# Start cache initialization
app.on_startup.append(routes._init_cache)
app.router.add_post('/api/lm/recipes/save-from-widget', routes.save_recipe_from_widget)
# Add route to get recipes for a specific Lora
app.router.add_get('/api/lm/recipes/for-lora', routes.get_recipes_for_lora)
# Add new endpoint for scanning and rebuilding the recipe cache
app.router.add_get('/api/lm/recipes/scan', routes.scan_recipes)
async def _init_cache(self, app):
"""Initialize cache on startup"""
async def render_page(self, request: web.Request) -> web.Response:
"""Handle GET /loras/recipes request."""
try:
# Initialize services first
await self.init_services()
# Now that services are initialized, get the lora scanner
lora_scanner = self.recipe_scanner._lora_scanner
# Get lora cache to ensure it's initialized
lora_cache = await lora_scanner.get_cached_data()
# Verify hash index is built
if hasattr(lora_scanner, '_hash_index'):
hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0
# Now that lora scanner is initialized, initialize recipe cache
await self.recipe_scanner.get_cached_data(force_refresh=True)
except Exception as e:
logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True)
await self.ensure_dependencies_ready()
async def handle_recipes_page(self, request: web.Request) -> web.Response:
"""Handle GET /loras/recipes request"""
try:
# Ensure services are initialized
await self.init_services()
# 获取用户语言设置
user_language = settings.get('language', 'en')
user_language = self.settings.get('language', 'en')
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
# 为模板环境添加i18n过滤器
if not hasattr(self.template_env, '_i18n_filter_added'):
self.template_env.filters['t'] = server_i18n.create_template_filter()
self.template_env._i18n_filter_added = True
self.server_i18n.set_locale(user_language)
# Skip initialization check and directly try to get cached data
try:
# Recipe scanner will initialize cache if needed
@@ -148,10 +64,10 @@ class RecipeRoutes:
rendered = template.render(
recipes=[], # Frontend will load recipes via API
is_initializing=False,
settings=settings,
settings=self.settings,
request=request,
# 添加服务端翻译函数
t=server_i18n.get_translation,
t=self.server_i18n.get_translation,
)
except Exception as cache_error:
logger.error(f"Error loading recipe cache data: {cache_error}")
@@ -159,10 +75,10 @@ class RecipeRoutes:
template = self.template_env.get_template('recipes.html')
rendered = template.render(
is_initializing=True,
settings=settings,
settings=self.settings,
request=request,
# 添加服务端翻译函数
t=server_i18n.get_translation,
t=self.server_i18n.get_translation,
)
logger.info("Recipe cache error, returning initialization page")
@@ -178,11 +94,11 @@ class RecipeRoutes:
status=500
)
async def get_recipes(self, request: web.Request) -> web.Response:
"""API endpoint for getting paginated recipes"""
async def list_recipes(self, request: web.Request) -> web.Response:
"""API endpoint for getting paginated recipes."""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Get query parameters with defaults
page = int(request.query.get('page', '1'))
@@ -250,11 +166,11 @@ class RecipeRoutes:
logger.error(f"Error retrieving recipes: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
async def get_recipe_detail(self, request: web.Request) -> web.Response:
"""Get detailed information about a specific recipe"""
async def get_recipe(self, request: web.Request) -> web.Response:
"""Get detailed information about a specific recipe."""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id']
@@ -305,12 +221,12 @@ class RecipeRoutes:
from datetime import datetime
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
async def analyze_recipe_image(self, request: web.Request) -> web.Response:
"""Analyze an uploaded image or URL for recipe metadata"""
async def analyze_uploaded_image(self, request: web.Request) -> web.Response:
"""Analyze an uploaded image or URL for recipe metadata."""
temp_path = None
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Check if request contains multipart data (image) or JSON data (url)
content_type = request.headers.get('Content-Type', '')
@@ -480,7 +396,7 @@ class RecipeRoutes:
"""Analyze a local image file for recipe metadata"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Get JSON data from request
data = await request.json()
@@ -573,7 +489,7 @@ class RecipeRoutes:
"""Save a recipe to the recipes folder"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
reader = await request.multipart()
@@ -779,7 +695,7 @@ class RecipeRoutes:
"""Delete a recipe by ID"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id']
@@ -829,7 +745,7 @@ class RecipeRoutes:
"""Get top tags used in recipes"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Get limit parameter with default
limit = int(request.query.get('limit', '20'))
@@ -864,7 +780,7 @@ class RecipeRoutes:
"""Get base models used in recipes"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Get all recipes from cache
cache = await self.recipe_scanner.get_cached_data()
@@ -895,7 +811,7 @@ class RecipeRoutes:
"""Process a recipe image for sharing by adding metadata to EXIF"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id']
@@ -957,7 +873,7 @@ class RecipeRoutes:
"""Serve a processed recipe image for download"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id']
@@ -1016,7 +932,7 @@ class RecipeRoutes:
"""Save a recipe from the LoRAs widget"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Get metadata using the metadata collector instead of workflow parsing
raw_metadata = get_metadata()
@@ -1216,7 +1132,7 @@ class RecipeRoutes:
"""Generate recipe syntax for LoRAs in the recipe, looking up proper file names using hash_index"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id']
@@ -1299,7 +1215,7 @@ class RecipeRoutes:
"""Update recipe metadata (name and tags)"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
recipe_id = request.match_info['recipe_id']
data = await request.json()
@@ -1329,7 +1245,7 @@ class RecipeRoutes:
"""Reconnect a deleted LoRA in a recipe to a local LoRA file"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Parse request data
data = await request.json()
@@ -1438,7 +1354,7 @@ class RecipeRoutes:
"""Get recipes that use a specific Lora"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
lora_hash = request.query.get('hash')
@@ -1487,7 +1403,7 @@ class RecipeRoutes:
"""API endpoint for scanning and rebuilding the recipe cache"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Force refresh the recipe cache
logger.info("Manually triggering recipe cache rebuild")
@@ -1508,7 +1424,7 @@ class RecipeRoutes:
"""Find all duplicate recipes based on fingerprints"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Get all duplicate recipes
duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes()
@@ -1566,7 +1482,7 @@ class RecipeRoutes:
"""Delete multiple recipes by ID"""
try:
# Ensure services are initialized
await self.init_services()
await self.ensure_dependencies_ready()
# Parse request data
data = await request.json()
@@ -1650,3 +1566,9 @@ class RecipeRoutes:
'success': False,
'error': str(e)
}, status=500)
# Legacy method aliases retained for compatibility with existing imports.
handle_recipes_page = render_page
get_recipes = list_recipes
get_recipe_detail = get_recipe
analyze_recipe_image = analyze_uploaded_image

View File

@@ -0,0 +1,229 @@
"""Smoke tests for the recipe routing scaffolding.
The cases keep the registrar/controller contract aligned with
``docs/architecture/recipe_routes.md`` so future refactors can focus on handler
logic.
"""
from __future__ import annotations
import asyncio
import importlib.util
import sys
import types
from collections import Counter
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict
import pytest
from aiohttp import web
REPO_ROOT = Path(__file__).resolve().parents[2]
PY_PACKAGE_PATH = REPO_ROOT / "py"
spec = importlib.util.spec_from_file_location(
"py_local",
PY_PACKAGE_PATH / "__init__.py",
submodule_search_locations=[str(PY_PACKAGE_PATH)],
)
py_local = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(py_local)
sys.modules.setdefault("py_local", py_local)
base_routes_module = importlib.import_module("py_local.routes.base_recipe_routes")
recipe_routes_module = importlib.import_module("py_local.routes.recipe_routes")
registrar_module = importlib.import_module("py_local.routes.recipe_route_registrar")
@pytest.fixture(autouse=True)
def reset_service_registry(monkeypatch: pytest.MonkeyPatch):
"""Ensure each test starts from a clean registry state."""
services_module = importlib.import_module("py_local.services.service_registry")
registry = services_module.ServiceRegistry
previous_services = dict(registry._services)
previous_locks = dict(registry._locks)
registry._services.clear()
registry._locks.clear()
try:
yield
finally:
registry._services = previous_services
registry._locks = previous_locks
def _make_stub_scanner():
class _StubScanner:
def __init__(self):
self._cache = types.SimpleNamespace()
async def _lora_get_cached_data(): # pragma: no cover - smoke hook
return None
self._lora_scanner = types.SimpleNamespace(
get_cached_data=_lora_get_cached_data,
_hash_index=types.SimpleNamespace(_hash_to_path={}),
)
async def get_cached_data(self, force_refresh: bool = False):
return self._cache
return _StubScanner()
def test_attach_dependencies_resolves_services_once(monkeypatch: pytest.MonkeyPatch):
base_module = base_routes_module
services_module = importlib.import_module("py_local.services.service_registry")
registry = services_module.ServiceRegistry
server_i18n = importlib.import_module("py_local.services.server_i18n").server_i18n
scanner = _make_stub_scanner()
civitai_client = object()
filter_calls = Counter()
async def fake_get_recipe_scanner():
return scanner
async def fake_get_civitai_client():
return civitai_client
def fake_create_filter():
filter_calls["create_filter"] += 1
return object()
monkeypatch.setattr(registry, "get_recipe_scanner", fake_get_recipe_scanner)
monkeypatch.setattr(registry, "get_civitai_client", fake_get_civitai_client)
monkeypatch.setattr(server_i18n, "create_template_filter", fake_create_filter)
async def scenario():
routes = base_module.BaseRecipeRoutes()
await routes.attach_dependencies()
await routes.attach_dependencies() # idempotent
assert routes.recipe_scanner is scanner
assert routes.lora_scanner is scanner._lora_scanner
assert routes.civitai_client is civitai_client
assert routes.template_env.filters["t"] is not None
assert filter_calls["create_filter"] == 1
asyncio.run(scenario())
def test_register_startup_hooks_appends_once():
routes = base_routes_module.BaseRecipeRoutes()
app = web.Application()
routes.register_startup_hooks(app)
routes.register_startup_hooks(app)
startup_bound_to_routes = [
callback for callback in app.on_startup if getattr(callback, "__self__", None) is routes
]
assert routes.attach_dependencies in startup_bound_to_routes
assert routes.prewarm_cache in startup_bound_to_routes
assert len(startup_bound_to_routes) == 2
def test_to_route_mapping_uses_handler_owner(monkeypatch: pytest.MonkeyPatch):
class DummyOwner:
async def render_page(self, request):
return web.Response(text="ok")
async def list_recipes(self, request): # pragma: no cover - invoked via mapping
return web.json_response({})
class DummyRoutes(base_routes_module.BaseRecipeRoutes):
def get_handler_owner(self): # noqa: D401 - simple override for test
return DummyOwner()
monkeypatch.setattr(
base_routes_module.BaseRecipeRoutes,
"_HANDLER_NAMES",
("render_page", "list_recipes"),
)
routes = DummyRoutes()
mapping = routes.to_route_mapping()
assert set(mapping.keys()) == {"render_page", "list_recipes"}
assert asyncio.iscoroutinefunction(mapping["render_page"])
# Cached mapping reused on subsequent calls
assert routes.to_route_mapping() is mapping
def test_recipe_route_registrar_binds_every_route():
class FakeRouter:
def __init__(self):
self.calls: list[tuple[str, str, Callable[..., Awaitable[Any]]]] = []
def add_get(self, path, handler):
self.calls.append(("GET", path, handler))
def add_post(self, path, handler):
self.calls.append(("POST", path, handler))
def add_put(self, path, handler):
self.calls.append(("PUT", path, handler))
def add_delete(self, path, handler):
self.calls.append(("DELETE", path, handler))
class FakeApp:
def __init__(self):
self.router = FakeRouter()
app = FakeApp()
registrar = registrar_module.RecipeRouteRegistrar(app)
handler_mapping = {
definition.handler_name: object()
for definition in registrar_module.ROUTE_DEFINITIONS
}
registrar.register_routes(handler_mapping)
assert {
(method, path)
for method, path, _ in app.router.calls
} == {(d.method, d.path) for d in registrar_module.ROUTE_DEFINITIONS}
def test_recipe_routes_setup_routes_uses_registrar(monkeypatch: pytest.MonkeyPatch):
registered_mappings: list[Dict[str, Callable[..., Awaitable[Any]]]] = []
class DummyRegistrar:
def __init__(self, app):
self.app = app
def register_routes(self, mapping):
registered_mappings.append(mapping)
monkeypatch.setattr(recipe_routes_module, "RecipeRouteRegistrar", DummyRegistrar)
expected_mapping = {name: object() for name in ("render_page", "list_recipes")}
def fake_to_route_mapping(self):
return expected_mapping
monkeypatch.setattr(base_routes_module.BaseRecipeRoutes, "to_route_mapping", fake_to_route_mapping)
monkeypatch.setattr(
base_routes_module.BaseRecipeRoutes,
"_HANDLER_NAMES",
tuple(expected_mapping.keys()),
)
app = web.Application()
recipe_routes_module.RecipeRoutes.setup_routes(app)
assert registered_mappings == [expected_mapping]
recipe_callbacks = {
cb
for cb in app.on_startup
if isinstance(getattr(cb, "__self__", None), recipe_routes_module.RecipeRoutes)
}
assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes_module.RecipeRoutes}
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies", "prewarm_cache"}