feat: enhance gateway configuration and add companion backend integration with Docker Compose

This commit is contained in:
2026-06-01 10:30:10 +08:00
parent 3c948c5601
commit e750460956
10 changed files with 405 additions and 9 deletions
+2
View File
@@ -0,0 +1,2 @@
DATAMALL_API_KEY=replace-with-your-lta-datamall-account-key
PORT=8000
+3
View File
@@ -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`).
+37 -3
View File
@@ -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 .
+46 -4
View File
@@ -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)
+74
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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"
+123
View File
@@ -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())
+54
View File
@@ -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"] == {}
+26
View File
@@ -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/"