Add subcription feature 3

This commit is contained in:
Urtzi Alfaro
2026-01-15 20:45:49 +01:00
parent a4c3b7da3f
commit b674708a4c
83 changed files with 9451 additions and 6828 deletions

View File

@@ -51,7 +51,8 @@ class HeaderManager:
'accept',
'accept-language',
'user-agent',
'x-internal-service' # Required for internal service-to-service ML/alert triggers
'x-internal-service', # Required for internal service-to-service ML/alert triggers
'stripe-signature' # Required for Stripe webhook signature verification
]
def __init__(self):

View File

@@ -25,7 +25,7 @@ from app.middleware.rate_limiting import APIRateLimitMiddleware
from app.middleware.subscription import SubscriptionMiddleware
from app.middleware.demo_middleware import DemoMiddleware
from app.middleware.read_only_mode import ReadOnlyModeMiddleware
from app.routes import auth, tenant, nominatim, subscription, demo, pos, geocoding, poi_context, webhooks
from app.routes import auth, tenant, registration, nominatim, subscription, demo, pos, geocoding, poi_context, webhooks
# Initialize logger
logger = structlog.get_logger()
@@ -40,36 +40,61 @@ try:
except Exception as e:
logger.debug(f"Could not check file descriptor limits: {e}")
# Redis client for SSE streaming
# Global Redis client for SSE streaming
redis_client = None
class GatewayService(StandardFastAPIService):
"""Gateway Service with standardized monitoring setup"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Initialize HeaderManager early
header_manager.initialize()
logger.info("HeaderManager initialized")
# Initialize Redis during service creation so it's available when needed
try:
# We need to run the async initialization in a sync context
import asyncio
try:
# Check if there's already a running event loop
loop = asyncio.get_running_loop()
# If there is, we'll initialize Redis later in on_startup
self.redis_initialized = False
self.redis_client = None
except RuntimeError:
# No event loop running, safe to run the async function
import asyncio
import nest_asyncio
nest_asyncio.apply() # Allow nested event loops
async def init_redis():
await initialize_redis(settings.REDIS_URL, db=0, max_connections=50)
return await get_redis_client()
self.redis_client = asyncio.run(init_redis())
self.redis_initialized = True
logger.info("Connected to Redis for SSE streaming")
except Exception as e:
logger.error(f"Failed to initialize Redis during service creation: {e}")
self.redis_initialized = False
self.redis_client = None
async def on_startup(self, app):
"""Custom startup logic for Gateway"""
global redis_client
# Initialize HeaderManager
header_manager.initialize()
logger.info("HeaderManager initialized")
# Initialize Redis
try:
await initialize_redis(settings.REDIS_URL, db=0, max_connections=50)
redis_client = await get_redis_client()
logger.info("Connected to Redis for SSE streaming")
# Add API rate limiting middleware with Redis client
app.add_middleware(APIRateLimitMiddleware, redis_client=redis_client)
logger.info("API rate limiting middleware enabled")
# NOTE: SubscriptionMiddleware and AuthMiddleware are instantiated without redis_client
# They will gracefully degrade (skip Redis-dependent features) when redis_client is None
# For future enhancement: consider using lifespan context to inject redis_client into middleware
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
# Initialize Redis if not already done during service creation
if not self.redis_initialized:
try:
await initialize_redis(settings.REDIS_URL, db=0, max_connections=50)
self.redis_client = await get_redis_client()
redis_client = self.redis_client # Update global variable
self.redis_initialized = True
logger.info("Connected to Redis for SSE streaming")
except Exception as e:
logger.error(f"Failed to connect to Redis during startup: {e}")
# Register custom metrics for gateway-specific operations
if self.telemetry_providers and self.telemetry_providers.app_metrics:
@@ -103,6 +128,23 @@ service = GatewayService(
# Create FastAPI app
app = service.create_app()
# Add API rate limiting middleware with Redis client - this needs to be done after app creation
# but before other middleware that might depend on it
# Wait for Redis to be initialized if not already done
if not hasattr(service, 'redis_client') or not service.redis_client:
# Wait briefly for Redis initialization to complete
import time
time.sleep(1)
# Check again after allowing time for initialization
if hasattr(service, 'redis_client') and service.redis_client:
app.add_middleware(APIRateLimitMiddleware, redis_client=service.redis_client)
logger.info("API rate limiting middleware enabled")
else:
logger.warning("Redis client not available for API rate limiting middleware")
else:
app.add_middleware(APIRateLimitMiddleware, redis_client=service.redis_client)
logger.info("API rate limiting middleware enabled")
# Add gateway-specific middleware (in REVERSE order of execution)
# Execution order: RequestIDMiddleware -> DemoMiddleware -> AuthMiddleware -> ReadOnlyModeMiddleware -> SubscriptionMiddleware -> APIRateLimitMiddleware -> RateLimitMiddleware -> LoggingMiddleware
app.add_middleware(LoggingMiddleware)
@@ -115,6 +157,7 @@ app.add_middleware(RequestIDMiddleware)
# Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
app.include_router(registration.router, prefix="/api/v1", tags=["registration"])
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
app.include_router(subscription.router, prefix="/api/v1", tags=["subscriptions"])
# Notification routes are now handled by tenant router at /api/v1/tenants/{tenant_id}/notifications/*
@@ -122,9 +165,9 @@ app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location
app.include_router(geocoding.router, prefix="/api/v1/geocoding", tags=["geocoding"])
app.include_router(pos.router, prefix="/api/v1/pos", tags=["pos"])
app.include_router(demo.router, prefix="/api/v1", tags=["demo"])
app.include_router(webhooks.router, prefix="/api/v1", tags=["webhooks"])
# Also include webhooks at /webhooks prefix to support direct webhook URLs like /webhooks/stripe
app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks-external"])
# Include webhooks at the root level to handle /api/v1/webhooks/*
# Webhook routes are defined with full /api/v1/webhooks/* paths for consistency
app.include_router(webhooks.router, prefix="", tags=["webhooks"])
# ================================================================
@@ -257,7 +300,7 @@ def _determine_event_type(event_data: dict) -> str:
# SERVER-SENT EVENTS (SSE) ENDPOINT
# ================================================================
@app.get("/api/events")
@app.get("/api/v1/events")
async def events_stream(
request: Request,
tenant_id: str,

View File

@@ -34,10 +34,15 @@ PUBLIC_ROUTES = [
"/api/v1/auth/register",
"/api/v1/auth/refresh",
"/api/v1/auth/verify",
"/api/v1/auth/start-registration", # Registration step 1 - SetupIntent creation
"/api/v1/auth/complete-registration", # Registration step 2 - Completion after 3DS
"/api/v1/auth/verify-email", # Email verification
"/api/v1/nominatim/search",
"/api/v1/plans",
"/api/v1/demo/accounts",
"/api/v1/demo/sessions"
"/api/v1/demo/sessions",
"/api/v1/webhooks/stripe", # Stripe webhook endpoint - bypasses auth for signature verification
"/api/v1/webhooks/generic" # Generic webhook endpoint
]
# Routes accessible with demo session (no JWT required, just demo session header)
@@ -74,7 +79,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
logger.info(f"Auth check - path: {request.url.path}, demo_header: {demo_session_header}, demo_query: {demo_session_query}, has_demo_state: {hasattr(request.state, 'is_demo_session')}")
# For SSE endpoint with demo_session_id in query params, validate it here
if request.url.path == "/api/events" and demo_session_query and not hasattr(request.state, "is_demo_session"):
if request.url.path == "/api/v1/events" and demo_session_query and not hasattr(request.state, "is_demo_session"):
logger.info(f"SSE endpoint with demo_session_id query param: {demo_session_query}")
# Validate demo session via demo-session service using JWT service token
import httpx
@@ -240,14 +245,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
"""
Extract JWT token from Authorization header or query params for SSE.
For SSE endpoints (/api/events), browsers' EventSource API cannot send
For SSE endpoints (/api/v1/events), browsers' EventSource API cannot send
custom headers, so we must accept token as query parameter.
For all other routes, token must be in Authorization header (more secure).
Security note: Query param tokens are logged. Use short expiry and filter logs.
"""
# SSE endpoint exception: token in query param (EventSource API limitation)
if request.url.path == "/api/events":
if request.url.path == "/api/v1/events":
token = request.query_params.get("token")
if token:
logger.debug("Token extracted from query param for SSE endpoint")

View File

@@ -50,6 +50,7 @@ DEMO_ALLOWED_OPERATIONS = {
"/api/v1/tenants/batch/sales-summary",
"/api/v1/tenants/batch/production-summary",
"/api/v1/auth/me/onboarding/complete", # Allow completing onboarding (no-op for demos)
"/api/v1/tenants/*/notifications/send", # Allow notifications (ML insights, alerts, etc.)
# Note: Forecast generation is explicitly blocked (see DEMO_BLOCKED_PATHS)
],

View File

@@ -33,6 +33,7 @@ READ_ONLY_WHITELIST_PATTERNS = [
r'^/api/v1/tenants/.*/procurement/ml/insights/.*', # Allow ML insights (supplier analysis, price forecasting)
r'^/api/v1/tenants/.*/forecasting/ml/insights/.*', # Allow ML insights (rules generation)
r'^/api/v1/tenants/.*/forecasting/operations/.*', # Allow forecasting operations
r'^/api/v1/webhooks/.*', # Webhook endpoints - no tenant context
]
@@ -55,7 +56,7 @@ class ReadOnlyModeMiddleware(BaseHTTPMiddleware):
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{self.tenant_service_url}/api/v1/subscriptions/{tenant_id}/status",
f"{self.tenant_service_url}/api/v1/tenants/{tenant_id}/subscriptions/status",
headers={"Authorization": authorization}
)

View File

@@ -178,6 +178,7 @@ class SubscriptionMiddleware(BaseHTTPMiddleware):
r'/api/v1/auth/.*',
r'/api/v1/subscriptions/.*', # Subscription management itself
r'/api/v1/tenants/[^/]+/members.*', # Basic tenant info
r'/api/v1/webhooks/.*', # Webhook endpoints - no tenant context
r'/docs.*',
r'/openapi\.json',
# Training monitoring endpoints (WebSocket and status checks)

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

@@ -24,6 +24,8 @@ 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"""

View File

@@ -1,8 +1,13 @@
"""
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
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
@@ -16,16 +21,20 @@ router = APIRouter()
# ================================================================
# WEBHOOK ENDPOINTS - Direct routing to tenant service
# All routes use /api/v1 prefix for consistency
# ================================================================
@router.post("/stripe")
# Stripe webhook endpoint
@router.post("/api/v1/webhooks/stripe")
async def proxy_stripe_webhook(request: Request):
"""Proxy Stripe webhook requests to tenant service"""
"""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")
@router.post("/generic")
# Generic webhook endpoint
@router.post("/api/v1/webhooks/generic")
async def proxy_generic_webhook(request: Request):
"""Proxy generic webhook requests to tenant service"""
"""Proxy generic webhook requests to tenant service (path: /webhooks/generic)"""
return await _proxy_to_tenant_service(request, "/webhooks/generic")
# ================================================================