mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 18:26:56 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf2ee4682 | ||
|
|
21dee4cd37 | ||
|
|
e86074ac21 | ||
|
|
fc947437c4 | ||
|
|
c0968b2fd1 |
+2
-1
@@ -1,2 +1,3 @@
|
||||
DATAMALL_API_KEY=replace-with-your-lta-datamall-account-key
|
||||
PORT=8000
|
||||
PORT=8067
|
||||
GATEWAY_PORT=8067
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
|
||||
+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,6 +5,7 @@ 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
|
||||
@@ -46,14 +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) using $(GATEWAY_CONFIG_PATH)"
|
||||
@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) uv run uvicorn gateway_framework.main:app --host 0.0.0.0 --port $(PORT) --reload --app-dir src
|
||||
GATEWAY_CONFIG_PATH=$(GATEWAY_CONFIG_PATH) GATEWAY_PORT=$(GATEWAY_PORT) uv run python -m gateway_framework.main
|
||||
|
||||
stop:
|
||||
-$(CONTAINER_ENGINE) rm -f $(CONTAINER_NAME)
|
||||
@@ -74,15 +76,15 @@ check-companion-parity:
|
||||
uv run python -m gateway_framework.companion --config $(GATEWAY_CONFIG_PATH) --openapi $(COMPANION_OPENAPI)
|
||||
|
||||
compose-up:
|
||||
docker compose -f $(COMPOSE_FILE) up --build -d lta-datamall-api
|
||||
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) up --build -d lta-datamall-api
|
||||
$(MAKE) check-companion-parity CONFIG_PROFILE=container
|
||||
docker compose -f $(COMPOSE_FILE) up --build -d gateway
|
||||
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) up --build -d gateway
|
||||
|
||||
compose-down:
|
||||
docker compose -f $(COMPOSE_FILE) down --remove-orphans
|
||||
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) down --remove-orphans
|
||||
|
||||
compose-logs:
|
||||
docker compose -f $(COMPOSE_FILE) logs -f
|
||||
$(CONTAINER_ENGINE) compose -f $(COMPOSE_FILE) logs -f
|
||||
|
||||
fmt:
|
||||
uv run ruff check .
|
||||
|
||||
@@ -39,7 +39,7 @@ uv sync
|
||||
|
||||
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.
|
||||
- `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
|
||||
@@ -49,6 +49,12 @@ 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`.
|
||||
|
||||
@@ -82,6 +88,8 @@ 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.
|
||||
@@ -95,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
|
||||
@@ -125,6 +134,7 @@ Notes:
|
||||
- `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`
|
||||
@@ -203,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`
|
||||
@@ -211,7 +223,7 @@ make stop
|
||||
Example:
|
||||
```bash
|
||||
make build CONTAINER_ENGINE=docker IMAGE_TAG=dev
|
||||
make run CONTAINER_ENGINE=docker PORT=8080 ENV_FILE=.env CONFIG_PROFILE=local
|
||||
make run CONTAINER_ENGINE=docker PORT=8080 GATEWAY_PORT=8080 ENV_FILE=.env CONFIG_PROFILE=local
|
||||
```
|
||||
|
||||
## GitHub Packages (GHCR)
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -12,8 +13,8 @@ settings:
|
||||
|
||||
upstreams:
|
||||
lta_datamall:
|
||||
base_url: http://lta-datamall-api/
|
||||
port: 8000
|
||||
base_url: http://${GATEWAY_UPSTREAM_HOST:-host.docker.internal}/
|
||||
port: 8068
|
||||
timeout_seconds: 20
|
||||
|
||||
routes:
|
||||
|
||||
@@ -2,6 +2,7 @@ settings:
|
||||
title: LTA DataMall API Gateway
|
||||
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
|
||||
|
||||
@@ -18,5 +18,6 @@ services:
|
||||
- lta-datamall-api
|
||||
environment:
|
||||
GATEWAY_CONFIG_PATH: config/gateway.container.yaml
|
||||
GATEWAY_PORT: ${PORT:-8000}
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
- "${PORT:-8000}:${PORT:-8000}"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -12,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)
|
||||
@@ -86,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():
|
||||
@@ -95,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
+65
-1
@@ -31,6 +31,7 @@ routes:
|
||||
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:
|
||||
@@ -73,6 +74,68 @@ routes:
|
||||
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"))
|
||||
|
||||
@@ -94,4 +157,5 @@ def test_container_gateway_config_matches_local_route_set() -> None:
|
||||
assert {route.path for route in local_config.routes} == {
|
||||
route.path for route in container_config.routes
|
||||
}
|
||||
assert container_config.upstreams["lta_datamall"].resolved_base_url() == "http://lta-datamall-api:8000/"
|
||||
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
|
||||
Reference in New Issue
Block a user