mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-20 09:47:59 +00:00
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:
+38
@@ -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
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""OpenAPI API Gateway framework package."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .app import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -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"),
|
||||
)
|
||||
Reference in New Issue
Block a user