Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

240
gateway/app/routes/auth.py Normal file
View File

@@ -0,0 +1,240 @@
# ================================================================
# gateway/app/routes/auth.py
# ================================================================
"""
Authentication and User Management Routes for API Gateway
Unified proxy to auth microservice
"""
import logging
import httpx
from fastapi import APIRouter, Request, Response, HTTPException, status
from fastapi.responses import JSONResponse
from typing import Dict, Any
from app.core.config import settings
from app.core.header_manager import header_manager
from app.core.service_discovery import ServiceDiscovery
from shared.monitoring.metrics import MetricsCollector
logger = logging.getLogger(__name__)
router = APIRouter()
# Initialize service discovery and metrics
service_discovery = ServiceDiscovery()
metrics = MetricsCollector("gateway")
# Register custom metrics for auth routes
metrics.register_counter("gateway_auth_requests_total", "Total authentication requests through gateway")
metrics.register_counter("gateway_auth_responses_total", "Total authentication responses from gateway")
metrics.register_counter("gateway_auth_errors_total", "Total authentication errors in gateway")
# Auth service configuration
AUTH_SERVICE_URL = settings.AUTH_SERVICE_URL or "http://auth-service:8000"
class AuthProxy:
"""Authentication service proxy with enhanced error handling"""
def __init__(self):
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
async def forward_request(
self,
method: str,
path: str,
request: Request
) -> Response:
"""Forward request to auth service with proper error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try:
# Get auth service URL (with service discovery if available)
auth_url = await self._get_auth_service_url()
target_url = f"{auth_url}/{path}"
# Prepare headers (remove hop-by-hop headers)
# IMPORTANT: Use request.headers directly to get headers added by middleware
# Also check request.state for headers injected by middleware
headers = self._prepare_headers(request.headers, request)
# Get request body
body = await request.body()
# Forward request
logger.info(f"Forwarding {method} /{path} to auth service")
response = await self.client.request(
method=method,
url=target_url,
headers=headers,
content=body,
params=dict(request.query_params)
)
# Record metrics
metrics.increment_counter("gateway_auth_requests_total")
metrics.increment_counter(
"gateway_auth_responses_total",
labels={"status_code": str(response.status_code)}
)
# Prepare response headers
response_headers = self._prepare_response_headers(dict(response.headers))
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
media_type=response.headers.get("content-type")
)
except httpx.TimeoutException:
logger.error(f"Timeout forwarding {method} /{path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "timeout"})
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="Authentication service timeout"
)
except httpx.ConnectError:
logger.error(f"Connection error forwarding {method} /{path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "connection"})
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Error forwarding {method} /{path} to auth service: {e}")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "unknown"})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal gateway error"
)
async def _get_auth_service_url(self) -> str:
"""Get auth service URL with service discovery"""
try:
# Try service discovery first
service_url = await service_discovery.get_service_url("auth-service")
if service_url:
return service_url
except Exception as e:
logger.warning(f"Service discovery failed: {e}")
# Fall back to configured URL
return AUTH_SERVICE_URL
def _prepare_headers(self, headers, request=None) -> Dict[str, str]:
"""Prepare headers for forwarding using unified HeaderManager"""
# Use unified HeaderManager to get all headers
if request:
all_headers = header_manager.get_all_headers_for_proxy(request)
logger.debug(f"DEBUG: Added headers from HeaderManager: {list(all_headers.keys())}")
else:
# Fallback: convert headers to dict manually
all_headers = {}
if hasattr(headers, '_list'):
for k, v in headers.__dict__.get('_list', []):
key = k.decode() if isinstance(k, bytes) else k
value = v.decode() if isinstance(v, bytes) else v
all_headers[key] = value
elif hasattr(headers, 'raw'):
for k, v in headers.raw:
key = k.decode() if isinstance(k, bytes) else k
value = v.decode() if isinstance(v, bytes) else v
all_headers[key] = value
else:
# Headers is already a dict
all_headers = dict(headers)
# Debug logging
logger.info(f"📤 Forwarding headers - x_user_id: {all_headers.get('x-user-id', 'MISSING')}, x_is_demo: {all_headers.get('x-is-demo', 'MISSING')}, x_demo_session_id: {all_headers.get('x-demo-session-id', 'MISSING')}, headers: {list(all_headers.keys())}")
return all_headers
def _prepare_response_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
"""Prepare response headers"""
# Remove server-specific headers
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in {'server', 'date'}
}
# Add CORS headers if needed
if settings.CORS_ORIGINS:
filtered_headers['Access-Control-Allow-Origin'] = '*'
filtered_headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
filtered_headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
return filtered_headers
# Initialize proxy
auth_proxy = AuthProxy()
# ================================================================
# CATCH-ALL ROUTE for all auth and user endpoints
# ================================================================
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_auth_requests(path: str, request: Request):
"""Catch-all proxy for all auth and user requests"""
return await auth_proxy.forward_request(request.method, f"api/v1/auth/{path}", request)
# ================================================================
# HEALTH CHECK for auth service
# ================================================================
@router.get("/health")
async def auth_service_health():
"""Check auth service health"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{AUTH_SERVICE_URL}/health")
if response.status_code == 200:
return {
"status": "healthy",
"auth_service": "available",
"response_time_ms": response.elapsed.total_seconds() * 1000
}
else:
return {
"status": "unhealthy",
"auth_service": "error",
"status_code": response.status_code
}
except Exception as e:
logger.error(f"Auth service health check failed: {e}")
return {
"status": "unhealthy",
"auth_service": "unavailable",
"error": str(e)
}
# ================================================================
# CLEANUP
# ================================================================
@router.on_event("shutdown")
async def cleanup():
"""Cleanup resources"""
await auth_proxy.client.aclose()

View File

@@ -0,0 +1,58 @@
"""
Demo Session Routes - Proxy to demo-session service
"""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import structlog
from app.core.config import settings
from app.core.header_manager import header_manager
logger = structlog.get_logger()
router = APIRouter()
@router.api_route("/demo/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_demo_service(path: str, request: Request):
"""
Proxy all demo requests to the demo-session service
These endpoints are public and don't require authentication
"""
# Build the target URL
demo_service_url = settings.DEMO_SESSION_SERVICE_URL.rstrip('/')
target_url = f"{demo_service_url}/api/v1/demo/{path}"
# Get request body
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
params=request.query_params,
content=body
)
# Return the response
return JSONResponse(
content=response.json() if response.content else {},
status_code=response.status_code,
headers=dict(response.headers)
)
except httpx.RequestError as e:
logger.error("Failed to proxy to demo-session service", error=str(e), url=target_url)
raise HTTPException(status_code=503, detail="Demo service unavailable")
except Exception as e:
logger.error("Unexpected error proxying to demo-session service", error=str(e))
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -0,0 +1,71 @@
# gateway/app/routes/geocoding.py
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import structlog
from app.core.config import settings
from app.core.header_manager import header_manager
logger = structlog.get_logger()
router = APIRouter()
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
async def proxy_geocoding(request: Request, path: str):
"""
Proxies all geocoding requests to the External Service geocoding endpoints.
Forwards requests from /api/v1/geocoding/* to external-service:8000/api/v1/geocoding/*
"""
try:
# Construct the external service URL
external_url = f"{settings.EXTERNAL_SERVICE_URL}/api/v1/geocoding/{path}"
# Get request body for POST/PUT/PATCH
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Make the proxied request
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=request.method,
url=external_url,
params=request.query_params,
headers=headers,
content=body
)
# Return the response from external service
return JSONResponse(
content=response.json() if response.text else None,
status_code=response.status_code,
headers=dict(response.headers)
)
except httpx.RequestError as exc:
logger.error("External service geocoding request failed", error=str(exc), path=path)
raise HTTPException(
status_code=503,
detail=f"Geocoding service unavailable: {exc}"
)
except httpx.HTTPStatusError as exc:
logger.error(
f"External service geocoding responded with error {exc.response.status_code}",
detail=exc.response.text,
path=path
)
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Geocoding service error: {exc.response.text}"
)
except Exception as exc:
logger.error("Unexpected error in geocoding proxy", error=str(exc), path=path)
raise HTTPException(
status_code=500,
detail="Internal server error in geocoding proxy"
)

