From 21dee4cd375679fefd0f255b7be50384a6ffbd5c Mon Sep 17 00:00:00 2001 From: furyhawk Date: Mon, 1 Jun 2026 15:17:50 +0800 Subject: [PATCH] feat: enhance gateway configuration to support environment variable placeholders for upstream host --- README.md | 3 +- config/gateway.container.yaml | 2 +- src/gateway_framework/config.py | 31 +++++++++++++++++ tests/test_config.py | 62 +++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 59dce89..f960bcf 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/config/gateway.container.yaml b/config/gateway.container.yaml index 4e080f2..6cf3354 100644 --- a/config/gateway.container.yaml +++ b/config/gateway.container.yaml @@ -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 diff --git a/src/gateway_framework/config.py b/src/gateway_framework/config.py index fa2ce49..28101fc 100644 --- a/src/gateway_framework/config.py +++ b/src/gateway_framework/config.py @@ -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: diff --git a/tests/test_config.py b/tests/test_config.py index 8c6147c..44530f0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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"))