Compare commits

...
22 changed files with 877 additions and 20 deletions
+3
View File
@@ -0,0 +1,3 @@
DATAMALL_API_KEY=replace-with-your-lta-datamall-account-key
PORT=8067
GATEWAY_PORT=8067
+59
View File
@@ -0,0 +1,59 @@
name: Release Image
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to publish (for example v0.2.1). Optional."
required: false
type: string
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=${{ github.event.inputs.release_tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '' }}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+34
View File
@@ -0,0 +1,34 @@
# Changelog
## 0.2.1 - 2026-06-01
### Added
- Companion backend route-parity checker with a dedicated test module.
- Integrated dev compose workflow for running the companion backend behind the gateway.
### Changed
- Split gateway upstream targeting into local and container config profiles.
- Expanded the sample LTA DataMall gateway config to cover all companion backend `/api/v1` bus endpoints.
- Documented one-command profile selection and companion integration workflows.
## 0.2.0 - 2026-05-31
### Added
- FastAPI gateway framework scaffold with config-driven proxy routing.
- Administrative portal and dashboard with scalable frontend assets.
- Runtime configuration editing endpoints.
- API key lifecycle management (create, list, revoke) and optional route protection.
- In-memory response caching with TTL and max-entry controls.
- Containerization support via Dockerfile and Docker/Podman Makefile workflow.
- GitHub Actions test workflow and Dependabot configuration.
- Test suite covering configuration, proxy behavior, admin endpoints, and caching.
### 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`).
+1 -1
View File
@@ -16,4 +16,4 @@ COPY openapi_json ./openapi_json
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "gateway_framework.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "src"]
CMD ["uv", "run", "python", "-m", "gateway_framework.main"]
+40 -4
View File
@@ -5,21 +5,38 @@ IMAGE_NAME ?= openapi-api-gateway
IMAGE_TAG ?= latest
CONTAINER_NAME ?= openapi-api-gateway
PORT ?= 8000
GATEWAY_PORT ?= $(PORT)
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"
@@ -30,10 +47,15 @@ run:
$(MAKE) stop >/dev/null 2>&1 || true
$(CONTAINER_ENGINE) run -d \
--name $(CONTAINER_NAME) \
-p $(PORT):8000 \
-p $(PORT):$(GATEWAY_PORT) \
-e GATEWAY_CONFIG_PATH=$(GATEWAY_CONFIG_PATH) \
-e GATEWAY_PORT=$(GATEWAY_PORT) \
$(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) (bind port $(GATEWAY_PORT))"
run-local:
GATEWAY_CONFIG_PATH=$(GATEWAY_CONFIG_PATH) GATEWAY_PORT=$(GATEWAY_PORT) uv run python -m gateway_framework.main
stop:
-$(CONTAINER_ENGINE) rm -f $(CONTAINER_NAME)
@@ -50,6 +72,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:
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) up --build -d lta-datamall-api
$(MAKE) check-companion-parity CONFIG_PROFILE=container
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) up --build -d gateway
compose-down:
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) down --remove-orphans
compose-logs:
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) logs -f
fmt:
uv run ruff check .
+77 -5
View File
@@ -37,16 +37,64 @@ 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` uses `GATEWAY_UPSTREAM_HOST` for host-container networking and defaults to `host.docker.internal` (set `GATEWAY_UPSTREAM_HOST=host.containers.internal` for Podman), with default `gateway_port: 8067`.
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`
To run on a different gateway port, pass `GATEWAY_PORT`:
```bash
make run-local CONFIG_PROFILE=local GATEWAY_PORT=8088
```
## 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`.
If you use the repository defaults from `.env.example`, the gateway is exposed on port `8067`.
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:
@@ -55,6 +103,7 @@ settings:
title: string
version: string
description: string
gateway_port: 8067
external_openapi_file: path/to/openapi.json
cache_enabled: false
cache_ttl_seconds: 30
@@ -66,6 +115,7 @@ settings:
upstreams:
service_name:
base_url: https://service.example.com/
port: 8443
timeout_seconds: 15
routes:
@@ -83,6 +133,8 @@ Notes:
- `path` is the public gateway path.
- `upstream_path` overrides forwarded path.
- `strip_prefix` removes a leading path segment before forwarding.
- `port` overrides the upstream URL port without changing the host or scheme.
- `settings.gateway_port` controls the gateway listen port unless `GATEWAY_PORT` (or `PORT`) is set.
## Administrative Portal and Dashboard
- Admin UI: `GET /admin/portal`
@@ -93,6 +145,9 @@ Notes:
- List API keys: `GET /admin/api-keys`
- Create API key: `POST /admin/api-keys` with body `{"name":"client-name"}`
- Revoke API key: `DELETE /admin/api-keys/{key_id}`
- Cache status: `GET /admin/cache`
- Invalidate cache by route/query fragment: `POST /admin/cache/invalidate`
- Clear all cache entries: `DELETE /admin/cache`
Optional admin auth:
- Set environment variable `ADMIN_API_KEY` before startup.
@@ -109,6 +164,8 @@ Optional admin auth:
- Configure memory bound with `settings.cache_max_entries`.
- Cache currently applies to proxied `GET`/`HEAD` responses with `2xx` status.
- Gateway adds `x-gateway-cache: MISS` for first fetch and `x-gateway-cache: HIT` for cached responses.
- Admin invalidation payload example:
- `{"path": "/api/v1/bus-arrival", "method": "GET", "query_contains": "BusStopCode=12345"}`
## Scaling and Configurability Guidance
- Add routes through config, not code, for repeatable deployments.
@@ -141,11 +198,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
@@ -154,7 +213,9 @@ make stop
### Useful Variables
- `CONTAINER_ENGINE=docker` (or `podman`)
- `PORT=8080`
- `PORT=8067`
- `GATEWAY_PORT=8067`
- `GATEWAY_UPSTREAM_HOST=host.docker.internal` (set to `host.containers.internal` for Podman host networking)
- `IMAGE_NAME=openapi-api-gateway`
- `IMAGE_TAG=latest`
- `ENV_FILE=.env`
@@ -162,5 +223,16 @@ 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 GATEWAY_PORT=8080 ENV_FILE=.env CONFIG_PROFILE=local
```
## GitHub Packages (GHCR)
- Automated image publishing is configured in `.github/workflows/release-image.yml`.
- On push of a release tag (for example `v0.2.1`), CI builds and pushes:
- `ghcr.io/furyhawk/api_gateway:v0.2.1`
- `ghcr.io/furyhawk/api_gateway:latest`
Pull example:
```bash
docker pull ghcr.io/furyhawk/api_gateway:v0.2.1
```
+75
View File
@@ -0,0 +1,75 @@
settings:
title: LTA DataMall API Gateway
version: 0.2.1
description: Configurable gateway for OpenAPI-based upstream services
gateway_port: 8067
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://${GATEWAY_UPSTREAM_HOST:-host.docker.internal}/
port: 8068
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
+45 -3
View File
@@ -1,7 +1,8 @@
settings:
title: LTA DataMall API Gateway
version: 0.1.0
description: Configurable gateway for OpenAPI-based upstream services
version: 0.2.1
description: Configurable gateway for a locally running LTA DataMall backend
gateway_port: 8067
external_openapi_file: openapi_json/lta_datamall_openapi_v0-1-1.json
cache_enabled: true
cache_ttl_seconds: 30
@@ -12,7 +13,8 @@ settings:
upstreams:
lta_datamall:
base_url: https://api.example.com/
base_url: http://127.0.0.1/
port: 8068
timeout_seconds: 20
routes:
@@ -31,3 +33,43 @@ routes:
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
+23
View File
@@ -0,0 +1,23 @@
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
GATEWAY_PORT: ${PORT:-8000}
ports:
- "${PORT:-8000}:${PORT:-8000}"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "openapi-api-gateway"
version = "0.1.0"
version = "0.2.1"
description = "Configurable OpenAPI-first API gateway framework"
readme = "README.md"
requires-python = ">=3.11"
+49
View File
@@ -30,6 +30,12 @@ class ApiKeyCreateRequest(BaseModel):
name: str
class CacheInvalidateRequest(BaseModel):
path: str
method: str = "GET"
query_contains: str | None = None
def _resolve_config_path(explicit_path: str | None = None) -> str:
if explicit_path:
return explicit_path
@@ -122,16 +128,59 @@ def _register_management_routes(app: FastAPI) -> None:
async def admin_dashboard(request: Request) -> dict[str, object]:
require_admin_access(request)
cfg: GatewayConfig = request.app.state.gateway_config
cache: ResponseCache = request.app.state.response_cache
return {
"title": cfg.settings.title,
"version": cfg.settings.version,
"require_api_key": cfg.settings.require_api_key,
"admin_api_key_required": bool(request.app.state.admin_api_key),
"cache_enabled": cfg.settings.cache_enabled,
"cache_entries": cache.size(),
"cache_ttl_seconds": cfg.settings.cache_ttl_seconds,
"cache_max_entries": cfg.settings.cache_max_entries,
"upstreams": sorted(cfg.upstreams.keys()),
"routes_count": len(cfg.routes),
"api_keys_file": str(request.app.state.api_key_store.path),
}
@app.get("/admin/cache", tags=["Admin"], summary="Get cache status")
async def get_cache_status(request: Request) -> dict[str, object]:
require_admin_access(request)
cfg: GatewayConfig = request.app.state.gateway_config
cache: ResponseCache = request.app.state.response_cache
return {
"enabled": cfg.settings.cache_enabled,
"ttl_seconds": cfg.settings.cache_ttl_seconds,
"max_entries": cfg.settings.cache_max_entries,
"entries": cache.size(),
}
@app.delete("/admin/cache", tags=["Admin"], summary="Clear all cached responses")
async def clear_cache(request: Request) -> dict[str, int]:
require_admin_access(request)
cache: ResponseCache = request.app.state.response_cache
cleared = cache.size()
cache.clear()
return {"cleared_entries": cleared}
@app.post("/admin/cache/invalidate", tags=["Admin"], summary="Invalidate cache by route fragment")
async def invalidate_cache(
request: Request,
payload: CacheInvalidateRequest = Body(...),
) -> dict[str, object]:
require_admin_access(request)
cache: ResponseCache = request.app.state.response_cache
fragments = [f"{payload.method.upper()}|", payload.path]
if payload.query_contains:
fragments.append(payload.query_contains)
invalidated = cache.invalidate_by_contains(fragments)
return {
"invalidated_entries": invalidated,
"method": payload.method.upper(),
"path": payload.path,
"query_contains": payload.query_contains,
}
@app.get("/admin/config", tags=["Admin"], summary="Get active gateway YAML config")
async def get_admin_config(request: Request) -> dict[str, str]:
require_admin_access(request)
+16
View File
@@ -64,3 +64,19 @@ class ResponseCache:
def clear(self) -> None:
self._entries.clear()
def size(self) -> int:
self._purge_expired()
return len(self._entries)
def invalidate_by_contains(self, contains: list[str]) -> int:
self._purge_expired()
if not contains:
return 0
invalidated = 0
for key in list(self._entries.keys()):
if all(fragment in key for fragment in contains):
self._entries.pop(key, None)
invalidated += 1
return invalidated
+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())
+48
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
from typing import Literal
from urllib.parse import urlsplit, urlunsplit
import yaml
from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError, field_validator
@@ -11,6 +15,7 @@ class GatewaySettings(BaseModel):
title: str = "OpenAPI API Gateway"
version: str = "0.1.0"
description: str = "Configurable API gateway"
gateway_port: int = Field(default=8000, ge=1, le=65535)
external_openapi_file: str | None = None
cache_enabled: bool = False
cache_ttl_seconds: float = Field(default=30.0, ge=1.0, le=3600.0)
@@ -22,8 +27,23 @@ class GatewaySettings(BaseModel):
class UpstreamConfig(BaseModel):
base_url: AnyHttpUrl
port: int | None = Field(default=None, ge=1, le=65535)
timeout_seconds: float = Field(default=15.0, ge=0.1, le=120.0)
def resolved_base_url(self) -> str:
url = urlsplit(str(self.base_url))
if self.port is None:
return str(self.base_url)
host = url.hostname or ""
if url.username:
auth = url.username
if url.password:
auth = f"{auth}:{url.password}"
host = f"{auth}@{host}"
netloc = f"{host}:{self.port}"
return urlunsplit((url.scheme, netloc, url.path, url.query, url.fragment))
class RouteConfig(BaseModel):
path: str
@@ -70,6 +90,32 @@ class GatewayConfig(BaseModel):
routes: list[RouteConfig]
_ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
def _expand_env_placeholders(value: str) -> str:
def replacer(match: re.Match[str]) -> str:
var_name = match.group(1)
default_value = match.group(2)
if var_name in os.environ:
return os.environ[var_name]
if default_value is not None:
return default_value
raise ValueError(f"Missing required environment variable: {var_name}")
return _ENV_PLACEHOLDER_PATTERN.sub(replacer, value)
def _resolve_env_placeholders(value: Any) -> Any:
if isinstance(value, str):
return _expand_env_placeholders(value)
if isinstance(value, list):
return [_resolve_env_placeholders(item) for item in value]
if isinstance(value, dict):
return {key: _resolve_env_placeholders(item) for key, item in value.items()}
return value
def load_gateway_config(config_path: str | Path) -> GatewayConfig:
path = Path(config_path)
if not path.exists():
@@ -79,6 +125,8 @@ def load_gateway_config(config_path: str | Path) -> GatewayConfig:
if not isinstance(raw, dict):
raise ValueError("Gateway config must be a YAML object")
raw = _resolve_env_placeholders(raw)
try:
config = GatewayConfig.model_validate(raw)
except ValidationError as exc:
+32
View File
@@ -1,3 +1,35 @@
from __future__ import annotations
import os
import uvicorn
from .app import create_app
from .config import load_gateway_config
app = create_app()
def _resolve_runtime_port() -> int:
override = os.getenv("GATEWAY_PORT") or os.getenv("PORT")
if override:
try:
port = int(override)
except ValueError as exc:
raise ValueError("GATEWAY_PORT must be an integer") from exc
if not 1 <= port <= 65535:
raise ValueError("GATEWAY_PORT must be between 1 and 65535")
return port
config_path = os.getenv("GATEWAY_CONFIG_PATH", "config/gateway.yaml")
return load_gateway_config(config_path).settings.gateway_port
def run() -> None:
host = os.getenv("GATEWAY_HOST", "0.0.0.0")
port = _resolve_runtime_port()
uvicorn.run("gateway_framework.main:app", host=host, port=port)
if __name__ == "__main__":
run()
+1 -1
View File
@@ -83,7 +83,7 @@ async def proxy_request(
) -> Response:
upstream = config.upstreams[route.upstream]
target_path = build_target_path(request, route)
target_url = urljoin(str(upstream.base_url), target_path.lstrip("/"))
target_url = urljoin(upstream.resolved_base_url(), target_path.lstrip("/"))
cache = None
cache_key = ""
+60
View File
@@ -8,6 +8,7 @@ import pytest
from gateway_framework.api_keys import ApiKeyStore
from gateway_framework.app import create_app
from gateway_framework.cache import ResponseCache
from gateway_framework.config import load_gateway_config
@@ -19,6 +20,7 @@ def _prepare_state(app, config_file: Path) -> None:
app.state.gateway_config_path = Path(config_file)
app.state.admin_api_key = ""
app.state.http_client = httpx.AsyncClient(transport=httpx.MockTransport(failing_upstream))
app.state.response_cache = ResponseCache(ttl_seconds=60, max_entries=100)
store = ApiKeyStore(Path(config_file.parent / "api_keys.json"))
store.ensure_exists()
app.state.api_key_store = store
@@ -203,3 +205,61 @@ routes:
valid = await client.get("/api/v1/demo", headers={"x-api-key": api_key})
assert valid.status_code == 502
@pytest.mark.anyio
async def test_admin_cache_controls(tmp_path) -> None:
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
settings:
cache_enabled: true
upstreams:
demo:
base_url: https://example.com/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
app = create_app(str(config_file))
_prepare_state(app, config_file)
app.state.response_cache.set(
"GET|https://example.com/api/v1/demo|a=1||",
status_code=200,
headers={"content-type": "application/json"},
body=b"{}",
media_type="application/json",
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
status_before = await client.get("/admin/cache")
assert status_before.status_code == 200
assert status_before.json()["entries"] == 1
invalidated = await client.post(
"/admin/cache/invalidate",
json={"path": "/api/v1/demo", "method": "GET"},
)
assert invalidated.status_code == 200
assert invalidated.json()["invalidated_entries"] == 1
app.state.response_cache.set(
"GET|https://example.com/api/v1/demo|a=2||",
status_code=200,
headers={"content-type": "application/json"},
body=b"{}",
media_type="application/json",
)
cleared = await client.delete("/admin/cache")
assert cleared.status_code == 200
assert cleared.json()["cleared_entries"] == 1
status_after = await client.get("/admin/cache")
assert status_after.status_code == 200
assert status_after.json()["entries"] == 0
+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"] == {}
+94
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
from gateway_framework.config import load_gateway_config
@@ -14,6 +16,7 @@ settings:
upstreams:
demo:
base_url: https://example.com/
port: 8443
routes:
- path: /api/v1/demo
methods: [GET]
@@ -27,6 +30,8 @@ routes:
assert config.settings.title == "Test Gateway"
assert "demo" in config.upstreams
assert config.routes[0].path == "/api/v1/demo"
assert config.upstreams["demo"].port == 8443
assert config.settings.gateway_port == 8000
def test_load_gateway_config_unknown_upstream(tmp_path) -> None:
@@ -36,6 +41,7 @@ def test_load_gateway_config_unknown_upstream(tmp_path) -> None:
upstreams:
known:
base_url: https://example.com/
port: 8443
routes:
- path: /api/v1/demo
methods: [GET]
@@ -55,6 +61,7 @@ def test_load_gateway_config_invalid_route_path(tmp_path) -> None:
upstreams:
demo:
base_url: https://example.com/
port: 8443
routes:
- path: api/v1/demo
methods: [GET]
@@ -65,3 +72,90 @@ routes:
with pytest.raises(ValueError, match="route path must start"):
load_gateway_config(config_file)
def test_load_gateway_config_expands_env_with_default(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("GATEWAY_UPSTREAM_HOST", raising=False)
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
upstreams:
demo:
base_url: http://${GATEWAY_UPSTREAM_HOST:-host.docker.internal}/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
config = load_gateway_config(config_file)
assert str(config.upstreams["demo"].base_url) == "http://host.docker.internal/"
def test_load_gateway_config_expands_env_override(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("GATEWAY_UPSTREAM_HOST", "host.containers.internal")
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
upstreams:
demo:
base_url: http://${GATEWAY_UPSTREAM_HOST:-host.docker.internal}/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
config = load_gateway_config(config_file)
assert str(config.upstreams["demo"].base_url) == "http://host.containers.internal/"
def test_load_gateway_config_missing_required_env_var_raises(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("GATEWAY_UPSTREAM_HOST", raising=False)
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
upstreams:
demo:
base_url: http://${GATEWAY_UPSTREAM_HOST}/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
with pytest.raises(ValueError, match="Missing required environment variable"):
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 local_config.settings.gateway_port == container_config.settings.gateway_port
assert container_config.upstreams["lta_datamall"].port is not None
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from gateway_framework.main import _resolve_runtime_port
def test_resolve_runtime_port_from_env(monkeypatch) -> None:
monkeypatch.setenv("GATEWAY_PORT", "18080")
assert _resolve_runtime_port() == 18080
def test_resolve_runtime_port_from_config_when_env_absent(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("GATEWAY_PORT", raising=False)
monkeypatch.delenv("PORT", raising=False)
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
settings:
gateway_port: 19090
upstreams:
demo:
base_url: https://example.com/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
monkeypatch.setenv("GATEWAY_CONFIG_PATH", str(config_file))
assert _resolve_runtime_port() == 19090
+8 -4
View File
@@ -72,7 +72,9 @@ async def test_proxy_request_forwards_to_upstream() -> None:
)
config = GatewayConfig(
upstreams={"demo": UpstreamConfig(base_url="https://example.com/", timeout_seconds=5)},
upstreams={
"demo": UpstreamConfig(base_url="https://example.com/", port=8443, timeout_seconds=5)
},
routes=[],
)
route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo")
@@ -84,7 +86,7 @@ async def test_proxy_request_forwards_to_upstream() -> None:
assert response.status_code == 200
assert response.body == b'{"ok":true}'
assert response.headers.get("connection") is None
assert seen["url"] == "https://example.com/api/v1/bus?a=1"
assert seen["url"] == "https://example.com:8443/api/v1/bus?a=1"
assert seen["x_forwarded_proto"] == "http"
assert seen["x_forwarded_host"] == "gateway.local"
@@ -103,7 +105,9 @@ async def test_proxy_request_cache_hit_skips_second_upstream_call() -> None:
config = GatewayConfig(
settings=GatewaySettings(cache_enabled=True, cache_ttl_seconds=60, cache_max_entries=100),
upstreams={"demo": UpstreamConfig(base_url="https://example.com/", timeout_seconds=5)},
upstreams={
"demo": UpstreamConfig(base_url="https://example.com/", port=8443, timeout_seconds=5)
},
routes=[],
)
route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo")
@@ -136,7 +140,7 @@ async def test_proxy_request_returns_502_when_upstream_unreachable() -> None:
raise httpx.ConnectError("connection failed", request=req)
config = GatewayConfig(
upstreams={"demo": UpstreamConfig(base_url="https://example.com/")},
upstreams={"demo": UpstreamConfig(base_url="https://example.com/", port=8443)},
routes=[],
)
route = RouteConfig(path="/api/v1/bus", methods=["GET"], upstream="demo")
Generated
+1 -1
View File
@@ -179,7 +179,7 @@ wheels = [
[[package]]
name = "openapi-api-gateway"
version = "0.1.0"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },