Files
orion/middleware/vendor_context.py
Samir Boulahtit 5a9f44f3d1 Complete shop API consolidation to /api/v1/shop/* with middleware-based vendor context
## API Migration (Complete)

### New Shop API Endpoints Created
- **Products API** (app/api/v1/shop/products.py)
  - GET /api/v1/shop/products - Product catalog with pagination/search/filters
  - GET /api/v1/shop/products/{id} - Product details

- **Cart API** (app/api/v1/shop/cart.py)
  - GET /api/v1/shop/cart/{session_id} - Get cart
  - POST /api/v1/shop/cart/{session_id}/items - Add to cart
  - PUT /api/v1/shop/cart/{session_id}/items/{product_id} - Update quantity
  - DELETE /api/v1/shop/cart/{session_id}/items/{product_id} - Remove item
  - DELETE /api/v1/shop/cart/{session_id} - Clear cart

- **Orders API** (app/api/v1/shop/orders.py)
  - POST /api/v1/shop/orders - Place order (authenticated)
  - GET /api/v1/shop/orders - Order history (authenticated)
  - GET /api/v1/shop/orders/{id} - Order details (authenticated)

- **Auth API** (app/api/v1/shop/auth.py)
  - POST /api/v1/shop/auth/register - Customer registration
  - POST /api/v1/shop/auth/login - Customer login (sets cookie at path=/shop)
  - POST /api/v1/shop/auth/logout - Customer logout
  - POST /api/v1/shop/auth/forgot-password - Password reset request
  - POST /api/v1/shop/auth/reset-password - Password reset

**Total: 18 new shop API endpoints**

### Middleware Enhancement
Updated VendorContextMiddleware (middleware/vendor_context.py):
- Added is_shop_api_request() to detect /api/v1/shop/* routes
- Added extract_vendor_from_referer() to extract vendor from Referer header
  - Supports path-based: /vendors/wizamart/shop/* → wizamart
  - Supports subdomain: wizamart.platform.com → wizamart
  - Supports custom domain: customshop.com → customshop.com
- Modified dispatch() to handle shop API specially (no longer skips)
- Vendor context now injected into request.state.vendor for shop API calls

### Frontend Migration (Complete)
Updated all shop templates to use new API endpoints:
- app/templates/shop/account/login.html - Updated login endpoint
- app/templates/shop/account/register.html - Updated register endpoint
- app/templates/shop/product.html - Updated 4 API calls (products, cart)
- app/templates/shop/cart.html - Updated 3 API calls (get, update, delete)
- app/templates/shop/products.html - Activated product loading from API

**Total: 9 API endpoint migrations across 5 templates**

### Old Endpoint Cleanup (Complete)
Removed deprecated /api/v1/public/vendors/* shop endpoints:
- Deleted app/api/v1/public/vendors/auth.py
- Deleted app/api/v1/public/vendors/products.py
- Deleted app/api/v1/public/vendors/cart.py
- Deleted app/api/v1/public/vendors/orders.py
- Deleted app/api/v1/public/vendors/payments.py (empty)
- Deleted app/api/v1/public/vendors/search.py (empty)
- Deleted app/api/v1/public/vendors/shop.py (empty)

Updated app/api/v1/public/__init__.py to only include vendor lookup endpoints:
- GET /api/v1/public/vendors/by-code/{code}
- GET /api/v1/public/vendors/by-subdomain/{subdomain}
- GET /api/v1/public/vendors/{id}/info

**Result: Only 3 truly public endpoints remain**

### Error Page Improvements
Updated all shop error templates to use base_url:
- app/templates/shop/errors/*.html (10 files)
- Updated error_renderer.py to calculate base_url from vendor context
- Links now work correctly for path-based, subdomain, and custom domain access

### CMS Route Handler
Added catch-all CMS route to app/routes/vendor_pages.py:
- Handles /{vendor_code}/{slug} for content pages
- Uses content_page_service for two-tier lookup (vendor override → platform default)

### Template Architecture Fix
Updated app/templates/shop/base.html:
- Changed x-data to use {% block alpine_data %} for component override
- Allows pages to specify custom Alpine.js components
- Enables page-specific state while extending shared shopLayoutData()

### Documentation (Complete)
Created comprehensive documentation:
- docs/api/shop-api-reference.md - Complete API reference with examples
- docs/architecture/API_CONSOLIDATION_PROPOSAL.md - Analysis of 3 options
- docs/architecture/API_MIGRATION_STATUS.md - Migration tracking (100% complete)
- Updated docs/api/index.md - Added Shop API section
- Updated docs/frontend/shop/architecture.md - New API structure and component pattern

## Benefits Achieved

### Cleaner URLs (~40% shorter)
Before: /api/v1/public/vendors/{vendor_id}/products
After:  /api/v1/shop/products

### Better Architecture
- Middleware-driven vendor context (no manual vendor_id passing)
- Proper separation of concerns (public vs shop vs vendor APIs)
- Consistent authentication pattern
- RESTful design

### Developer Experience
- No need to track vendor_id in frontend state
- Automatic vendor context from Referer header
- Simpler API calls
- Better documentation

## Testing
- Verified middleware extracts vendor from Referer correctly
- Tested all shop API endpoints with vendor context
- Confirmed products page loads and displays products
- Verified error pages show correct links
- No old API references remain in templates

Migration Status:  100% Complete (8/8 success criteria met)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:03:05 +01:00

525 lines
20 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 typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
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) -> Optional[dict]:
"""
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) -> Optional[Vendor]:
"""
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
else:
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: Optional[dict]) -> 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) -> Optional[dict]:
"""
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(
f"[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]
logger.debug(
f"[VENDOR] Extracted vendor from Referer path: {vendor_code}",
extra={"vendor_code": vendor_code, "method": "referer_path"}
)
return {
"subdomain": vendor_code,
"detection_method": "referer_path",
"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(
f"[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(
f"[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(
f"[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(
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)
def get_current_vendor(request: Request) -> Optional[Vendor]:
"""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 fastapi import HTTPException
raise HTTPException(
status_code=404,
detail="Vendor not found or not active"
)
return vendor
return dependency