Fix new services implementation 2

This commit is contained in:
Urtzi Alfaro
2025-08-14 13:26:59 +02:00
parent 262b3dc9c4
commit 0951547e92
39 changed files with 1203 additions and 917 deletions

View File

@@ -5,7 +5,7 @@ Handles routing, authentication, rate limiting, and cross-cutting concerns
import asyncio
import structlog
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi import FastAPI, Request, HTTPException, Depends, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import httpx
@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
from app.middleware.auth import AuthMiddleware
from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware
from app.routes import auth, tenant, notification, nominatim, user, inventory
from app.routes import auth, tenant, notification, nominatim, user
from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector
@@ -60,7 +60,6 @@ app.include_router(user.router, prefix="/api/v1/users", tags=["users"])
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
app.include_router(inventory.router, prefix="/api/v1/inventory", tags=["inventory"])
@app.on_event("startup")
async def startup_event():
@@ -117,6 +116,92 @@ async def metrics():
"""Metrics endpoint for monitoring"""
return {"metrics": "enabled"}
# ================================================================
# WEBSOCKET ROUTING FOR TRAINING SERVICE
# ================================================================
@app.websocket("/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live")
async def websocket_training_progress(websocket: WebSocket, tenant_id: str, job_id: str):
"""WebSocket proxy for training progress updates"""
await websocket.accept()
# Get token from query params
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=1008, reason="Authentication token required")
return
# Build HTTP URL to training service (we'll use HTTP client to proxy)
training_service_base = settings.TRAINING_SERVICE_URL.rstrip('/')
training_ws_url = f"{training_service_base}/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live?token={token}"
try:
# Use HTTP client to connect to training service WebSocket
async with httpx.AsyncClient() as client:
# Since we can't easily proxy WebSocket with httpx, let's try a different approach
# We'll make periodic HTTP requests to get training status
logger.info(f"Starting WebSocket proxy for training job {job_id}")
# Send initial connection confirmation
await websocket.send_json({
"type": "connection_established",
"job_id": job_id,
"tenant_id": tenant_id
})
# Poll for training updates
last_status = None
while True:
try:
# Make HTTP request to get current training status
status_url = f"{training_service_base}/api/v1/tenants/{tenant_id}/training/jobs/{job_id}/status"
response = await client.get(
status_url,
headers={"Authorization": f"Bearer {token}"},
timeout=5.0
)
if response.status_code == 200:
current_status = response.json()
# Only send update if status changed
if current_status != last_status:
await websocket.send_json({
"type": "training_progress",
"data": current_status
})
last_status = current_status
# If training is completed or failed, we can stop polling
if current_status.get('status') in ['completed', 'failed', 'cancelled']:
await websocket.send_json({
"type": "training_" + current_status.get('status', 'completed'),
"data": current_status
})
break
# Wait before next poll
await asyncio.sleep(2)
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
break
except httpx.TimeoutException:
# Continue polling even if request times out
await asyncio.sleep(5)
continue
except Exception as e:
logger.error(f"Error polling training status: {e}")
await asyncio.sleep(5)
continue
except WebSocketDisconnect:
logger.info("WebSocket client disconnected during setup")
except Exception as e:
logger.error(f"WebSocket proxy error: {e}")
await websocket.close(code=1011, reason="Internal server error")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -1,216 +0,0 @@
# gateway/app/routes/inventory.py
"""
Inventory routes for API Gateway - Handles inventory management 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
logger = logging.getLogger(__name__)
router = APIRouter()
# Inventory service URL - add to settings
INVENTORY_SERVICE_URL = "http://inventory-service:8000"
# ================================================================
# TENANT-SCOPED INVENTORY ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/inventory/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant ingredient requests to inventory service"""
base_path = f"/api/v1/ingredients"
# 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_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/inventory/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"""
base_path = f"/api/v1/stock"
# 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_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/inventory/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alerts(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory alert requests to inventory service"""
base_path = f"/api/v1/alerts"
# 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_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/inventory/dashboard{path:path}", methods=["GET", "OPTIONS"])
async def proxy_tenant_inventory_dashboard(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory dashboard requests to inventory service"""
base_path = f"/api/v1/dashboard"
# 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_inventory_service(request, target_path)
# ================================================================
# DIRECT INVENTORY ENDPOINTS (for backward compatibility)
# ================================================================
@router.api_route("/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_ingredients(request: Request, path: str = ""):
"""Proxy ingredient requests to inventory service"""
base_path = f"/api/v1/ingredients"
if not path or path == "/" or path == "":
target_path = base_path
else:
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/stock{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_stock(request: Request, path: str = ""):
"""Proxy stock requests to inventory service"""
base_path = f"/api/v1/stock"
if not path or path == "/" or path == "":
target_path = base_path
else:
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_alerts(request: Request, path: str = ""):
"""Proxy inventory alert requests to inventory service"""
base_path = f"/api/v1/alerts"
if not path or path == "/" or path == "":
target_path = base_path
else:
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
# ================================================================
# PROXY HELPER FUNCTION
# ================================================================
async def _proxy_to_inventory_service(request: Request, target_path: str):
"""Proxy request to inventory service with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"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"{INVENTORY_SERVICE_URL}{target_path}"
# Forward headers and add user/tenant context
headers = dict(request.headers)
headers.pop("host", None)
# 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, # Connection timeout
read=600.0, # Read timeout: 10 minutes
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,
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 inventory service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except httpx.ConnectTimeout:
logger.error(f"Connection timeout to inventory service: {INVENTORY_SERVICE_URL}{target_path}")
raise HTTPException(
status_code=503,
detail="Inventory service temporarily unavailable"
)
except httpx.ReadTimeout:
logger.error(f"Read timeout from inventory service: {INVENTORY_SERVICE_URL}{target_path}")
raise HTTPException(
status_code=504,
detail="Inventory service response timeout"
)
except Exception as e:
logger.error(f"Unexpected error proxying to inventory service {INVENTORY_SERVICE_URL}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -134,6 +134,26 @@ async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...
target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/")
return await _proxy_to_notification_service(request, target_path)
# ================================================================
# TENANT-SCOPED INVENTORY SERVICE ENDPOINTS
# ================================================================
@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)
@router.api_route("/{tenant_id}/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant ingredient requests to inventory service"""
# 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)
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
@@ -162,6 +182,10 @@ 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):
"""Proxy request to inventory service"""
return await _proxy_request(request, target_path, settings.INVENTORY_SERVICE_URL)
async def _proxy_request(request: Request, target_path: str, service_url: str):
"""Generic proxy function with enhanced error handling"""