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

@@ -1,31 +1,52 @@
# middleware/context_middleware.py
# middleware/context.py
"""
Context Detection Middleware (Class-Based)
DEPRECATED: This module is deprecated in favor of middleware/frontend_type.py
Detects the request context type (API, Admin, Vendor Dashboard, Shop, or Fallback)
and injects it into request.state for use by error handlers and other components.
The RequestContext enum and ContextMiddleware have been replaced by:
- FrontendType enum (app/modules/enums.py)
- FrontendTypeMiddleware (middleware/frontend_type.py)
- FrontendDetector (app/core/frontend_detector.py)
MUST run AFTER vendor_context_middleware to have access to clean_path.
MUST run BEFORE theme_context_middleware (which needs context_type).
This file is kept for backwards compatibility during the migration period.
All new code should use FrontendType and FrontendTypeMiddleware instead.
Class-based middleware provides:
- Better state management
- Easier testing
- More organized code
- Standard ASGI pattern
Migration guide:
- RequestContext.API -> Check with FrontendDetector.is_api_request()
- RequestContext.ADMIN -> FrontendType.ADMIN
- RequestContext.VENDOR_DASHBOARD -> FrontendType.VENDOR
- RequestContext.SHOP -> FrontendType.STOREFRONT
- RequestContext.FALLBACK -> FrontendType.PLATFORM (or handle API separately)
- get_request_context(request) -> get_frontend_type(request)
- request.state.context_type -> request.state.frontend_type
"""
import logging
import warnings
from enum import Enum
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
logger = logging.getLogger(__name__)
class RequestContext(str, Enum):
"""Request context types for the application."""
"""
DEPRECATED: Use FrontendType enum instead.
Request context types for the application.
This enum is kept for backwards compatibility.
Migration:
- API -> Use FrontendDetector.is_api_request() + FrontendType
- ADMIN -> FrontendType.ADMIN
- VENDOR_DASHBOARD -> FrontendType.VENDOR
- SHOP -> FrontendType.STOREFRONT
- FALLBACK -> FrontendType.PLATFORM
"""
API = "api"
ADMIN = "admin"
@@ -34,169 +55,12 @@ class RequestContext(str, Enum):
FALLBACK = "fallback"
class ContextManager:
"""Manages context detection for multi-area application."""
@staticmethod
def detect_context(request: Request) -> RequestContext:
"""
Detect the request context type.
Priority order:
1. API → /api/* paths (highest priority, always JSON)
2. Admin → /admin/* paths or admin.* subdomain
3. Vendor Dashboard → /vendor/* paths (vendor management area)
4. Shop → Vendor storefront (custom domain, subdomain, or shop paths)
5. Fallback → Unknown/generic context
CRITICAL: Uses clean_path (if available) instead of original path.
This ensures correct context detection for path-based routing.
Args:
request: FastAPI request object
Returns:
RequestContext enum value
"""
# Use clean_path if available (extracted by vendor_context_middleware)
# Falls back to original path if clean_path not set
# This is critical for correct context detection with path-based routing
path = getattr(request.state, "clean_path", request.url.path)
host = request.headers.get("host", "")
# Remove port from host if present
if ":" in host:
host = host.split(":")[0]
logger.debug(
"[CONTEXT] Detecting context",
extra={
"original_path": request.url.path,
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
"path_to_check": path,
"host": host,
},
)
# 1. API context (highest priority)
if path.startswith("/api/"):
logger.debug("[CONTEXT] Detected as API", extra={"path": path})
return RequestContext.API
# 2. Admin context
if ContextManager._is_admin_context(request, host, path):
logger.debug(
"[CONTEXT] Detected as ADMIN", extra={"path": path, "host": host}
)
return RequestContext.ADMIN
# 3. Vendor Dashboard context (vendor management area)
# Check both clean_path and original path for vendor dashboard
original_path = request.url.path
if ContextManager._is_vendor_dashboard_context(
path
) or ContextManager._is_vendor_dashboard_context(original_path):
logger.debug(
"[CONTEXT] Detected as VENDOR_DASHBOARD",
extra={"path": path, "original_path": original_path},
)
return RequestContext.VENDOR_DASHBOARD
# 4. Shop context (vendor storefront)
# Check if vendor context exists (set by vendor_context_middleware)
if hasattr(request.state, "vendor") and request.state.vendor:
# If we have a vendor and it's not admin or vendor dashboard, it's shop
logger.debug(
"[CONTEXT] Detected as SHOP (has vendor context)",
extra={"vendor": request.state.vendor.name},
)
return RequestContext.SHOP
# Also check shop-specific paths
if path.startswith("/shop/"):
logger.debug("[CONTEXT] Detected as SHOP (from path)", extra={"path": path})
return RequestContext.SHOP
# 5. Fallback for unknown contexts
logger.debug("[CONTEXT] Detected as FALLBACK", extra={"path": path})
return RequestContext.FALLBACK
@staticmethod
def _is_admin_context(request: Request, host: str, path: str) -> bool:
"""Check if request is in admin context."""
# Admin subdomain (admin.platform.com)
if host.startswith("admin."):
return True
# Admin path (/admin/*)
if path.startswith("/admin"):
return True
return False
@staticmethod
def _is_vendor_dashboard_context(path: str) -> bool:
"""Check if request is in vendor dashboard context."""
# Vendor dashboard paths (/vendor/{code}/*)
# Note: This is the vendor management area, not the shop
# Important: /vendors/{code}/shop/* should NOT match this
if path.startswith("/vendor/") and not path.startswith("/vendors/"):
return True
return False
class ContextMiddleware(BaseHTTPMiddleware):
"""
Middleware to detect and inject request context into request.state.
Class-based middleware provides:
- Better lifecycle management
- Easier to test and extend
- Standard ASGI pattern
- Clear separation of concerns
Runs SECOND in middleware chain (after vendor_context_middleware).
Depends on:
request.state.clean_path (set by vendor_context_middleware)
request.state.vendor (set by vendor_context_middleware)
Sets:
request.state.context_type: RequestContext enum value
"""
async def dispatch(self, request: Request, call_next):
"""
Detect context and inject into request state.
"""
# Detect context
context_type = ContextManager.detect_context(request)
# Inject into request state
request.state.context_type = context_type
# Log context detection with full details
logger.debug(
f"[CONTEXT_MIDDLEWARE] Context detected: {context_type.value}",
extra={
"path": request.url.path,
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
"host": request.headers.get("host", ""),
"context": context_type.value,
"has_vendor": hasattr(request.state, "vendor")
and request.state.vendor is not None,
},
)
# Continue processing
response = await call_next(request)
return response
def get_request_context(request: Request) -> RequestContext:
"""
DEPRECATED: Use get_frontend_type() from middleware.frontend_type instead.
Helper function to get current request context.
This function maps FrontendType to RequestContext for backwards compatibility.
Args:
request: FastAPI request object
@@ -204,4 +68,33 @@ def get_request_context(request: Request) -> RequestContext:
Returns:
RequestContext enum value (defaults to FALLBACK if not set)
"""
return getattr(request.state, "context_type", RequestContext.FALLBACK)
warnings.warn(
"get_request_context() is deprecated. Use get_frontend_type() from "
"middleware.frontend_type instead.",
DeprecationWarning,
stacklevel=2,
)
# Get the new frontend_type
frontend_type = get_frontend_type(request)
# Map FrontendType to RequestContext for backwards compatibility
mapping = {
FrontendType.ADMIN: RequestContext.ADMIN,
FrontendType.VENDOR: RequestContext.VENDOR_DASHBOARD,
FrontendType.STOREFRONT: RequestContext.SHOP,
FrontendType.PLATFORM: RequestContext.FALLBACK,
}
# Check if it's an API request
if request.url.path.startswith("/api/"):
return RequestContext.API
return mapping.get(frontend_type, RequestContext.FALLBACK)
# ContextManager and ContextMiddleware are removed.
# They have been replaced by FrontendDetector and FrontendTypeMiddleware.
# Import from the new locations:
# from app.core.frontend_detector import FrontendDetector
# from middleware.frontend_type import FrontendTypeMiddleware, get_frontend_type

