diff --git a/.env.example b/.env.example index 21c3fb7..fdf070f 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DATAMALL_API_KEY=replace-with-your-lta-datamall-account-key -PORT=8000 +PORT=8067 +GATEWAY_PORT=8067 diff --git a/Dockerfile b/Dockerfile index 856180b..31566dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index d33fa8a..59566ef 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index accc657..59dce89 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. +- `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 @@ -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,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` @@ -211,7 +222,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) diff --git a/config/gateway.container.yaml b/config/gateway.container.yaml index 555a147..73344ea 100644 --- a/config/gateway.container.yaml +++ b/config/gateway.container.yaml @@ -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 diff --git a/config/gateway.yaml b/config/gateway.yaml index a25340c..e7178ee 100644 --- a/config/gateway.yaml +++ b/config/gateway.yaml @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 20c5aad..6a758f9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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}" diff --git a/src/gateway_framework/config.py b/src/gateway_framework/config.py index bf90e18..fa2ce49 100644 --- a/src/gateway_framework/config.py +++ b/src/gateway_framework/config.py @@ -12,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) diff --git a/src/gateway_framework/main.py b/src/gateway_framework/main.py index 793f52d..64e57fb 100644 --- a/src/gateway_framework/main.py +++ b/src/gateway_framework/main.py @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py index 5680c1a..8c6147c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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: @@ -94,4 +95,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 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..62ed473 --- /dev/null +++ b/tests/test_main.py @@ -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