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
This commit is contained in:
2026-05-31 11:47:08 +08:00
commit 1528a7c769
11 changed files with 1044 additions and 0 deletions
+38
View File
@@ -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
+48
View File
@@ -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`
+81
View File
@@ -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.
+27
View File
@@ -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
@@ -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"
}
}
}
}
+31
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""OpenAPI API Gateway framework package."""
+116
View File
@@ -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
+86
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from .app import create_app
app = create_app()
+96
View File
@@ -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"),
)