View File

@@ -0,0 +1,92 @@
# middleware/frontend_type.py
"""
Frontend Type Detection Middleware
Sets request.state.frontend_type for all requests using centralized FrontendDetector.
This middleware replaces the old ContextMiddleware and provides a unified way to
detect which frontend (ADMIN, VENDOR, STOREFRONT, PLATFORM) is being accessed.
MUST run AFTER VendorContextMiddleware to have access to vendor context.
MUST run BEFORE LanguageMiddleware (which needs frontend_type).
Sets:
request.state.frontend_type: FrontendType enum value
"""
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.frontend_detector import FrontendDetector
from app.modules.enums import FrontendType
logger = logging.getLogger(__name__)
class FrontendTypeMiddleware(BaseHTTPMiddleware):
"""
Middleware to detect and inject frontend type into request state.
Uses FrontendDetector for centralized, consistent detection across the app.
Runs AFTER VendorContextMiddleware in request chain.
Depends on:
request.state.vendor (optional, set by VendorContextMiddleware)
request.state.clean_path (optional, set by VendorContextMiddleware)
Sets:
request.state.frontend_type: FrontendType enum value
"""
async def dispatch(self, request: Request, call_next):
"""Detect frontend type and inject into request state."""
host = request.headers.get("host", "")
# Use clean_path if available (from vendor_context_middleware), else original path
path = getattr(request.state, "clean_path", None) or request.url.path
# Check if vendor context exists (set by VendorContextMiddleware)
has_vendor_context = (
hasattr(request.state, "vendor")
and request.state.vendor is not None
)
# Detect frontend type using centralized detector
frontend_type = FrontendDetector.detect(
host=host,
path=path,
has_vendor_context=has_vendor_context,
)
# Store in request state
request.state.frontend_type = frontend_type
# Log detection for debugging
logger.debug(
f"[FRONTEND_TYPE_MIDDLEWARE] Frontend type detected: {frontend_type.value}",
extra={
"path": request.url.path,
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
"host": host,
"frontend_type": frontend_type.value,
"has_vendor": has_vendor_context,
},
)
# Continue processing
response = await call_next(request)
return response
def get_frontend_type(request: Request) -> FrontendType:
"""
Helper function to get current frontend type from request.
Args:
request: FastAPI request object
Returns:
FrontendType enum value (defaults to PLATFORM if not set)
"""
return getattr(request.state, "frontend_type", FrontendType.PLATFORM)

