mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 11:16:30 +01:00
- Split monolithic build route handler into focused modules - Extract validation, multi-copy, include/exclude, themes, and partner routes - Add response utilities and telemetry decorators - Create route pattern documentation - Fix multi-copy detection bug (tag key mismatch) - Improve code maintainability and testability Roadmap 9 M1 Phase 1-2
12 KiB
12 KiB
Route Handler Patterns
Status: ✅ Active Standard (R9 M1)
Last Updated: 2026-02-20
This document defines the standard patterns for FastAPI route handlers in the MTG Deckbuilder web application.
Table of Contents
- Overview
- Standard Route Pattern
- Decorators
- Request Handling
- Response Building
- Error Handling
- Examples
Overview
All route handlers should follow these principles:
- Consistency: Use standard patterns for request/response handling
- Clarity: Clear separation between validation, business logic, and response building
- Observability: Proper logging and telemetry
- Error Handling: Use custom exceptions, not HTTPException directly
- Type Safety: Full type hints for all parameters and return types
Standard Route Pattern
from fastapi import APIRouter, Request, Query, Form
from fastapi.responses import HTMLResponse, JSONResponse
from ..decorators.telemetry import track_route_access, log_route_errors
from ..utils.responses import build_template_response, build_error_response
from exceptions import ValidationError, NotFoundError # From code/exceptions.py
router = APIRouter()
@router.get("/endpoint", response_class=HTMLResponse)
@track_route_access("event_name") # Optional: for telemetry
@log_route_errors("route_name") # Optional: for error logging
async def endpoint_handler(
request: Request,
param: str = Query(..., description="Parameter description"),
) -> HTMLResponse:
"""
Brief description of what this endpoint does.
Args:
request: FastAPI request object
param: Query parameter description
Returns:
HTMLResponse with rendered template
Raises:
ValidationError: When parameter validation fails
NotFoundError: When resource is not found
"""
try:
# 1. Validate inputs
if not param:
raise ValidationError("parameter_required", details={"param": "required"})
# 2. Call service layer (business logic)
from ..services.your_service import process_request
result = await process_request(param)
if not result:
raise NotFoundError("resource_not_found", details={"param": param})
# 3. Build and return response
from ..app import templates
context = {
"result": result,
"param": param,
}
return build_template_response(
request, templates, "path/template.html", context
)
except (ValidationError, NotFoundError):
# Let custom exception handlers in app.py handle these
raise
except Exception as e:
# Log unexpected errors and re-raise
LOGGER.error(f"Unexpected error in endpoint_handler: {e}", exc_info=True)
raise
Decorators
Telemetry Decorators
Located in code/web/decorators/telemetry.py:
from ..decorators.telemetry import (
track_route_access, # Track route access
track_build_time, # Track operation timing
log_route_errors, # Enhanced error logging
)
@router.get("/build/step1")
@track_route_access("build_step1_access")
@log_route_errors("build_step1")
async def step1_handler(request: Request):
# Route implementation
...
When to use:
@track_route_access: For all user-facing routes (telemetry)@track_build_time: For deck building operations (performance monitoring)@log_route_errors: For routes with complex error handling
Decorator Ordering
Order matters! Apply decorators from bottom to top:
@router.get("/endpoint") # 1. Router decorator (bottom)
@track_route_access("event") # 2. Telemetry (before error handler)
@log_route_errors("route") # 3. Error logging (top)
async def handler(...):
...
Request Handling
Query Parameters
from fastapi import Query
@router.get("/search")
async def search_cards(
request: Request,
query: str = Query(..., min_length=1, max_length=100, description="Search query"),
limit: int = Query(20, ge=1, le=100, description="Results limit"),
) -> JSONResponse:
...
Form Data
from fastapi import Form
@router.post("/build/create")
async def create_deck(
request: Request,
commander: str = Form(..., description="Commander name"),
themes: list[str] = Form(default=[], description="Theme tags"),
) -> HTMLResponse:
...
JSON Body (Pydantic Models)
from pydantic import BaseModel, Field
class BuildRequest(BaseModel):
"""Build request validation model."""
commander: str = Field(..., min_length=1, max_length=200)
themes: list[str] = Field(default_factory=list, max_items=5)
power_bracket: int = Field(default=2, ge=1, le=4)
@router.post("/api/build")
async def api_build_deck(
request: Request,
build_req: BuildRequest,
) -> JSONResponse:
# build_req is automatically validated
...
Session Data
from ..services.tasks import get_session, set_session_value
@router.post("/step2")
async def step2_handler(request: Request):
sid = request.cookies.get("sid")
if not sid:
raise ValidationError("session_required")
session = get_session(sid)
commander = session.get("commander")
# Update session
set_session_value(sid, "step", "2")
...
Response Building
Template Responses
Use build_template_response from code/web/utils/responses.py:
from ..utils.responses import build_template_response
from ..app import templates
context = {
"title": "Page Title",
"data": result_data,
}
return build_template_response(
request, templates, "path/template.html", context
)
JSON Responses
from ..utils.responses import build_success_response
data = {
"commander": "Atraxa, Praetors' Voice",
"themes": ["Proliferate", "Superfriends"],
}
return build_success_response(data, status_code=200)
HTMX Partial Responses
from ..utils.responses import build_htmx_response
html_content = templates.get_template("partials/result.html").render(context)
return build_htmx_response(
content=html_content,
trigger={"deckUpdated": {"commander": "Atraxa"}},
retarget="#result-container",
)
Error Responses
from ..utils.responses import build_error_response
# Manual error response (prefer raising custom exceptions instead)
return build_error_response(
request,
status_code=400,
error_type="ValidationError",
message="Invalid commander name",
detail="Commander 'Foo' does not exist",
fields={"commander": ["Commander 'Foo' does not exist"]}
)
Error Handling
Use Custom Exceptions
Always use custom exceptions from code/exceptions.py, not HTTPException:
from exceptions import (
ValidationError,
NotFoundError,
CommanderValidationError,
ThemeError,
)
# ❌ DON'T DO THIS
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Invalid input")
# ✅ DO THIS INSTEAD
raise ValidationError("Invalid input", code="VALIDATION_ERR", details={"field": "value"})
Exception Hierarchy
See code/exceptions.py for the full hierarchy. Common exceptions:
DeckBuilderError- Base class for all custom exceptionsMTGSetupError- Setup-related errorsCSVError- Data loading errorsCommanderValidationError- Commander validation failuresCommanderTypeError,CommanderColorError, etc.
ThemeError- Theme-related errorsPriceError- Price checking errorsLibraryOrganizationError- Deck organization errors
Let Exception Handlers Handle It
The app.py exception handlers will convert custom exceptions to HTTP responses:
@router.get("/commander/{name}")
async def get_commander(request: Request, name: str):
# Validate
if not name:
raise ValidationError("Commander name required", code="CMD_NAME_REQUIRED")
# Business logic
try:
commander = await load_commander(name)
except CommanderNotFoundError as e:
# Re-raise to let global handler convert to 404
raise
# Return success
return build_success_response({"commander": commander})
Examples
Example 1: Simple GET with Template Response
from fastapi import Request
from fastapi.responses import HTMLResponse
from ..utils.responses import build_template_response
from ..decorators.telemetry import track_route_access
from ..app import templates
@router.get("/commanders", response_class=HTMLResponse)
@track_route_access("commanders_list_view")
async def list_commanders(request: Request) -> HTMLResponse:
"""Display the commanders catalog page."""
from ..services.commander_catalog_loader import load_commander_catalog
catalog = load_commander_catalog()
context = {"commanders": catalog.commanders}
return build_template_response(
request, templates, "commanders/list.html", context
)
Example 2: POST with Form Data and Session
from fastapi import Request, Form
from fastapi.responses import HTMLResponse
from ..utils.responses import build_template_response, build_htmx_response
from ..services.tasks import get_session, set_session_value
from exceptions import CommanderValidationError
@router.post("/build/select_commander", response_class=HTMLResponse)
async def select_commander(
request: Request,
commander: str = Form(..., description="Selected commander name"),
) -> HTMLResponse:
"""Handle commander selection in deck builder wizard."""
# Validate commander
if not commander or len(commander) > 200:
raise CommanderValidationError(
f"Invalid commander name: {commander}",
code="CMD_INVALID",
details={"name": commander}
)
# Store in session
sid = request.cookies.get("sid")
if sid:
set_session_value(sid, "commander", commander)
# Return HTMX partial
from ..app import templates
context = {"commander": commander, "step": "themes"}
html = templates.get_template("build/step2_themes.html").render(context)
return build_htmx_response(
content=html,
trigger={"commanderSelected": {"name": commander}},
)
Example 3: API Endpoint with JSON Response
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from ..utils.responses import build_success_response
from exceptions import ThemeError
class ThemeSearchRequest(BaseModel):
"""Theme search request model."""
query: str = Field(..., min_length=1, max_length=100)
limit: int = Field(default=10, ge=1, le=50)
@router.post("/api/themes/search")
async def search_themes(
request: Request,
search: ThemeSearchRequest,
) -> JSONResponse:
"""API endpoint to search for themes."""
from ..services.theme_catalog_loader import search_themes as _search
results = _search(search.query, limit=search.limit)
if not results:
raise ThemeError(
f"No themes found matching '{search.query}'",
code="THEME_NOT_FOUND",
details={"query": search.query}
)
return build_success_response({
"query": search.query,
"count": len(results),
"themes": [{"id": t.id, "name": t.name} for t in results],
})
Migration Guide
For Existing Routes
When updating existing routes to follow this pattern:
- Add type hints if missing
- Replace HTTPException with custom exceptions
- Use response builders instead of direct Response construction
- Add telemetry decorators where appropriate
- Add docstrings following the standard format
- Separate concerns: validation → business logic → response
Checklist
- Route has full type hints
- Uses custom exceptions (not HTTPException)
- Uses response builder utilities
- Has telemetry decorators (if applicable)
- Has complete docstring
- Separates validation, logic, and response
- Handles errors gracefully
Related Documentation:
- Service Layer Architecture (M2)
- Validation Framework (M3)
- Error Handling Guide (M4)
- Testing Standards (M5)
Last Updated: 2026-02-20
Roadmap: R9 M1 - Route Handler Standardization