Files
orion/app/exceptions/error_renderer.py
Samir Boulahtit b769f5a047 refactor: centralize frontend detection with FrontendDetector
Major architecture change to unify frontend detection:

## Problem Solved
- Eliminated code duplication across 3 middleware files
- Fixed incomplete path detection (now detects /api/v1/admin/*)
- Unified on FrontendType enum (deprecates RequestContext)
- Added request.state.frontend_type for all requests

## New Components
- app/core/frontend_detector.py: Centralized FrontendDetector class
- middleware/frontend_type.py: FrontendTypeMiddleware (replaces ContextMiddleware)
- docs/architecture/frontend-detection.md: Complete architecture documentation

## Changes
- main.py: Use FrontendTypeMiddleware instead of ContextMiddleware
- middleware/context.py: Deprecated (kept for backwards compatibility)
- middleware/platform_context.py: Use FrontendDetector.is_admin()
- middleware/vendor_context.py: Use FrontendDetector.is_admin()
- middleware/language.py: Use FrontendType instead of context_value
- app/exceptions/handler.py: Use FrontendType.STOREFRONT
- app/exceptions/error_renderer.py: Use FrontendType
- Customer routes: Cookie path changed from /shop to /storefront

## Documentation
- docs/architecture/frontend-detection.md: New comprehensive docs
- docs/architecture/middleware.md: Updated for new system
- docs/architecture/request-flow.md: Updated for FrontendType
- docs/backend/middleware-reference.md: Updated API reference

## Tests
- tests/unit/core/test_frontend_detector.py: 37 new tests
- tests/unit/middleware/test_frontend_type.py: 11 new tests
- tests/unit/middleware/test_context.py: Updated for compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:15:19 +01:00

368 lines
12 KiB
Python

# app/exceptions/error_renderer.py
"""
Error Page Renderer
Renders context-aware error pages using Jinja2 templates.
Handles fallback logic and context-specific customization.
"""
import logging
from pathlib import Path
from typing import Any
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
logger = logging.getLogger(__name__)
class ErrorPageRenderer:
"""Renders context-aware error pages using Jinja2 templates."""
# Map status codes to friendly names
STATUS_CODE_NAMES = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
422: "Validation Error",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
}
# Map status codes to user-friendly messages
STATUS_CODE_MESSAGES = {
400: "The request could not be processed due to invalid data.",
401: "You need to be authenticated to access this resource.",
403: "You don't have permission to access this resource.",
404: "The page you're looking for doesn't exist or has been moved.",
422: "The submitted data contains validation errors.",
429: "Too many requests. Please slow down and try again later.",
500: "Something went wrong on our end. We're working to fix it.",
502: "Unable to reach the service. Please try again later.",
503: "The service is temporarily unavailable. Please try again later.",
}
@staticmethod
def get_templates_dir() -> Path:
"""Get the templates directory path."""
# Assuming templates are in app/templates/
base_dir = Path(__file__).resolve().parent.parent
return base_dir / "templates"
@staticmethod
def render_error_page(
request: Request,
status_code: int,
error_code: str,
message: str,
details: dict[str, Any] | None = None,
show_debug: bool = False,
) -> HTMLResponse:
"""
Render appropriate error page based on request frontend type.
Template Selection Priority:
1. Frontend-specific error page: {frontend}/errors/{status_code}.html
2. Frontend-specific generic: {frontend}/errors/generic.html
3. Shared fallback error page: shared/{status_code}-fallback.html
4. Shared fallback generic: shared/generic-fallback.html
Args:
request: FastAPI request object
status_code: HTTP status code
error_code: Application error code
message: Error message
details: Additional error details
show_debug: Whether to show debug information
Returns:
HTMLResponse with rendered error page
"""
# Get frontend type
frontend_type = get_frontend_type(request)
# Prepare template data
template_data = ErrorPageRenderer._prepare_template_data(
request=request,
frontend_type=frontend_type,
status_code=status_code,
error_code=error_code,
message=message,
details=details,
show_debug=show_debug,
)
# Find and render template
templates_dir = ErrorPageRenderer.get_templates_dir()
templates = Jinja2Templates(directory=str(templates_dir))
# Try to find appropriate template
template_path = ErrorPageRenderer._find_template(
frontend_type=frontend_type,
status_code=status_code,
)
logger.info(
f"Rendering error page: {template_path} for {status_code} in {frontend_type.value} frontend",
extra={
"status_code": status_code,
"error_code": error_code,
"frontend": frontend_type.value,
"template": template_path,
},
)
try:
# Render template
return templates.TemplateResponse(
template_path,
{
"request": request,
**template_data,
},
status_code=status_code,
)
except Exception as e:
logger.error(
f"Failed to render error template {template_path}: {e}", exc_info=True
)
# Return basic HTML as absolute fallback
return ErrorPageRenderer._render_basic_html_fallback(
status_code=status_code,
error_code=error_code,
message=message,
)
@staticmethod
def _find_template(
frontend_type: FrontendType,
status_code: int,
) -> str:
"""
Find appropriate error template based on frontend type and status code.
Priority:
1. {frontend}/errors/{status_code}.html
2. {frontend}/errors/generic.html
3. shared/{status_code}-fallback.html
4. shared/generic-fallback.html
"""
templates_dir = ErrorPageRenderer.get_templates_dir()
# Map frontend type to folder name
frontend_folders = {
FrontendType.ADMIN: "admin",
FrontendType.VENDOR: "vendor",
FrontendType.STOREFRONT: "storefront",
FrontendType.PLATFORM: "fallback", # Platform uses fallback templates
}
frontend_folder = frontend_folders.get(frontend_type, "fallback")
# Try frontend-specific status code template
specific_template = f"{frontend_folder}/errors/{status_code}.html"
if (templates_dir / specific_template).exists():
return specific_template
# Try frontend-specific generic template
generic_template = f"{frontend_folder}/errors/generic.html"
if (templates_dir / generic_template).exists():
return generic_template
# Try shared fallback status code template
fallback_specific = f"shared/{status_code}-fallback.html"
if (templates_dir / fallback_specific).exists():
return fallback_specific
# Use shared fallback generic template (must exist)
return "shared/generic-fallback.html"
@staticmethod
def _prepare_template_data(
request: Request,
frontend_type: FrontendType,
status_code: int,
error_code: str,
message: str,
details: dict[str, Any] | None,
show_debug: bool,
) -> dict[str, Any]:
"""Prepare data dictionary for error template."""
# Get friendly status name
status_name = ErrorPageRenderer.STATUS_CODE_NAMES.get(
status_code, f"Error {status_code}"
)
# Get default user-friendly message
default_message = ErrorPageRenderer.STATUS_CODE_MESSAGES.get(
status_code, "An error occurred while processing your request."
)
# Use provided message or default
user_message = message if message else default_message
# Determine if we should show debug info
# Only show to admins (we can check user role if available)
display_debug = show_debug and ErrorPageRenderer._is_admin_user(request)
# Get frontend-specific data
frontend_data = ErrorPageRenderer._get_frontend_data(request, frontend_type)
return {
"status_code": status_code,
"status_name": status_name,
"error_code": error_code,
"message": user_message,
"details": details or {},
"show_debug": display_debug,
"frontend_type": frontend_type.value,
"path": request.url.path,
**frontend_data,
}
@staticmethod
def _get_frontend_data(
request: Request, frontend_type: FrontendType
) -> dict[str, Any]:
"""Get frontend-specific data for error templates."""
data = {}
# Add vendor information if available (for storefront frontend)
if frontend_type == FrontendType.STOREFRONT:
vendor = getattr(request.state, "vendor", None)
if vendor:
# Pass minimal vendor info for templates
data["vendor"] = {
"id": vendor.id,
"name": vendor.name,
"subdomain": vendor.subdomain,
"logo": getattr(vendor, "logo", None),
}
# Add theme information if available
theme = getattr(request.state, "theme", None)
if theme:
# Theme can be a dict or object, handle both
if isinstance(theme, dict):
data["theme"] = theme
else:
# If it's an object, convert to dict
data["theme"] = {
"colors": getattr(theme, "colors", {}),
"fonts": getattr(theme, "fonts", {}),
"branding": getattr(theme, "branding", {}),
"custom_css": getattr(theme, "custom_css", None),
}
# Calculate base_url for storefront links
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
base_url = "/"
if access_method == "path" and vendor:
# Use the full_prefix from vendor_context to determine which pattern was used
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
base_url = f"{full_prefix}{vendor.subdomain}/"
data["base_url"] = base_url
return data
@staticmethod
def _is_admin_user(request: Request) -> bool:
"""
Check if current user is an admin (for debug info display).
This is a placeholder - implement based on your auth system.
"""
# TODO: Implement actual admin check based on JWT/session
# For now, check if we're in admin frontend
frontend_type = get_frontend_type(request)
return frontend_type == FrontendType.ADMIN
@staticmethod
def _render_basic_html_fallback(
status_code: int,
error_code: str,
message: str,
) -> HTMLResponse:
"""
Render a basic HTML error page as absolute fallback.
Used when template rendering fails.
"""
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{status_code} - Error</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: white;
padding: 2rem;
}}
.container {{
text-align: center;
max-width: 600px;
}}
h1 {{
font-size: 6rem;
font-weight: 700;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}}
h2 {{
font-size: 1.5rem;
font-weight: 400;
margin-bottom: 1rem;
}}
p {{
font-size: 1.1rem;
margin-bottom: 2rem;
opacity: 0.9;
}}
.error-code {{
font-size: 0.9rem;
opacity: 0.7;
font-family: 'Courier New', monospace;
}}
</style>
</head>
<body>
<div class="container">
<h1>{status_code}</h1>
<h2>Error</h2>
<p>{message}</p>
<div class="error-code">Error Code: {error_code}</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content, status_code=status_code)