View File

@@ -18,6 +18,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.modules.enums import FrontendType
from app.utils.i18n import (
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES,
@@ -45,9 +46,8 @@ class LanguageMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
"""Process the request and set language."""
# Get context type from previous middleware
context_type = getattr(request.state, "context_type", None)
context_value = context_type.value if context_type else None
# Get frontend type from FrontendTypeMiddleware
frontend_type = getattr(request.state, "frontend_type", None)
# Get vendor from previous middleware (if available)
vendor = getattr(request.state, "vendor", None)
@@ -59,13 +59,13 @@ class LanguageMiddleware(BaseHTTPMiddleware):
accept_language = request.headers.get("accept-language")
browser_language = parse_accept_language(accept_language)
# Resolve language based on context
if context_value == "admin":
# Resolve language based on frontend type
if frontend_type == FrontendType.ADMIN:
# Admin dashboard: English only (for now)
# TODO: Implement admin language support later
language = "en"
elif context_value == "vendor_dashboard":
elif frontend_type == FrontendType.VENDOR:
# Vendor dashboard
user_preferred = self._get_user_language_from_token(request)
vendor_dashboard = vendor.dashboard_language if vendor else None
@@ -75,7 +75,7 @@ class LanguageMiddleware(BaseHTTPMiddleware):
vendor_dashboard=vendor_dashboard,
)
elif context_value == "shop":
elif frontend_type == FrontendType.STOREFRONT:
# Storefront
customer_preferred = self._get_customer_language_from_token(request)
vendor_storefront = vendor.storefront_language if vendor else None
@@ -89,12 +89,12 @@ class LanguageMiddleware(BaseHTTPMiddleware):
enabled_languages=enabled_languages,
)
elif context_value == "api":
# API requests: Use Accept-Language or cookie
elif frontend_type == FrontendType.PLATFORM:
# Platform marketing pages: Use cookie, browser, or default
language = cookie_language or browser_language or DEFAULT_LANGUAGE
else:
# Fallback: Use cookie, browser, or default
# Fallback (API or unknown): Use Accept-Language or cookie
language = cookie_language or browser_language or DEFAULT_LANGUAGE
# Validate language is supported
@@ -109,13 +109,14 @@ class LanguageMiddleware(BaseHTTPMiddleware):
"code": language,
"cookie": cookie_language,
"browser": browser_language,
"context": context_value,
"frontend_type": frontend_type.value if frontend_type else None,
}
# Log language detection for debugging
frontend_value = frontend_type.value if frontend_type else "unknown"
logger.debug(
f"Language detected: {language} "
f"(context={context_value}, cookie={cookie_language}, browser={browser_language})"
f"(frontend={frontend_value}, cookie={cookie_language}, browser={browser_language})"
)
# Process request

