mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 02:06:50 +00:00
feat: enhance gateway configuration and add companion backend integration with Docker Compose
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
DATAMALL_API_KEY=replace-with-your-lta-datamall-account-key
|
||||
PORT=8000
|
||||
@@ -15,6 +15,9 @@
|
||||
### Changed
|
||||
- Replaced deprecated FastAPI startup/shutdown `on_event` handlers with lifespan.
|
||||
- Migrated admin portal tests away from deprecated TestClient usage.
|
||||
- Expanded the sample LTA DataMall gateway config to cover all companion backend `/api/v1` bus endpoints.
|
||||
- Split gateway upstream targeting into explicit local and container profiles and added a compose workflow for the companion backend.
|
||||
- Added a companion OpenAPI parity checker and Makefile target to validate gateway route alignment automatically.
|
||||
|
||||
### Notes
|
||||
- The generated API keys file is intentionally gitignored (`config/api_keys.json`).
|
||||
|
||||
@@ -6,20 +6,36 @@ IMAGE_TAG ?= latest
|
||||
CONTAINER_NAME ?= openapi-api-gateway
|
||||
PORT ?= 8000
|
||||
ENV_FILE ?= .env
|
||||
CONFIG_PROFILE ?= local
|
||||
COMPOSE_FILE ?= docker-compose.dev.yml
|
||||
COMPANION_OPENAPI ?= http://127.0.0.1:8068/openapi.json
|
||||
|
||||
ifeq ($(origin GATEWAY_CONFIG_PATH), undefined)
|
||||
ifeq ($(CONFIG_PROFILE),container)
|
||||
GATEWAY_CONFIG_PATH := config/gateway.container.yaml
|
||||
else
|
||||
GATEWAY_CONFIG_PATH := config/gateway.yaml
|
||||
endif
|
||||
endif
|
||||
|
||||
RUN_ENV := $(if $(wildcard $(ENV_FILE)),--env-file $(ENV_FILE),)
|
||||
|
||||
.PHONY: help build run stop logs shell test test-container fmt clean
|
||||
.PHONY: help build run run-local stop logs shell test test-container fmt clean check-companion-parity compose-up compose-down compose-logs
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " make build Build container image"
|
||||
@echo " make run Run gateway container"
|
||||
@echo " make run Run gateway container with CONFIG_PROFILE=local|container"
|
||||
@echo " make run-local Run gateway with uv and CONFIG_PROFILE=local|container"
|
||||
@echo " make stop Stop and remove container"
|
||||
@echo " make logs Follow container logs"
|
||||
@echo " make shell Open shell in running container"
|
||||
@echo " make test Run local pytest suite with uv"
|
||||
@echo " make test-container Run tests in container image"
|
||||
@echo " make check-companion-parity Compare selected gateway profile to companion OpenAPI"
|
||||
@echo " make compose-up Build and start companion backend plus gateway with Docker Compose"
|
||||
@echo " make compose-down Stop the dev compose stack"
|
||||
@echo " make compose-logs Follow logs for the dev compose stack"
|
||||
@echo " make fmt Run ruff format checks"
|
||||
@echo " make clean Remove image and stopped container"
|
||||
|
||||
@@ -31,9 +47,13 @@ run:
|
||||
$(CONTAINER_ENGINE) run -d \
|
||||
--name $(CONTAINER_NAME) \
|
||||
-p $(PORT):8000 \
|
||||
-e GATEWAY_CONFIG_PATH=$(GATEWAY_CONFIG_PATH) \
|
||||
$(RUN_ENV) \
|
||||
$(IMAGE_NAME):$(IMAGE_TAG)
|
||||
@echo "Gateway is running on http://127.0.0.1:$(PORT)"
|
||||
@echo "Gateway is running on http://127.0.0.1:$(PORT) using $(GATEWAY_CONFIG_PATH)"
|
||||
|
||||
run-local:
|
||||
GATEWAY_CONFIG_PATH=$(GATEWAY_CONFIG_PATH) uv run uvicorn gateway_framework.main:app --host 0.0.0.0 --port $(PORT) --reload --app-dir src
|
||||
|
||||
stop:
|
||||
-$(CONTAINER_ENGINE) rm -f $(CONTAINER_NAME)
|
||||
@@ -50,6 +70,20 @@ test:
|
||||
test-container:
|
||||
$(CONTAINER_ENGINE) run --rm $(IMAGE_NAME):$(IMAGE_TAG) uv run pytest -q
|
||||
|
||||
check-companion-parity:
|
||||
uv run python -m gateway_framework.companion --config $(GATEWAY_CONFIG_PATH) --openapi $(COMPANION_OPENAPI)
|
||||
|
||||
compose-up:
|
||||
docker compose -f $(COMPOSE_FILE) up --build -d lta-datamall-api
|
||||
$(MAKE) check-companion-parity CONFIG_PROFILE=container
|
||||
docker compose -f $(COMPOSE_FILE) up --build -d gateway
|
||||
|
||||
compose-down:
|
||||
docker compose -f $(COMPOSE_FILE) down --remove-orphans
|
||||
|
||||
compose-logs:
|
||||
docker compose -f $(COMPOSE_FILE) logs -f
|
||||
|
||||
fmt:
|
||||
uv run ruff check .
|
||||
|
||||
|
||||
@@ -37,16 +37,56 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. Update `config/gateway.yaml` with real upstream URLs.
|
||||
3. Choose a gateway profile:
|
||||
- `config/gateway.yaml` targets a locally running companion backend on `127.0.0.1:8068`.
|
||||
- `config/gateway.container.yaml` targets the companion backend inside the bundled compose network.
|
||||
4. Run the gateway with the managed environment:
|
||||
|
||||
```bash
|
||||
uv run uvicorn gateway_framework.main:app --reload --app-dir src
|
||||
make run-local CONFIG_PROFILE=local
|
||||
```
|
||||
|
||||
5. Open the docs:
|
||||
- `http://127.0.0.1:8000/docs`
|
||||
|
||||
## Companion Backend Integration
|
||||
This gateway is a good front door for the companion backend at `furyhawk/lta_datamall_api`.
|
||||
|
||||
The gateway now ships with two explicit upstream profiles:
|
||||
- `config/gateway.yaml` for a local backend on `http://127.0.0.1:8068`
|
||||
- `config/gateway.container.yaml` for the compose service `http://lta-datamall-api:8000`
|
||||
|
||||
Both profiles are aligned to the companion backend's published route surface:
|
||||
- public route prefix: `/api/v1`
|
||||
- backend health endpoints remain available at `/healthz` and `/readyz`
|
||||
|
||||
One-command profile selection:
|
||||
- Local host pairing: `make run-local CONFIG_PROFILE=local`
|
||||
- Containerized gateway: `make run CONFIG_PROFILE=container`
|
||||
- Route parity check against the running companion backend OpenAPI: `make check-companion-parity CONFIG_PROFILE=local`
|
||||
|
||||
Suggested local pairing workflow:
|
||||
1. Run `furyhawk/lta_datamall_api` on port `8068`.
|
||||
2. Start this gateway with `make run-local CONFIG_PROFILE=local`.
|
||||
3. Use this gateway for edge concerns such as admin config editing, gateway API keys, and response caching while the companion backend owns the LTA DataMall domain logic.
|
||||
|
||||
## Integrated Dev Compose Workflow
|
||||
Use the bundled compose file when you want this repo to start both the gateway and the companion backend together.
|
||||
|
||||
1. Create `.env` from `.env.example` and set `DATAMALL_API_KEY`.
|
||||
2. Start the stack:
|
||||
|
||||
```bash
|
||||
make compose-up
|
||||
```
|
||||
|
||||
3. Open the gateway at `http://127.0.0.1:8000`.
|
||||
|
||||
Notes:
|
||||
- `make compose-up` starts the companion backend first, checks its live `/openapi.json`, then starts the gateway.
|
||||
- The compose workflow uses Docker Compose because the companion backend is built directly from its Git repository.
|
||||
- Tail logs with `make compose-logs` and stop the stack with `make compose-down`.
|
||||
|
||||
## Configuration Model
|
||||
`config/gateway.yaml` uses this model:
|
||||
|
||||
@@ -148,11 +188,13 @@ make build
|
||||
|
||||
### Run Container
|
||||
```bash
|
||||
make run
|
||||
make run CONFIG_PROFILE=local
|
||||
```
|
||||
|
||||
Gateway will be available at `http://127.0.0.1:8000`.
|
||||
|
||||
Use `CONFIG_PROFILE=container` when the upstream backend is running inside the compose network.
|
||||
|
||||
### Stop and Inspect
|
||||
```bash
|
||||
make logs
|
||||
@@ -169,7 +211,7 @@ make stop
|
||||
Example:
|
||||
```bash
|
||||
make build CONTAINER_ENGINE=docker IMAGE_TAG=dev
|
||||
make run CONTAINER_ENGINE=docker PORT=8080 ENV_FILE=.env
|
||||
make run CONTAINER_ENGINE=docker PORT=8080 ENV_FILE=.env CONFIG_PROFILE=local
|
||||
```
|
||||
|
||||
## GitHub Packages (GHCR)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
settings:
|
||||
title: LTA DataMall API Gateway
|
||||
version: 0.2.0
|
||||
description: Configurable gateway for OpenAPI-based upstream services
|
||||
external_openapi_file: openapi_json/lta_datamall_openapi_v0-1-1.json
|
||||
cache_enabled: true
|
||||
cache_ttl_seconds: 30
|
||||
cache_max_entries: 500
|
||||
require_api_key: false
|
||||
api_keys_file: config/api_keys.json
|
||||
admin_api_key_env: ADMIN_API_KEY
|
||||
|
||||
upstreams:
|
||||
lta_datamall:
|
||||
base_url: http://lta-datamall-api/
|
||||
port: 8000
|
||||
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
|
||||
|
||||
- path: /api/v1/bus-routes
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/bus-routes
|
||||
summary: Bus routes passthrough
|
||||
tags: [Bus]
|
||||
operation_id: gateway_bus_routes
|
||||
|
||||
- path: /api/v1/bus-stops
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/bus-stops
|
||||
summary: Bus stops passthrough
|
||||
tags: [Bus]
|
||||
operation_id: gateway_bus_stops
|
||||
|
||||
- path: /api/v1/passenger-volume/bus
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/passenger-volume/bus
|
||||
summary: Bus passenger volume passthrough
|
||||
tags: [Passenger Volume]
|
||||
operation_id: gateway_passenger_volume_bus
|
||||
|
||||
- path: /api/v1/passenger-volume/od-bus
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/passenger-volume/od-bus
|
||||
summary: Origin-destination bus passenger volume passthrough
|
||||
tags: [Passenger Volume]
|
||||
operation_id: gateway_passenger_volume_od_bus
|
||||
|
||||
- path: /api/v1/planned-bus-routes
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/planned-bus-routes
|
||||
summary: Planned bus routes passthrough
|
||||
tags: [Bus]
|
||||
operation_id: gateway_planned_bus_routes
|
||||
+18
-2
@@ -1,7 +1,7 @@
|
||||
settings:
|
||||
title: LTA DataMall API Gateway
|
||||
version: 0.2.0
|
||||
description: Configurable gateway for OpenAPI-based upstream services
|
||||
description: Configurable gateway for a locally running LTA DataMall backend
|
||||
external_openapi_file: openapi_json/lta_datamall_openapi_v0-1-1.json
|
||||
cache_enabled: true
|
||||
cache_ttl_seconds: 30
|
||||
@@ -12,7 +12,7 @@ settings:
|
||||
|
||||
upstreams:
|
||||
lta_datamall:
|
||||
base_url: http://host.containers.internal/
|
||||
base_url: http://127.0.0.1/
|
||||
port: 8068
|
||||
timeout_seconds: 20
|
||||
|
||||
@@ -56,3 +56,19 @@ routes:
|
||||
summary: Bus passenger volume passthrough
|
||||
tags: [Passenger Volume]
|
||||
operation_id: gateway_passenger_volume_bus
|
||||
|
||||
- path: /api/v1/passenger-volume/od-bus
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/passenger-volume/od-bus
|
||||
summary: Origin-destination bus passenger volume passthrough
|
||||
tags: [Passenger Volume]
|
||||
operation_id: gateway_passenger_volume_od_bus
|
||||
|
||||
- path: /api/v1/planned-bus-routes
|
||||
methods: [GET]
|
||||
upstream: lta_datamall
|
||||
upstream_path: /api/v1/planned-bus-routes
|
||||
summary: Planned bus routes passthrough
|
||||
tags: [Bus]
|
||||
operation_id: gateway_planned_bus_routes
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
lta-datamall-api:
|
||||
build:
|
||||
context: https://github.com/furyhawk/lta_datamall_api.git#main
|
||||
container_name: lta-datamall-api
|
||||
environment:
|
||||
DATAMALL_API_KEY: ${DATAMALL_API_KEY}
|
||||
APP_PORT: 8000
|
||||
VALKEY_ENABLED: "false"
|
||||
ports:
|
||||
- "8068:8000"
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: .
|
||||
container_name: openapi-api-gateway-dev
|
||||
depends_on:
|
||||
- lta-datamall-api
|
||||
environment:
|
||||
GATEWAY_CONFIG_PATH: config/gateway.container.yaml
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from .config import GatewayConfig, load_gateway_config
|
||||
|
||||
DEFAULT_COMPANION_OPENAPI = "http://127.0.0.1:8068/openapi.json"
|
||||
DEFAULT_PATH_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteParityReport:
|
||||
missing_in_gateway: tuple[str, ...]
|
||||
extra_in_gateway: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def is_aligned(self) -> bool:
|
||||
return not self.missing_in_gateway and not self.extra_in_gateway
|
||||
|
||||
|
||||
def gateway_route_paths(config: GatewayConfig, prefix: str = DEFAULT_PATH_PREFIX) -> set[str]:
|
||||
return {route.path for route in config.routes if route.path.startswith(prefix)}
|
||||
|
||||
|
||||
def openapi_route_paths(document: dict[str, object], prefix: str = DEFAULT_PATH_PREFIX) -> set[str]:
|
||||
raw_paths = document.get("paths")
|
||||
if not isinstance(raw_paths, dict):
|
||||
raise ValueError("OpenAPI document must contain an object-valued 'paths' field")
|
||||
|
||||
return {
|
||||
path
|
||||
for path in raw_paths
|
||||
if isinstance(path, str) and path.startswith(prefix)
|
||||
}
|
||||
|
||||
|
||||
def compare_route_sets(gateway_paths: set[str], openapi_paths: set[str]) -> RouteParityReport:
|
||||
return RouteParityReport(
|
||||
missing_in_gateway=tuple(sorted(openapi_paths - gateway_paths)),
|
||||
extra_in_gateway=tuple(sorted(gateway_paths - openapi_paths)),
|
||||
)
|
||||
|
||||
|
||||
def compare_gateway_to_openapi(
|
||||
config: GatewayConfig,
|
||||
document: dict[str, object],
|
||||
prefix: str = DEFAULT_PATH_PREFIX,
|
||||
) -> RouteParityReport:
|
||||
return compare_route_sets(
|
||||
gateway_route_paths(config, prefix=prefix),
|
||||
openapi_route_paths(document, prefix=prefix),
|
||||
)
|
||||
|
||||
|
||||
def load_openapi_document(source: str, *, timeout_seconds: float = 15.0) -> dict[str, object]:
|
||||
parsed = urlparse(source)
|
||||
if parsed.scheme in {"http", "https"}:
|
||||
response = httpx.get(source, timeout=timeout_seconds)
|
||||
response.raise_for_status()
|
||||
payload = yaml.safe_load(response.text)
|
||||
else:
|
||||
payload = yaml.safe_load(Path(source).read_text(encoding="utf-8"))
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("OpenAPI document must deserialize to an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare a gateway config profile against the companion backend OpenAPI document.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config/gateway.yaml",
|
||||
help="Path to the gateway YAML config to validate.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--openapi",
|
||||
default=DEFAULT_COMPANION_OPENAPI,
|
||||
help="Path or URL to the companion OpenAPI document.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prefix",
|
||||
default=DEFAULT_PATH_PREFIX,
|
||||
help="Only compare paths under this prefix.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
config = load_gateway_config(args.config)
|
||||
document = load_openapi_document(args.openapi)
|
||||
report = compare_gateway_to_openapi(config, document, prefix=args.prefix)
|
||||
|
||||
if report.is_aligned:
|
||||
print(
|
||||
f"Gateway config '{args.config}' matches companion OpenAPI '{args.openapi}' for prefix '{args.prefix}'."
|
||||
)
|
||||
return 0
|
||||
|
||||
if report.missing_in_gateway:
|
||||
print("Missing gateway routes:")
|
||||
for path in report.missing_in_gateway:
|
||||
print(path)
|
||||
|
||||
if report.extra_in_gateway:
|
||||
print("Extra gateway routes:")
|
||||
for path in report.extra_in_gateway:
|
||||
print(path)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from gateway_framework.companion import compare_gateway_to_openapi, load_openapi_document
|
||||
from gateway_framework.config import load_gateway_config
|
||||
|
||||
|
||||
def test_compare_gateway_to_openapi_detects_matching_route_set() -> None:
|
||||
config = load_gateway_config("config/gateway.yaml")
|
||||
document = {
|
||||
"openapi": "3.0.3",
|
||||
"paths": {
|
||||
"/api/v1/bus-arrival": {},
|
||||
"/api/v1/bus-services": {},
|
||||
"/api/v1/bus-routes": {},
|
||||
"/api/v1/bus-stops": {},
|
||||
"/api/v1/passenger-volume/bus": {},
|
||||
"/api/v1/passenger-volume/od-bus": {},
|
||||
"/api/v1/planned-bus-routes": {},
|
||||
"/healthz": {},
|
||||
},
|
||||
}
|
||||
|
||||
report = compare_gateway_to_openapi(config, document)
|
||||
|
||||
assert report.is_aligned is True
|
||||
assert report.missing_in_gateway == ()
|
||||
assert report.extra_in_gateway == ()
|
||||
|
||||
|
||||
def test_compare_gateway_to_openapi_detects_missing_route() -> None:
|
||||
config = load_gateway_config("config/gateway.yaml")
|
||||
document = {
|
||||
"openapi": "3.0.3",
|
||||
"paths": {
|
||||
"/api/v1/bus-arrival": {},
|
||||
},
|
||||
}
|
||||
|
||||
report = compare_gateway_to_openapi(config, document)
|
||||
|
||||
assert report.is_aligned is False
|
||||
assert report.missing_in_gateway == ()
|
||||
assert "/api/v1/bus-services" in report.extra_in_gateway
|
||||
|
||||
|
||||
def test_load_openapi_document_reads_local_file(tmp_path) -> None:
|
||||
openapi_file = tmp_path / "companion-openapi.json"
|
||||
openapi_file.write_text(json.dumps({"openapi": "3.0.3", "paths": {"/api/v1/bus-arrival": {}}}), encoding="utf-8")
|
||||
|
||||
loaded = load_openapi_document(str(openapi_file))
|
||||
|
||||
assert loaded["paths"]["/api/v1/bus-arrival"] == {}
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway_framework.config import load_gateway_config
|
||||
@@ -69,3 +71,27 @@ routes:
|
||||
|
||||
with pytest.raises(ValueError, match="route path must start"):
|
||||
load_gateway_config(config_file)
|
||||
|
||||
|
||||
def test_sample_gateway_config_matches_companion_backend_route_set() -> None:
|
||||
config = load_gateway_config(Path("config/gateway.yaml"))
|
||||
|
||||
assert {route.path for route in config.routes} == {
|
||||
"/api/v1/bus-arrival",
|
||||
"/api/v1/bus-services",
|
||||
"/api/v1/bus-routes",
|
||||
"/api/v1/bus-stops",
|
||||
"/api/v1/passenger-volume/bus",
|
||||
"/api/v1/passenger-volume/od-bus",
|
||||
"/api/v1/planned-bus-routes",
|
||||
}
|
||||
|
||||
|
||||
def test_container_gateway_config_matches_local_route_set() -> None:
|
||||
local_config = load_gateway_config(Path("config/gateway.yaml"))
|
||||
container_config = load_gateway_config(Path("config/gateway.container.yaml"))
|
||||
|
||||
assert {route.path for route in local_config.routes} == {
|
||||
route.path for route in container_config.routes
|
||||
}
|
||||
assert container_config.upstreams["lta_datamall"].resolved_base_url() == "http://lta-datamall-api:8000/"
|
||||
|
||||
Reference in New Issue
Block a user