mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 02:06:50 +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