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>
This commit is contained in:
2026-02-03 16:15:19 +01:00
parent e77535e2cd
commit b769f5a047
17 changed files with 1393 additions and 915 deletions

View File

@@ -0,0 +1,203 @@
# app/core/frontend_detector.py
"""
Centralized Frontend Detection
Single source of truth for detecting which frontend type a request targets.
Handles both development (path-based) and production (domain-based) routing.
Detection priority:
1. Admin subdomain (admin.oms.lu)
2. Path-based admin/vendor (/admin/*, /vendor/*, /api/v1/admin/*)
3. Custom domain lookup (mybakery.lu -> STOREFRONT)
4. Vendor subdomain (wizamart.oms.lu -> STOREFRONT)
5. Storefront paths (/storefront/*, /api/v1/storefront/*)
6. Default to PLATFORM (marketing pages)
This module unifies frontend detection that was previously duplicated across:
- middleware/platform_context.py
- middleware/vendor_context.py
- middleware/context.py
All middleware and routes should use FrontendDetector for frontend detection.
"""
import logging
from app.modules.enums import FrontendType
logger = logging.getLogger(__name__)
class FrontendDetector:
"""
Centralized frontend detection for dev and prod modes.
Provides consistent detection of frontend type from request characteristics.
All path/domain detection logic should be centralized here.
"""
# Reserved subdomains (not vendor shops)
RESERVED_SUBDOMAINS = frozenset({"www", "admin", "api", "vendor", "portal"})
# Path patterns for each frontend type
# Note: Order matters - more specific patterns should be checked first
ADMIN_PATH_PREFIXES = ("/admin", "/api/v1/admin")
VENDOR_PATH_PREFIXES = ("/vendor/", "/api/v1/vendor") # Note: /vendor/ not /vendors/
STOREFRONT_PATH_PREFIXES = (
"/storefront",
"/api/v1/storefront",
"/shop", # Legacy support
"/api/v1/shop", # Legacy support
"/vendors/", # Path-based vendor access
)
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
@classmethod
def detect(
cls,
host: str,
path: str,
has_vendor_context: bool = False,
) -> FrontendType:
"""
Detect frontend type from request.
Args:
host: Request host header (e.g., "oms.lu", "wizamart.oms.lu", "localhost:8000")
path: Request path (e.g., "/admin/vendors", "/storefront/products")
has_vendor_context: True if request.state.vendor is set (from middleware)
Returns:
FrontendType enum value
"""
host = cls._strip_port(host)
subdomain = cls._get_subdomain(host)
logger.debug(
"[FRONTEND_DETECTOR] Detecting frontend type",
extra={
"host": host,
"path": path,
"subdomain": subdomain,
"has_vendor_context": has_vendor_context,
},
)
# 1. Admin subdomain (admin.oms.lu)
if subdomain == "admin":
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain")
return FrontendType.ADMIN
# 2. Path-based detection (works for dev and prod)
# Check in priority order
if cls._matches_any(path, cls.ADMIN_PATH_PREFIXES):
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path")
return FrontendType.ADMIN
if cls._matches_any(path, cls.VENDOR_PATH_PREFIXES):
logger.debug("[FRONTEND_DETECTOR] Detected VENDOR from path")
return FrontendType.VENDOR
if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES):
logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path")
return FrontendType.STOREFRONT
if cls._matches_any(path, cls.PLATFORM_PATH_PREFIXES):
logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path")
return FrontendType.PLATFORM
# 3. Vendor subdomain detection (wizamart.oms.lu)
# If subdomain exists and is not reserved -> it's a vendor shop
if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS:
logger.debug(
f"[FRONTEND_DETECTOR] Detected STOREFRONT from subdomain: {subdomain}"
)
return FrontendType.STOREFRONT
# 4. Custom domain detection (handled by middleware setting vendor context)
# If vendor is set but no storefront path -> still storefront
if has_vendor_context:
logger.debug(
"[FRONTEND_DETECTOR] Detected STOREFRONT from vendor context"
)
return FrontendType.STOREFRONT
# 5. Default: PLATFORM (marketing pages like /, /pricing, /about)
logger.debug("[FRONTEND_DETECTOR] Defaulting to PLATFORM")
return FrontendType.PLATFORM
@classmethod
def _strip_port(cls, host: str) -> str:
"""Remove port from host if present (e.g., localhost:8000 -> localhost)."""
return host.split(":")[0] if ":" in host else host
@classmethod
def _get_subdomain(cls, host: str) -> str | None:
"""
Extract subdomain from host (e.g., 'wizamart' from 'wizamart.oms.lu').
Returns None for localhost, IP addresses, or root domains.
Handles special case of admin.localhost for development.
"""
if host in ("localhost", "127.0.0.1"):
return None
parts = host.split(".")
# Handle localhost subdomains (e.g., admin.localhost)
if len(parts) == 2 and parts[1] == "localhost":
return parts[0].lower()
if len(parts) >= 3: # subdomain.domain.tld
return parts[0].lower()
return None
@classmethod
def _matches_any(cls, path: str, prefixes: tuple[str, ...]) -> bool:
"""Check if path starts with any of the given prefixes."""
return any(path.startswith(prefix) for prefix in prefixes)
# =========================================================================
# Convenience methods for specific frontend types
# =========================================================================
@classmethod
def is_admin(cls, host: str, path: str) -> bool:
"""Check if request targets admin frontend."""
return cls.detect(host, path) == FrontendType.ADMIN
@classmethod
def is_vendor(cls, host: str, path: str) -> bool:
"""Check if request targets vendor dashboard frontend."""
return cls.detect(host, path) == FrontendType.VENDOR
@classmethod
def is_storefront(
cls,
host: str,
path: str,
has_vendor_context: bool = False,
) -> bool:
"""Check if request targets storefront frontend."""
return cls.detect(host, path, has_vendor_context) == FrontendType.STOREFRONT
@classmethod
def is_platform(cls, host: str, path: str) -> bool:
"""Check if request targets platform marketing frontend."""
return cls.detect(host, path) == FrontendType.PLATFORM
@classmethod
def is_api_request(cls, path: str) -> bool:
"""Check if request is for API endpoints (any frontend's API)."""
return path.startswith("/api/")
# Convenience function for backwards compatibility
def get_frontend_type(host: str, path: str, has_vendor_context: bool = False) -> FrontendType:
"""
Convenience function to detect frontend type.
Wrapper around FrontendDetector.detect() for simpler imports.
"""
return FrontendDetector.detect(host, path, has_vendor_context)

