feat: enhance gateway configuration to support environment variable placeholders for upstream host

This commit is contained in:
2026-06-01 15:17:50 +08:00
parent e86074ac21
commit 21dee4cd37
4 changed files with 96 additions and 2 deletions
+2 -1
View File
@@ -39,7 +39,7 @@ uv sync
3. Choose a gateway profile: 3. Choose a gateway profile:
- `config/gateway.yaml` targets a locally running companion backend on `127.0.0.1:8068`. - `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`. - `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: 4. Run the gateway with the managed environment:
```bash ```bash
@@ -215,6 +215,7 @@ make stop
- `CONTAINER_ENGINE=docker` (or `podman`) - `CONTAINER_ENGINE=docker` (or `podman`)
- `PORT=8067` - `PORT=8067`
- `GATEWAY_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_NAME=openapi-api-gateway`
- `IMAGE_TAG=latest` - `IMAGE_TAG=latest`
- `ENV_FILE=.env` - `ENV_FILE=.env`
+1 -1
View File
@@ -13,7 +13,7 @@ settings:
upstreams: upstreams:
lta_datamall: lta_datamall:
base_url: http://host.docker.internal/ # Podman: use http://host.containers.internal/ base_url: http://${GATEWAY_UPSTREAM_HOST:-host.docker.internal}/
port: 8068 port: 8068
timeout_seconds: 20 timeout_seconds: 20
+31
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import os
import re
from pathlib import Path from pathlib import Path
from typing import Any
from typing import Literal from typing import Literal
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
@@ -87,6 +90,32 @@ class GatewayConfig(BaseModel):
routes: list[RouteConfig] 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: def load_gateway_config(config_path: str | Path) -> GatewayConfig:
path = Path(config_path) path = Path(config_path)
if not path.exists(): if not path.exists():
@@ -96,6 +125,8 @@ def load_gateway_config(config_path: str | Path) -> GatewayConfig:
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise ValueError("Gateway config must be a YAML object") raise ValueError("Gateway config must be a YAML object")
raw = _resolve_env_placeholders(raw)
try: try:
config = GatewayConfig.model_validate(raw) config = GatewayConfig.model_validate(raw)
except ValidationError as exc: except ValidationError as exc:
+62
View File
@@ -74,6 +74,68 @@ routes:
load_gateway_config(config_file) 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: def test_sample_gateway_config_matches_companion_backend_route_set() -> None:
config = load_gateway_config(Path("config/gateway.yaml")) config = load_gateway_config(Path("config/gateway.yaml"))