feat: implement /platforms/ URL prefix routing strategy

- Update middleware to use /platforms/{code}/ prefix for dev routing
- Change DEFAULT_PLATFORM_CODE from 'oms' to 'main'
- Add 'main' platform for main marketing site (wizamart.lu)
- Remove hardcoded /oms and /loyalty routes from main.py
- Update platform_pages.py homepage to handle vendor landing pages

URL structure:
- localhost:9999/ → Main marketing site ('main' platform)
- localhost:9999/platforms/oms/ → OMS platform
- localhost:9999/platforms/loyalty/ → Loyalty platform
- oms.lu/ → OMS platform (production)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 18:25:56 +01:00
parent 3875ad91df
commit a2407ae418
4 changed files with 749 additions and 498 deletions

View File

@@ -7,7 +7,11 @@ This middleware runs BEFORE VendorContextMiddleware to establish platform contex
Handles two routing modes:
1. Production: Domain-based (oms.lu, loyalty.lu → Platform detection)
2. Development: Path-based (localhost:9999/oms/*, localhost:9999/loyalty/* → Platform detection)
2. Development: Path-based (localhost:9999/platforms/oms/*, localhost:9999/platforms/loyalty/*)
URL Structure:
- Main marketing site: localhost:9999/ (no platform prefix) → uses 'main' platform
- Platform sites: localhost:9999/platforms/{code}/ → uses specific platform
Also provides platform_clean_path for downstream middleware to use.
"""
@@ -16,7 +20,7 @@ import logging
from fastapi import Request
from sqlalchemy.orm import Session
from starlette.middleware.base import BaseHTTPMiddleware
# Note: We use pure ASGI middleware (not BaseHTTPMiddleware) to enable path rewriting
from app.core.config import settings
from app.core.database import get_db
@@ -24,8 +28,8 @@ from models.database.platform import Platform
logger = logging.getLogger(__name__)
# Default platform code for backward compatibility
DEFAULT_PLATFORM_CODE = "oms"
# Default platform code for main marketing site
DEFAULT_PLATFORM_CODE = "main"
class PlatformContextManager:
@@ -38,8 +42,15 @@ class PlatformContextManager:
Priority order:
1. Domain-based (production): oms.lu → platform code "oms"
2. Path-based (development): localhost:9999/oms/* → platform code "oms"
3. Default: localhost without path prefix → default platform
2. Path-based (development): localhost:9999/platforms/oms/* → platform code "oms"
3. Default: localhost without /platforms/ prefix → 'main' platform (marketing site)
URL Structure:
- / → Main marketing site ('main' platform)
- /about → Main marketing site about page
- /platforms/oms/ → OMS platform homepage
- /platforms/oms/pricing → OMS platform pricing
- /platforms/loyalty/ → Loyalty platform homepage
Returns dict with platform info or None if not detected.
"""
@@ -75,42 +86,33 @@ class PlatformContextManager:
"original_path": path,
}
# Method 2: Path-based detection (development)
# Check for path prefix like /oms/, /loyalty/
if path.startswith("/"):
path_parts = path[1:].split("/") # Remove leading / and split
if path_parts and path_parts[0]:
potential_platform_code = path_parts[0].lower()
# Check if this could be a platform code (not vendor paths)
if potential_platform_code not in [
"vendor",
"vendors",
"admin",
"api",
"static",
"media",
"assets",
"health",
"docs",
"redoc",
"openapi.json",
]:
return {
"path_prefix": potential_platform_code,
"detection_method": "path",
"host": host,
"original_path": path,
"clean_path": "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/",
}
# Method 2: Path-based detection (development) - ONLY for /platforms/ prefix
# Check for path prefix like /platforms/oms/, /platforms/loyalty/
if path.startswith("/platforms/"):
# Extract: /platforms/oms/pricing → code="oms", clean_path="/pricing"
path_after_platforms = path[11:] # Remove "/platforms/"
parts = path_after_platforms.split("/", 1)
platform_code = parts[0].lower()
# Method 3: Default platform for localhost without prefix
if platform_code:
clean_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
return {
"path_prefix": platform_code,
"detection_method": "path",
"host": host,
"original_path": path,
"clean_path": clean_path,
}
# Method 3: Default platform for localhost without /platforms/ prefix
# This serves the main marketing site
if host_without_port in ["localhost", "127.0.0.1"]:
return {
"path_prefix": DEFAULT_PLATFORM_CODE,
"detection_method": "default",
"host": host,
"original_path": path,
"clean_path": path,
"clean_path": path, # No path rewrite for main site
}
return None
@@ -248,54 +250,73 @@ class PlatformContextManager:
return False
class PlatformContextMiddleware(BaseHTTPMiddleware):
class PlatformContextMiddleware:
"""
Middleware to inject platform context into request state.
ASGI Middleware to inject platform context and rewrite URL paths.
This middleware:
1. Detects platform from domain (production) or path prefix (development)
2. Rewrites the URL path to remove platform prefix for routing
3. Stores platform info in request state for handlers
Runs BEFORE VendorContextMiddleware to establish platform context.
Sets:
request.state.platform: Platform object
request.state.platform_context: Detection metadata
request.state.platform_clean_path: Path without platform prefix
Sets in scope['state']:
platform: Platform object
platform_context: Detection metadata
platform_clean_path: Path without platform prefix
platform_original_path: Original path before rewrite
"""
async def dispatch(self, request: Request, call_next):
"""
Detect and inject platform context.
"""
# Skip platform detection for static files
if PlatformContextManager.is_static_file_request(request):
logger.debug(
f"[PLATFORM] Skipping platform detection for static file: {request.url.path}"
)
request.state.platform = None
request.state.platform_context = None
request.state.platform_clean_path = request.url.path
return await call_next(request)
def __init__(self, app):
self.app = app
# Skip platform detection for system endpoints
if request.url.path in ["/health", "/docs", "/redoc", "/openapi.json"]:
logger.debug(
f"[PLATFORM] Skipping platform detection for system path: {request.url.path}"
)
request.state.platform = None
request.state.platform_context = None
request.state.platform_clean_path = request.url.path
return await call_next(request)
async def __call__(self, scope, receive, send):
"""ASGI interface - allows path rewriting before routing."""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# Admin requests are global (no platform context)
if PlatformContextManager.is_admin_request(request):
logger.debug(
f"[PLATFORM] Admin request - no platform context: {request.url.path}"
)
request.state.platform = None
request.state.platform_context = None
request.state.platform_clean_path = request.url.path
return await call_next(request)
# Initialize state dict if not present
if "state" not in scope:
scope["state"] = {}
path = scope["path"]
host = ""
for header_name, header_value in scope.get("headers", []):
if header_name == b"host":
host = header_value.decode("utf-8")
break
# Skip for static files
if self._is_static_file(path):
scope["state"]["platform"] = None
scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path
scope["state"]["platform_original_path"] = path
await self.app(scope, receive, send)
return
# Skip for system endpoints
if path in ["/health", "/docs", "/redoc", "/openapi.json"]:
scope["state"]["platform"] = None
scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path
scope["state"]["platform_original_path"] = path
await self.app(scope, receive, send)
return
# Skip for admin requests
if self._is_admin_request(path, host):
scope["state"]["platform"] = None
scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path
scope["state"]["platform_original_path"] = path
await self.app(scope, receive, send)
return
# Detect platform context
platform_context = PlatformContextManager.detect_platform_context(request)
platform_context = self._detect_platform_context(path, host)
if platform_context:
db_gen = get_db()
@@ -306,52 +327,123 @@ class PlatformContextMiddleware(BaseHTTPMiddleware):
)
if platform:
request.state.platform = platform
request.state.platform_context = platform_context
request.state.platform_clean_path = PlatformContextManager.extract_clean_path(
request, platform_context
)
clean_path = platform_context.get("clean_path", path)
# Store in scope state
scope["state"]["platform"] = platform
scope["state"]["platform_context"] = platform_context
scope["state"]["platform_clean_path"] = clean_path
scope["state"]["platform_original_path"] = path
# REWRITE THE PATH for routing
# This is the key: FastAPI will route based on this rewritten path
if platform_context.get("detection_method") == "path":
scope["path"] = clean_path
# Also update raw_path if present
if "raw_path" in scope:
scope["raw_path"] = clean_path.encode("utf-8")
logger.debug(
"[PLATFORM_CONTEXT] Platform detected",
extra={
"platform_id": platform.id,
"platform_code": platform.code,
"platform_name": platform.name,
"detection_method": platform_context.get("detection_method"),
"original_path": request.url.path,
"clean_path": request.state.platform_clean_path,
},
f"[PLATFORM] Detected: {platform.code}, "
f"original={path}, routed={scope['path']}"
)
else:
# Platform code detected but not found in database
# This could be a vendor path like /vendors/...
logger.debug(
"[PLATFORM] Platform code not found, may be vendor path",
extra={
"context": platform_context,
"detection_method": platform_context.get("detection_method"),
},
)
request.state.platform = None
request.state.platform_context = None
request.state.platform_clean_path = request.url.path
# Platform code not found in database
scope["state"]["platform"] = None
scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path
scope["state"]["platform_original_path"] = path
finally:
db.close()
else:
logger.debug(
"[PLATFORM] No platform context detected",
extra={
"path": request.url.path,
"host": request.headers.get("host", ""),
},
)
request.state.platform = None
request.state.platform_context = None
request.state.platform_clean_path = request.url.path
scope["state"]["platform"] = None
scope["state"]["platform_context"] = None
scope["state"]["platform_clean_path"] = path
scope["state"]["platform_original_path"] = path
# Continue to next middleware
return await call_next(request)
await self.app(scope, receive, send)
def _detect_platform_context(self, path: str, host: str) -> dict | None:
"""
Detect platform from path or host.
URL Structure:
- / → Main marketing site ('main' platform)
- /about → Main marketing site about page
- /platforms/oms/ → OMS platform homepage
- /platforms/oms/pricing → OMS platform pricing
- /platforms/loyalty/ → Loyalty platform homepage
"""
host_without_port = host.split(":")[0] if ":" in host else host
# Method 1: Domain-based (production)
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
if "." in host_without_port:
parts = host_without_port.split(".")
if len(parts) == 2: # Root domain like oms.lu
return {
"domain": host_without_port,
"detection_method": "domain",
"host": host,
"original_path": path,
"clean_path": path, # No path rewrite for domain-based
}
# Method 2: Path-based (development) - ONLY for /platforms/ prefix
if path.startswith("/platforms/"):
# Extract: /platforms/oms/pricing → code="oms", clean_path="/pricing"
path_after_platforms = path[11:] # Remove "/platforms/"
parts = path_after_platforms.split("/", 1)
platform_code = parts[0].lower()
if platform_code:
clean_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
return {
"path_prefix": platform_code,
"detection_method": "path",
"host": host,
"original_path": path,
"clean_path": clean_path,
}
# Method 3: Default for localhost - serves main marketing site
if host_without_port in ["localhost", "127.0.0.1"]:
return {
"path_prefix": DEFAULT_PLATFORM_CODE,
"detection_method": "default",
"host": host,
"original_path": path,
"clean_path": path, # No path rewrite for main site
}
return None
def _is_static_file(self, path: str) -> bool:
"""Check if path is for static files."""
path_lower = 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_lower.endswith(static_extensions):
return True
if any(path_lower.startswith(p) for p in static_paths):
return True
if "favicon.ico" in path_lower:
return True
return False
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
if path.startswith("/admin"):
return True
return False
def get_current_platform(request: Request) -> Platform | None: