Merge pull request #8 from BlackRoad-OS/codex/setup-public-api-gateway-for-railway

Harden gateway config and upstream routing
This commit is contained in:
Alexa Amundson
2025-11-19 15:29:14 -06:00
committed by GitHub
12 changed files with 115 additions and 48 deletions

View File

@@ -65,13 +65,13 @@ app/
| Variable | Required | Description | | Variable | Required | Description |
| --- | --- | --- | | --- | --- | --- |
| `NODE_ENV` | No (default `development`) | Deployment environment label. | | `NODE_ENV` | No (default `development`) | Deployment environment label. |
| `PUBLIC_API_URL` | Yes | External URL for this gateway. | | `PUBLIC_API_URL` | Yes (non-dev) | External URL for this gateway. |
| `CORE_API_URL` | Yes | Upstream Core backend base URL. | | `CORE_API_URL` | Yes (non-dev) | Upstream Core backend base URL. |
| `AGENTS_API_URL` | No | Upstream Agents API base URL. | | `AGENTS_API_URL` | No | Upstream Agents API base URL. |
| `API_KEYS` | Yes | Comma-separated list of API keys authorized for `/v1` routes. | | `API_KEYS` | Yes (non-dev) | Comma-separated list of API keys authorized for `/v1` routes. |
| `RATE_LIMIT_WINDOW` | No | Optional rate limit window (seconds). | | `LOG_LEVEL` | No | Application log level (default `info`). |
| `RATE_LIMIT_MAX` | No | Optional rate limit max requests per window. | | `REQUEST_TIMEOUT_MS` | No | Default upstream request timeout in milliseconds (default `10000`). |
| `GIT_COMMIT` | No | Commit SHA used for `/version`. | | `GIT_COMMIT` / `RAILWAY_GIT_COMMIT_SHA` | No | Commit SHA used for `/version`. |
| `BUILD_TIME` | No | Build timestamp used for `/version`. | | `BUILD_TIME` | No | Build timestamp used for `/version`. |
## Railway deployment ## Railway deployment

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from app.clients.upstream import UpstreamClient
from app.config import Settings
def build_agents_client(settings: Settings) -> UpstreamClient:
return UpstreamClient(
"agents",
str(settings.agents_api_url) if settings.agents_api_url else None,
default_timeout=settings.request_timeout_seconds,
)

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from app.clients.upstream import UpstreamClient
from app.config import Settings
def build_core_client(settings: Settings) -> UpstreamClient:
return UpstreamClient(
"core",
str(settings.core_api_url) if settings.core_api_url else None,
default_timeout=settings.request_timeout_seconds,
)

View File

@@ -17,7 +17,7 @@ class UpstreamClient:
name (str): The name of the upstream service. name (str): The name of the upstream service.
base_url (Optional[str]): The base URL of the upstream service. base_url (Optional[str]): The base URL of the upstream service.
""" """
def __init__(self, name: str, base_url: Optional[str]): def __init__(self, name: str, base_url: Optional[str], default_timeout: float = DEFAULT_TIMEOUT):
""" """
Initialize an UpstreamClient. Initialize an UpstreamClient.
@@ -27,6 +27,7 @@ class UpstreamClient:
""" """
self.name = name self.name = name
self.base_url = base_url.rstrip("/") if base_url else None self.base_url = base_url.rstrip("/") if base_url else None
self.default_timeout = default_timeout
async def request(self, method: str, path: str, **kwargs) -> Dict[str, Any]: async def request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
""" """
@@ -51,7 +52,7 @@ class UpstreamClient:
raise UpstreamError(source=self.name, message=f"{self.name} upstream not configured", status_code=502) raise UpstreamError(source=self.name, message=f"{self.name} upstream not configured", status_code=502)
url = f"{self.base_url}/{path.lstrip('/')}" url = f"{self.base_url}/{path.lstrip('/')}"
timeout = kwargs.pop("timeout", DEFAULT_TIMEOUT) timeout = kwargs.pop("timeout", self.default_timeout)
try: try:
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
@@ -75,11 +76,3 @@ class UpstreamClient:
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise UpstreamError(self.name, message=f"{self.name} upstream request failed", details={"path": path}) from exc raise UpstreamError(self.name, message=f"{self.name} upstream request failed", details={"path": path}) from exc
def get_core_client(core_api_url: Optional[str]) -> UpstreamClient:
return UpstreamClient("core", core_api_url)
def get_agents_client(agents_api_url: Optional[str]) -> UpstreamClient:
return UpstreamClient("agents", agents_api_url)

