Initial commit: API gateway with admin console
- 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>
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user