diff --git a/gateway/app.py b/gateway/app.py index 3d6d94c..e019bac 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os from pathlib import Path from typing import Any @@ -12,6 +13,7 @@ from fastapi.staticfiles import StaticFiles from services.shared.models import HealthResponse app = FastAPI(title="api-gateway", version="1.0.0") +logger = logging.getLogger("api-gateway.proxy") _LEGAL_HTML = { "privacy": """ @@ -159,6 +161,12 @@ SERVICE_URLS = { "supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"), "sales": os.getenv("SALES_SERVICE_URL", "http://localhost:8020"), } +SERVICE_PATH_PREFIXES = { + "sales": os.getenv("SALES_SERVICE_PATH_PREFIX", "/api/v1"), +} +SERVICE_PATH_PREFIX_EXEMPTIONS = { + "sales": ("api/v1", "internal"), +} def _env_flag(name: str, default: bool = False) -> bool: @@ -168,6 +176,18 @@ def _env_flag(name: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _resolve_service_path(service: str, path: str) -> str: + normalized = path.lstrip("/") + prefix = SERVICE_PATH_PREFIXES.get(service, "").strip("/") + if not prefix: + return normalized + + exemptions = SERVICE_PATH_PREFIX_EXEMPTIONS.get(service, ()) + if any(normalized == item or normalized.startswith(f"{item}/") for item in exemptions): + return normalized + return f"{prefix}/{normalized}" if normalized else prefix + + @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="api-gateway") @@ -353,7 +373,8 @@ async def _forward(method: str, service: str, path: str, request: Request) -> Re if not base: raise HTTPException(status_code=404, detail="Unknown service") - url = f"{base.rstrip('/')}/{path.lstrip('/')}" + target_path = _resolve_service_path(service, path) + url = f"{base.rstrip('/')}/{target_path}" headers = { "X-User": request.headers.get("X-User", ""), "X-Role": request.headers.get("X-Role", ""), @@ -383,13 +404,30 @@ async def _forward(method: str, service: str, path: str, request: Request) -> Re if payload == b"": payload = None - async with httpx.AsyncClient(timeout=20) as client: - resp = await client.request( + try: + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.request( + method, + url, + params=dict(request.query_params), + content=payload, + headers=headers, + ) + except httpx.RequestError: + logger.warning( + "Upstream service unavailable: service=%s base_url=%s method=%s path=%s", + service, + base, method, - url, - params=dict(request.query_params), - content=payload, - headers=headers, + path, + exc_info=True, + ) + return JSONResponse( + status_code=502, + content={ + "detail": "Upstream service unavailable", + "service": service, + }, ) if 300 <= resp.status_code < 400 and resp.headers.get("location"): diff --git a/tests/test_gateway_proxy.py b/tests/test_gateway_proxy.py index e126465..775329e 100644 --- a/tests/test_gateway_proxy.py +++ b/tests/test_gateway_proxy.py @@ -150,6 +150,27 @@ def test_gateway_forwards_delete_requests(monkeypatch): assert DummyAsyncClient.last_request['url'].endswith('/queues/que_delete_me') +def test_gateway_prefixes_sales_public_api_paths(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.get('/proxy/sales/pipelines?limit=25') + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['url'].endswith('/api/v1/pipelines') + assert DummyAsyncClient.last_request['params'] == {'limit': '25'} + + +def test_gateway_does_not_prefix_sales_internal_paths(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.post('/proxy/sales/internal/sales-sync/telegram', json={'chat_id': '123'}) + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['url'].endswith('/internal/sales-sync/telegram') + + def test_gateway_preserves_binary_audio_response(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 return DummyResponse( @@ -171,3 +192,20 @@ def test_gateway_preserves_binary_audio_response(monkeypatch): assert response.content == b'RIFFdemo' assert response.headers['content-type'].startswith('audio/wav') assert 'demo-call.wav' in response.headers['content-disposition'] + + +def test_gateway_returns_bad_gateway_when_upstream_unavailable(monkeypatch): + async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 + raise gateway_module.httpx.ConnectError("All connection attempts failed") + + monkeypatch.setattr(DummyAsyncClient, 'request', request) + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.get('/proxy/sales/pipelines') + + assert response.status_code == 502 + assert response.json() == { + 'detail': 'Upstream service unavailable', + 'service': 'sales', + }