View File

@@ -22,6 +22,8 @@ from fastapi import Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.frontend_detector import FrontendDetector
from app.modules.enums import FrontendType
from app.modules.tenancy.models import Platform
# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting
@@ -61,7 +63,7 @@ class PlatformContextManager:
host_without_port = host.split(":")[0] if ":" in host else host
# Skip platform detection for admin routes - admin is global
if PlatformContextManager.is_admin_request(request):
if FrontendDetector.is_admin(host, path):
return None
# Method 1: Domain-based detection (production)
@@ -208,17 +210,15 @@ class PlatformContextManager:
@staticmethod
def is_admin_request(request: Request) -> bool:
"""Check if request is for admin interface."""
"""
Check if request is for admin interface.
DEPRECATED: Use FrontendDetector.is_admin() instead.
Kept for backwards compatibility.
"""
host = request.headers.get("host", "")
path = request.url.path
if ":" in host:
host = host.split(":")[0]
if host.startswith("admin."):
return True
return path.startswith("/admin")
return FrontendDetector.is_admin(host, path)
@staticmethod
def is_static_file_request(request: Request) -> bool:
@@ -299,7 +299,7 @@ class PlatformContextMiddleware:
return
# Skip for admin requests
if self._is_admin_request(path, host):
if FrontendDetector.is_admin(host, path):
scope["state"]["platform"] = None
scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path
@@ -427,11 +427,13 @@ class PlatformContextMiddleware:
return "favicon.ico" in path_lower
def _is_admin_request(self, path: str, host: str) -> bool:
"""Check if request is for admin interface."""
host_without_port = host.split(":")[0] if ":" in host else host
if host_without_port.startswith("admin."):
return True
return path.startswith("/admin")
"""
Check if request is for admin interface.
DEPRECATED: Use FrontendDetector.is_admin() instead.
Kept for backwards compatibility.
"""
return FrontendDetector.is_admin(host, path)
def get_current_platform(request: Request) -> Platform | None:

View File

@@ -23,6 +23,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
from app.core.database import get_db
from app.core.frontend_detector import FrontendDetector
from app.modules.tenancy.models import Vendor
from app.modules.tenancy.models import VendorDomain
@@ -194,22 +195,20 @@ class VendorContextManager:
@staticmethod
def is_admin_request(request: Request) -> bool:
"""Check if request is for admin interface."""
"""
Check if request is for admin interface.
DEPRECATED: Use FrontendDetector.is_admin() instead.
Kept for backwards compatibility.
"""
host = request.headers.get("host", "")
path = request.url.path
if ":" in host:
host = host.split(":")[0]
if host.startswith("admin."):
return True
return path.startswith("/admin")
return FrontendDetector.is_admin(host, path)
@staticmethod
def is_api_request(request: Request) -> bool:
"""Check if request is for API endpoints."""
return request.url.path.startswith("/api/")
return FrontendDetector.is_api_request(request.url.path)
@staticmethod
def is_shop_api_request(request: Request) -> bool: