Fix sales API proxy endpoints and tests
This commit is contained in:
+45
-7
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -12,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from services.shared.models import HealthResponse
|
from services.shared.models import HealthResponse
|
||||||
|
|
||||||
app = FastAPI(title="api-gateway", version="1.0.0")
|
app = FastAPI(title="api-gateway", version="1.0.0")
|
||||||
|
logger = logging.getLogger("api-gateway.proxy")
|
||||||
|
|
||||||
_LEGAL_HTML = {
|
_LEGAL_HTML = {
|
||||||
"privacy": """<!doctype html>
|
"privacy": """<!doctype html>
|
||||||
@@ -159,6 +161,12 @@ SERVICE_URLS = {
|
|||||||
"supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"),
|
"supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"),
|
||||||
"sales": os.getenv("SALES_SERVICE_URL", "http://localhost:8020"),
|
"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:
|
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"}
|
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)
|
@app.get("/health", response_model=HealthResponse)
|
||||||
def health() -> HealthResponse:
|
def health() -> HealthResponse:
|
||||||
return HealthResponse(status="ok", service="api-gateway")
|
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:
|
if not base:
|
||||||
raise HTTPException(status_code=404, detail="Unknown service")
|
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 = {
|
headers = {
|
||||||
"X-User": request.headers.get("X-User", ""),
|
"X-User": request.headers.get("X-User", ""),
|
||||||
"X-Role": request.headers.get("X-Role", ""),
|
"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"":
|
if payload == b"":
|
||||||
payload = None
|
payload = None
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
try:
|
||||||
resp = await client.request(
|
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,
|
method,
|
||||||
url,
|
path,
|
||||||
params=dict(request.query_params),
|
exc_info=True,
|
||||||
content=payload,
|
)
|
||||||
headers=headers,
|
return JSONResponse(
|
||||||
|
status_code=502,
|
||||||
|
content={
|
||||||
|
"detail": "Upstream service unavailable",
|
||||||
|
"service": service,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if 300 <= resp.status_code < 400 and resp.headers.get("location"):
|
if 300 <= resp.status_code < 400 and resp.headers.get("location"):
|
||||||
|
|||||||
@@ -150,6 +150,27 @@ def test_gateway_forwards_delete_requests(monkeypatch):
|
|||||||
assert DummyAsyncClient.last_request['url'].endswith('/queues/que_delete_me')
|
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):
|
def test_gateway_preserves_binary_audio_response(monkeypatch):
|
||||||
async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201
|
async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201
|
||||||
return DummyResponse(
|
return DummyResponse(
|
||||||
@@ -171,3 +192,20 @@ def test_gateway_preserves_binary_audio_response(monkeypatch):
|
|||||||
assert response.content == b'RIFFdemo'
|
assert response.content == b'RIFFdemo'
|
||||||
assert response.headers['content-type'].startswith('audio/wav')
|
assert response.headers['content-type'].startswith('audio/wav')
|
||||||
assert 'demo-call.wav' in response.headers['content-disposition']
|
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',
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user