Spaces:
Running
Running
File size: 7,547 Bytes
83d6851 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | from __future__ import annotations
from typing import Dict
from urllib.parse import quote
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.background import BackgroundTask
from app.config import get_settings
from app.core.logger import get_logger
from app.utils.http_utils import SharedAsyncClient
logger = get_logger(__name__)
_settings = get_settings()
router = APIRouter(tags=["WhatsApp"])
# RFC 7230 hop-by-hop headers. They are meaningless when a gateway relays a
# request to another service and must never be forwarded.
_HOP_BY_HOP_HEADERS = frozenset({
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
})
# Request headers that must never reach the internal WhatsApp service:
# hop-by-hop headers plus Host (httpx sets it from the target URL), length
# headers (httpx recomputes them from the body), content-encoding (httpx
# transparently decompresses responses), and the gateway's own credentials
# (Authorization / cookie) which belong to the main API, not the backend.
_BLOCKED_REQUEST_HEADERS = _HOP_BY_HOP_HEADERS | {
"host",
"content-length",
"accept-encoding",
"authorization",
"cookie",
}
# Response headers preserved when relaying the upstream reply back to the
# client. Everything else (server version headers, content-encoding, hop-by-hop
# headers, ...) is dropped so internal implementation details never leak.
_RESPONSE_HEADERS_ALLOWLIST = frozenset({
"content-type",
"content-disposition",
"content-language",
"cache-control",
"etag",
"expires",
"last-modified",
"location",
"retry-after",
"www-authenticate",
"x-request-id",
"x-correlation-id",
"content-range",
"accept-ranges",
})
_ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD")
# Characters preserved while percent-encoding the forwarded path so a client
# supplied path can never produce a malformed upstream URL.
_PATH_SAFE_CHARS = "/:@!$&'()*+,;=~-_."
def _build_proxy_client() -> SharedAsyncClient:
"""Shared, lazily-created httpx client for upstream WhatsApp calls."""
timeout = httpx.Timeout(
timeout=_settings.whatsapp_service_timeout,
connect=_settings.whatsapp_service_connect_timeout,
)
return SharedAsyncClient(timeout=timeout, follow_redirects=False)
_proxy_client = _build_proxy_client()
async def close_whatsapp_proxy_client() -> None:
"""Close the shared upstream client. Called once on application shutdown."""
await _proxy_client.close()
def _forward_request_headers(request: Request, body: bytes) -> Dict[str, str]:
"""Build the header set forwarded to the WhatsApp service.
Keeps application headers (content-type, accept, apikey, x-*, ...) while
stripping hop-by-hop / gateway-credential headers. The `apikey` header the
WhatsApp service authenticates with is taken from the client request when
present (per-instance token) and falls back to the configured global key.
"""
headers: Dict[str, str] = {}
for name, value in request.headers.items():
if name.lower() in _BLOCKED_REQUEST_HEADERS:
continue
headers[name] = value
if body:
headers.setdefault("content-type", "application/octet-stream")
client_apikey = request.headers.get("apikey", "")
if client_apikey:
headers["apikey"] = client_apikey
elif _settings.whatsapp_service_global_api_key:
headers["apikey"] = _settings.whatsapp_service_global_api_key
return headers
def _filter_response_headers(headers: httpx.Headers) -> Dict[str, str]:
return {
name: value
for name, value in headers.items()
if name.lower() in _RESPONSE_HEADERS_ALLOWLIST
}
def _unavailable_response(status_code: int, detail: str) -> JSONResponse:
"""Sanitized gateway error response — never leaks upstream hostnames/traces."""
return JSONResponse(
status_code=status_code,
content={"success": False, "detail": detail},
)
@router.api_route(
"/{path:path}",
methods=list(_ALLOWED_METHODS),
summary="Forward a request to the internal WhatsApp service",
description=(
"Proxies any HTTP request under /api/whatsapp to the corresponding "
"endpoint of the internal WhatsApp service, preserving the HTTP method, "
"path, query string, request body and relevant headers. The upstream "
"response (status code and body) is returned unchanged."
),
)
async def proxy_to_whatsapp(request: Request, path: str):
if not _settings.whatsapp_service_enabled:
return _unavailable_response(503, "WhatsApp service is not enabled")
base_url = _settings.whatsapp_service_url.rstrip("/")
encoded_path = quote(path, safe=_PATH_SAFE_CHARS).lstrip("/")
target = f"{base_url}/{encoded_path}"
if request.url.query:
target = f"{target}?{request.url.query}"
body = await request.body()
headers = _forward_request_headers(request, body)
logger.info(
"Proxying %s %s -> %s (apikey=%s)",
request.method,
request.url.path,
target,
"yes" if headers.get("apikey") else "no",
)
try:
client = await _proxy_client.get()
upstream = await client.send(
client.build_request(
request.method,
target,
content=body or None,
headers=headers,
),
stream=True,
)
except httpx.TimeoutException as exc:
logger.error("WhatsApp service timed out: %s %s: %s", request.method, target, exc)
return _unavailable_response(504, "WhatsApp service timed out")
except httpx.HTTPError as exc:
logger.error("WhatsApp service unreachable: %s %s: %s", request.method, target, exc)
return _unavailable_response(502, "WhatsApp service is unavailable")
except Exception:
logger.exception("Unexpected gateway error proxying %s %s", request.method, target)
return _unavailable_response(502, "WhatsApp gateway error")
return StreamingResponse(
upstream.aiter_bytes(),
status_code=upstream.status_code,
headers=_filter_response_headers(upstream.headers),
media_type=None,
background=BackgroundTask(upstream.aclose),
)
async def get_whatsapp_health() -> Dict[str, object]:
"""Non-fatal liveness probe used by the main application's /health endpoint.
Never raises — a degraded/unreachable WhatsApp service must not take the
gateway's own health check down with it.
"""
if not _settings.whatsapp_service_enabled:
return {"configured": False, "reachable": False}
url = f"{_settings.whatsapp_service_url.rstrip('/')}/server/ok"
try:
client = await _proxy_client.get()
resp = await client.get(
url,
timeout=_settings.whatsapp_service_connect_timeout,
)
except (httpx.HTTPError, httpx.TimeoutException) as exc:
logger.warning("WhatsApp service health check failed: %s", exc)
return {"configured": True, "reachable": False, "status": "unreachable"}
if resp.status_code == 200:
return {"configured": True, "reachable": True, "status": "ok"}
return {
"configured": True,
"reachable": True,
"status": f"unhealthy (HTTP {resp.status_code})",
}
|