View File

@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os
from datetime import datetime from datetime import datetime
from functools import lru_cache from functools import lru_cache
from typing import List, Optional from typing import List, Optional
from pydantic import AnyHttpUrl, Field, field_validator from pydantic import AnyHttpUrl, Field, field_validator, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -12,20 +13,31 @@ class Settings(BaseSettings):
"""Application configuration loaded from environment variables.""" """Application configuration loaded from environment variables."""
env: str = Field("development", alias="NODE_ENV") env: str = Field("development", alias="NODE_ENV")
public_api_url: AnyHttpUrl = Field(..., alias="PUBLIC_API_URL") public_api_url: Optional[AnyHttpUrl] = Field(None, alias="PUBLIC_API_URL")
core_api_url: AnyHttpUrl = Field(..., alias="CORE_API_URL") core_api_url: Optional[AnyHttpUrl] = Field(None, alias="CORE_API_URL")
agents_api_url: Optional[AnyHttpUrl] = Field(None, alias="AGENTS_API_URL") agents_api_url: Optional[AnyHttpUrl] = Field(None, alias="AGENTS_API_URL")
api_keys: List[str] = Field(default_factory=list, alias="API_KEYS") api_keys: List[str] = Field(default_factory=list, alias="API_KEYS")
app_version: str = "0.1.0" app_version: str = Field("0.1.0", alias="APP_VERSION")
build_time: str = Field(default_factory=lambda: datetime.utcnow().isoformat()) build_time: str = Field(
default_factory=lambda: datetime.utcnow().isoformat(), alias="BUILD_TIME"
)
commit: Optional[str] = Field(None, alias="GIT_COMMIT") commit: Optional[str] = Field(None, alias="GIT_COMMIT")
log_level: str = Field("info", alias="LOG_LEVEL")
port: int = Field(8000, alias="PORT")
request_timeout_ms: int = Field(10000, alias="REQUEST_TIMEOUT_MS")
model_config = { model_config = {
"case_sensitive": False, "case_sensitive": False,
"populate_by_name": True, "populate_by_name": True,
} }
@field_validator("commit", mode="before")
@classmethod
def prefer_railway_commit(cls, value: Optional[str]) -> Optional[str]:
return value or os.getenv("RAILWAY_GIT_COMMIT_SHA")
@field_validator("api_keys", mode="before") @field_validator("api_keys", mode="before")
@classmethod @classmethod
def split_keys(cls, value: Optional[str]) -> List[str]: def split_keys(cls, value: Optional[str]) -> List[str]:
@@ -35,6 +47,22 @@ class Settings(BaseSettings):
return [key.strip() for key in value.split(",") if key.strip()] return [key.strip() for key in value.split(",") if key.strip()]
return value return value
@model_validator(mode="after")
def validate_required(cls, values: "Settings") -> "Settings":
if values.env.lower() != "development":
missing = []
if not values.public_api_url:
missing.append("PUBLIC_API_URL")
if not values.core_api_url:
missing.append("CORE_API_URL")
if not values.api_keys:
missing.append("API_KEYS")
if missing:
raise ValueError(
f"Missing required environment variables: {', '.join(missing)}"
)
return values
@property @property
def core_configured(self) -> bool: def core_configured(self) -> bool:
return bool(self.core_api_url) return bool(self.core_api_url)
@@ -43,6 +71,10 @@ class Settings(BaseSettings):
def agents_configured(self) -> bool: def agents_configured(self) -> bool:
return bool(self.agents_api_url) return bool(self.agents_api_url)
@property
def request_timeout_seconds(self) -> float:
return max(self.request_timeout_ms, 0) / 1000
@lru_cache() @lru_cache()
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -6,7 +6,13 @@ from fastapi import HTTPException
class UpstreamError(HTTPException): class UpstreamError(HTTPException):
def __init__(self, source: str, status_code: int = 502, message: str = "Upstream service error", details: Optional[Dict[str, Any]] = None): def __init__(
self,
source: str,
status_code: int = 502,
message: str = "Upstream service error",
details: Optional[Dict[str, Any]] = None,
):
self.source = source self.source = source
self.details = details or {} self.details = details or {}
super().__init__(status_code=status_code, detail=message) super().__init__(status_code=status_code, detail=message)

