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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user