Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub 9cfe01a681 build(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 06:38:56 +00:00
furyhawk fc947437c4 feat: update gateway configuration to support dynamic gateway port and enhance Docker integration 2026-06-01 11:26:46 +08:00
furyhawk c0968b2fd1 fix: update upstream base URL and port in gateway configuration 2026-06-01 10:55:45 +08:00
furyhawk 9b43ed73cf Release v0.2.1 2026-06-01 10:32:45 +08:00
furyhawk e750460956 feat: enhance gateway configuration and add companion backend integration with Docker Compose 2026-06-01 10:30:10 +08:00
furyhawk 3c948c5601 fix: update base URL for upstream service to use internal host 2026-06-01 10:11:13 +08:00
furyhawk 6d905fcb7c feat: add additional bus-related routes and passenger volume endpoint 2026-06-01 09:59:09 +08:00
furyhawk 8d90d49afc feat: add port configuration for upstreams and update proxy request handling 2026-06-01 09:53:58 +08:00
furyhawk d1e3ce76be feat: implement cache management endpoints and functionality 2026-06-01 00:01:09 +08:00
furyhawk d428f4f321 ci: support manual release tag for image publish 2026-05-31 23:52:43 +08:00
furyhawk 472ff5e205 ci: publish container image to ghcr 2026-05-31 23:52:25 +08:00
22 changed files with 763 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@v4
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 }}
+14
View File
@@ -1,5 +1,16 @@
# 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
@@ -15,6 +26,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`).
+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 .
+76 -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` targets the companion backend inside the bundled compose network, 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,8 @@ make stop
### Useful Variables
- `CONTAINER_ENGINE=docker` (or `podman`)
- `PORT=8080`
- `PORT=8067`
- `GATEWAY_PORT=8067`
- `IMAGE_NAME=openapi-api-gateway`
- `IMAGE_TAG=latest`
- `ENV_FILE=.env`
@@ -162,5 +222,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://host.containers.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.2.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.2.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())
+17
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit, urlunsplit
import yaml
from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError, field_validator
@@ -11,6 +12,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 +24,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
+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"] == {}
+32
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,28 @@ 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 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" },