View File

@@ -0,0 +1,61 @@
# gateway/app/routes/nominatim.py
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import structlog
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
@router.get("/search")
async def proxy_nominatim_search(request: Request):
"""
Proxies requests to the Nominatim geocoding search API.
"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try:
# Construct the internal Nominatim URL
# All query parameters from the client request are forwarded
nominatim_url = f"{settings.NOMINATIM_SERVICE_URL}/nominatim/search"
# httpx client for making async HTTP requests
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
nominatim_url,
params=request.query_params # Forward all query parameters from frontend
)
response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
# Return the JSON response from Nominatim directly
return JSONResponse(content=response.json())
except httpx.RequestError as exc:
logger.error("Nominatim service request failed", error=str(exc))
raise HTTPException(
status_code=503,
detail=f"Nominatim service unavailable: {exc}"
)
except httpx.HTTPStatusError as exc:
logger.error(f"Nominatim service responded with error {exc.response.status_code}",
detail=exc.response.text)
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Nominatim service error: {exc.response.text}"
)
except Exception as exc:
logger.error("Unexpected error in Nominatim proxy", error=str(exc))
raise HTTPException(status_code=500, detail="Internal server error in Nominatim proxy")

View File

@@ -0,0 +1,88 @@
"""
POI Context Proxy Router
Forwards all POI context requests to the External Service
"""
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
import httpx
import structlog
from app.core.config import settings
from app.core.header_manager import header_manager
logger = structlog.get_logger()
router = APIRouter()
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
async def proxy_poi_context(request: Request, path: str):
"""
Proxies all POI context requests to the External Service.
Forwards requests from /api/v1/poi-context/* to external-service:8000/api/v1/poi-context/*
Args:
request: The incoming request
path: The path after /api/v1/poi-context/
Returns:
JSONResponse with the response from the external service
Raises:
HTTPException: If the external service is unavailable or returns an error
"""
try:
# Construct the external service URL
external_url = f"{settings.EXTERNAL_SERVICE_URL}/poi-context/{path}"
logger.debug("Proxying POI context request",
method=request.method,
path=path,
external_url=external_url)
# Get request body for POST/PUT/PATCH requests
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Make the request to the external service
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.request(
method=request.method,
url=external_url,
params=request.query_params,
headers=headers,
content=body
)
logger.debug("POI context proxy response",
status_code=response.status_code,
path=path)
# Return the response from the external service
return JSONResponse(
content=response.json() if response.text else None,
status_code=response.status_code,
headers=dict(response.headers)
)
except httpx.RequestError as exc:
logger.error("External service POI request failed",
error=str(exc),
path=path,
external_url=external_url)
raise HTTPException(
status_code=503,
detail=f"POI service unavailable: {exc}"
)
except Exception as exc:
logger.error("Unexpected error in POI proxy",
error=str(exc),
path=path)
raise HTTPException(
status_code=500,
detail="Internal server error in POI proxy"
)

89
gateway/app/routes/pos.py Normal file
View File

@@ -0,0 +1,89 @@
"""
POS routes for API Gateway - Global POS endpoints
"""
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
from app.core.config import settings
from app.core.header_manager import header_manager
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# GLOBAL POS ENDPOINTS (No tenant context required)
# ================================================================
@router.api_route("/supported-systems", methods=["GET", "OPTIONS"])
async def proxy_supported_systems(request: Request):
"""Proxy supported POS systems request to POS service"""
target_path = "/api/v1/pos/supported-systems"
return await _proxy_to_pos_service(request, target_path)
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_pos_service(request: Request, target_path: str):
"""Proxy request to POS service"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{settings.POS_SERVICE_URL}{target_path}"
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0,
read=60.0,
write=30.0,
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying to POS service {target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -0,0 +1,116 @@
"""
Registration routes for API Gateway - Handles registration-specific endpoints
These endpoints don't require a tenant ID and are used during the registration flow
"""
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
from app.core.header_manager import header_manager
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# REGISTRATION ENDPOINTS - Direct routing to tenant service
# These endpoints are called during registration before a tenant exists
# ================================================================
@router.post("/registration-payment-setup")
async def proxy_registration_payment_setup(request: Request):
"""Proxy registration payment setup request to tenant service"""
return await _proxy_to_tenant_service(request, "/api/v1/tenants/registration-payment-setup")
@router.post("/verify-and-complete-registration")
async def proxy_verify_and_complete_registration(request: Request):
"""Proxy verification and registration completion to tenant service"""
return await _proxy_to_tenant_service(request, "/api/v1/tenants/verify-and-complete-registration")
@router.post("/payment-customers/create")
async def proxy_registration_customer_create(request: Request):
"""Proxy registration customer creation to tenant service"""
return await _proxy_to_tenant_service(request, "/api/v1/payment-customers/create")
@router.get("/setup-intents/{setup_intent_id}/verify")
async def proxy_registration_setup_intent_verify(request: Request, setup_intent_id: str):
"""Proxy registration setup intent verification to tenant service"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/setup-intents/{setup_intent_id}/verify")
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_tenant_service(request: Request, target_path: str):
"""Generic proxy function with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID, Stripe-Signature",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{settings.TENANT_SERVICE_URL}{target_path}"
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Debug logging
logger.info(f"Forwarding registration request to {url}")
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0,
read=60.0,
write=30.0,
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying registration request to {settings.TENANT_SERVICE_URL}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -0,0 +1,317 @@
"""
Subscription routes for API Gateway - Direct subscription endpoints
New URL Pattern Architecture:
- Registration: /registration/payment-setup, /registration/complete, /registration/state/{state_id}
- Tenant Subscription: /tenants/{tenant_id}/subscription/*
- Setup Intents: /setup-intents/{setup_intent_id}/verify
- Payment Customers: /payment-customers/create
- Plans: /plans (public)
"""
from fastapi import APIRouter, Request, Response, HTTPException, Path
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
from app.core.header_manager import header_manager
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# PUBLIC ENDPOINTS (No Authentication)
# ================================================================
@router.api_route("/plans", methods=["GET", "OPTIONS"])
async def proxy_plans(request: Request):
"""Proxy plans request to tenant service"""
target_path = "/plans"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/plans/{tier}", methods=["GET", "OPTIONS"])
async def proxy_plan_details(request: Request, tier: str = Path(...)):
"""Proxy specific plan details request to tenant service"""
target_path = f"/plans/{tier}"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/plans/{tier}/features", methods=["GET", "OPTIONS"])
async def proxy_plan_features(request: Request, tier: str = Path(...)):
"""Proxy plan features request to tenant service"""
target_path = f"/plans/{tier}/features"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/plans/{tier}/limits", methods=["GET", "OPTIONS"])
async def proxy_plan_limits(request: Request, tier: str = Path(...)):
"""Proxy plan limits request to tenant service"""
target_path = f"/plans/{tier}/limits"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/plans/compare", methods=["GET", "OPTIONS"])
async def proxy_plan_compare(request: Request):
"""Proxy plan comparison request to tenant service"""
target_path = "/plans/compare"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# REGISTRATION FLOW ENDPOINTS (No Tenant Context)
# ================================================================
@router.api_route("/registration/payment-setup", methods=["POST", "OPTIONS"])
async def proxy_registration_payment_setup(request: Request):
"""Proxy registration payment setup request to tenant service"""
target_path = "/api/v1/registration/payment-setup"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/registration/complete", methods=["POST", "OPTIONS"])
async def proxy_registration_complete(request: Request):
"""Proxy registration completion request to tenant service"""
target_path = "/api/v1/registration/complete"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/registration/state/{state_id}", methods=["GET", "OPTIONS"])
async def proxy_registration_state(request: Request, state_id: str = Path(...)):
"""Proxy registration state request to tenant service"""
target_path = f"/api/v1/registration/state/{state_id}"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# TENANT SUBSCRIPTION STATUS ENDPOINTS
# ================================================================
@router.api_route("/tenants/{tenant_id}/subscription/status", methods=["GET", "OPTIONS"])
async def proxy_subscription_status(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription status request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/status"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/details", methods=["GET", "OPTIONS"])
async def proxy_subscription_details(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription details request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/details"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/tier", methods=["GET", "OPTIONS"])
async def proxy_subscription_tier(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription tier request to tenant service (cached)"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/tier"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/limits", methods=["GET", "OPTIONS"])
async def proxy_subscription_limits(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription limits request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/limits"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/usage", methods=["GET", "OPTIONS"])
async def proxy_subscription_usage(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription usage request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/usage"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/features/{feature}", methods=["GET", "OPTIONS"])
async def proxy_subscription_feature(request: Request, tenant_id: str = Path(...), feature: str = Path(...)):
"""Proxy subscription feature check request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/features/{feature}"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# SUBSCRIPTION MANAGEMENT ENDPOINTS
# ================================================================
@router.api_route("/tenants/{tenant_id}/subscription/cancel", methods=["POST", "OPTIONS"])
async def proxy_subscription_cancel(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription cancellation request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/cancel"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/reactivate", methods=["POST", "OPTIONS"])
async def proxy_subscription_reactivate(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription reactivation request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/reactivate"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/validate-upgrade/{new_plan}", methods=["GET", "OPTIONS"])
async def proxy_validate_upgrade(request: Request, tenant_id: str = Path(...), new_plan: str = Path(...)):
"""Proxy plan upgrade validation request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/validate-upgrade/{new_plan}"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/upgrade", methods=["POST", "OPTIONS"])
async def proxy_subscription_upgrade(request: Request, tenant_id: str = Path(...)):
"""Proxy subscription upgrade request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/upgrade"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# QUOTA & LIMIT CHECK ENDPOINTS
# ================================================================
@router.api_route("/tenants/{tenant_id}/subscription/limits/locations", methods=["GET", "OPTIONS"])
async def proxy_location_limits(request: Request, tenant_id: str = Path(...)):
"""Proxy location limits check request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/limits/locations"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/limits/products", methods=["GET", "OPTIONS"])
async def proxy_product_limits(request: Request, tenant_id: str = Path(...)):
"""Proxy product limits check request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/limits/products"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/limits/users", methods=["GET", "OPTIONS"])
async def proxy_user_limits(request: Request, tenant_id: str = Path(...)):
"""Proxy user limits check request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/limits/users"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/limits/recipes", methods=["GET", "OPTIONS"])
async def proxy_recipe_limits(request: Request, tenant_id: str = Path(...)):
"""Proxy recipe limits check request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/limits/recipes"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/limits/suppliers", methods=["GET", "OPTIONS"])
async def proxy_supplier_limits(request: Request, tenant_id: str = Path(...)):
"""Proxy supplier limits check request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/limits/suppliers"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# PAYMENT MANAGEMENT ENDPOINTS
# ================================================================
@router.api_route("/tenants/{tenant_id}/subscription/payment-method", methods=["GET", "POST", "OPTIONS"])
async def proxy_payment_method(request: Request, tenant_id: str = Path(...)):
"""Proxy payment method request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/payment-method"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/tenants/{tenant_id}/subscription/invoices", methods=["GET", "OPTIONS"])
async def proxy_invoices(request: Request, tenant_id: str = Path(...)):
"""Proxy invoices request to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/subscription/invoices"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# SETUP INTENT VERIFICATION
# ================================================================
@router.api_route("/setup-intents/{setup_intent_id}/verify", methods=["GET", "OPTIONS"])
async def proxy_setup_intent_verify(request: Request, setup_intent_id: str = Path(...)):
"""Proxy SetupIntent verification request to tenant service"""
target_path = f"/api/v1/setup-intents/{setup_intent_id}/verify"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# PAYMENT CUSTOMER MANAGEMENT
# ================================================================
@router.api_route("/payment-customers/create", methods=["POST", "OPTIONS"])
async def proxy_payment_customer_create(request: Request):
"""Proxy payment customer creation request to tenant service"""
target_path = "/api/v1/payment-customers/create"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# USAGE FORECAST ENDPOINTS
# ================================================================
@router.api_route("/usage-forecast", methods=["GET", "OPTIONS"])
async def proxy_usage_forecast(request: Request):
"""Proxy usage forecast request to tenant service"""
target_path = "/api/v1/usage-forecast"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/usage-forecast/track-usage", methods=["POST", "OPTIONS"])
async def proxy_track_usage(request: Request):
"""Proxy track usage request to tenant service"""
target_path = "/api/v1/usage-forecast/track-usage"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_tenant_service(request: Request, target_path: str):
"""Proxy request to tenant service"""
return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL)
async def _proxy_request(request: Request, target_path: str, service_url: str):
"""Generic proxy function with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{service_url}{target_path}"
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Debug logging
user_context = getattr(request.state, 'user', None)
service_context = getattr(request.state, 'service', None)
if user_context:
logger.info(f"Forwarding subscription request to {url} with user context: user_id={user_context.get('user_id')}, email={user_context.get('email')}, subscription_tier={user_context.get('subscription_tier', 'not_set')}")
elif service_context:
logger.debug(f"Forwarding subscription request to {url} with service context: service_name={service_context.get('service_name')}, user_type=service")
else:
logger.warning(f"No user or service context available when forwarding subscription request to {url}")
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0,
read=60.0,
write=30.0,
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying subscription request to {service_url}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -0,0 +1,303 @@
"""
Telemetry routes for API Gateway - Handles frontend telemetry data
This module provides endpoints for:
- Receiving OpenTelemetry traces from frontend
- Proxying traces to Signoz OTel collector
- Providing a secure, authenticated endpoint for frontend telemetry
"""
from fastapi import APIRouter, Request, HTTPException, status
from fastapi.responses import JSONResponse, Response
import httpx
import logging
import os
from typing import Optional
from app.core.config import settings
from app.core.header_manager import header_manager
from shared.monitoring.metrics import MetricsCollector, create_metrics_collector
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
# Get Signoz OTel collector endpoint from environment or use default
SIGNOZ_OTEL_COLLECTOR = os.getenv(
"SIGNOZ_OTEL_COLLECTOR_URL",
"http://signoz-otel-collector.bakery-ia.svc.cluster.local:4318"
)
@router.post("/v1/traces")
async def receive_frontend_traces(request: Request):
"""
Receive OpenTelemetry traces from frontend and proxy to Signoz
This endpoint:
- Accepts OTLP trace data from frontend
- Validates the request
- Proxies to Signoz OTel collector
- Handles errors gracefully
"""
# Handle OPTIONS requests for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
# Get the trace data from the request
body = await request.body()
if not body:
logger.warning("Received empty trace data from frontend")
return JSONResponse(
status_code=400,
content={"error": "Empty trace data"}
)
# Log the trace reception (without sensitive data)
logger.info(
"Received frontend traces, content_length=%s, content_type=%s, user_agent=%s",
len(body),
request.headers.get("content-type"),
request.headers.get("user-agent")
)
# Forward to Signoz OTel collector
target_url = f"{SIGNOZ_OTEL_COLLECTOR}/v1/traces"
# Set up headers for the Signoz collector
forward_headers = {
"Content-Type": request.headers.get("content-type", "application/json"),
"User-Agent": "bakery-gateway/1.0",
"X-Forwarded-For": request.headers.get("x-forwarded-for", "frontend"),
"X-Tenant-ID": request.headers.get("x-tenant-id", "unknown")
}
# Add authentication if configured
signoz_auth_token = os.getenv("SIGNOZ_AUTH_TOKEN")
if signoz_auth_token:
forward_headers["Authorization"] = f"Bearer {signoz_auth_token}"
# Send to Signoz collector
timeout_config = httpx.Timeout(
connect=5.0,
read=10.0,
write=5.0,
pool=5.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.post(
url=target_url,
content=body,
headers=forward_headers
)
# Log the response from Signoz
logger.info(
"Forwarded traces to Signoz, signoz_status=%s, signoz_response_time=%s",
response.status_code,
response.elapsed.total_seconds()
)
# Return success response to frontend
return JSONResponse(
status_code=200,
content={
"message": "Traces received and forwarded to Signoz",
"signoz_status": response.status_code,
"trace_count": 1 # We don't know exact count without parsing
}
)
except httpx.HTTPStatusError as e:
logger.error(
"Signoz collector returned error, status_code=%s, error_message=%s",
e.response.status_code,
str(e)
)
return JSONResponse(
status_code=502,
content={
"error": "Signoz collector error",
"details": str(e),
"signoz_status": e.response.status_code
}
)
except httpx.RequestError as e:
logger.error(
"Failed to connect to Signoz collector, error=%s, collector_url=%s",
str(e),
SIGNOZ_OTEL_COLLECTOR
)
return JSONResponse(
status_code=503,
content={
"error": "Signoz collector unavailable",
"details": str(e)
}
)
except Exception as e:
logger.error(
"Unexpected error processing traces, error=%s, error_type=%s",
str(e),
type(e).__name__
)
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"details": str(e)
}
)
@router.post("/v1/metrics")
async def receive_frontend_metrics(request: Request):
"""
Receive OpenTelemetry metrics from frontend and proxy to Signoz
"""
# Handle OPTIONS requests for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
body = await request.body()
if not body:
return JSONResponse(
status_code=400,
content={"error": "Empty metrics data"}
)
logger.info(
"Received frontend metrics, content_length=%s, content_type=%s",
len(body),
request.headers.get("content-type")
)
# Forward to Signoz OTel collector
target_url = f"{SIGNOZ_OTEL_COLLECTOR}/v1/metrics"
forward_headers = {
"Content-Type": request.headers.get("content-type", "application/json"),
"User-Agent": "bakery-gateway/1.0",
"X-Forwarded-For": request.headers.get("x-forwarded-for", "frontend"),
"X-Tenant-ID": request.headers.get("x-tenant-id", "unknown")
}
# Add authentication if configured
signoz_auth_token = os.getenv("SIGNOZ_AUTH_TOKEN")
if signoz_auth_token:
forward_headers["Authorization"] = f"Bearer {signoz_auth_token}"
timeout_config = httpx.Timeout(
connect=5.0,
read=10.0,
write=5.0,
pool=5.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.post(
url=target_url,
content=body,
headers=forward_headers
)
logger.info(
"Forwarded metrics to Signoz, signoz_status=%s",
response.status_code
)
return JSONResponse(
status_code=200,
content={
"message": "Metrics received and forwarded to Signoz",
"signoz_status": response.status_code
}
)
except Exception as e:
logger.error(
"Error processing metrics, error=%s",
str(e)
)
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"details": str(e)
}
)
@router.get("/health")
async def telemetry_health():
"""
Health check endpoint for telemetry service
"""
return JSONResponse(
status_code=200,
content={
"status": "healthy",
"service": "telemetry-gateway",
"signoz_collector": SIGNOZ_OTEL_COLLECTOR
}
)
# Initialize metrics for this module
try:
metrics_collector = create_metrics_collector("gateway-telemetry")
except Exception as e:
logger.error("Failed to create metrics collector, error=%s", str(e))
metrics_collector = None
@router.on_event("startup")
async def startup_event():
"""Initialize telemetry metrics on startup"""
try:
if metrics_collector:
# Register telemetry-specific metrics
metrics_collector.register_counter(
"gateway_telemetry_traces_received",
"Number of trace batches received from frontend"
)
metrics_collector.register_counter(
"gateway_telemetry_metrics_received",
"Number of metric batches received from frontend"
)
metrics_collector.register_counter(
"gateway_telemetry_errors",
"Number of telemetry processing errors"
)
logger.info(
"Telemetry gateway initialized, signoz_collector=%s",
SIGNOZ_OTEL_COLLECTOR
)
except Exception as e:
logger.error(
"Failed to initialize telemetry metrics, error=%s",
str(e)
)

View File

@@ -0,0 +1,822 @@
# gateway/app/routes/tenant.py - COMPLETELY UPDATED
"""
Tenant routes for API Gateway - Handles all tenant-scoped endpoints
"""
from fastapi import APIRouter, Request, Response, HTTPException, Path
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
from app.core.header_manager import header_manager
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# TENANT MANAGEMENT ENDPOINTS
# ================================================================
@router.post("/register")
async def create_tenant(request: Request):
"""Proxy tenant creation to tenant service"""
return await _proxy_to_tenant_service(request, "/api/v1/tenants/register")
@router.get("/{tenant_id}")
async def get_tenant(request: Request, tenant_id: str = Path(...)):
"""Get specific tenant details"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}")
@router.put("/{tenant_id}")
async def update_tenant(request: Request, tenant_id: str = Path(...)):
"""Update tenant details"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}")
@router.get("/{tenant_id}/members")
async def get_tenant_members(request: Request, tenant_id: str = Path(...)):
"""Get tenant members"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members")
@router.post("/{tenant_id}/members")
async def add_tenant_member(request: Request, tenant_id: str = Path(...)):
"""Add a team member to tenant"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members")
@router.post("/{tenant_id}/members/with-user")
async def add_tenant_member_with_user(request: Request, tenant_id: str = Path(...)):
"""Add a team member to tenant with user creation"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members/with-user")
@router.put("/{tenant_id}/members/{member_user_id}/role")
async def update_member_role(request: Request, tenant_id: str = Path(...), member_user_id: str = Path(...)):
"""Update team member role"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members/{member_user_id}/role")
@router.delete("/{tenant_id}/members/{member_user_id}")
async def remove_tenant_member(request: Request, tenant_id: str = Path(...), member_user_id: str = Path(...)):
"""Remove team member from tenant"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members/{member_user_id}")
@router.post("/{tenant_id}/transfer-ownership")
async def transfer_tenant_ownership(request: Request, tenant_id: str = Path(...)):
"""Transfer tenant ownership to another admin"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/transfer-ownership")
@router.get("/{tenant_id}/admins")
async def get_tenant_admins(request: Request, tenant_id: str = Path(...)):
"""Get all admins for a tenant"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/admins")
@router.get("/{tenant_id}/hierarchy")
async def get_tenant_hierarchy(request: Request, tenant_id: str = Path(...)):
"""Get tenant hierarchy information"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/hierarchy")
@router.api_route("/{tenant_id}/children", methods=["GET", "OPTIONS"])
async def get_tenant_children(request: Request, tenant_id: str = Path(...)):
"""Get tenant children"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/children")
@router.api_route("/{tenant_id}/bulk-children", methods=["POST", "OPTIONS"])
async def proxy_bulk_children(request: Request, tenant_id: str = Path(...)):
"""Proxy bulk children creation requests to tenant service"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/bulk-children")
@router.api_route("/{tenant_id}/children/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_children(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant children requests to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/children/{path}".rstrip("/")
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/{tenant_id}/access/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_access(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant access requests to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/access/{path}".rstrip("/")
return await _proxy_to_tenant_service(request, target_path)
@router.get("/{tenant_id}/my-access")
async def get_tenant_my_access(request: Request, tenant_id: str = Path(...)):
"""Get current user's access level for a tenant"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/my-access")
@router.get("/user/{user_id}")
async def get_user_tenants(request: Request, user_id: str = Path(...)):
"""Get all tenant memberships for a user (admin only)"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/users/{user_id}")
@router.get("/user/{user_id}/owned")
async def get_user_owned_tenants(request: Request, user_id: str = Path(...)):
"""Get all tenants owned by a user"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/user/{user_id}/owned")
@router.get("/user/{user_id}/tenants")
async def get_user_all_tenants(request: Request, user_id: str = Path(...)):
"""Get all tenants accessible by a user (both owned and member tenants)"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/user/{user_id}/tenants")
@router.get("/users/{user_id}/primary-tenant")
async def get_user_primary_tenant(request: Request, user_id: str = Path(...)):
"""Get the primary tenant for a user (used by auth service for subscription validation)"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/users/{user_id}/primary-tenant")
@router.delete("/user/{user_id}/memberships")
async def delete_user_tenants(request: Request, user_id: str = Path(...)):
"""Get all tenant memberships for a user (admin only)"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/user/{user_id}/memberships")
# ================================================================
# TENANT SETTINGS ENDPOINTS
# ================================================================
@router.get("/{tenant_id}/settings")
async def get_tenant_settings(request: Request, tenant_id: str = Path(...)):
"""Get all settings for a tenant"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/settings")
@router.put("/{tenant_id}/settings")
async def update_tenant_settings(request: Request, tenant_id: str = Path(...)):
"""Update tenant settings"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/settings")
@router.get("/{tenant_id}/settings/{category}")
async def get_category_settings(request: Request, tenant_id: str = Path(...), category: str = Path(...)):
"""Get settings for a specific category"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/settings/{category}")
@router.put("/{tenant_id}/settings/{category}")
async def update_category_settings(request: Request, tenant_id: str = Path(...), category: str = Path(...)):
"""Update settings for a specific category"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/settings/{category}")
@router.post("/{tenant_id}/settings/{category}/reset")
async def reset_category_settings(request: Request, tenant_id: str = Path(...), category: str = Path(...)):
"""Reset a category to default values"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/settings/{category}/reset")
# ================================================================
# TENANT SUBSCRIPTION ENDPOINTS
# ================================================================
# NOTE: All subscription endpoints have been moved to gateway/app/routes/subscription.py
# as part of the architecture redesign for better separation of concerns.
# This wildcard route has been removed to avoid conflicts with the new specific routes.
@router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"])
async def proxy_available_plans(request: Request):
"""Proxy available plans request to tenant service"""
target_path = "/api/v1/plans/available"
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# BATCH OPERATIONS ENDPOINTS
# IMPORTANT: Route order matters! Keep specific routes before wildcards:
# 1. Exact matches first (/batch/sales-summary)
# 2. Wildcard paths second (/batch{path:path})
# 3. Tenant-scoped wildcards last (/{tenant_id}/batch{path:path})
# ================================================================
@router.api_route("/batch/sales-summary", methods=["POST"])
async def proxy_batch_sales_summary(request: Request):
"""Proxy batch sales summary request to sales service"""
target_path = "/api/v1/batch/sales-summary"
return await _proxy_to_sales_service(request, target_path)
@router.api_route("/batch/production-summary", methods=["POST"])
async def proxy_batch_production_summary(request: Request):
"""Proxy batch production summary request to production service"""
target_path = "/api/v1/batch/production-summary"
return await _proxy_to_production_service(request, target_path)
@router.api_route("/batch{path:path}", methods=["POST", "GET", "PUT", "DELETE", "OPTIONS"])
async def proxy_batch_operations(request: Request, path: str = ""):
"""Proxy batch operations that span multiple tenants to appropriate services"""
# For batch operations, route based on the path after /batch/
if path.startswith("/sales-summary"):
# Route batch sales summary to sales service
# The sales service batch endpoints are at /api/v1/batch/... not /api/v1/sales/batch/...
target_path = f"/api/v1/batch{path}"
return await _proxy_to_sales_service(request, target_path)
elif path.startswith("/production-summary"):
# Route batch production summary to production service
# The production service batch endpoints are at /api/v1/batch/... not /api/v1/production/batch/...
target_path = f"/api/v1/batch{path}"
return await _proxy_to_production_service(request, target_path)
else:
# Default to sales service for other batch operations
# The service batch endpoints are at /api/v1/batch/... not /api/v1/sales/batch/...
target_path = f"/api/v1/batch{path}"
return await _proxy_to_sales_service(request, target_path)
@router.api_route("/{tenant_id}/batch{path:path}", methods=["POST", "GET", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_batch_operations(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant-scoped batch operations to appropriate services"""
# For tenant-scoped batch operations, route based on the path after /batch/
if path.startswith("/sales-summary"):
# Route tenant batch sales summary to sales service
# The sales service batch endpoints are at /api/v1/batch/... not /api/v1/sales/batch/...
target_path = f"/api/v1/batch{path}"
return await _proxy_to_sales_service(request, target_path)
elif path.startswith("/production-summary"):
# Route tenant batch production summary to production service
# The production service batch endpoints are at /api/v1/batch/... not /api/v1/production/batch/...
target_path = f"/api/v1/batch{path}"
return await _proxy_to_production_service(request, target_path)
else:
# Default to sales service for other tenant batch operations
# The service batch endpoints are at /api/v1/batch/... not /api/v1/sales/batch/...
target_path = f"/api/v1/batch{path}"
return await _proxy_to_sales_service(request, target_path)
# ================================================================
# TENANT-SCOPED DATA SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/sales{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_all_tenant_sales_alternative(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy all tenant sales requests - handles both base and sub-paths"""
base_path = f"/api/v1/tenants/{tenant_id}/sales"
# If path is empty or just "/", use base path
if not path or path == "/" or path == "":
target_path = base_path
else:
# Ensure path starts with "/"
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_sales_service(request, target_path)
@router.api_route("/{tenant_id}/enterprise/batch{path:path}", methods=["POST", "GET", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_enterprise_batch(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy enterprise batch requests (spanning multiple tenants within an enterprise) to appropriate services"""
# Forward to orchestrator service for enterprise-level operations
target_path = f"/api/v1/tenants/{tenant_id}/enterprise/batch{path}".rstrip("/")
return await _proxy_to_orchestrator_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/weather/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_weather(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant weather requests to external service"""
target_path = f"/api/v1/tenants/{tenant_id}/weather/{path}".rstrip("/")
return await _proxy_to_external_service(request, target_path)
@router.api_route("/{tenant_id}/traffic/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_traffic(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant traffic requests to external service"""
target_path = f"/api/v1/tenants/{tenant_id}/traffic/{path}".rstrip("/")
return await _proxy_to_external_service(request, target_path)
@router.api_route("/{tenant_id}/external/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_external(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant external service requests (v2.0 city-based optimized endpoints)"""
# Route to external service with normal path structure
target_path = f"/api/v1/tenants/{tenant_id}/external/{path}".rstrip("/")
return await _proxy_to_external_service(request, target_path)
# Service-specific analytics routes (must come BEFORE the general analytics route)
@router.api_route("/{tenant_id}/procurement/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_procurement_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant procurement analytics requests to procurement service"""
target_path = f"/api/v1/tenants/{tenant_id}/procurement/analytics/{path}".rstrip("/")
return await _proxy_to_procurement_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/inventory/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_inventory_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory analytics requests to inventory service"""
target_path = f"/api/v1/tenants/{tenant_id}/inventory/analytics/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/production/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_production_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant production analytics requests to production service"""
target_path = f"/api/v1/tenants/{tenant_id}/production/analytics/{path}".rstrip("/")
return await _proxy_to_production_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/sales/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_sales_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant sales analytics requests to sales service"""
target_path = f"/api/v1/tenants/{tenant_id}/sales/analytics/{path}".rstrip("/")
return await _proxy_to_sales_service(request, target_path)
@router.api_route("/{tenant_id}/analytics/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant analytics requests to sales service (fallback for non-service-specific analytics)"""
target_path = f"/api/v1/tenants/{tenant_id}/analytics/{path}".rstrip("/")
return await _proxy_to_sales_service(request, target_path)
# ================================================================
# TENANT-SCOPED AI INSIGHTS ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/insights{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
async def proxy_tenant_insights(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant AI insights requests to AI insights service"""
target_path = f"/api/v1/tenants/{tenant_id}/insights{path}".rstrip("/")
return await _proxy_to_ai_insights_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/onboarding/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_onboarding(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant onboarding requests to tenant service"""
target_path = f"/api/v1/tenants/{tenant_id}/onboarding/{path}".rstrip("/")
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# TENANT-SCOPED TRAINING SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/training/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_training(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant training requests to training service"""
target_path = f"/api/v1/tenants/{tenant_id}/training/{path}".rstrip("/")
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/models/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant model requests to training service"""
target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/")
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/statistics", methods=["GET", "OPTIONS"])
async def proxy_tenant_statistics(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant statistics requests to training service"""
target_path = f"/api/v1/tenants/{tenant_id}/statistics"
return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED FORECASTING SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/forecasting/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_forecasting(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant forecasting requests to forecasting service"""
target_path = f"/api/v1/tenants/{tenant_id}/forecasting/{path}".rstrip("/")
return await _proxy_to_forecasting_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/forecasting/enterprise/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_forecasting_enterprise(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant forecasting enterprise requests to forecasting service"""
target_path = f"/api/v1/tenants/{tenant_id}/forecasting/enterprise/{path}".rstrip("/")
return await _proxy_to_forecasting_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_forecasts(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant forecast requests to forecasting service"""
target_path = f"/api/v1/tenants/{tenant_id}/forecasts/{path}".rstrip("/")
return await _proxy_to_forecasting_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/predictions/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_predictions(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant prediction requests to forecasting service"""
target_path = f"/api/v1/tenants/{tenant_id}/predictions/{path}".rstrip("/")
return await _proxy_to_forecasting_service(request, target_path)
# ================================================================
# TENANT-SCOPED NOTIFICATION SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/notifications/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant notification requests to notification service"""
target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/")
return await _proxy_to_notification_service(request, target_path)
# ================================================================
# TENANT-SCOPED ALERT ANALYTICS ENDPOINTS (Must come BEFORE inventory alerts)
# ================================================================
# Exact match for /alerts endpoint (without additional path)
@router.api_route("/{tenant_id}/alerts", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alerts_list(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant alerts list requests to alert processor service"""
target_path = f"/api/v1/tenants/{tenant_id}/alerts"
return await _proxy_to_alert_processor_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/alerts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alert_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant alert analytics requests to alert processor service"""
target_path = f"/api/v1/tenants/{tenant_id}/alerts/{path}".rstrip("/")
return await _proxy_to_alert_processor_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED INVENTORY SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alerts_inventory(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant alerts requests to inventory service (legacy/food-safety alerts)"""
target_path = f"/api/v1/tenants/{tenant_id}/alerts{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/inventory/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_inventory(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory requests to inventory service"""
# The inventory service expects /api/v1/tenants/{tenant_id}/inventory/{path}
# Keep the full path structure for inventory service
target_path = f"/api/v1/tenants/{tenant_id}/inventory/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
# Specific route for ingredients without additional path
@router.api_route("/{tenant_id}/ingredients", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_ingredients_base(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant ingredient requests to inventory service (base path)"""
target_path = f"/api/v1/tenants/{tenant_id}/ingredients"
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/ingredients/count", methods=["GET"])
async def proxy_tenant_ingredients_count(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant ingredient count requests to inventory service"""
# Inventory service uses RouteBuilder('inventory').build_base_route("ingredients/count")
# which generates /api/v1/tenants/{tenant_id}/inventory/ingredients/count
target_path = f"/api/v1/tenants/{tenant_id}/inventory/ingredients/count"
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/ingredients/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_ingredients_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant ingredient requests to inventory service (with additional path)"""
# The inventory service ingredient endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/ingredients/{path}
# Keep the full tenant path structure
target_path = f"/api/v1/tenants/{tenant_id}/ingredients/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/stock/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_stock(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant stock requests to inventory service"""
# The inventory service stock endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/stock/{path}
target_path = f"/api/v1/tenants/{tenant_id}/stock/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/dashboard/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_dashboard(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant dashboard requests to orchestrator service"""
# The orchestrator service dashboard endpoints are tenant-scoped: /api/v1/tenants/{tenant_id}/dashboard/{path}
target_path = f"/api/v1/tenants/{tenant_id}/dashboard/{path}".rstrip("/")
return await _proxy_to_orchestrator_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/transformations", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_transformations_base(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant transformations requests to inventory service (base path)"""
target_path = f"/api/v1/tenants/{tenant_id}/transformations"
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/transformations/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_transformations_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant transformations requests to inventory service (with additional path)"""
# The inventory service transformations endpoints are tenant-scoped: /api/v1/tenants/{tenant_id}/transformations/{path}
target_path = f"/api/v1/tenants/{tenant_id}/transformations/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/sustainability/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_sustainability(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant sustainability requests to inventory service"""
# The inventory service sustainability endpoints are tenant-scoped: /api/v1/tenants/{tenant_id}/sustainability/{path}
target_path = f"/api/v1/tenants/{tenant_id}/sustainability/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED PRODUCTION SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/production/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_production(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant production requests to production service"""
target_path = f"/api/v1/tenants/{tenant_id}/production/{path}".rstrip("/")
return await _proxy_to_production_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED ORCHESTRATOR SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/enterprise/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_enterprise(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant enterprise dashboard requests to orchestrator service"""
target_path = f"/api/v1/tenants/{tenant_id}/enterprise/{path}".rstrip("/")
return await _proxy_to_orchestrator_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/orchestrator/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_orchestrator(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant orchestrator requests to orchestrator service"""
target_path = f"/api/v1/tenants/{tenant_id}/orchestrator/{path}".rstrip("/")
return await _proxy_to_orchestrator_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED ORDERS SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/orders", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_orders_base(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant orders requests to orders service (base path)"""
target_path = f"/api/v1/tenants/{tenant_id}/orders"
return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/orders/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_orders_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant orders requests to orders service (with additional path)"""
target_path = f"/api/v1/tenants/{tenant_id}/orders/{path}".rstrip("/")
return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/customers/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_customers(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant customers requests to orders service"""
target_path = f"/api/v1/tenants/{tenant_id}/orders/customers/{path}".rstrip("/")
return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/procurement/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_procurement(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant procurement requests to procurement service"""
# For all procurement routes, we need to maintain the /procurement/ part in the path
# The procurement service now uses standardized paths with RouteBuilder
target_path = f"/api/v1/tenants/{tenant_id}/procurement/{path}".rstrip("/")
return await _proxy_to_procurement_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED SUPPLIER SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/suppliers", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_suppliers_base(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant supplier requests to suppliers service (base path)"""
target_path = f"/api/v1/tenants/{tenant_id}/suppliers"
return await _proxy_to_suppliers_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/suppliers/count", methods=["GET"])
async def proxy_tenant_suppliers_count(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant supplier count requests to suppliers service"""
# Suppliers service uses RouteBuilder('suppliers').build_operations_route("count")
# which generates /api/v1/tenants/{tenant_id}/suppliers/operations/count
target_path = f"/api/v1/tenants/{tenant_id}/suppliers/operations/count"
return await _proxy_to_suppliers_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/suppliers/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_suppliers_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant supplier requests to suppliers service (with additional path)"""
target_path = f"/api/v1/tenants/{tenant_id}/suppliers/{path}".rstrip("/")
return await _proxy_to_suppliers_service(request, target_path, tenant_id=tenant_id)
# NOTE: Purchase orders are now accessed via the main procurement route:
# /api/v1/tenants/{tenant_id}/procurement/purchase-orders/*
# Legacy route removed to enforce standardized structure
@router.api_route("/{tenant_id}/deliveries{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_deliveries(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant delivery requests to suppliers service"""
target_path = f"/api/v1/tenants/{tenant_id}/deliveries{path}".rstrip("/")
return await _proxy_to_suppliers_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED LOCATIONS ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/locations", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_locations_base(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant locations requests to tenant service (base path)"""
target_path = f"/api/v1/tenants/{tenant_id}/locations"
return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/{tenant_id}/locations/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_locations_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant locations requests to tenant service (with additional path)"""
target_path = f"/api/v1/tenants/{tenant_id}/locations/{path}".rstrip("/")
return await _proxy_to_tenant_service(request, target_path)
# ================================================================
# TENANT-SCOPED DISTRIBUTION SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/distribution/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_distribution(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant distribution requests to distribution service"""
target_path = f"/api/v1/tenants/{tenant_id}/distribution/{path}".rstrip("/")
return await _proxy_to_distribution_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED RECIPES SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/recipes", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_recipes_base(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant recipes requests to recipes service (base path)"""
target_path = f"/api/v1/tenants/{tenant_id}/recipes"
return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/recipes/count", methods=["GET"])
async def proxy_tenant_recipes_count(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant recipes count requests to recipes service"""
target_path = f"/api/v1/tenants/{tenant_id}/recipes/count"
return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/recipes/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_recipes_with_path(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant recipes requests to recipes service (with additional path)"""
target_path = f"/api/v1/tenants/{tenant_id}/recipes/{path}".rstrip("/")
return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED POS SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/pos/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_pos(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant POS requests to POS service"""
target_path = f"/api/v1/tenants/{tenant_id}/pos/{path}".rstrip("/")
return await _proxy_to_pos_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_tenant_service(request: Request, target_path: str):
"""Proxy request to tenant service"""
return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL)
async def _proxy_to_sales_service(request: Request, target_path: str):
"""Proxy request to sales service"""
return await _proxy_request(request, target_path, settings.SALES_SERVICE_URL)
async def _proxy_to_external_service(request: Request, target_path: str):
"""Proxy request to external service"""
return await _proxy_request(request, target_path, settings.EXTERNAL_SERVICE_URL)
async def _proxy_to_training_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to training service"""
return await _proxy_request(request, target_path, settings.TRAINING_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_forecasting_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to forecasting service"""
return await _proxy_request(request, target_path, settings.FORECASTING_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_notification_service(request: Request, target_path: str):
"""Proxy request to notification service"""
return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL)
async def _proxy_to_inventory_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to inventory service"""
return await _proxy_request(request, target_path, settings.INVENTORY_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_production_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to production service"""
return await _proxy_request(request, target_path, settings.PRODUCTION_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_orders_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to orders service"""
return await _proxy_request(request, target_path, settings.ORDERS_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_suppliers_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to suppliers service"""
return await _proxy_request(request, target_path, settings.SUPPLIERS_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_recipes_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to recipes service"""
return await _proxy_request(request, target_path, settings.RECIPES_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_pos_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to POS service"""
return await _proxy_request(request, target_path, settings.POS_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_procurement_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to procurement service"""
return await _proxy_request(request, target_path, settings.PROCUREMENT_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_alert_processor_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to alert processor service"""
return await _proxy_request(request, target_path, settings.ALERT_PROCESSOR_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_orchestrator_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to orchestrator service"""
return await _proxy_request(request, target_path, settings.ORCHESTRATOR_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_ai_insights_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to AI insights service"""
return await _proxy_request(request, target_path, settings.AI_INSIGHTS_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_distribution_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to distribution service"""
return await _proxy_request(request, target_path, settings.DISTRIBUTION_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_request(request: Request, target_path: str, service_url: str, tenant_id: str = None):
"""Generic proxy function with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{service_url}{target_path}"
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Add tenant ID header if provided (override if needed)
if tenant_id:
headers["x-tenant-id"] = tenant_id
# Debug logging
user_context = getattr(request.state, 'user', None)
if user_context:
logger.info(f"Forwarding request to {url} with user context: user_id={user_context.get('user_id')}, email={user_context.get('email')}, tenant_id={tenant_id}, subscription_tier={user_context.get('subscription_tier', 'not_set')}")
else:
logger.warning(f"No user context available when forwarding request to {url}. request.state.user: {getattr(request.state, 'user', 'NOT_SET')}")
# Get request body if present
body = None
files = None
data = None
if request.method in ["POST", "PUT", "PATCH"]:
content_type = request.headers.get("content-type", "")
logger.info(f"Processing {request.method} request with content-type: {content_type}")
# Handle multipart/form-data (file uploads)
if "multipart/form-data" in content_type:
logger.info("Detected multipart/form-data, parsing form...")
# For multipart/form-data, we need to re-parse and forward as files
form = await request.form()
logger.info(f"Form parsed, found {len(form)} fields: {list(form.keys())}")
# Extract files and form fields separately
files_dict = {}
data_dict = {}
for key, value in form.items():
if hasattr(value, 'file'): # It's a file
# Read file content
file_content = await value.read()
files_dict[key] = (value.filename, file_content, value.content_type)
logger.info(f"Found file field '{key}': filename={value.filename}, size={len(file_content)}, type={value.content_type}")
else: # It's a regular form field
data_dict[key] = value
logger.info(f"Found form field '{key}': value={value}")
files = files_dict if files_dict else None
data = data_dict if data_dict else None
logger.info(f"Forwarding multipart request with files={list(files.keys()) if files else None}, data={list(data.keys()) if data else None}")
# For multipart requests, we need to get fresh headers since httpx will set content-type
# Get all headers again to ensure we have the complete set
headers = header_manager.get_all_headers_for_proxy(request)
# httpx will automatically set content-type for multipart, so we don't need to remove it
else:
# For other content types, use body as before
body = await request.body()
logger.info(f"Using raw body, size: {len(body)} bytes")
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0, # Connection timeout
read=600.0, # Read timeout: 10 minutes (was 30s)
write=30.0, # Write timeout
pool=30.0 # Pool timeout
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
files=files,
data=data,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying to {service_url}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

205
gateway/app/routes/user.py Normal file
View File

@@ -0,0 +1,205 @@
# ================================================================
# gateway/app/routes/user.py
# ================================================================
"""
Authentication routes for API Gateway
"""
import logging
import httpx
from fastapi import APIRouter, Request, Response, HTTPException, status
from fastapi.responses import JSONResponse
from typing import Dict, Any
import json
from app.core.config import settings
from app.core.header_manager import header_manager
from app.core.service_discovery import ServiceDiscovery
from shared.monitoring.metrics import MetricsCollector
logger = logging.getLogger(__name__)
router = APIRouter()
# Initialize service discovery and metrics
service_discovery = ServiceDiscovery()
metrics = MetricsCollector("gateway")
# Auth service configuration
AUTH_SERVICE_URL = settings.AUTH_SERVICE_URL or "http://auth-service:8000"
class UserProxy:
"""Authentication service proxy with enhanced error handling"""
def __init__(self):
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
async def forward_request(
self,
method: str,
path: str,
request: Request
) -> Response:
"""Forward request to auth service with proper error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400" # Cache preflight for 24 hours
}
)
try:
# Get auth service URL (with service discovery if available)
auth_url = await self._get_auth_service_url()
# FIX: Auth service uses /api/v1/auth/ prefix, not /api/v1/users/
target_url = f"{auth_url}/api/v1/auth/{path}"
# Prepare headers (remove hop-by-hop headers)
# IMPORTANT: Use request.headers directly to get headers added by middleware
# Also check request.state for headers injected by middleware
headers = self._prepare_headers(request.headers, request)
# Get request body
body = await request.body()
# Forward request
logger.info(f"Forwarding {method} {path} to auth service")
response = await self.client.request(
method=method,
url=target_url,
headers=headers,
content=body,
params=dict(request.query_params)
)
# Record metrics
metrics.increment_counter("gateway_auth_requests_total")
metrics.increment_counter(
"gateway_auth_responses_total",
labels={"status_code": str(response.status_code)}
)
# Prepare response headers
response_headers = self._prepare_response_headers(dict(response.headers))
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
media_type=response.headers.get("content-type")
)
except httpx.TimeoutException:
logger.error(f"Timeout forwarding {method} {path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "timeout"})
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="Authentication service timeout"
)
except httpx.ConnectError:
logger.error(f"Connection error forwarding {method} {path} to auth service")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "connection"})
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service unavailable"
)
except Exception as e:
logger.error(f"Error forwarding {method} {path} to auth service: {e}")
metrics.increment_counter("gateway_auth_errors_total", labels={"error": "unknown"})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal gateway error"
)
async def _get_auth_service_url(self) -> str:
"""Get auth service URL with service discovery"""
try:
# Try service discovery first
service_url = await service_discovery.get_service_url("auth-service")
if service_url:
return service_url
except Exception as e:
logger.warning(f"Service discovery failed: {e}")
# Fall back to configured URL
return AUTH_SERVICE_URL
def _prepare_headers(self, headers, request=None) -> Dict[str, str]:
"""Prepare headers for forwarding using unified HeaderManager"""
# Use unified HeaderManager to get all headers
if request:
all_headers = header_manager.get_all_headers_for_proxy(request)
else:
# Fallback: convert headers to dict manually
all_headers = {}
if hasattr(headers, '_list'):
for k, v in headers.__dict__.get('_list', []):
key = k.decode() if isinstance(k, bytes) else k
value = v.decode() if isinstance(v, bytes) else v
all_headers[key] = value
elif hasattr(headers, 'raw'):
for k, v in headers.raw:
key = k.decode() if isinstance(k, bytes) else k
value = v.decode() if isinstance(v, bytes) else v
all_headers[key] = value
else:
# Headers is already a dict
all_headers = dict(headers)
return all_headers
def _prepare_response_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
"""Prepare response headers"""
# Remove server-specific headers
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in {'server', 'date'}
}
# Add CORS headers if needed
if settings.CORS_ORIGINS:
filtered_headers['Access-Control-Allow-Origin'] = '*'
filtered_headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
filtered_headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
return filtered_headers
# Initialize proxy
user_proxy = UserProxy()
# ================================================================
# USER MANAGEMENT ENDPOINTS - Proxied to auth service
# ================================================================
@router.get("/delete/{user_id}/deletion-preview")
async def preview_user_deletion(user_id: str, request: Request):
"""Proxy user deletion preview to auth service"""
return await user_proxy.forward_request("GET", f"delete/{user_id}/deletion-preview", request)
@router.delete("/delete/{user_id}")
async def delete_user(user_id: str, request: Request):
"""Proxy admin user deletion to auth service"""
return await user_proxy.forward_request("DELETE", f"delete/{user_id}", request)
# ================================================================
# CATCH-ALL ROUTE for any other user endpoints
# ================================================================
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_auth_requests(path: str, request: Request):
"""Catch-all proxy for auth requests"""
return await user_proxy.forward_request(request.method, path, request)

View File

@@ -0,0 +1,116 @@
"""
Webhook routes for API Gateway - Handles webhook endpoints
Route Configuration Notes:
- Stripe configures webhook URL as: https://domain.com/api/v1/webhooks/stripe
- Gateway receives /api/v1/webhooks/* routes and proxies to tenant service at /webhooks/*
- Gateway routes use /api/v1 prefix, but tenant service routes use /webhooks/* prefix
"""
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
from app.core.header_manager import header_manager
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# WEBHOOK ENDPOINTS - Direct routing to tenant service
# All routes use /api/v1 prefix for consistency
# ================================================================
# Stripe webhook endpoint
@router.post("/api/v1/webhooks/stripe")
async def proxy_stripe_webhook(request: Request):
"""Proxy Stripe webhook requests to tenant service (path: /webhooks/stripe)"""
logger.info("Received Stripe webhook at /api/v1/webhooks/stripe")
return await _proxy_to_tenant_service(request, "/webhooks/stripe")
# Generic webhook endpoint
@router.post("/api/v1/webhooks/generic")
async def proxy_generic_webhook(request: Request):
"""Proxy generic webhook requests to tenant service (path: /webhooks/generic)"""
return await _proxy_to_tenant_service(request, "/webhooks/generic")
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_tenant_service(request: Request, target_path: str):
"""Proxy request to tenant service"""
return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL)
async def _proxy_request(request: Request, target_path: str, service_url: str):
"""Generic proxy function with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID, Stripe-Signature",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{service_url}{target_path}"
# Use unified HeaderManager for consistent header forwarding
headers = header_manager.get_all_headers_for_proxy(request)
# Debug logging
logger.info(f"Forwarding webhook request to {url}")
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0,
read=60.0,
write=30.0,
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying webhook request to {service_url}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)