View File

@@ -14,7 +14,8 @@ from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from middleware.context import RequestContext, get_request_context
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
logger = logging.getLogger(__name__)
@@ -65,11 +66,11 @@ class ErrorPageRenderer:
show_debug: bool = False,
) -> HTMLResponse:
"""
Render appropriate error page based on request context.
Render appropriate error page based on request frontend type.
Template Selection Priority:
1. Context-specific error page: {context}/errors/{status_code}.html
2. Context-specific generic: {context}/errors/generic.html
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
@@ -84,13 +85,13 @@ class ErrorPageRenderer:
Returns:
HTMLResponse with rendered error page
"""
# Get request context
context_type = get_request_context(request)
# Get frontend type
frontend_type = get_frontend_type(request)
# Prepare template data
template_data = ErrorPageRenderer._prepare_template_data(
request=request,
context_type=context_type,
frontend_type=frontend_type,
status_code=status_code,
error_code=error_code,
message=message,
@@ -104,16 +105,16 @@ class ErrorPageRenderer:
# Try to find appropriate template
template_path = ErrorPageRenderer._find_template(
context_type=context_type,
frontend_type=frontend_type,
status_code=status_code,
)
logger.info(
f"Rendering error page: {template_path} for {status_code} in {context_type.value} context",
f"Rendering error page: {template_path} for {status_code} in {frontend_type.value} frontend",
extra={
"status_code": status_code,
"error_code": error_code,
"context": context_type.value,
"frontend": frontend_type.value,
"template": template_path,
},
)
@@ -141,38 +142,37 @@ class ErrorPageRenderer:
@staticmethod
def _find_template(
context_type: RequestContext,
frontend_type: FrontendType,
status_code: int,
) -> str:
"""
Find appropriate error template based on context and status code.
Find appropriate error template based on frontend type and status code.
Priority:
1. {context}/errors/{status_code}.html
2. {context}/errors/generic.html
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 context type to folder name
context_folders = {
RequestContext.ADMIN: "admin",
RequestContext.VENDOR_DASHBOARD: "vendor",
RequestContext.SHOP: "shop",
RequestContext.API: "fallback", # API shouldn't get here, but just in case
RequestContext.FALLBACK: "fallback",
# Map frontend type to folder name
frontend_folders = {
FrontendType.ADMIN: "admin",
FrontendType.VENDOR: "vendor",
FrontendType.STOREFRONT: "storefront",
FrontendType.PLATFORM: "fallback", # Platform uses fallback templates
}
context_folder = context_folders.get(context_type, "fallback")
frontend_folder = frontend_folders.get(frontend_type, "fallback")
# Try context-specific status code template
specific_template = f"{context_folder}/errors/{status_code}.html"
# 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 context-specific generic template
generic_template = f"{context_folder}/errors/generic.html"
# Try frontend-specific generic template
generic_template = f"{frontend_folder}/errors/generic.html"
if (templates_dir / generic_template).exists():
return generic_template
@@ -187,7 +187,7 @@ class ErrorPageRenderer:
@staticmethod
def _prepare_template_data(
request: Request,
context_type: RequestContext,
frontend_type: FrontendType,
status_code: int,
error_code: str,
message: str,
@@ -212,8 +212,8 @@ class ErrorPageRenderer:
# Only show to admins (we can check user role if available)
display_debug = show_debug and ErrorPageRenderer._is_admin_user(request)
# Get context-specific data
context_data = ErrorPageRenderer._get_context_data(request, context_type)
# Get frontend-specific data
frontend_data = ErrorPageRenderer._get_frontend_data(request, frontend_type)
return {
"status_code": status_code,
@@ -222,20 +222,20 @@ class ErrorPageRenderer:
"message": user_message,
"details": details or {},
"show_debug": display_debug,
"context_type": context_type.value,
"frontend_type": frontend_type.value,
"path": request.url.path,
**context_data,
**frontend_data,
}
@staticmethod
def _get_context_data(
request: Request, context_type: RequestContext
def _get_frontend_data(
request: Request, frontend_type: FrontendType
) -> dict[str, Any]:
"""Get context-specific data for error templates."""
"""Get frontend-specific data for error templates."""
data = {}
# Add vendor information if available (for shop context)
if context_type == RequestContext.SHOP:
# 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
@@ -261,7 +261,7 @@ class ErrorPageRenderer:
"custom_css": getattr(theme, "custom_css", None),
}
# Calculate base_url for shop links
# Calculate base_url for storefront links
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
@@ -289,9 +289,9 @@ class ErrorPageRenderer:
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 context
context_type = get_request_context(request)
return context_type == RequestContext.ADMIN
# 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(

View File

@@ -16,7 +16,8 @@ from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from middleware.context import RequestContext, get_request_context
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
from .base import WizamartException
from .error_renderer import ErrorPageRenderer
@@ -382,17 +383,17 @@ def _is_html_page_request(request: Request) -> bool:
def _redirect_to_login(request: Request) -> RedirectResponse:
"""
Redirect to appropriate login page based on request context.
Redirect to appropriate login page based on request frontend type.
Uses context detection to determine admin vs vendor vs shop login.
Uses FrontendType detection to determine admin vs vendor vs storefront login.
Properly handles multi-access routing (domain, subdomain, path-based).
"""
context_type = get_request_context(request)
frontend_type = get_frontend_type(request)
if context_type == RequestContext.ADMIN:
if frontend_type == FrontendType.ADMIN:
logger.debug("Redirecting to /admin/login")
return RedirectResponse(url="/admin/login", status_code=302)
if context_type == RequestContext.VENDOR_DASHBOARD:
if frontend_type == FrontendType.VENDOR:
# Extract vendor code from the request path
# Path format: /vendor/{vendor_code}/...
path_parts = request.url.path.split("/")
@@ -417,8 +418,8 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
if context_type == RequestContext.SHOP:
# For shop context, redirect to shop login (customer login)
if frontend_type == FrontendType.STOREFRONT:
# For storefront context, redirect to storefront login (customer login)
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
vendor = getattr(request.state, "vendor", None)
vendor_context = getattr(request.state, "vendor_context", None)
@@ -437,11 +438,11 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
)
base_url = f"{full_prefix}{vendor.subdomain}/"
login_url = f"{base_url}shop/account/login"
login_url = f"{base_url}storefront/account/login"
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
# Fallback to root for unknown contexts
logger.debug("Unknown context, redirecting to /")
# Fallback to root for unknown contexts (PLATFORM)
logger.debug("Platform context, redirecting to /")
return RedirectResponse(url="/", status_code=302)

View File

@@ -10,7 +10,7 @@ Public and authenticated endpoints for customer operations in storefront:
Uses vendor from middleware context (VendorContextMiddleware).
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/shop (restricted to shop routes only)
- Sets HTTP-only cookie with path=/storefront (restricted to storefront routes only)
- Returns token in response for localStorage (API calls)
"""
@@ -182,14 +182,14 @@ def customer_login(
else "unknown"
)
cookie_path = "/shop"
cookie_path = "/storefront"
if access_method == "path":
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
cookie_path = f"{full_prefix}{vendor.subdomain}/storefront"
response.set_cookie(
key="customer_token",
@@ -240,14 +240,14 @@ def customer_logout(request: Request, response: Response):
else "unknown"
)
cookie_path = "/shop"
cookie_path = "/storefront"
if access_method == "path" and vendor:
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
cookie_path = f"{full_prefix}{vendor.subdomain}/storefront"
response.delete_cookie(key="customer_token", path=cookie_path)