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:
- `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:
```bash
@@ -215,6 +215,7 @@ make stop
- `CONTAINER_ENGINE=docker` (or `podman`)
- `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`
+1 -1
View File
@@ -13,7 +13,7 @@ settings:
upstreams:
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
timeout_seconds: 20
+31
View File
@@ -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
@@ -87,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():
@@ -96,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:
+62
View File
@@ -74,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"))