- FastAPI async gateway with httpx proxying to multiple upstreams - SQLite database with SQLAlchemy ORM - Admin console: manage services, users, API keys, endpoint access - Per-key, per-endpoint granular access control - OpenAPI document sync and caching (5-minute TTL) - Request/response logging with full transaction inspection - In-memory rate limiting (per-key, fixed-window) - Tiered log retention (7d payloads, 90d rows, incremental vacuum) - TLS verification toggle per service (for self-signed certificates) - Service connectivity validation with automatic endpoint refresh - Request browser with filters and deep-link inspection - Docker setup with persistent volume - Modal forms for create/edit flows Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
209 lines
7.6 KiB
Python
209 lines
7.6 KiB
Python
import re
|
|
import time
|
|
from functools import lru_cache
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import config, ratelimit, security
|
|
from app.database import get_db
|
|
from app.models import ApiKey, Endpoint, RequestLog, Service, utcnow
|
|
|
|
router = APIRouter()
|
|
|
|
# Hop-by-hop headers must not be forwarded either direction
|
|
_HOP_BY_HOP = {
|
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
|
|
}
|
|
|
|
# One client per TLS-verification mode (verify is a client-level setting in httpx).
|
|
_clients: dict[bool, httpx.AsyncClient] = {}
|
|
|
|
|
|
async def get_client(verify_tls: bool = True) -> httpx.AsyncClient:
|
|
client = _clients.get(verify_tls)
|
|
if client is None:
|
|
client = _clients[verify_tls] = httpx.AsyncClient(
|
|
follow_redirects=False, verify=verify_tls)
|
|
return client
|
|
|
|
|
|
async def close_client() -> None:
|
|
for client in _clients.values():
|
|
await client.aclose()
|
|
_clients.clear()
|
|
|
|
|
|
@lru_cache(maxsize=1024)
|
|
def _pattern_to_regex(pattern: str) -> re.Pattern:
|
|
"""Endpoint path pattern -> regex. '{param}' = one segment, '*' = any
|
|
within a segment, '**' = any depth."""
|
|
regex = ""
|
|
for token in re.split(r"(\{[^}]*\}|\*\*|\*)", pattern):
|
|
if not token:
|
|
continue
|
|
if token == "**":
|
|
regex += ".*"
|
|
elif token == "*":
|
|
regex += "[^/]*"
|
|
elif token.startswith("{") and token.endswith("}"):
|
|
regex += "[^/]+"
|
|
else:
|
|
regex += re.escape(token)
|
|
return re.compile("^" + regex + "$")
|
|
|
|
|
|
def match_endpoints(endpoints: list[Endpoint], method: str, path: str) -> list[Endpoint]:
|
|
"""All endpoints of a service that match this request, most specific
|
|
(exact method) first."""
|
|
matches = [
|
|
e for e in endpoints
|
|
if e.method in ("*", method) and _pattern_to_regex(e.path).match(path)
|
|
]
|
|
return sorted(matches, key=lambda e: e.method == "*")
|
|
|
|
|
|
def _error(status: int, code: str, message: str) -> JSONResponse:
|
|
return JSONResponse(status_code=status, content={"error": code, "message": message})
|
|
|
|
|
|
# Payload capture for transaction inspection
|
|
MAX_STORED_BODY = 64 * 1024
|
|
_TEXTUAL_TYPES = ("application/json", "application/xml",
|
|
"application/x-www-form-urlencoded", "text/", "+json", "+xml")
|
|
|
|
|
|
def _readable(body: bytes, content_type: str) -> str:
|
|
"""Body as storable text: textual payloads are kept (truncated at 64 KB),
|
|
binary ones are summarized."""
|
|
if not body:
|
|
return ""
|
|
ct = (content_type or "").lower()
|
|
if not any(t in ct for t in _TEXTUAL_TYPES):
|
|
return f"<{len(body)} bytes of {ct or 'unknown content type'}>"
|
|
text = body[:MAX_STORED_BODY].decode("utf-8", "replace")
|
|
if len(body) > MAX_STORED_BODY:
|
|
text += "\n… (truncated)"
|
|
return text
|
|
|
|
|
|
@router.api_route(
|
|
"/{slug}/{path:path}",
|
|
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
)
|
|
@router.api_route(
|
|
"/{slug}",
|
|
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
)
|
|
async def gateway(slug: str, request: Request, path: str = "",
|
|
db: Session = Depends(get_db)):
|
|
start = time.perf_counter()
|
|
|
|
# --- authenticate ---
|
|
plain_key = request.headers.get(config.API_KEY_HEADER)
|
|
if not plain_key:
|
|
return _error(401, "missing_api_key", f"Provide your API key in the {config.API_KEY_HEADER} header.")
|
|
|
|
api_key = (
|
|
db.query(ApiKey)
|
|
.filter(ApiKey.key_hash == security.hash_api_key(plain_key))
|
|
.one_or_none()
|
|
)
|
|
if api_key is None or not api_key.is_active or not api_key.user.is_active:
|
|
return _error(403, "invalid_api_key", "API key is unknown or has been revoked.")
|
|
|
|
# --- resolve service ---
|
|
service = db.query(Service).filter(Service.slug == slug).one_or_none()
|
|
if service is None or not service.is_active:
|
|
return _error(404, "unknown_service", f"No active service registered under '{slug}'.")
|
|
|
|
# --- read the request payload once (forwarded and logged) ---
|
|
body = await request.body()
|
|
stored_request = _readable(body, request.headers.get("content-type", ""))
|
|
|
|
def deny(status: int, code: str, message: str, endpoint: Endpoint | None) -> JSONResponse:
|
|
response = _error(status, code, message)
|
|
_log(db, api_key, service, endpoint, request, request_path, status, start,
|
|
stored_request, response.body.decode())
|
|
return response
|
|
|
|
# --- resolve endpoint & authorize ---
|
|
request_path = "/" + path
|
|
matched = match_endpoints(service.endpoints, request.method, request_path)
|
|
if not matched:
|
|
return deny(404, "unknown_endpoint",
|
|
f"No endpoint of '{slug}' matches {request.method} {request_path}.", None)
|
|
|
|
granted_ids = {e.id for e in api_key.endpoints}
|
|
endpoint = next((e for e in matched if e.id in granted_ids), None)
|
|
if endpoint is None:
|
|
return deny(403, "access_denied",
|
|
f"This API key has no access to {request.method} {request_path} on '{slug}'.",
|
|
matched[0])
|
|
|
|
# --- rate limit ---
|
|
if not ratelimit.check(api_key.id, api_key.rate_limit_per_minute):
|
|
return deny(429, "rate_limited",
|
|
f"Rate limit of {api_key.rate_limit_per_minute} requests/minute exceeded.",
|
|
endpoint)
|
|
|
|
# --- proxy upstream ---
|
|
upstream_url = service.base_url.rstrip("/") + request_path
|
|
headers = {
|
|
k: v for k, v in request.headers.items()
|
|
if k.lower() not in _HOP_BY_HOP and k.lower() != config.API_KEY_HEADER.lower()
|
|
}
|
|
headers["x-forwarded-for"] = request.client.host if request.client else ""
|
|
headers["x-forwarded-proto"] = request.url.scheme
|
|
|
|
client = await get_client(service.verify_tls)
|
|
try:
|
|
upstream = await client.request(
|
|
request.method,
|
|
upstream_url,
|
|
params=request.query_params,
|
|
content=body,
|
|
headers=headers,
|
|
timeout=service.timeout_seconds or config.PROXY_DEFAULT_TIMEOUT,
|
|
)
|
|
except httpx.TimeoutException:
|
|
return deny(504, "upstream_timeout", "The upstream API did not respond in time.", endpoint)
|
|
except httpx.HTTPError:
|
|
return deny(502, "upstream_unreachable", "Could not reach the upstream API.", endpoint)
|
|
|
|
_log(db, api_key, service, endpoint, request, request_path, upstream.status_code, start,
|
|
stored_request, _readable(upstream.content, upstream.headers.get("content-type", "")))
|
|
|
|
response_headers = {
|
|
k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_BY_HOP
|
|
}
|
|
return Response(
|
|
content=upstream.content,
|
|
status_code=upstream.status_code,
|
|
headers=response_headers,
|
|
media_type=upstream.headers.get("content-type"),
|
|
)
|
|
|
|
|
|
def _log(db: Session, api_key: ApiKey, service: Service, endpoint: Endpoint | None,
|
|
request: Request, request_path: str, status: int, start: float,
|
|
request_body: str = "", response_body: str = "") -> None:
|
|
api_key.last_used_at = utcnow()
|
|
db.add(RequestLog(
|
|
api_key_id=api_key.id,
|
|
service_id=service.id,
|
|
endpoint_id=endpoint.id if endpoint else None,
|
|
method=request.method,
|
|
path=request_path,
|
|
query_string=request.url.query,
|
|
status_code=status,
|
|
latency_ms=round((time.perf_counter() - start) * 1000, 2),
|
|
client_ip=request.client.host if request.client else "",
|
|
request_body=request_body,
|
|
response_body=response_body,
|
|
))
|
|
db.commit()
|