Files
orion/middleware/vendor_context.py
Samir Boulahtit 8a367077e1 refactor: migrate vendor APIs to token-based context and consolidate architecture
## Vendor-in-Token Architecture (Complete Migration)
- Migrate all vendor API endpoints from require_vendor_context() to token_vendor_id
- Update permission dependencies to extract vendor from JWT token
- Add vendor exceptions: VendorAccessDeniedException, VendorOwnerOnlyException,
  InsufficientVendorPermissionsException
- Shop endpoints retain require_vendor_context() for URL-based detection
- Add AUTH-004 architecture rule enforcing vendor context patterns
- Fix marketplace router missing /marketplace prefix

## Exception Pattern Fixes (API-003/API-004)
- Services raise domain exceptions, endpoints let them bubble up
- Add code_quality and content_page exception modules
- Move business logic from endpoints to services (admin, auth, content_page)
- Fix exception handling in admin, shop, and vendor endpoints

## Tailwind CSS Consolidation
- Consolidate CSS to per-area files (admin, vendor, shop, platform)
- Remove shared/cdn-fallback.html and shared/css/tailwind.min.css
- Update all templates to use area-specific Tailwind output files
- Remove Node.js config (package.json, postcss.config.js, tailwind.config.js)

## Documentation & Cleanup
- Update vendor-in-token-architecture.md with completed migration status
- Update architecture-rules.md with new rules
- Move migration docs to docs/development/migration/
- Remove duplicate/obsolete documentation files
- Merge pytest.ini settings into pyproject.toml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 22:24:45 +01:00

573 lines
21 KiB
Python

