mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 18:26:56 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93876f130f | ||
|
|
fc947437c4 | ||
|
|
c0968b2fd1 | ||
|
|
9b43ed73cf | ||
|
|
e750460956 | ||
|
|
3c948c5601 | ||
|
|
6d905fcb7c | ||
|
|
8d90d49afc | ||
|
|
d1e3ce76be | ||
|
|
d428f4f321 | ||
|
|
472ff5e205 |
@@ -0,0 +1,3 @@
|
||||
DATAMALL_API_KEY=replace-with-your-lta-datamall-account-key
|
||||
PORT=8067
|
||||
GATEWAY_PORT=8067
|
||||
@@ -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@v4
|
||||
|
||||
- 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 }}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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 .
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user