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)

View File

@@ -0,0 +1,278 @@
# Frontend Detection Architecture
This document describes the centralized frontend detection system that identifies which frontend (ADMIN, VENDOR, STOREFRONT, or PLATFORM) a request targets.
## Overview
The application serves multiple frontends from a single codebase:
| Frontend | Description | Example URLs |
|----------|-------------|--------------|
| **ADMIN** | Platform administration | `/admin/*`, `/api/v1/admin/*`, `admin.oms.lu/*` |
| **VENDOR** | Vendor dashboard | `/vendor/*`, `/api/v1/vendor/*` |
| **STOREFRONT** | Customer-facing shop | `/storefront/*`, `/vendors/*`, `wizamart.oms.lu/*` |
| **PLATFORM** | Marketing pages | `/`, `/pricing`, `/about` |
The `FrontendDetector` class provides centralized, consistent detection of which frontend a request targets.
## Architecture
### Components
```
┌─────────────────────────────────────────────────────────────────┐
│ Request Processing │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. PlatformContextMiddleware → Sets request.state.platform │
│ │
│ 2. VendorContextMiddleware → Sets request.state.vendor │
│ │
│ 3. FrontendTypeMiddleware → Sets request.state.frontend_type│
│ │ │
│ └──→ Uses FrontendDetector.detect() │
│ │
│ 4. LanguageMiddleware → Uses frontend_type for language │
│ │
│ 5. ThemeContextMiddleware → Uses frontend_type for theming │
│ │
│ 6. FastAPI Router → Handles request │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Key Files
| File | Purpose |
|------|---------|
| `app/core/frontend_detector.py` | Centralized detection logic |
| `middleware/frontend_type.py` | Middleware that sets `request.state.frontend_type` |
| `app/modules/enums.py` | Defines `FrontendType` enum |
## FrontendType Enum
```python
class FrontendType(str, Enum):
PLATFORM = "platform" # Marketing pages (/, /pricing, /about)
ADMIN = "admin" # Admin panel (/admin/*)
VENDOR = "vendor" # Vendor dashboard (/vendor/*)
STOREFRONT = "storefront" # Customer shop (/storefront/*, /vendors/*)
```
## Detection Priority
The `FrontendDetector` uses the following priority order:
```
1. Admin subdomain (admin.oms.lu) → ADMIN
2. Path-based detection:
- /admin/* or /api/v1/admin/* → ADMIN
- /vendor/* or /api/v1/vendor/* → VENDOR
- /storefront/*, /shop/*, /vendors/* → STOREFRONT
- /api/v1/platform/* → PLATFORM
3. Vendor subdomain (wizamart.oms.lu) → STOREFRONT
4. Vendor context set by middleware → STOREFRONT
5. Default → PLATFORM
```
### Path Patterns
```python
# Admin paths
ADMIN_PATH_PREFIXES = ("/admin", "/api/v1/admin")
# Vendor dashboard paths
VENDOR_PATH_PREFIXES = ("/vendor/", "/api/v1/vendor")
# Storefront paths
STOREFRONT_PATH_PREFIXES = (
"/storefront",
"/api/v1/storefront",
"/shop", # Legacy support
"/api/v1/shop", # Legacy support
"/vendors/", # Path-based vendor access
)
# Platform paths
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
```
### Reserved Subdomains
These subdomains are NOT treated as vendor storefronts:
```python
RESERVED_SUBDOMAINS = {"www", "admin", "api", "vendor", "portal"}
```
## Usage
### In Middleware/Routes
```python
from middleware.frontend_type import get_frontend_type
from app.modules.enums import FrontendType
@router.get("/some-route")
async def some_route(request: Request):
frontend_type = get_frontend_type(request)
if frontend_type == FrontendType.ADMIN:
# Admin-specific logic
pass
elif frontend_type == FrontendType.STOREFRONT:
# Storefront-specific logic
pass
```
### Direct Detection (without request)
```python
from app.core.frontend_detector import FrontendDetector
from app.modules.enums import FrontendType
# Full detection
frontend_type = FrontendDetector.detect(
host="wizamart.oms.lu",
path="/products",
has_vendor_context=True
)
# Returns: FrontendType.STOREFRONT
# Convenience methods
if FrontendDetector.is_admin(host, path):
# Admin logic
pass
if FrontendDetector.is_storefront(host, path, has_vendor_context=True):
# Storefront logic
pass
```
## Detection Scenarios
### Development Mode (localhost)
| Request | Host | Path | Frontend |
|---------|------|------|----------|
| Admin page | localhost | /admin/vendors | ADMIN |
| Admin API | localhost | /api/v1/admin/users | ADMIN |
| Vendor dashboard | localhost | /vendor/settings | VENDOR |
| Vendor API | localhost | /api/v1/vendor/products | VENDOR |
| Storefront | localhost | /storefront/products | STOREFRONT |
| Storefront (path-based) | localhost | /vendors/wizamart/products | STOREFRONT |
| Marketing | localhost | /pricing | PLATFORM |
### Production Mode (domains)
| Request | Host | Path | Frontend |
|---------|------|------|----------|
| Admin subdomain | admin.oms.lu | /dashboard | ADMIN |
| Vendor subdomain | wizamart.oms.lu | /products | STOREFRONT |
| Custom domain | mybakery.lu | /products | STOREFRONT |
| Platform root | oms.lu | /pricing | PLATFORM |
## Migration from RequestContext
The previous `RequestContext` enum is deprecated. Here's the mapping:
| Old (RequestContext) | New (FrontendType) |
|---------------------|-------------------|
| `API` | Use `FrontendDetector.is_api_request()` + FrontendType |
| `ADMIN` | `FrontendType.ADMIN` |
| `VENDOR_DASHBOARD` | `FrontendType.VENDOR` |
| `SHOP` | `FrontendType.STOREFRONT` |
| `FALLBACK` | `FrontendType.PLATFORM` |
### Code Migration
**Before (deprecated):**
```python
from middleware.context import RequestContext, get_request_context
context = get_request_context(request)
if context == RequestContext.SHOP:
# Storefront logic
pass
```
**After:**
```python
from middleware.frontend_type import get_frontend_type
from app.modules.enums import FrontendType
frontend_type = get_frontend_type(request)
if frontend_type == FrontendType.STOREFRONT:
# Storefront logic
pass
```
## Request State
After `FrontendTypeMiddleware` runs, the following is available:
```python
request.state.frontend_type # FrontendType enum value
```
This is used by:
- `LanguageMiddleware` - to determine language resolution strategy
- `ErrorRenderer` - to select appropriate error templates
- `ExceptionHandler` - to redirect to correct login page
- Route handlers - for frontend-specific logic
## Testing
### Unit Tests
Tests are located in:
- `tests/unit/core/test_frontend_detector.py` - FrontendDetector tests
- `tests/unit/middleware/test_frontend_type.py` - Middleware tests
### Running Tests
```bash
# Run all frontend detection tests
pytest tests/unit/core/test_frontend_detector.py tests/unit/middleware/test_frontend_type.py -v
# Run with coverage
pytest tests/unit/core/test_frontend_detector.py tests/unit/middleware/test_frontend_type.py --cov=app.core.frontend_detector --cov=middleware.frontend_type
```
## Best Practices
### DO
1. **Use `get_frontend_type(request)`** in route handlers
2. **Use `FrontendDetector.detect()`** when you have host/path but no request
3. **Use convenience methods** like `is_admin()`, `is_storefront()` for boolean checks
4. **Import from the correct location:**
```python
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
from app.core.frontend_detector import FrontendDetector
```
### DON'T
1. **Don't use `RequestContext`** - it's deprecated
2. **Don't duplicate path detection logic** - use FrontendDetector
3. **Don't hardcode path patterns** in middleware - they're centralized in FrontendDetector
4. **Don't check `request.state.context_type`** - use `request.state.frontend_type`
## Architecture Rules
These rules are enforced by `scripts/validate_architecture.py`:
| Rule | Description |
|------|-------------|
| MID-001 | Use FrontendDetector for frontend detection |
| MID-002 | Don't hardcode path patterns in middleware |
| MID-003 | Use FrontendType enum, not RequestContext |
## Related Documentation
- [Middleware Stack](middleware.md) - Overall middleware architecture
- [Request Flow](request-flow.md) - How requests are processed
- [URL Routing](url-routing/overview.md) - URL structure and routing patterns
- [Multi-Tenant Architecture](multi-tenant.md) - Tenant detection and isolation

View File

@@ -120,35 +120,35 @@ Injects: request.state.vendor = <Vendor object>
**Note on Path-Based Routing:** Previous implementations used a `PathRewriteMiddleware` to rewrite paths at runtime. This has been replaced with **double router mounting** in `main.py`, where shop routes are registered twice with different prefixes (`/shop` and `/vendors/{vendor_code}/shop`). This approach is simpler and uses FastAPI's native routing capabilities.
### 3. Context Detection Middleware
### 3. Frontend Type Detection Middleware
**Purpose**: Determine the type/context of the request
**Purpose**: Determine which frontend the request targets
**What it does**:
- Analyzes the request path (using clean_path)
- Determines which interface is being accessed:
- `API` - `/api/*` paths
- `ADMIN` - `/admin/*` paths or `admin.*` subdomain
- `VENDOR_DASHBOARD` - `/vendor/*` paths (management area)
- `SHOP` - Storefront pages (has vendor + not admin/vendor/API)
- `FALLBACK` - Unknown context
- Injects `request.state.context_type`
- Uses centralized `FrontendDetector` class for all detection logic
- Determines which frontend is being accessed:
- `ADMIN` - `/admin/*`, `/api/v1/admin/*` paths or `admin.*` subdomain
- `VENDOR` - `/vendor/*`, `/api/v1/vendor/*` paths (management area)
- `STOREFRONT` - Customer shop pages (`/storefront/*`, `/vendors/*`, vendor subdomains)
- `PLATFORM` - Marketing pages (`/`, `/pricing`, `/about`)
- Injects `request.state.frontend_type` (FrontendType enum)
**Detection Rules**:
**Detection Priority** (handled by `FrontendDetector`):
```python
if path.startswith("/api/"):
context = API
elif path.startswith("/admin/") or host.startswith("admin."):
context = ADMIN
elif path.startswith("/vendor/"):
context = VENDOR_DASHBOARD
elif request.state.vendor exists:
context = SHOP
else:
context = FALLBACK
1. Admin subdomain (admin.oms.lu) ADMIN
2. Path-based detection:
- /admin/* or /api/v1/admin/* ADMIN
- /vendor/* or /api/v1/vendor/* VENDOR
- /storefront/*, /shop/*, /vendors/* STOREFRONT
- /api/v1/platform/* PLATFORM
3. Vendor subdomain (wizamart.oms.lu) STOREFRONT
4. Vendor context set by middleware STOREFRONT
5. Default PLATFORM
```
**Why it's useful**: Error handlers and templates adapt based on context
**Why it's useful**: Error handlers, templates, and language detection adapt based on frontend type
**See**: [Frontend Detection Architecture](frontend-detection.md) for complete details
### 4. Theme Context Middleware
@@ -221,15 +221,18 @@ Each middleware file contains one primary class or a tightly related set of clas
class LoggingMiddleware(BaseHTTPMiddleware):
"""Request/response logging middleware"""
# middleware/context.py
class ContextManager: # Business logic
class ContextMiddleware: # ASGI wrapper
class RequestContext(Enum): # Related enum
# middleware/frontend_type.py
class FrontendTypeMiddleware: # ASGI wrapper for frontend detection
# Uses FrontendDetector from app/core/frontend_detector.py
# middleware/auth.py
class AuthManager: # Authentication logic
```
> **Note**: The old `middleware/context.py` with `ContextMiddleware` and `RequestContext` is deprecated.
> Use `FrontendTypeMiddleware` and `FrontendType` enum instead.
> See [Frontend Detection Architecture](frontend-detection.md) for migration guide.
#### One Test File Per Component
Follow the Single Responsibility Principle - each test file tests exactly one component:
@@ -368,8 +371,8 @@ async def get_products(request: Request):
}
</style>
{# Access context #}
{% if request.state.context_type.value == "admin" %}
{# Access frontend type #}
{% if request.state.frontend_type.value == "admin" %}
<div class="admin-badge">Admin Mode</div>
{% endif %}
```
@@ -394,11 +397,11 @@ async def get_products(request: Request):
↓ Sets: request.state.vendor_id = 1
↓ Sets: request.state.clean_path = "/shop/products"
3. ContextDetectionMiddleware
Analyzes path: "/shop/products"
↓ Has vendor: Yes
Not admin/api/vendor dashboard
↓ Sets: request.state.context_type = RequestContext.SHOP
3. FrontendTypeMiddleware
Uses FrontendDetector with path: "/shop/products"
↓ Has vendor context: Yes
Detects storefront frontend
↓ Sets: request.state.frontend_type = FrontendType.STOREFRONT
4. ThemeContextMiddleware
↓ Loads theme for vendor_id = 1
@@ -430,10 +433,10 @@ Each middleware component handles errors gracefully:
- If database error: Logs error, allows request to continue
- Fallback: Request proceeds without vendor context
### ContextDetectionMiddleware
### FrontendTypeMiddleware
- If clean_path missing: Uses original path
- If vendor missing: Defaults to FALLBACK context
- Always sets a context_type (never None)
- If vendor missing: Defaults to PLATFORM frontend type
- Always sets a frontend_type (never None)
### ThemeContextMiddleware
- If vendor missing: Skips theme loading
@@ -479,8 +482,10 @@ Middleware is registered in `main.py`:
# Add in REVERSE order (LIFO execution)
app.add_middleware(LoggingMiddleware)
app.add_middleware(ThemeContextMiddleware)
app.add_middleware(ContextDetectionMiddleware)
app.add_middleware(LanguageMiddleware)
app.add_middleware(FrontendTypeMiddleware)
app.add_middleware(VendorContextMiddleware)
app.add_middleware(PlatformContextMiddleware)
```
**Note**: FastAPI's `add_middleware` executes in **reverse order** (Last In, First Out)

View File

@@ -147,27 +147,25 @@ app.include_router(shop_pages.router, prefix="/vendors/{vendor_code}/shop")
**Detection Logic**:
```python
host = request.headers.get("host", "")
path = request.state.clean_path # "/shop/products"
has_vendor = hasattr(request.state, 'vendor') and request.state.vendor
if path.startswith("/api/"):
context = RequestContext.API
elif path.startswith("/admin/"):
context = RequestContext.ADMIN
elif path.startswith("/vendor/"):
context = RequestContext.VENDOR_DASHBOARD
elif hasattr(request.state, 'vendor') and request.state.vendor:
context = RequestContext.SHOP # ← Our example
else:
context = RequestContext.FALLBACK
# FrontendDetector handles all detection logic centrally
frontend_type = FrontendDetector.detect(host, path, has_vendor)
# Returns: FrontendType.STOREFRONT # ← Our example
request.state.context_type = context
request.state.frontend_type = frontend_type
```
**Request State After**:
```python
request.state.context_type = RequestContext.SHOP
request.state.frontend_type = FrontendType.STOREFRONT
```
> **Note**: Detection logic is centralized in `app/core/frontend_detector.py`.
> See [Frontend Detection Architecture](frontend-detection.md) for details.
### 6. ThemeContextMiddleware
**What happens**:
@@ -363,7 +361,7 @@ sequenceDiagram
Context->>Router: Route request
Router->>Handler: Call API handler
Handler->>DB: Query products
DB-->>Handler: Product data
DB-->>Handler: Product data
Handler-->>Router: JSON response
Router-->>Client: {products: [...]}
```
@@ -390,7 +388,7 @@ sequenceDiagram
Context->>Theme: Pass request
Note over Theme: Skip theme<br/>(No vendor)
Theme->>Router: Route request
Router->>Handler: Call handler
Router->>Handler: Call handler
Handler->>Template: Render admin template
Template-->>Client: Admin HTML page
```
@@ -423,7 +421,7 @@ sequenceDiagram
Context->>Theme: Pass request
Theme->>DB: Query theme
DB-->>Theme: Theme config
Note over Theme: Set theme in request.state
Note over Theme: Set theme in request.state
Theme->>Router: Route request
Router->>Handler: Call handler
Handler->>DB: Query products for vendor
@@ -450,12 +448,12 @@ After VendorContextMiddleware:
{
vendor: <Vendor: Wizamart>,
vendor_id: 1,
clean_path: "/shop/products",
clean_path: "/shop/products",
frontend_type: FrontendType.STOREFRONT
}
After ThemeContextMiddleware:
{
{
vendor: <Vendor: Wizamart>,
vendor_id: 1,
clean_path: "/shop/products",
@@ -463,7 +461,7 @@ After ThemeContextMiddleware:
theme: {
primary_color: "#3B82F6",
secondary_color: "#10B981",
logo_url: "/static/vendors/wizamart/logo.png",
logo_url: "/static/vendors/wizamart/logo.png",
custom_css: "..."
}
}
@@ -481,7 +479,7 @@ Typical request timings:
| - ThemeContextMiddleware | 2ms | 1% |
| Database Queries | 15ms | 10% |
| Business Logic | 50ms | 35% |
| Template Rendering | 75ms | 52% |
| Template Rendering | 75ms | 52% |
| **Total** | **145ms** | **100%** |
## Error Handling in Flow
@@ -550,7 +548,7 @@ async def debug_state(request: Request):
"has_theme": bool(getattr(request.state, 'theme', None))
}
```
### Check Middleware Order
In `main.py`, middleware registration order is critical:
@@ -562,7 +560,8 @@ In `main.py`, middleware registration order is critical:
app.add_middleware(LanguageMiddleware) # Runs fifth
app.add_middleware(FrontendTypeMiddleware) # Runs fourth
app.add_middleware(VendorContextMiddleware) # Runs second
app.add_middleware(ThemeContextMiddleware) # Runs fifth
app.add_middleware(ContextDetectionMiddleware) # Runs fourth
```
app.add_middleware(LanguageMiddleware) # Runs fifth
app.add_middleware(FrontendTypeMiddleware) # Runs fourth
app.add_middleware(VendorContextMiddleware) # Runs second
```

View File

@@ -66,51 +66,57 @@ ASGI middleware that wraps VendorContextManager for FastAPI integration.
---
## Request Context Detection
## Frontend Type Detection
### RequestContext
### FrontendType
Enum defining all possible request context types in the application.
Enum defining all possible frontend types in the application.
::: middleware.context.RequestContext
::: app.modules.enums.FrontendType
options:
show_source: false
heading_level: 4
show_root_heading: false
members:
- API
- PLATFORM
- ADMIN
- VENDOR_DASHBOARD
- SHOP
- FALLBACK
- VENDOR
- STOREFRONT
### ContextManager
### FrontendDetector
Detects the type of request (API, Admin, Vendor Dashboard, Shop) based on URL patterns.
Centralized class for detecting which frontend a request targets based on URL patterns.
**Context Detection Rules:**
- `/api/` → API context
- `/admin/` → Admin context
- `/vendor/` → Vendor Dashboard context
- `/shop/` → Shop context
- Default → Fallback context
**Detection Rules (Priority Order):**
1. Admin subdomain (`admin.*`) → ADMIN
2. Path-based detection:
- `/admin/*`, `/api/v1/admin/*` → ADMIN
- `/vendor/*`, `/api/v1/vendor/*` → VENDOR
- `/storefront/*`, `/shop/*`, `/vendors/*` → STOREFRONT
- `/api/v1/platform/*` → PLATFORM
3. Vendor subdomain → STOREFRONT
4. Vendor context set → STOREFRONT
5. Default → PLATFORM
::: middleware.context.ContextManager
::: app.core.frontend_detector.FrontendDetector
options:
show_source: false
heading_level: 4
show_root_heading: false
### ContextMiddleware
### FrontendTypeMiddleware
ASGI middleware for context detection. Must run AFTER VendorContextMiddleware.
ASGI middleware for frontend type detection. Must run AFTER VendorContextMiddleware.
::: middleware.context.ContextMiddleware
::: middleware.frontend_type.FrontendTypeMiddleware
options:
show_source: false
heading_level: 4
show_root_heading: false
> **Note**: The old `RequestContext` enum and `ContextMiddleware` are deprecated.
> See [Frontend Detection Architecture](../architecture/frontend-detection.md) for migration guide.
---
## Theme Management
@@ -235,20 +241,24 @@ The middleware stack must be configured in the correct order for proper function
```mermaid
graph TD
A[Request] --> B[LoggingMiddleware]
B --> C[VendorContextMiddleware]
C --> D[ContextMiddleware]
D --> E[ThemeContextMiddleware]
E --> F[Application Routes]
F --> G[Response]
B --> C[PlatformContextMiddleware]
C --> D[VendorContextMiddleware]
D --> E[FrontendTypeMiddleware]
E --> F[LanguageMiddleware]
F --> G[ThemeContextMiddleware]
G --> H[Application Routes]
H --> I[Response]
```
**Critical Dependencies:**
1. **LoggingMiddleware** runs first for request timing
2. **VendorContextMiddleware** detects vendor and sets clean_path
3. **ContextMiddleware** detects context type (API/Admin/Vendor/Shop)
4. **ThemeContextMiddleware** loads vendor theme based on context
2. **PlatformContextMiddleware** detects platform and sets platform context
3. **VendorContextMiddleware** detects vendor and sets clean_path
4. **FrontendTypeMiddleware** detects frontend type (ADMIN/VENDOR/STOREFRONT/PLATFORM)
5. **LanguageMiddleware** resolves language based on frontend type
6. **ThemeContextMiddleware** loads vendor theme based on context
**Note:** Path-based routing (e.g., `/vendors/{code}/shop/*`) is handled by double router mounting in `main.py`, not by middleware.
**Note:** Path-based routing (e.g., `/vendors/{code}/storefront/*`) is handled by double router mounting in `main.py`, not by middleware.
---
@@ -258,22 +268,28 @@ Middleware components inject the following variables into `request.state`:
| Variable | Set By | Type | Description |
|----------|--------|------|-------------|
| `platform` | PlatformContextMiddleware | Platform | Current platform object |
| `vendor` | VendorContextMiddleware | Vendor | Current vendor object |
| `vendor_id` | VendorContextMiddleware | int | Current vendor ID |
| `clean_path` | VendorContextMiddleware | str | Path without vendor prefix |
| `context_type` | ContextMiddleware | RequestContext | Request context (API/Admin/Vendor/Shop) |
| `frontend_type` | FrontendTypeMiddleware | FrontendType | Frontend type (ADMIN/VENDOR/STOREFRONT/PLATFORM) |
| `language` | LanguageMiddleware | str | Detected language code |
| `theme` | ThemeContextMiddleware | dict | Vendor theme configuration |
**Usage in Routes:**
```python
from fastapi import Request
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
@app.get("/shop/products")
@app.get("/storefront/products")
async def get_products(request: Request):
vendor = request.state.vendor
context = request.state.context_type
frontend_type = get_frontend_type(request)
theme = request.state.theme
return {"vendor": vendor.name, "context": context}
if frontend_type == FrontendType.STOREFRONT:
return {"vendor": vendor.name, "frontend": frontend_type.value}
```
---
@@ -308,6 +324,8 @@ For testing examples, see the [Testing Guide](../testing/testing-guide.md).
## Related Documentation
- [Frontend Detection Architecture](../architecture/frontend-detection.md) - Frontend type detection system
- [Middleware Architecture](../architecture/middleware.md) - Middleware stack overview
- [Authentication Guide](../api/authentication.md) - User authentication and JWT tokens
- [RBAC Documentation](../api/rbac.md) - Role-based access control
- [Error Handling](../api/error-handling.md) - Exception handling patterns

16
main.py
View File

@@ -69,7 +69,7 @@ from app.modules.routes import (
get_vendor_page_routes,
)
from app.utils.i18n import get_jinja2_globals
from middleware.context import ContextMiddleware
from middleware.frontend_type import FrontendTypeMiddleware
from middleware.language import LanguageMiddleware
from middleware.logging import LoggingMiddleware
from middleware.theme_context import ThemeContextMiddleware
@@ -122,15 +122,15 @@ app.add_middleware(
# Desired execution order:
# 1. PlatformContextMiddleware (detect platform from domain/path)
# 2. VendorContextMiddleware (detect vendor, uses platform_clean_path)
# 3. ContextMiddleware (detect context using clean_path)
# 4. LanguageMiddleware (detect language based on context)
# 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector)
# 4. LanguageMiddleware (detect language based on frontend type)
# 5. ThemeContextMiddleware (load theme)
# 6. LoggingMiddleware (log all requests)
#
# Therefore we add them in REVERSE:
# - Add ThemeContextMiddleware FIRST (runs LAST in request)
# - Add LanguageMiddleware SECOND
# - Add ContextMiddleware THIRD
# - Add FrontendTypeMiddleware THIRD
# - Add VendorContextMiddleware FOURTH
# - Add PlatformContextMiddleware FIFTH
# - Add LoggingMiddleware LAST (runs FIRST for timing)
@@ -152,9 +152,9 @@ app.add_middleware(ThemeContextMiddleware)
logger.info("Adding LanguageMiddleware (detects language based on context)")
app.add_middleware(LanguageMiddleware)
# Add context detection middleware (runs after vendor context extraction)
logger.info("Adding ContextMiddleware (detects context type using clean_path)")
app.add_middleware(ContextMiddleware)
# Add frontend type detection middleware (runs after vendor context extraction)
logger.info("Adding FrontendTypeMiddleware (detects frontend type using FrontendDetector)")
app.add_middleware(FrontendTypeMiddleware)
# Add vendor context middleware (runs after platform context)
logger.info("Adding VendorContextMiddleware (detects vendor, uses platform_clean_path)")
@@ -170,7 +170,7 @@ logger.info(" Execution order (request →):")
logger.info(" 1. LoggingMiddleware (timing)")
logger.info(" 2. PlatformContextMiddleware (platform detection)")
logger.info(" 3. VendorContextMiddleware (vendor detection)")
logger.info(" 4. ContextMiddleware (context detection)")
logger.info(" 4. FrontendTypeMiddleware (frontend type detection)")
logger.info(" 5. LanguageMiddleware (language detection)")
logger.info(" 6. ThemeContextMiddleware (theme loading)")
logger.info(" 7. FastAPI Router")

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:

View File

@@ -0,0 +1,256 @@
# tests/unit/core/test_frontend_detector.py
"""
Unit tests for FrontendDetector.
Tests cover:
- Detection for all frontend types (ADMIN, VENDOR, STOREFRONT, PLATFORM)
- Path-based detection (dev mode)
- Subdomain-based detection (prod mode)
- Custom domain detection
- Legacy /shop/ path support
- Priority order of detection methods
"""
import pytest
from app.core.frontend_detector import FrontendDetector, get_frontend_type
from app.modules.enums import FrontendType
@pytest.mark.unit
class TestFrontendDetectorAdmin:
"""Test suite for admin frontend detection."""
def test_detect_admin_from_subdomain(self):
"""Test admin detection from admin subdomain."""
result = FrontendDetector.detect(host="admin.oms.lu", path="/dashboard")
assert result == FrontendType.ADMIN
def test_detect_admin_from_subdomain_with_port(self):
"""Test admin detection from admin subdomain with port."""
result = FrontendDetector.detect(host="admin.localhost:8000", path="/dashboard")
assert result == FrontendType.ADMIN
def test_detect_admin_from_path(self):
"""Test admin detection from /admin path."""
result = FrontendDetector.detect(host="localhost", path="/admin/vendors")
assert result == FrontendType.ADMIN
def test_detect_admin_from_api_path(self):
"""Test admin detection from /api/v1/admin path."""
result = FrontendDetector.detect(host="localhost", path="/api/v1/admin/users")
assert result == FrontendType.ADMIN
def test_detect_admin_nested_path(self):
"""Test admin detection with nested admin path."""
result = FrontendDetector.detect(host="oms.lu", path="/admin/vendors/123/products")
assert result == FrontendType.ADMIN
@pytest.mark.unit
class TestFrontendDetectorVendor:
"""Test suite for vendor dashboard frontend detection."""
def test_detect_vendor_from_path(self):
"""Test vendor detection from /vendor/ path."""
result = FrontendDetector.detect(host="localhost", path="/vendor/settings")
assert result == FrontendType.VENDOR
def test_detect_vendor_from_api_path(self):
"""Test vendor detection from /api/v1/vendor path."""
result = FrontendDetector.detect(host="localhost", path="/api/v1/vendor/products")
assert result == FrontendType.VENDOR
def test_detect_vendor_nested_path(self):
"""Test vendor detection with nested vendor path."""
result = FrontendDetector.detect(host="oms.lu", path="/vendor/dashboard/analytics")
assert result == FrontendType.VENDOR
def test_vendors_plural_not_vendor_dashboard(self):
"""Test that /vendors/ path is NOT vendor dashboard (it's storefront)."""
result = FrontendDetector.detect(host="localhost", path="/vendors/wizamart/storefront")
assert result == FrontendType.STOREFRONT
@pytest.mark.unit
class TestFrontendDetectorStorefront:
"""Test suite for storefront frontend detection."""
def test_detect_storefront_from_path(self):
"""Test storefront detection from /storefront path."""
result = FrontendDetector.detect(host="localhost", path="/storefront/products")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_api_path(self):
"""Test storefront detection from /api/v1/storefront path."""
result = FrontendDetector.detect(host="localhost", path="/api/v1/storefront/cart")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_vendors_path(self):
"""Test storefront detection from /vendors/ path (path-based vendor access)."""
result = FrontendDetector.detect(host="localhost", path="/vendors/wizamart/products")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_vendor_subdomain(self):
"""Test storefront detection from vendor subdomain."""
result = FrontendDetector.detect(host="wizamart.oms.lu", path="/products")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_vendor_context(self):
"""Test storefront detection when vendor context is set."""
result = FrontendDetector.detect(
host="mybakery.lu", path="/about", has_vendor_context=True
)
assert result == FrontendType.STOREFRONT
def test_detect_storefront_legacy_shop_path(self):
"""Test storefront detection from legacy /shop path."""
result = FrontendDetector.detect(host="localhost", path="/shop/products")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_legacy_shop_api_path(self):
"""Test storefront detection from legacy /api/v1/shop path."""
result = FrontendDetector.detect(host="localhost", path="/api/v1/shop/cart")
assert result == FrontendType.STOREFRONT
@pytest.mark.unit
class TestFrontendDetectorPlatform:
"""Test suite for platform marketing frontend detection."""
def test_detect_platform_from_root(self):
"""Test platform detection from root path."""
result = FrontendDetector.detect(host="localhost", path="/")
assert result == FrontendType.PLATFORM
def test_detect_platform_from_marketing_page(self):
"""Test platform detection from marketing page."""
result = FrontendDetector.detect(host="oms.lu", path="/pricing")
assert result == FrontendType.PLATFORM
def test_detect_platform_from_about(self):
"""Test platform detection from about page."""
result = FrontendDetector.detect(host="localhost", path="/about")
assert result == FrontendType.PLATFORM
def test_detect_platform_from_api_path(self):
"""Test platform detection from /api/v1/platform path."""
result = FrontendDetector.detect(host="localhost", path="/api/v1/platform/config")
assert result == FrontendType.PLATFORM
@pytest.mark.unit
class TestFrontendDetectorPriority:
"""Test suite for detection priority order."""
def test_admin_subdomain_priority_over_path(self):
"""Test that admin subdomain takes priority."""
result = FrontendDetector.detect(host="admin.oms.lu", path="/storefront/products")
assert result == FrontendType.ADMIN
def test_admin_path_priority_over_vendor_context(self):
"""Test that admin path takes priority over vendor context."""
result = FrontendDetector.detect(
host="localhost", path="/admin/dashboard", has_vendor_context=True
)
assert result == FrontendType.ADMIN
def test_path_priority_over_subdomain(self):
"""Test that explicit path takes priority for vendor/storefront."""
# /vendor/ path on a vendor subdomain -> VENDOR (path wins)
result = FrontendDetector.detect(host="wizamart.oms.lu", path="/vendor/settings")
assert result == FrontendType.VENDOR
@pytest.mark.unit
class TestFrontendDetectorHelpers:
"""Test suite for helper methods."""
def test_strip_port(self):
"""Test port stripping from host."""
assert FrontendDetector._strip_port("localhost:8000") == "localhost"
assert FrontendDetector._strip_port("oms.lu") == "oms.lu"
assert FrontendDetector._strip_port("admin.localhost:9999") == "admin.localhost"
def test_get_subdomain(self):
"""Test subdomain extraction."""
assert FrontendDetector._get_subdomain("wizamart.oms.lu") == "wizamart"
assert FrontendDetector._get_subdomain("admin.oms.lu") == "admin"
assert FrontendDetector._get_subdomain("oms.lu") is None
assert FrontendDetector._get_subdomain("localhost") is None
assert FrontendDetector._get_subdomain("127.0.0.1") is None
def test_is_admin(self):
"""Test is_admin convenience method."""
assert FrontendDetector.is_admin("admin.oms.lu", "/dashboard") is True
assert FrontendDetector.is_admin("localhost", "/admin/vendors") is True
assert FrontendDetector.is_admin("localhost", "/vendor/settings") is False
def test_is_vendor(self):
"""Test is_vendor convenience method."""
assert FrontendDetector.is_vendor("localhost", "/vendor/settings") is True
assert FrontendDetector.is_vendor("localhost", "/api/v1/vendor/products") is True
assert FrontendDetector.is_vendor("localhost", "/admin/dashboard") is False
def test_is_storefront(self):
"""Test is_storefront convenience method."""
assert FrontendDetector.is_storefront("localhost", "/storefront/products") is True
assert FrontendDetector.is_storefront("wizamart.oms.lu", "/products") is True
assert FrontendDetector.is_storefront("localhost", "/admin/dashboard") is False
def test_is_platform(self):
"""Test is_platform convenience method."""
assert FrontendDetector.is_platform("localhost", "/") is True
assert FrontendDetector.is_platform("oms.lu", "/pricing") is True
assert FrontendDetector.is_platform("localhost", "/admin/dashboard") is False
def test_is_api_request(self):
"""Test is_api_request convenience method."""
assert FrontendDetector.is_api_request("/api/v1/vendors") is True
assert FrontendDetector.is_api_request("/api/v1/admin/users") is True
assert FrontendDetector.is_api_request("/admin/dashboard") is False
@pytest.mark.unit
class TestGetFrontendTypeFunction:
"""Test suite for get_frontend_type convenience function."""
def test_get_frontend_type_admin(self):
"""Test get_frontend_type returns admin."""
result = get_frontend_type("localhost", "/admin/dashboard")
assert result == FrontendType.ADMIN
def test_get_frontend_type_vendor(self):
"""Test get_frontend_type returns vendor."""
result = get_frontend_type("localhost", "/vendor/settings")
assert result == FrontendType.VENDOR
def test_get_frontend_type_storefront(self):
"""Test get_frontend_type returns storefront."""
result = get_frontend_type("localhost", "/storefront/products")
assert result == FrontendType.STOREFRONT
def test_get_frontend_type_platform(self):
"""Test get_frontend_type returns platform."""
result = get_frontend_type("localhost", "/pricing")
assert result == FrontendType.PLATFORM
@pytest.mark.unit
class TestReservedSubdomains:
"""Test suite for reserved subdomain handling."""
def test_www_subdomain_not_storefront(self):
"""Test that www subdomain is not treated as vendor storefront."""
result = FrontendDetector.detect(host="www.oms.lu", path="/")
assert result == FrontendType.PLATFORM
def test_api_subdomain_not_storefront(self):
"""Test that api subdomain is not treated as vendor storefront."""
result = FrontendDetector.detect(host="api.oms.lu", path="/v1/products")
assert result == FrontendType.PLATFORM
def test_portal_subdomain_not_storefront(self):
"""Test that portal subdomain is not treated as vendor storefront."""
result = FrontendDetector.detect(host="portal.oms.lu", path="/")
assert result == FrontendType.PLATFORM

View File

@@ -1,31 +1,31 @@
# tests/unit/middleware/test_context.py
"""
Comprehensive unit tests for ContextMiddleware and ContextManager.
DEPRECATED: Tests for backward compatibility of middleware.context module.
Tests cover:
- Context detection for API, Admin, Vendor Dashboard, Shop, and Fallback
- Clean path usage for correct context detection
- Host and path-based context determination
- Middleware state injection
- Edge cases and error handling
The ContextMiddleware and ContextManager classes have been replaced by:
- FrontendTypeMiddleware (middleware/frontend_type.py)
- FrontendDetector (app/core/frontend_detector.py)
These tests verify the backward compatibility layer still works for code
that uses the deprecated RequestContext enum and get_request_context() function.
For new tests, see:
- tests/unit/core/test_frontend_detector.py
- tests/unit/middleware/test_frontend_type.py
"""
from unittest.mock import AsyncMock, Mock
import warnings
from unittest.mock import Mock
import pytest
from fastapi import Request
from middleware.context import (
ContextManager,
ContextMiddleware,
RequestContext,
get_request_context,
)
from middleware.context import RequestContext, get_request_context
@pytest.mark.unit
class TestRequestContextEnum:
"""Test suite for RequestContext enum."""
class TestRequestContextEnumBackwardCompatibility:
"""Test suite for deprecated RequestContext enum."""
def test_request_context_values(self):
"""Test RequestContext enum has correct values."""
@@ -42,554 +42,90 @@ class TestRequestContextEnum:
@pytest.mark.unit
class TestContextManagerDetection:
"""Test suite for ContextManager.detect_context()."""
class TestGetRequestContextBackwardCompatibility:
"""Test suite for deprecated get_request_context() function."""
# ========================================================================
# API Context Tests (Highest Priority)
# ========================================================================
def test_detect_api_context(self):
"""Test API context detection."""
def test_get_request_context_returns_api_for_api_paths(self):
"""Test get_request_context returns API for /api/ paths."""
request = Mock(spec=Request)
request.url = Mock(path="/api/v1/vendors")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/api/v1/vendors")
context = ContextManager.detect_context(request)
assert context == RequestContext.API
def test_detect_api_context_nested_path(self):
"""Test API context detection with nested path."""
request = Mock(spec=Request)
request.url = Mock(path="/api/v1/vendors/123/products")
request.headers = {"host": "platform.com"}
request.state = Mock(clean_path="/api/v1/vendors/123/products")
context = ContextManager.detect_context(request)
assert context == RequestContext.API
def test_detect_api_context_with_clean_path(self):
"""Test API context detection uses clean_path when available."""
request = Mock(spec=Request)
request.url = Mock(path="/vendor/testvendor/api/products")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/api/products")
context = ContextManager.detect_context(request)
assert context == RequestContext.API
# ========================================================================
# Admin Context Tests
# ========================================================================
def test_detect_admin_context_from_subdomain(self):
"""Test admin context detection from subdomain."""
request = Mock(spec=Request)
request.url = Mock(path="/dashboard")
request.headers = {"host": "admin.platform.com"}
request.state = Mock(clean_path="/dashboard")
context = ContextManager.detect_context(request)
assert context == RequestContext.ADMIN
def test_detect_admin_context_from_path(self):
"""Test admin context detection from path."""
request = Mock(spec=Request)
request.url = Mock(path="/admin/dashboard")
request.headers = {"host": "platform.com"}
request.state = Mock(clean_path="/admin/dashboard")
context = ContextManager.detect_context(request)
assert context == RequestContext.ADMIN
def test_detect_admin_context_with_port(self):
"""Test admin context detection with port number."""
request = Mock(spec=Request)
request.url = Mock(path="/dashboard")
request.headers = {"host": "admin.localhost:8000"}
request.state = Mock(clean_path="/dashboard")
context = ContextManager.detect_context(request)
assert context == RequestContext.ADMIN
def test_detect_admin_context_nested_path(self):
"""Test admin context detection with nested admin path."""
request = Mock(spec=Request)
request.url = Mock(path="/admin/vendors/list")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/admin/vendors/list")
context = ContextManager.detect_context(request)
assert context == RequestContext.ADMIN
# ========================================================================
# Vendor Dashboard Context Tests
# ========================================================================
def test_detect_vendor_dashboard_context(self):
"""Test vendor dashboard context detection."""
request = Mock(spec=Request)
request.url = Mock(path="/vendor/testvendor/dashboard")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/dashboard")
context = ContextManager.detect_context(request)
assert context == RequestContext.VENDOR_DASHBOARD
def test_detect_vendor_dashboard_context_direct_path(self):
"""Test vendor dashboard with direct /vendor/ path."""
request = Mock(spec=Request)
request.url = Mock(path="/vendor/settings")
request.headers = {"host": "testvendor.platform.com"}
request.state = Mock(clean_path="/vendor/settings")
context = ContextManager.detect_context(request)
assert context == RequestContext.VENDOR_DASHBOARD
def test_not_detect_vendors_plural_as_dashboard(self):
"""Test that /vendors/ path is not detected as vendor dashboard."""
request = Mock(spec=Request)
request.url = Mock(path="/vendors/testvendor/shop")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/shop")
# Should not be vendor dashboard
context = ContextManager.detect_context(request)
assert context != RequestContext.VENDOR_DASHBOARD
# ========================================================================
# Shop Context Tests
# ========================================================================
def test_detect_shop_context_with_vendor_state(self):
"""Test shop context detection when vendor exists in request state."""
request = Mock(spec=Request)
request.url = Mock(path="/products")
request.headers = {"host": "testvendor.platform.com"}
mock_vendor = Mock()
mock_vendor.name = "Test Vendor"
request.state = Mock(clean_path="/products", vendor=mock_vendor)
context = ContextManager.detect_context(request)
assert context == RequestContext.SHOP
def test_detect_shop_context_from_shop_path(self):
"""Test shop context detection from /shop/ path."""
request = Mock(spec=Request)
request.url = Mock(path="/shop/products")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/shop/products", vendor=None)
context = ContextManager.detect_context(request)
assert context == RequestContext.SHOP
def test_detect_shop_context_custom_domain(self):
"""Test shop context with custom domain and vendor."""
request = Mock(spec=Request)
request.url = Mock(path="/products")
request.headers = {"host": "customdomain.com"}
mock_vendor = Mock(name="Custom Vendor")
request.state = Mock(clean_path="/products", vendor=mock_vendor)
context = ContextManager.detect_context(request)
assert context == RequestContext.SHOP
# ========================================================================
# Fallback Context Tests
# ========================================================================
def test_detect_fallback_context(self):
"""Test fallback context for unknown paths."""
request = Mock(spec=Request)
request.url = Mock(path="/random/path")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/random/path", vendor=None)
context = ContextManager.detect_context(request)
assert context == RequestContext.FALLBACK
def test_detect_fallback_context_root(self):
"""Test fallback context for root path."""
request = Mock(spec=Request)
request.url = Mock(path="/")
request.headers = {"host": "platform.com"}
request.state = Mock(clean_path="/", vendor=None)
context = ContextManager.detect_context(request)
assert context == RequestContext.FALLBACK
def test_detect_fallback_context_no_vendor(self):
"""Test fallback context when no vendor context exists."""
request = Mock(spec=Request)
request.url = Mock(path="/about")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/about", vendor=None)
context = ContextManager.detect_context(request)
assert context == RequestContext.FALLBACK
# ========================================================================
# Clean Path Tests
# ========================================================================
def test_uses_clean_path_when_available(self):
"""Test that clean_path is used over original path."""
request = Mock(spec=Request)
request.url = Mock(path="/vendor/testvendor/api/products")
request.headers = {"host": "localhost"}
# clean_path shows the rewritten path
request.state = Mock(clean_path="/api/products")
context = ContextManager.detect_context(request)
# Should detect as API based on clean_path, not original path
assert context == RequestContext.API
def test_falls_back_to_original_path(self):
"""Test falls back to original path when clean_path not set."""
request = Mock(spec=Request)
request.url = Mock(path="/api/vendors")
request.headers = {"host": "localhost"}
request.state = Mock(spec=[]) # No clean_path attribute
context = ContextManager.detect_context(request)
assert context == RequestContext.API
# ========================================================================
# Priority Order Tests
# ========================================================================
def test_api_has_highest_priority(self):
"""Test API context takes precedence over admin."""
request = Mock(spec=Request)
request.url = Mock(path="/api/admin/users")
request.headers = {"host": "admin.platform.com"}
request.state = Mock(clean_path="/api/admin/users")
context = ContextManager.detect_context(request)
# API should win even though it's admin subdomain
assert context == RequestContext.API
def test_admin_has_priority_over_shop(self):
"""Test admin context takes precedence over shop."""
request = Mock(spec=Request)
request.url = Mock(path="/admin/shops")
request.headers = {"host": "localhost"}
mock_vendor = Mock()
request.state = Mock(clean_path="/admin/shops", vendor=mock_vendor)
context = ContextManager.detect_context(request)
# Admin should win even though vendor exists
assert context == RequestContext.ADMIN
def test_vendor_dashboard_priority_over_shop(self):
"""Test vendor dashboard takes precedence over shop."""
request = Mock(spec=Request)
request.url = Mock(path="/vendor/settings")
request.headers = {"host": "testvendor.platform.com"}
mock_vendor = Mock()
request.state = Mock(clean_path="/vendor/settings", vendor=mock_vendor)
context = ContextManager.detect_context(request)
assert context == RequestContext.VENDOR_DASHBOARD
@pytest.mark.unit
class TestContextManagerHelpers:
"""Test suite for ContextManager helper methods."""
def test_is_admin_context_from_subdomain(self):
"""Test _is_admin_context with admin subdomain."""
request = Mock()
assert (
ContextManager._is_admin_context(
request, "admin.platform.com", "/dashboard"
)
is True
)
def test_is_admin_context_from_path(self):
"""Test _is_admin_context with admin path."""
request = Mock()
assert (
ContextManager._is_admin_context(request, "localhost", "/admin/users")
is True
)
def test_is_admin_context_both(self):
"""Test _is_admin_context with both subdomain and path."""
request = Mock()
assert (
ContextManager._is_admin_context(
request, "admin.platform.com", "/admin/users"
)
is True
)
def test_is_not_admin_context(self):
"""Test _is_admin_context returns False for non-admin."""
request = Mock()
assert (
ContextManager._is_admin_context(request, "vendor.platform.com", "/shop")
is False
)
def test_is_vendor_dashboard_context(self):
"""Test _is_vendor_dashboard_context with /vendor/ path."""
assert ContextManager._is_vendor_dashboard_context("/vendor/settings") is True
def test_is_vendor_dashboard_context_nested(self):
"""Test _is_vendor_dashboard_context with nested vendor path."""
assert (
ContextManager._is_vendor_dashboard_context("/vendor/products/list") is True
)
def test_is_not_vendor_dashboard_context_vendors_plural(self):
"""Test _is_vendor_dashboard_context excludes /vendors/ path."""
assert (
ContextManager._is_vendor_dashboard_context("/vendors/shop123/products")
is False
)
def test_is_not_vendor_dashboard_context(self):
"""Test _is_vendor_dashboard_context returns False for non-vendor paths."""
assert ContextManager._is_vendor_dashboard_context("/shop/products") is False
@pytest.mark.unit
class TestContextMiddleware:
"""Test suite for ContextMiddleware."""
@pytest.mark.asyncio
async def test_middleware_sets_context(self):
"""Test middleware successfully sets context in request state."""
middleware = ContextMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/api/vendors")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/api/vendors", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert hasattr(request.state, "context_type")
assert request.state.context_type == RequestContext.API
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_middleware_sets_admin_context(self):
"""Test middleware sets admin context."""
middleware = ContextMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/admin/dashboard")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/admin/dashboard")
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.context_type == RequestContext.ADMIN
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_sets_vendor_dashboard_context(self):
"""Test middleware sets vendor dashboard context."""
middleware = ContextMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/vendor/settings")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/vendor/settings")
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.context_type == RequestContext.VENDOR_DASHBOARD
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_sets_shop_context(self):
"""Test middleware sets shop context."""
middleware = ContextMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/products")
request.headers = {"host": "shop.platform.com"}
mock_vendor = Mock()
request.state = Mock(clean_path="/products", vendor=mock_vendor)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.context_type == RequestContext.SHOP
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_sets_fallback_context(self):
"""Test middleware sets fallback context."""
middleware = ContextMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/random")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/random", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.context_type == RequestContext.FALLBACK
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_returns_response(self):
"""Test middleware returns response from call_next."""
middleware = ContextMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/api/test")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/api/test")
expected_response = Mock()
call_next = AsyncMock(return_value=expected_response)
response = await middleware.dispatch(request, call_next)
assert response is expected_response
@pytest.mark.unit
class TestGetRequestContextHelper:
"""Test suite for get_request_context helper function."""
def test_get_request_context_exists(self):
"""Test getting request context when it exists."""
request = Mock(spec=Request)
request.state.context_type = RequestContext.API
context = get_request_context(request)
assert context == RequestContext.API
def test_get_request_context_default(self):
"""Test getting request context returns FALLBACK as default."""
request = Mock(spec=Request)
request.state = Mock(spec=[]) # No context_type attribute
context = get_request_context(request)
assert context == RequestContext.FALLBACK
def test_get_request_context_for_all_types(self):
"""Test getting all context types."""
for expected_context in RequestContext:
request = Mock(spec=Request)
request.state.context_type = expected_context
request.state = Mock()
request.state.frontend_type = None
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
context = get_request_context(request)
assert context == expected_context
assert context == RequestContext.API
def test_get_request_context_deprecation_warning(self):
"""Test get_request_context raises DeprecationWarning."""
from app.modules.enums import FrontendType
@pytest.mark.unit
class TestEdgeCases:
"""Test suite for edge cases and error scenarios."""
def test_detect_context_empty_path(self):
"""Test context detection with empty path."""
request = Mock(spec=Request)
request.url = Mock(path="")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="", vendor=None)
request.url = Mock(path="/admin/dashboard")
request.state = Mock()
request.state.frontend_type = FrontendType.ADMIN
context = ContextManager.detect_context(request)
with pytest.warns(DeprecationWarning, match="get_request_context.*deprecated"):
get_request_context(request)
assert context == RequestContext.FALLBACK
def test_get_request_context_maps_admin(self):
"""Test get_request_context maps FrontendType.ADMIN to RequestContext.ADMIN."""
from app.modules.enums import FrontendType
def test_detect_context_missing_host(self):
"""Test context detection with missing host header."""
request = Mock(spec=Request)
request.url = Mock(path="/shop/products")
request.headers = {}
request.state = Mock(clean_path="/shop/products", vendor=None)
request.url = Mock(path="/admin/dashboard")
request.state = Mock()
request.state.frontend_type = FrontendType.ADMIN
context = ContextManager.detect_context(request)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
context = get_request_context(request)
assert context == RequestContext.ADMIN
def test_get_request_context_maps_vendor(self):
"""Test get_request_context maps FrontendType.VENDOR to RequestContext.VENDOR_DASHBOARD."""
from app.modules.enums import FrontendType
request = Mock(spec=Request)
request.url = Mock(path="/vendor/settings")
request.state = Mock()
request.state.frontend_type = FrontendType.VENDOR
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
context = get_request_context(request)
assert context == RequestContext.VENDOR_DASHBOARD
def test_get_request_context_maps_storefront(self):
"""Test get_request_context maps FrontendType.STOREFRONT to RequestContext.SHOP."""
from app.modules.enums import FrontendType
request = Mock(spec=Request)
request.url = Mock(path="/storefront/products")
request.state = Mock()
request.state.frontend_type = FrontendType.STOREFRONT
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
context = get_request_context(request)
assert context == RequestContext.SHOP
def test_detect_context_case_sensitivity(self):
"""Test that context detection is case-sensitive for paths."""
def test_get_request_context_maps_platform_to_fallback(self):
"""Test get_request_context maps FrontendType.PLATFORM to RequestContext.FALLBACK."""
from app.modules.enums import FrontendType
request = Mock(spec=Request)
request.url = Mock(path="/API/vendors") # Uppercase
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/API/vendors")
request.url = Mock(path="/pricing")
request.state = Mock()
request.state.frontend_type = FrontendType.PLATFORM
context = ContextManager.detect_context(request)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
context = get_request_context(request)
# Should NOT match /api/ because it's case-sensitive
assert context != RequestContext.API
def test_detect_context_path_with_query_params(self):
"""Test context detection handles path with query parameters."""
request = Mock(spec=Request)
request.url = Mock(path="/api/vendors?page=1")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/api/vendors?page=1")
# path.startswith should still work
context = ContextManager.detect_context(request)
assert context == RequestContext.API
def test_detect_context_admin_substring(self):
"""Test that 'admin' substring doesn't trigger false positive."""
request = Mock(spec=Request)
request.url = Mock(path="/administration/docs")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/administration/docs")
context = ContextManager.detect_context(request)
# Should match because path starts with /admin
assert context == RequestContext.ADMIN
def test_detect_context_no_state_attribute(self):
"""Test context detection when request has no state."""
request = Mock(spec=Request)
request.url = Mock(path="/api/vendors")
request.headers = {"host": "localhost"}
# No state attribute at all
delattr(request, "state")
# Should still work, falling back to url.path
with pytest.raises(AttributeError):
# This will raise because we're trying to access request.state
ContextManager.detect_context(request)
assert context == RequestContext.FALLBACK

View File

@@ -0,0 +1,195 @@
# tests/unit/middleware/test_frontend_type.py
"""
Unit tests for FrontendTypeMiddleware.
Tests cover:
- Middleware sets frontend_type in request state
- All frontend types are correctly detected
- get_frontend_type helper function
"""
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi import Request
from app.modules.enums import FrontendType
from middleware.frontend_type import FrontendTypeMiddleware, get_frontend_type
@pytest.mark.unit
class TestFrontendTypeMiddleware:
"""Test suite for FrontendTypeMiddleware."""
@pytest.mark.asyncio
async def test_middleware_sets_admin_frontend_type(self):
"""Test middleware sets ADMIN frontend type."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/admin/dashboard")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/admin/dashboard", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert hasattr(request.state, "frontend_type")
assert request.state.frontend_type == FrontendType.ADMIN
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_middleware_sets_vendor_frontend_type(self):
"""Test middleware sets VENDOR frontend type."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/vendor/settings")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/vendor/settings", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.frontend_type == FrontendType.VENDOR
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_sets_storefront_frontend_type(self):
"""Test middleware sets STOREFRONT frontend type."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/storefront/products")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/storefront/products", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.frontend_type == FrontendType.STOREFRONT
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_sets_storefront_with_vendor_context(self):
"""Test middleware sets STOREFRONT when vendor exists in state."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/products")
request.headers = {"host": "wizamart.oms.lu"}
mock_vendor = Mock()
mock_vendor.name = "Test Vendor"
request.state = Mock(clean_path="/products", vendor=mock_vendor)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.frontend_type == FrontendType.STOREFRONT
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_sets_platform_frontend_type(self):
"""Test middleware sets PLATFORM frontend type."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/pricing")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/pricing", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.frontend_type == FrontendType.PLATFORM
call_next.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_returns_response(self):
"""Test middleware returns response from call_next."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/admin/test")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/admin/test")
expected_response = Mock()
call_next = AsyncMock(return_value=expected_response)
response = await middleware.dispatch(request, call_next)
assert response is expected_response
@pytest.mark.asyncio
async def test_middleware_uses_clean_path_when_available(self):
"""Test middleware uses clean_path when available."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/vendors/wizamart/vendor/settings")
request.headers = {"host": "localhost"}
# clean_path shows the rewritten path
request.state = Mock(clean_path="/vendor/settings", vendor=None)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
# Should detect as VENDOR based on clean_path
assert request.state.frontend_type == FrontendType.VENDOR
@pytest.mark.asyncio
async def test_middleware_falls_back_to_url_path(self):
"""Test middleware falls back to url.path when clean_path not set."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/admin/dashboard")
request.headers = {"host": "localhost"}
# No clean_path attribute
request.state = Mock(spec=[])
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
assert request.state.frontend_type == FrontendType.ADMIN
@pytest.mark.unit
class TestGetFrontendTypeHelper:
"""Test suite for get_frontend_type helper function."""
def test_get_frontend_type_exists(self):
"""Test getting frontend type when it exists."""
request = Mock(spec=Request)
request.state.frontend_type = FrontendType.ADMIN
result = get_frontend_type(request)
assert result == FrontendType.ADMIN
def test_get_frontend_type_default(self):
"""Test getting frontend type returns PLATFORM as default."""
request = Mock(spec=Request)
request.state = Mock(spec=[]) # No frontend_type attribute
result = get_frontend_type(request)
assert result == FrontendType.PLATFORM
def test_get_frontend_type_for_all_types(self):
"""Test getting all frontend types."""
for expected_type in FrontendType:
request = Mock(spec=Request)
request.state.frontend_type = expected_type
result = get_frontend_type(request)
assert result == expected_type