# 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 fastapi import Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
from app.core.database import get_db
from models.database.vendor import Vendor
from models.database.vendor_domain import VendorDomain
logger = logging.getLogger(__name__)
class VendorContextManager:
"""Manages vendor context detection for multi-tenant routing."""
@staticmethod
def detect_vendor_context(request: Request) -> dict | None:
"""
Detect vendor context from request.
Priority order:
1. Custom domain (customdomain1.com)
2. Subdomain (vendor1.platform.com)
3. Path-based (/vendor/vendor1/ or /vendors/vendor1/)
Returns dict with vendor info or None if not found.
"""
host = request.headers.get("host", "")
path = request.url.path
# Remove port from host if present (e.g., localhost:8000 -> localhost)
if ":" in host:
host = host.split(":")[0]
# Method 1: Custom domain detection (HIGHEST PRIORITY)
# Check if this is a custom domain (not platform.com and not localhost)
platform_domain = getattr(settings, "platform_domain", "platform.com")
is_custom_domain = (
host
and not host.endswith(f".{platform_domain}")
and host != platform_domain
and host
not in ["localhost", "127.0.0.1", "admin.localhost", "admin.127.0.0.1"]
and not host.startswith("admin.")
)
if is_custom_domain:
normalized_domain = VendorDomain.normalize_domain(host)
return {
"domain": normalized_domain,
"detection_method": "custom_domain",
"host": host,
"original_host": request.headers.get("host", ""),
}
# Method 2: Subdomain detection (vendor1.platform.com)
if "." in host:
parts = host.split(".")
# Check if it's a valid subdomain (not www, admin, api)
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
subdomain = parts[0]
return {
"subdomain": subdomain,
"detection_method": "subdomain",
"host": host,
}
# 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": vendor_code,
"detection_method": "path",
"path_prefix": path[: prefix_len + len(vendor_code)],
"full_prefix": path[:prefix_len], # /vendor/ or /vendors/
"host": host,
}
return None
@staticmethod
def get_vendor_from_context(db: Session, context: dict) -> Vendor | None:
"""
Get vendor from database using context information.
Supports three methods:
1. Custom domain lookup (VendorDomain table)
2. Subdomain lookup (Vendor.subdomain)
3. Path-based lookup (Vendor.subdomain)
"""
if not context:
return None
vendor = None
# Method 1: Custom domain lookup
if context.get("detection_method") == "custom_domain":
domain = context.get("domain")
if domain:
vendor_domain = (
db.query(VendorDomain)
.filter(VendorDomain.domain == domain)
.filter(VendorDomain.is_active == True)
.filter(VendorDomain.is_verified == True)
.first()
)
if vendor_domain:
vendor = vendor_domain.vendor
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}"
)
return vendor
logger.warning(f"No active vendor found for custom domain: {domain}")
return None
# Method 2 & 3: Subdomain or path-based lookup
if "subdomain" in context:
subdomain = context["subdomain"]
vendor = (
db.query(Vendor)
.filter(func.lower(Vendor.subdomain) == subdomain.lower())
.filter(Vendor.is_active == True)
.first()
)
if vendor:
method = context.get("detection_method", "unknown")
logger.info(
f"[OK] Vendor found via {method}: {subdomain}{vendor.name}"
)
else:
logger.warning(f"No active vendor found for subdomain: {subdomain}")
return vendor
@staticmethod
def extract_clean_path(request: Request, vendor_context: dict | None) -> str:
"""
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 = 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 "/"
return request.url.path
@staticmethod
def is_admin_request(request: Request) -> bool:
"""Check if request is for admin interface."""
host = request.headers.get("host", "")
path = request.url.path
if ":" in host:
host = host.split(":")[0]
if host.startswith("admin."):
return True
if path.startswith("/admin"):
return True
return False
@staticmethod
def is_api_request(request: Request) -> bool:
"""Check if request is for API endpoints."""
return request.url.path.startswith("/api/")
@staticmethod
def is_shop_api_request(request: Request) -> bool:
"""Check if request is for shop API endpoints."""
return request.url.path.startswith("/api/v1/shop/")
@staticmethod
def extract_vendor_from_referer(request: Request) -> dict | None:
"""
Extract vendor context from Referer header.
Used for shop API requests where vendor context comes from the page
that made the API call (e.g., JavaScript on /vendors/wizamart/shop/products
calling /api/v1/shop/products).
Extracts vendor from Referer URL patterns:
- http://localhost:8000/vendors/wizamart/shop/... → wizamart
- http://wizamart.platform.com/shop/... → wizamart (subdomain)
- http://custom-domain.com/shop/... → custom-domain.com
Returns vendor context dict or None if unable to extract.
"""
referer = request.headers.get("referer") or request.headers.get("origin")
if not referer:
logger.debug("[VENDOR] No Referer/Origin header for shop API request")
return None
try:
from urllib.parse import urlparse
parsed = urlparse(referer)
referer_host = parsed.hostname or ""
referer_path = parsed.path or ""
# Remove port from host
if ":" in referer_host:
referer_host = referer_host.split(":")[0]
logger.debug(
"[VENDOR] Extracting vendor from Referer",
extra={
"referer": referer,
"referer_host": referer_host,
"referer_path": referer_path,
},
)
# Method 1: Path-based detection from referer path
# /vendors/wizamart/shop/products → wizamart
if referer_path.startswith("/vendors/") or referer_path.startswith(
"/vendor/"
):
prefix = (
"/vendors/" if referer_path.startswith("/vendors/") else "/vendor/"
)
path_parts = referer_path[len(prefix) :].split("/")
if len(path_parts) >= 1 and path_parts[0]:
vendor_code = path_parts[0]
prefix_len = len(prefix)
logger.debug(
f"[VENDOR] Extracted vendor from Referer path: {vendor_code}",
extra={"vendor_code": vendor_code, "method": "referer_path"},
)
# Use "path" as detection_method to be consistent with direct path detection
# This allows cookie path logic to work the same way
return {
"subdomain": vendor_code,
"detection_method": "path", # Consistent with direct path detection
"path_prefix": referer_path[
: prefix_len + len(vendor_code)
], # /vendor/vendor1
"full_prefix": prefix, # /vendor/ or /vendors/
"host": referer_host,
"referer": referer,
}
# Method 2: Subdomain detection from referer host
# wizamart.platform.com → wizamart
platform_domain = getattr(settings, "platform_domain", "platform.com")
if "." in referer_host:
parts = referer_host.split(".")
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
# Check if it's a subdomain of platform domain
if referer_host.endswith(f".{platform_domain}"):
subdomain = parts[0]
logger.debug(
f"[VENDOR] Extracted vendor from Referer subdomain: {subdomain}",
extra={
"subdomain": subdomain,
"method": "referer_subdomain",
},
)
return {
"subdomain": subdomain,
"detection_method": "referer_subdomain",
"host": referer_host,
"referer": referer,
}
# Method 3: Custom domain detection from referer host
# custom-shop.com → custom-shop.com
is_custom_domain = (
referer_host
and not referer_host.endswith(f".{platform_domain}")
and referer_host != platform_domain
and referer_host not in ["localhost", "127.0.0.1"]
and not referer_host.startswith("admin.")
)
if is_custom_domain:
from models.database.vendor_domain import VendorDomain
normalized_domain = VendorDomain.normalize_domain(referer_host)
logger.debug(
f"[VENDOR] Extracted vendor from Referer custom domain: {normalized_domain}",
extra={
"domain": normalized_domain,
"method": "referer_custom_domain",
},
)
return {
"domain": normalized_domain,
"detection_method": "referer_custom_domain",
"host": referer_host,
"referer": referer,
}
except Exception as e:
logger.warning(
f"[VENDOR] Failed to extract vendor from Referer: {e}",
extra={"referer": referer, "error": str(e)},
)
return None
@staticmethod
def is_static_file_request(request: Request) -> bool:
"""Check if request is for static files."""
path = request.url.path.lower()
static_extensions = (
".ico",
".css",
".js",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".woff",
".woff2",
".ttf",
".eot",
".webp",
".map",
".json",
".xml",
".txt",
".pdf",
".webmanifest",
)
static_paths = ("/static/", "/media/", "/assets/", "/.well-known/")
if path.endswith(static_extensions):
return True
if any(path.startswith(static_path) for static_path in static_paths):
return True
if "favicon.ico" in path:
return True
return False
class VendorContextMiddleware(BaseHTTPMiddleware):
"""
Middleware to inject vendor context into request state.
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
"""
async def dispatch(self, request: Request, call_next):
"""
Detect and inject vendor context.
"""
# Skip vendor detection for admin, static files, and system requests
if (
VendorContextManager.is_admin_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/static/system"},
)
request.state.vendor = None
request.state.vendor_context = None
request.state.clean_path = request.url.path
return await call_next(request)
# Handle shop API routes specially - extract vendor from Referer header
if VendorContextManager.is_shop_api_request(request):
logger.debug(
f"[VENDOR] Shop API request detected: {request.url.path}",
extra={
"path": request.url.path,
"referer": request.headers.get("referer", ""),
},
)
vendor_context = VendorContextManager.extract_vendor_from_referer(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 = request.url.path
logger.debug(
"[VENDOR_CONTEXT] Vendor detected from Referer for shop API",
extra={
"vendor_id": vendor.id,
"vendor_name": vendor.name,
"vendor_subdomain": vendor.subdomain,
"detection_method": vendor_context.get(
"detection_method"
),
"api_path": request.url.path,
"referer": vendor_context.get("referer", ""),
},
)
else:
logger.warning(
"[WARNING] Vendor context from Referer but vendor not found",
extra={
"context": vendor_context,
"detection_method": vendor_context.get(
"detection_method"
),
"api_path": request.url.path,
},
)
request.state.vendor = None
request.state.vendor_context = vendor_context
request.state.clean_path = request.url.path
finally:
db.close()
else:
logger.warning(
"[VENDOR] Shop API request without Referer header",
extra={"path": request.url.path},
)
request.state.vendor = None
request.state.vendor_context = None
request.state.clean_path = request.url.path
return await call_next(request)
# Skip vendor detection for other API routes (admin API, vendor API have vendor_id in URL)
if VendorContextManager.is_api_request(request):
logger.debug(
f"[VENDOR] Skipping vendor detection for non-shop API: {request.url.path}",
extra={"path": request.url.path, "reason": "api"},
)
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(
"[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(
"[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(
"[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)
def get_current_vendor(request: Request) -> Vendor | None:
"""Helper function to get current vendor from request state."""
return getattr(request.state, "vendor", None)
def require_vendor_context():
"""Dependency to require vendor context in endpoints."""
def dependency(request: Request):
vendor = get_current_vendor(request)
if not vendor:
from app.exceptions import VendorNotFoundException
raise VendorNotFoundException("unknown", identifier_type="context")
return vendor
return dependency