middleware fix for path-based vendor url

This commit is contained in:
2025-11-09 18:47:53 +01:00
parent 79dfcab09f
commit adbcee4ce3
13 changed files with 2078 additions and 810 deletions

View File

@@ -1,14 +1,23 @@
# middleware/context_middleware.py
"""
Context Detection Middleware
Context Detection Middleware (Class-Based)
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.
This middleware runs independently and complements vendor_context_middleware.
MUST run AFTER vendor_context_middleware to have access to clean_path.
MUST run BEFORE theme_context_middleware (which needs context_type).
Class-based middleware provides:
- Better state management
- Easier testing
- More organized code
- Standard ASGI pattern
"""
import logging
from enum import Enum
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
logger = logging.getLogger(__name__)
@@ -38,42 +47,68 @@ class ContextManager:
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
"""
path = request.url.path
# 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(
f"[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)
if ContextManager._is_vendor_dashboard_context(path):
logger.debug("[CONTEXT] Detected as VENDOR_DASHBOARD", extra={"path": 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
@@ -92,43 +127,59 @@ class ContextManager:
@staticmethod
def _is_vendor_dashboard_context(path: str) -> bool:
"""Check if request is in vendor dashboard context."""
# Vendor dashboard paths (/vendor/*)
# Vendor dashboard paths (/vendor/{code}/*)
# Note: This is the vendor management area, not the shop
if path.startswith("/vendor/"):
# Important: /vendors/{code}/shop/* should NOT match this
if path.startswith("/vendor/") and not path.startswith("/vendors/"):
return True
return False
async def context_middleware(request: Request, call_next):
class ContextMiddleware(BaseHTTPMiddleware):
"""
Middleware to detect and inject request context into request.state.
This should run AFTER vendor_context_middleware to have access to
vendor information if available.
Class-based middleware provides:
- Better lifecycle management
- Easier to test and extend
- Standard ASGI pattern
- Clear separation of concerns
Injects:
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
"""
# Detect context
context_type = ContextManager.detect_context(request)
# Inject into request state
request.state.context_type = context_type
async def dispatch(self, request: Request, call_next):
"""
Detect context and inject into request state.
"""
# Detect context
context_type = ContextManager.detect_context(request)
# Log context detection (debug level)
logger.debug(
f"[CONTEXT] Request context detected: {context_type.value}",
extra={
"path": request.url.path,
"host": request.headers.get("host", ""),
"context": context_type.value,
}
)
# Inject into request state
request.state.context_type = context_type
# Continue processing
response = await call_next(request)
return response
# 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:

View File

@@ -0,0 +1,63 @@
# middleware/path_rewrite_middleware.py
"""
Path Rewrite Middleware
Rewrites request paths for path-based vendor routing.
This allows /vendor/VENDORCODE/shop/products to be routed as /shop/products
MUST run AFTER vendor_context_middleware and BEFORE context_middleware.
"""
import logging
from fastapi import Request
from starlette.datastructures import URL
logger = logging.getLogger(__name__)
async def path_rewrite_middleware(request: Request, call_next):
"""
Middleware to rewrite request paths for vendor context.
If vendor_context_middleware set request.state.clean_path, this middleware
will rewrite the request path to use the clean path instead.
This allows FastAPI route matching to work correctly with path-based routing.
Example:
Original: /vendor/WIZAMART/shop/products
Clean path: /shop/products
After rewrite: Request is routed as if path was /shop/products
MUST run after vendor_context_middleware (which sets clean_path)
MUST run before context_middleware (which needs to see the clean path)
"""
# Check if vendor_context_middleware set a clean_path
if hasattr(request.state, 'clean_path'):
clean_path = request.state.clean_path
original_path = request.url.path
# Only rewrite if clean_path is different from original path
if clean_path != original_path:
logger.debug(
f"[PATH_REWRITE] Rewriting path",
extra={
"original_path": original_path,
"clean_path": clean_path,
"vendor": getattr(request.state, 'vendor', 'NOT SET'),
}
)
# Rewrite the path by modifying the request's scope
# This affects how FastAPI's router will see the path
request.scope['path'] = clean_path
# Also update request._url to reflect the change
# This ensures request.url.path returns the rewritten path
old_url = request.url
new_url = old_url.replace(path=clean_path)
request._url = new_url
# Continue to next middleware/handler
response = await call_next(request)
return response

View File

@@ -1,9 +1,17 @@
# middleware/theme_context.py
"""
Theme Context Middleware
Injects vendor-specific theme into request context
Theme Context Middleware (Class-Based)
Injects vendor-specific theme into request context.
Class-based middleware provides:
- Better state management
- Easier testing
- Standard ASGI pattern
"""
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
from sqlalchemy.orm import Session
@@ -31,7 +39,7 @@ class ThemeContextManager:
return theme.to_dict()
# Return default theme
return get_default_theme()
return ThemeContextManager.get_default_theme()
@staticmethod
def get_default_theme() -> dict:
@@ -76,40 +84,68 @@ class ThemeContextManager:
}
async def theme_context_middleware(request: Request, call_next):
class ThemeContextMiddleware(BaseHTTPMiddleware):
"""
Middleware to inject theme context into request state.
This runs AFTER vendor_context_middleware has set request.state.vendor
Class-based middleware provides:
- Better state management
- Easier testing
- Standard ASGI pattern
Runs LAST in middleware chain (after vendor_context_middleware and context_middleware).
Depends on:
request.state.vendor (set by vendor_context_middleware)
Sets:
request.state.theme: Theme dictionary
"""
# Only inject theme for shop pages (not admin or API)
if hasattr(request.state, 'vendor') and request.state.vendor:
vendor = request.state.vendor
# Get database session
db_gen = get_db()
db = next(db_gen)
async def dispatch(self, request: Request, call_next):
"""
Load and inject theme context.
"""
# Only inject theme for shop pages (not admin or API)
if hasattr(request.state, 'vendor') and request.state.vendor:
vendor = request.state.vendor
try:
# Get vendor theme
theme = ThemeContextManager.get_vendor_theme(db, vendor.id)
request.state.theme = theme
# Get database session
db_gen = get_db()
db = next(db_gen)
logger.debug(
f"Theme loaded for vendor {vendor.name}: {theme['theme_name']}"
)
except Exception as e:
logger.error(f"Failed to load theme for vendor {vendor.id}: {e}")
# Fallback to default theme
try:
# Get vendor theme
theme = ThemeContextManager.get_vendor_theme(db, vendor.id)
request.state.theme = theme
logger.debug(
f"[THEME] Theme loaded for vendor",
extra={
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"theme_name": theme.get('theme_name', 'default'),
}
)
except Exception as e:
logger.error(
f"[THEME] Failed to load theme for vendor {vendor.id}: {e}",
exc_info=True
)
# Fallback to default theme
request.state.theme = ThemeContextManager.get_default_theme()
finally:
db.close()
else:
# No vendor context, use default theme
request.state.theme = ThemeContextManager.get_default_theme()
finally:
db.close()
else:
# No vendor context, use default theme
request.state.theme = ThemeContextManager.get_default_theme()
logger.debug(
"[THEME] No vendor context, using default theme",
extra={"has_vendor": False}
)
response = await call_next(request)
return response
# Continue processing
response = await call_next(request)
return response
def get_current_theme(request: Request) -> dict:

View File

@@ -1,9 +1,22 @@
# middleware/vendor_context.py
"""
Vendor Context Middleware (Class-Based)
Detects vendor from host/domain/path and injects into request.state.
Handles three routing modes:
1. Custom domains (customdomain1.com → Vendor 1)
2. Subdomains (vendor1.platform.com → Vendor 1)
3. Path-based (/vendor/vendor1/ or /vendors/vendor1/ → Vendor 1)
Also extracts clean_path for nested routing patterns.
"""
import logging
from typing import Optional
from fastapi import Request
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from sqlalchemy import func
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
from app.core.database import get_db
from models.database.vendor import Vendor
@@ -23,7 +36,7 @@ class VendorContextManager:
Priority order:
1. Custom domain (customdomain1.com)
2. Subdomain (vendor1.platform.com)
3. Path-based (/vendor/vendor1/)
3. Path-based (/vendor/vendor1/ or /vendors/vendor1/)
Returns dict with vendor info or None if not found.
"""
@@ -48,7 +61,6 @@ class VendorContextManager:
)
if is_custom_domain:
# This could be a custom domain like customdomain1.com
normalized_domain = VendorDomain.normalize_domain(host)
return {
"domain": normalized_domain,
@@ -69,15 +81,23 @@ class VendorContextManager:
"host": host
}
# Method 3: Path-based detection (/vendor/vendorname/) - for development
if path.startswith("/vendor/"):
path_parts = path.split("/")
if len(path_parts) >= 3:
subdomain = path_parts[2]
# Method 3: Path-based detection (/vendor/vendorname/ or /vendors/vendorname/)
# Support BOTH patterns for flexibility
if path.startswith("/vendor/") or path.startswith("/vendors/"):
# Determine which pattern
if path.startswith("/vendors/"):
prefix_len = len("/vendors/")
else:
prefix_len = len("/vendor/")
path_parts = path[prefix_len:].split("/")
if len(path_parts) >= 1 and path_parts[0]:
vendor_code = path_parts[0]
return {
"subdomain": subdomain,
"subdomain": vendor_code,
"detection_method": "path",
"path_prefix": f"/vendor/{subdomain}",
"path_prefix": path[:prefix_len + len(vendor_code)],
"full_prefix": path[:prefix_len], # /vendor/ or /vendors/
"host": host
}
@@ -102,7 +122,6 @@ class VendorContextManager:
if context.get("detection_method") == "custom_domain":
domain = context.get("domain")
if domain:
# Look up vendor by custom domain
vendor_domain = (
db.query(VendorDomain)
.filter(VendorDomain.domain == domain)
@@ -113,12 +132,11 @@ class VendorContextManager:
if vendor_domain:
vendor = vendor_domain.vendor
# Check if vendor is active
if not vendor or not vendor.is_active:
logger.warning(f"Vendor for domain {domain} is not active")
return None
logger.info(f"[OK] Vendor found via custom domain: {domain} -> {vendor.name}")
logger.info(f"[OK] Vendor found via custom domain: {domain} {vendor.name}")
return vendor
else:
logger.warning(f"No active vendor found for custom domain: {domain}")
@@ -127,7 +145,6 @@ class VendorContextManager:
# Method 2 & 3: Subdomain or path-based lookup
if "subdomain" in context:
subdomain = context["subdomain"]
# Query vendor by subdomain (case-insensitive)
vendor = (
db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == subdomain.lower())
@@ -137,7 +154,7 @@ class VendorContextManager:
if vendor:
method = context.get("detection_method", "unknown")
logger.info(f"[OK] Vendor found via {method}: {subdomain} -> {vendor.name}")
logger.info(f"[OK] Vendor found via {method}: {subdomain} {vendor.name}")
else:
logger.warning(f"No active vendor found for subdomain: {subdomain}")
@@ -145,14 +162,19 @@ class VendorContextManager:
@staticmethod
def extract_clean_path(request: Request, vendor_context: Optional[dict]) -> str:
"""Extract clean path without vendor prefix for routing."""
"""
Extract clean path without vendor prefix for routing.
Supports both /vendor/ and /vendors/ prefixes.
"""
if not vendor_context:
return request.url.path
# Only strip path prefix for path-based detection
if vendor_context.get("detection_method") == "path":
path_prefix = vendor_context.get("path_prefix", "")
path = request.url.path
path_prefix = vendor_context.get("path_prefix", "")
if path.startswith(path_prefix):
clean_path = path[len(path_prefix):]
return clean_path if clean_path else "/"
@@ -165,15 +187,12 @@ class VendorContextManager:
host = request.headers.get("host", "")
path = request.url.path
# Remove port from host
if ":" in host:
host = host.split(":")[0]
# Check for admin subdomain
if host.startswith("admin."):
return True
# Check for admin path
if path.startswith("/admin"):
return True
@@ -189,82 +208,118 @@ class VendorContextManager:
"""Check if request is for static files."""
path = request.url.path.lower()
# Static file extensions
static_extensions = (
'.ico', '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg',
'.woff', '.woff2', '.ttf', '.eot', '.webp', '.map', '.json',
'.xml', '.txt', '.pdf', '.webmanifest'
)
# Static paths
static_paths = ('/static/', '/media/', '/assets/', '/.well-known/')
# Check if it's a static file by extension
if path.endswith(static_extensions):
return True
# Check if it's in a static directory
if any(path.startswith(static_path) for static_path in static_paths):
return True
# Special case: favicon.ico at any level
if 'favicon.ico' in path:
return True
return False
async def vendor_context_middleware(request: Request, call_next):
class VendorContextMiddleware(BaseHTTPMiddleware):
"""
Middleware to inject vendor context into request state.
Handles three routing modes:
1. Custom domains (customdomain1.com -> Vendor 1)
2. Subdomains (vendor1.platform.com -> Vendor 1)
3. Path-based (/vendor/vendor1/ -> Vendor 1)
Class-based middleware provides:
- Better state management
- Easier testing
- More organized code
- Standard ASGI pattern
Runs FIRST in middleware chain.
Sets:
request.state.vendor: Vendor object
request.state.vendor_context: Detection metadata
request.state.clean_path: Path without vendor prefix
"""
# Skip vendor detection for admin, API, static files, and system requests
if (VendorContextManager.is_admin_request(request) or
VendorContextManager.is_api_request(request) or
VendorContextManager.is_static_file_request(request) or
request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"]):
async def dispatch(self, request: Request, call_next):
"""
Detect and inject vendor context.
"""
# Skip vendor detection for admin, API, static files, and system requests
if (
VendorContextManager.is_admin_request(request) or
VendorContextManager.is_api_request(request) or
VendorContextManager.is_static_file_request(request) or
request.url.path in ["/", "/health", "/docs", "/redoc", "/openapi.json"]
):
logger.debug(
f"[VENDOR] Skipping vendor detection: {request.url.path}",
extra={"path": request.url.path, "reason": "admin/api/static/system"}
)
request.state.vendor = None
request.state.vendor_context = None
request.state.clean_path = request.url.path
return await call_next(request)
# Detect vendor context
vendor_context = VendorContextManager.detect_vendor_context(request)
if vendor_context:
db_gen = get_db()
db = next(db_gen)
try:
vendor = VendorContextManager.get_vendor_from_context(db, vendor_context)
if vendor:
request.state.vendor = vendor
request.state.vendor_context = vendor_context
request.state.clean_path = VendorContextManager.extract_clean_path(
request, vendor_context
)
logger.debug(
f"[VENDOR_CONTEXT] Vendor detected",
extra={
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_subdomain": vendor.subdomain,
"detection_method": vendor_context.get("detection_method"),
"original_path": request.url.path,
"clean_path": request.state.clean_path,
}
)
else:
logger.warning(
f"[WARNING] Vendor context detected but vendor not found",
extra={
"context": vendor_context,
"detection_method": vendor_context.get("detection_method"),
}
)
request.state.vendor = None
request.state.vendor_context = vendor_context
request.state.clean_path = request.url.path
finally:
db.close()
else:
logger.debug(
f"[VENDOR] No vendor context detected",
extra={
"path": request.url.path,
"host": request.headers.get("host", ""),
}
)
request.state.vendor = None
request.state.vendor_context = None
request.state.clean_path = request.url.path
# Continue to next middleware
return await call_next(request)
# Detect vendor context
vendor_context = VendorContextManager.detect_vendor_context(request)
if vendor_context:
db_gen = get_db()
db = next(db_gen)
try:
vendor = VendorContextManager.get_vendor_from_context(db, vendor_context)
if vendor:
request.state.vendor = vendor
request.state.vendor_context = vendor_context
request.state.clean_path = VendorContextManager.extract_clean_path(
request, vendor_context
)
logger.debug(
f"[VENDOR] Vendor context: {vendor.name} ({vendor.subdomain}) "
f"via {vendor_context['detection_method']}"
)
else:
logger.warning(
f"[WARNING] Vendor not found for context: {vendor_context}"
)
request.state.vendor = None
request.state.vendor_context = vendor_context
finally:
db.close()
else:
request.state.vendor = None
request.state.vendor_context = None
request.state.clean_path = request.url.path
return await call_next(request)
def get_current_vendor(request: Request) -> Optional[Vendor]:
"""Helper function to get current vendor from request state."""