View File

@@ -17,7 +17,10 @@ def api_key_auth(
api_keys: List[str] = Depends(get_api_keys), api_keys: List[str] = Depends(get_api_keys),
): ):
if not api_keys: if not api_keys:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key required") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "UNAUTHORIZED", "message": "Invalid or missing API key."},
)
provided_key = x_api_key provided_key = x_api_key
if not provided_key and authorization and authorization.startswith("Bearer "): if not provided_key and authorization and authorization.startswith("Bearer "):
@@ -26,4 +29,7 @@ def api_key_auth(
if provided_key in api_keys: if provided_key in api_keys:
return True return True
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "UNAUTHORIZED", "message": "Invalid or missing API key."},
)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette import status from starlette import status
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -20,6 +20,17 @@ class ErrorHandlerMiddleware(BaseHTTPMiddleware):
request_id=getattr(request.state, "request_id", None), request_id=getattr(request.state, "request_id", None),
) )
return JSONResponse(status_code=exc.status_code, content=payload) return JSONResponse(status_code=exc.status_code, content=payload)
except HTTPException as exc:
detail = exc.detail if isinstance(exc.detail, dict) else None
code = detail.get("code") if detail is not None else "BAD_REQUEST"
message = detail.get("message") if detail is not None else str(exc.detail)
payload = build_error_response(
code=code or "BAD_REQUEST",
message=message or "Request error",
details=detail.get("details") if detail is not None else None,
request_id=getattr(request.state, "request_id", None),
)
return JSONResponse(status_code=exc.status_code, content=payload)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
payload = build_error_response( payload = build_error_response(
code="INTERNAL_ERROR", code="INTERNAL_ERROR",

View File

@@ -4,14 +4,12 @@ from typing import Any, Dict
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from app.clients.upstream import UpstreamClient, get_agents_client from app.clients.agents_client import build_agents_client
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.middleware.auth import api_key_auth router = APIRouter()
router = APIRouter(dependencies=[Depends(api_key_auth)])
@router.get("/agents/health", summary="Proxy agents health", status_code=status.HTTP_200_OK) @router.get("/agents/ping", summary="Proxy agents ping", status_code=status.HTTP_200_OK)
async def agents_health(settings: Settings = Depends(get_settings)) -> Dict[str, Any]: async def agents_ping(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
agents_client: UpstreamClient = get_agents_client(settings.agents_api_url) agents_client = build_agents_client(settings)
return await agents_client.request("GET", "/health") return await agents_client.request("GET", "/ping")

View File

@@ -4,16 +4,12 @@ from typing import Any, Dict
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from app.clients.upstream import UpstreamClient, get_core_client from app.clients.core_client import build_core_client
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.middleware.auth import api_key_auth router = APIRouter()
router = APIRouter(dependencies=[Depends(api_key_auth)])
@router.get("/core/health", summary="Proxy core health", status_code=status.HTTP_200_OK) @router.get("/core/ping", summary="Proxy core ping", status_code=status.HTTP_200_OK)
async def core_health( async def core_ping(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
settings: Settings = Depends(get_settings), core_client = build_core_client(settings)
) -> Dict[str, Any]: return await core_client.request("GET", "/ping")
core_client: UpstreamClient = get_core_client(settings.core_api_url)
return await core_client.request("GET", "/health")

View File

@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.middleware.auth import api_key_auth
from app.routes.v1 import agents, core, health from app.routes.v1 import agents, core, health
router = APIRouter(prefix="/v1") router = APIRouter(prefix="/v1")
router.include_router(health.router) router.include_router(health.router)
router.include_router(core.router) router.include_router(core.router, dependencies=[Depends(api_key_auth)])
router.include_router(agents.router) router.include_router(agents.router, dependencies=[Depends(api_key_auth)])

View File

@@ -14,7 +14,7 @@
"CORE_API_URL", "CORE_API_URL",
"AGENTS_API_URL", "AGENTS_API_URL",
"API_KEYS", "API_KEYS",
"RATE_LIMIT_WINDOW", "LOG_LEVEL",
"RATE_LIMIT_MAX" "REQUEST_TIMEOUT_MS"
] ]
} }