From 1528a7c7697dfd21e9061fbd830f3c8e4b262730 Mon Sep 17 00:00:00 2001 From: furyhawk Date: Sun, 31 May 2026 11:47:08 +0800 Subject: [PATCH] Add initial implementation of OpenAPI API Gateway framework - Create core application structure with FastAPI - Implement dynamic route registration and management endpoints - Define configuration models for gateway settings, upstreams, and routes - Add proxy request handling for upstream services - Include example configuration and OpenAPI specification files - Set up development dependencies and project metadata --- .gitignore | 38 ++ AGENTS.md | 48 ++ README.md | 81 +++ config/gateway.yaml | 27 + openapi_json/lta_datamall_openapi_v0-1-1.json | 517 ++++++++++++++++++ pyproject.toml | 31 ++ src/gateway_framework/__init__.py | 1 + src/gateway_framework/app.py | 116 ++++ src/gateway_framework/config.py | 86 +++ src/gateway_framework/main.py | 3 + src/gateway_framework/proxy.py | 96 ++++ 11 files changed, 1044 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 config/gateway.yaml create mode 100644 openapi_json/lta_datamall_openapi_v0-1-1.json create mode 100644 pyproject.toml create mode 100644 src/gateway_framework/__init__.py create mode 100644 src/gateway_framework/app.py create mode 100644 src/gateway_framework/config.py create mode 100644 src/gateway_framework/main.py create mode 100644 src/gateway_framework/proxy.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb7c260 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class + +# Build and packaging artifacts +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test and tooling caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ + +# Local environment and secrets +.env +.env.* +!.env.example + +# Editor and OS files +.DS_Store +.idea/ +.vscode/ + +# Local runtime/log artifacts +*.log +*.pid diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5cf6bfc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +Instructions for AI coding agents in this workspace. + +## Project Snapshot +- Primary artifact: `openapi_json/lta_datamall_openapi_v0-1-1.json` +- Repository scope today: OpenAPI specification only (no server implementation files, tests, CI, or package manager config in repo) +- Domain: LTA DataMall bus APIs exposed via a versioned API gateway + +## Source of Truth +- Treat `openapi_json/lta_datamall_openapi_v0-1-1.json` as the canonical contract. +- Do not infer runtime behavior beyond what is explicitly defined in the spec. +- Link to existing spec sections instead of duplicating large API descriptions in new docs. + +## Working Rules +- Keep JSON valid and consistently formatted with 2-space indentation. +- Preserve existing naming patterns unless asked to refactor: + - Path versioning style: `/api/v1/...` + - Health probes: `/healthz`, `/readyz` + - FastAPI-style `operationId` names + - Existing query parameter names, including `$skip` +- Prefer additive, backward-compatible API changes for scalability: + - Add new optional parameters instead of changing required ones. + - Add new endpoints or response fields rather than removing/changing existing ones. +- For configurability, make behavior explicit in schema/parameters: + - Define parameter constraints (`minLength`, `maxLength`, `minimum`, enums, nullable) where known. + - Document defaults and optionality in schema metadata when adding fields. + +## Contract Quality Expectations +When editing or adding endpoints, prioritize these improvements: +- Reuse `components/schemas` for shared response/request shapes. +- Avoid unbounded `additionalProperties: true` on new models unless truly required. +- Include non-2xx responses (especially validation/auth/upstream failure) when behavior is known. +- Keep tag groupings coherent (`Health`, `Bus`, etc.) and summaries concise. + +## Validation Checklist +Run these checks after spec edits (ad hoc, since no build system is committed): +1. JSON validity check: + - `python -m json.tool openapi_json/lta_datamall_openapi_v0-1-1.json >/dev/null` +2. Optional structural check (if available locally): + - `npx @redocly/cli lint openapi_json/lta_datamall_openapi_v0-1-1.json` + +## Out of Scope By Default +- Do not scaffold app/server code, infra, or deployment manifests unless explicitly requested. +- Do not rename existing public endpoints/parameters in place unless explicitly requested. + +## Key File +- `openapi_json/lta_datamall_openapi_v0-1-1.json` diff --git a/README.md b/README.md new file mode 100644 index 0000000..c051951 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# OpenAPI API Gateway Framework + +This repository now includes a lightweight, configurable API gateway framework built with FastAPI. + +## What It Provides +- Dynamic gateway routes loaded from YAML (`config/gateway.yaml`) +- Configurable upstream targets and per-upstream timeout settings +- OpenAPI docs for gateway endpoints via FastAPI (`/docs`) +- Optional serving of an external OpenAPI contract (`/openapi/external.json`) +- Built-in management endpoints: + - `GET /healthz` + - `GET /readyz` + - `GET /admin/routes` + +## Project Structure +- `src/gateway_framework/app.py`: app factory and dynamic route registration +- `src/gateway_framework/proxy.py`: request forwarding/proxy logic +- `src/gateway_framework/config.py`: config schema and loader +- `src/gateway_framework/main.py`: ASGI entrypoint +- `config/gateway.yaml`: route and upstream configuration +- `openapi_json/lta_datamall_openapi_v0-1-1.json`: external OpenAPI contract + +## Quick Start +1. Create and activate a virtual environment. +2. Install dependencies: + +```bash +pip install -e . +``` + +3. Update `config/gateway.yaml` with real upstream URLs. +4. Run the gateway: + +```bash +uvicorn gateway_framework.main:app --reload --app-dir src +``` + +5. Open the docs: +- `http://127.0.0.1:8000/docs` + +## Configuration Model +`config/gateway.yaml` uses this model: + +```yaml +settings: + title: string + version: string + description: string + external_openapi_file: path/to/openapi.json + +upstreams: + service_name: + base_url: https://service.example.com/ + timeout_seconds: 15 + +routes: + - path: /api/v1/resource + methods: [GET, POST] + upstream: service_name + upstream_path: /v2/resource + strip_prefix: /api + summary: Optional operation summary + tags: [Gateway] + operation_id: gateway_resource_get +``` + +Notes: +- `path` is the public gateway path. +- `upstream_path` overrides forwarded path. +- `strip_prefix` removes a leading path segment before forwarding. + +## Scaling and Configurability Guidance +- Add routes through config, not code, for repeatable deployments. +- Split large configurations into environment-specific files and set `GATEWAY_CONFIG_PATH`. +- Keep route changes backward compatible (`/api/v1` stability) for client safety. +- Define explicit timeout values per upstream to isolate slow dependencies. + +## Next Steps +- Add auth/rate limiting middleware for production. +- Add OpenAPI linting in CI (for example, Redocly CLI). +- Add contract tests for every configured route. diff --git a/config/gateway.yaml b/config/gateway.yaml new file mode 100644 index 0000000..3c5fcde --- /dev/null +++ b/config/gateway.yaml @@ -0,0 +1,27 @@ +settings: + title: LTA DataMall API Gateway + version: 0.1.0 + description: Configurable gateway for OpenAPI-based upstream services + external_openapi_file: openapi_json/lta_datamall_openapi_v0-1-1.json + +upstreams: + lta_datamall: + base_url: https://api.example.com/ + timeout_seconds: 20 + +routes: + - path: /api/v1/bus-arrival + methods: [GET] + upstream: lta_datamall + upstream_path: /api/v1/bus-arrival + summary: Bus arrival passthrough + tags: [Bus] + operation_id: gateway_bus_arrival + + - path: /api/v1/bus-services + methods: [GET] + upstream: lta_datamall + upstream_path: /api/v1/bus-services + summary: Bus services passthrough + tags: [Bus] + operation_id: gateway_bus_services diff --git a/openapi_json/lta_datamall_openapi_v0-1-1.json b/openapi_json/lta_datamall_openapi_v0-1-1.json new file mode 100644 index 0000000..69c61a7 --- /dev/null +++ b/openapi_json/lta_datamall_openapi_v0-1-1.json @@ -0,0 +1,517 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "LTA DataMall Bus Backend", + "version": "0.1.1" + }, + "paths": { + "/healthz": { + "get": { + "tags": [ + "Health" + ], + "summary": "Healthz", + "operationId": "healthz_healthz_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Healthz Healthz Get" + } + } + } + } + } + } + }, + "/readyz": { + "get": { + "tags": [ + "Health" + ], + "summary": "Readyz", + "operationId": "readyz_readyz_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Readyz Readyz Get" + } + } + } + } + } + } + }, + "/api/v1/bus-arrival": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Bus Arrival", + "operationId": "bus_arrival_api_v1_bus_arrival_get", + "parameters": [ + { + "name": "BusStopCode", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 5, + "maxLength": 5, + "title": "Busstopcode" + } + }, + { + "name": "ServiceNo", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serviceno" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Bus Arrival Api V1 Bus Arrival Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/bus-services": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Bus Services", + "operationId": "bus_services_api_v1_bus_services_get", + "parameters": [ + { + "name": "ServiceNo", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Serviceno" + } + }, + { + "name": "$skip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "$Skip" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Bus Services Api V1 Bus Services Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/bus-routes": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Bus Routes", + "operationId": "bus_routes_api_v1_bus_routes_get", + "parameters": [ + { + "name": "$skip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "$Skip" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Bus Routes Api V1 Bus Routes Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/bus-stops": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Bus Stops", + "operationId": "bus_stops_api_v1_bus_stops_get", + "parameters": [ + { + "name": "BusStopCode", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 5, + "maxLength": 5 + }, + { + "type": "null" + } + ], + "title": "Busstopcode" + } + }, + { + "name": "$skip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "$Skip" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Bus Stops Api V1 Bus Stops Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/passenger-volume/bus": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Passenger Volume Bus", + "operationId": "passenger_volume_bus_api_v1_passenger_volume_bus_get", + "parameters": [ + { + "name": "Date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9]{6}$" + }, + { + "type": "null" + } + ], + "title": "Date" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Passenger Volume Bus Api V1 Passenger Volume Bus Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/passenger-volume/od-bus": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Passenger Volume Od Bus", + "operationId": "passenger_volume_od_bus_api_v1_passenger_volume_od_bus_get", + "parameters": [ + { + "name": "Date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9]{6}$" + }, + { + "type": "null" + } + ], + "title": "Date" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Passenger Volume Od Bus Api V1 Passenger Volume Od Bus Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/planned-bus-routes": { + "get": { + "tags": [ + "Bus" + ], + "summary": "Planned Bus Routes", + "operationId": "planned_bus_routes_api_v1_planned_bus_routes_get", + "parameters": [ + { + "name": "$skip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "$Skip" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Planned Bus Routes Api V1 Planned Bus Routes Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ef6a991 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "openapi-api-gateway" +version = "0.1.0" +description = "Configurable OpenAPI-first API gateway framework" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "httpx>=0.27.0", + "pydantic>=2.8.0", + "pyyaml>=6.0.0" +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "ruff>=0.6.0" +] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[build-system] +requires = ["setuptools>=69.0"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/src/gateway_framework/__init__.py b/src/gateway_framework/__init__.py new file mode 100644 index 0000000..dfb137d --- /dev/null +++ b/src/gateway_framework/__init__.py @@ -0,0 +1 @@ +"""OpenAPI API Gateway framework package.""" diff --git a/src/gateway_framework/app.py b/src/gateway_framework/app.py new file mode 100644 index 0000000..997fa3c --- /dev/null +++ b/src/gateway_framework/app.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Callable + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from .config import GatewayConfig, RouteConfig, load_gateway_config +from .proxy import proxy_request + +DEFAULT_CONFIG_PATH = "config/gateway.yaml" + + +def _resolve_config_path(explicit_path: str | None = None) -> str: + if explicit_path: + return explicit_path + return os.getenv("GATEWAY_CONFIG_PATH", DEFAULT_CONFIG_PATH) + + +def _make_proxy_handler(route: RouteConfig) -> Callable: + async def handler(request: Request): + return await proxy_request( + request=request, + client=request.app.state.http_client, + config=request.app.state.gateway_config, + route=route, + ) + + return handler + + +def _register_dynamic_routes(app: FastAPI, config: GatewayConfig) -> None: + for route in config.routes: + app.add_api_route( + path=route.path, + endpoint=_make_proxy_handler(route), + methods=route.methods, + summary=route.summary, + tags=route.tags, + operation_id=route.operation_id, + ) + + +def _register_management_routes(app: FastAPI) -> None: + @app.get("/healthz", tags=["Health"], summary="Liveness probe") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/readyz", tags=["Health"], summary="Readiness probe") + async def readyz() -> dict[str, str]: + return {"status": "ready"} + + @app.get("/admin/routes", tags=["Admin"], summary="List registered gateway routes") + async def list_routes(request: Request) -> list[dict[str, object]]: + cfg: GatewayConfig = request.app.state.gateway_config + return [ + { + "path": route.path, + "methods": route.methods, + "upstream": route.upstream, + "upstream_path": route.upstream_path, + "strip_prefix": route.strip_prefix, + } + for route in cfg.routes + ] + + @app.get( + "/openapi/external.json", + tags=["Admin"], + summary="Serve external upstream OpenAPI spec file", + ) + async def external_openapi(request: Request): + cfg: GatewayConfig = request.app.state.gateway_config + openapi_file = cfg.settings.external_openapi_file + if not openapi_file: + return JSONResponse( + status_code=404, + content={"error": "external_openapi_not_configured"}, + ) + + path = Path(openapi_file) + if not path.exists(): + return JSONResponse( + status_code=404, + content={"error": "external_openapi_not_found", "path": openapi_file}, + ) + + return JSONResponse(content=json.loads(path.read_text(encoding="utf-8"))) + + +def create_app(config_path: str | None = None) -> FastAPI: + resolved_path = _resolve_config_path(config_path) + gateway_config = load_gateway_config(resolved_path) + + app = FastAPI( + title=gateway_config.settings.title, + version=gateway_config.settings.version, + description=gateway_config.settings.description, + ) + + @app.on_event("startup") + async def startup() -> None: + app.state.http_client = httpx.AsyncClient() + app.state.gateway_config = gateway_config + + @app.on_event("shutdown") + async def shutdown() -> None: + await app.state.http_client.aclose() + + _register_management_routes(app) + _register_dynamic_routes(app, gateway_config) + return app diff --git a/src/gateway_framework/config.py b/src/gateway_framework/config.py new file mode 100644 index 0000000..490b425 --- /dev/null +++ b/src/gateway_framework/config.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError, field_validator + + +class GatewaySettings(BaseModel): + title: str = "OpenAPI API Gateway" + version: str = "0.1.0" + description: str = "Configurable API gateway" + external_openapi_file: str | None = None + + +class UpstreamConfig(BaseModel): + base_url: AnyHttpUrl + timeout_seconds: float = Field(default=15.0, ge=0.1, le=120.0) + + +class RouteConfig(BaseModel): + path: str + methods: list[Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]] + upstream: str + upstream_path: str | None = None + strip_prefix: str | None = None + summary: str | None = None + tags: list[str] = Field(default_factory=lambda: ["Gateway"]) + operation_id: str | None = None + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + if not value.startswith("/"): + msg = "route path must start with '/'" + raise ValueError(msg) + return value + + @field_validator("upstream_path") + @classmethod + def validate_upstream_path(cls, value: str | None) -> str | None: + if value is None: + return value + if not value.startswith("/"): + msg = "upstream_path must start with '/'" + raise ValueError(msg) + return value + + @field_validator("strip_prefix") + @classmethod + def validate_strip_prefix(cls, value: str | None) -> str | None: + if value is None: + return value + if not value.startswith("/"): + msg = "strip_prefix must start with '/'" + raise ValueError(msg) + return value.rstrip("/") + + +class GatewayConfig(BaseModel): + settings: GatewaySettings = Field(default_factory=GatewaySettings) + upstreams: dict[str, UpstreamConfig] + routes: list[RouteConfig] + + +def load_gateway_config(config_path: str | Path) -> GatewayConfig: + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"Gateway config was not found: {path}") + + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("Gateway config must be a YAML object") + + try: + config = GatewayConfig.model_validate(raw) + except ValidationError as exc: + raise ValueError(f"Invalid gateway config: {exc}") from exc + + unknown_upstreams = {route.upstream for route in config.routes} - set(config.upstreams) + if unknown_upstreams: + names = ", ".join(sorted(unknown_upstreams)) + raise ValueError(f"Routes reference unknown upstreams: {names}") + + return config diff --git a/src/gateway_framework/main.py b/src/gateway_framework/main.py new file mode 100644 index 0000000..793f52d --- /dev/null +++ b/src/gateway_framework/main.py @@ -0,0 +1,3 @@ +from .app import create_app + +app = create_app() diff --git a/src/gateway_framework/proxy.py b/src/gateway_framework/proxy.py new file mode 100644 index 0000000..527c6eb --- /dev/null +++ b/src/gateway_framework/proxy.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from urllib.parse import urljoin + +import httpx +from fastapi import Request +from starlette.responses import Response + +from .config import GatewayConfig, RouteConfig + +HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", +} + + +def build_target_path(request: Request, route: RouteConfig) -> str: + incoming_path = request.url.path + + if route.upstream_path: + return route.upstream_path + + if route.strip_prefix and incoming_path.startswith(route.strip_prefix): + stripped = incoming_path[len(route.strip_prefix) :] + return stripped if stripped.startswith("/") else f"/{stripped}" + + return incoming_path + + +def _forward_headers(request: Request) -> dict[str, str]: + headers: dict[str, str] = {} + for key, value in request.headers.items(): + lowered = key.lower() + if lowered in HOP_BY_HOP_HEADERS: + continue + headers[key] = value + + headers["x-forwarded-proto"] = request.url.scheme + headers["x-forwarded-host"] = request.url.hostname or "" + headers["x-forwarded-for"] = request.client.host if request.client else "" + return headers + + +def _response_headers(upstream: httpx.Response) -> dict[str, str]: + headers: dict[str, str] = {} + for key, value in upstream.headers.items(): + if key.lower() in HOP_BY_HOP_HEADERS: + continue + headers[key] = value + return headers + + +async def proxy_request( + *, + request: Request, + client: httpx.AsyncClient, + config: GatewayConfig, + route: RouteConfig, +) -> Response: + upstream = config.upstreams[route.upstream] + target_path = build_target_path(request, route) + target_url = urljoin(str(upstream.base_url), target_path.lstrip("/")) + + try: + upstream_response = await client.request( + method=request.method, + url=target_url, + params=request.query_params, + content=await request.body(), + headers=_forward_headers(request), + timeout=upstream.timeout_seconds, + ) + except httpx.RequestError as exc: + return Response( + content=( + '{"error":"upstream_unreachable","detail":"' + + str(exc) + + '"}' + ).encode("utf-8"), + status_code=502, + media_type="application/json", + ) + + return Response( + content=upstream_response.content, + status_code=upstream_response.status_code, + headers=_response_headers(upstream_response), + media_type=upstream_response.headers.get("content-type"), + )