Merge pull request #3 from BlackRoad-OS/codex/setup-blackroad-api-as-public-api-gateway

Add FastAPI public API gateway scaffolding and deployment
This commit is contained in:
Alexa Amundson
2025-11-19 14:13:47 -06:00
committed by GitHub
21 changed files with 537 additions and 26 deletions

44
.github/workflows/deploy-api.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Deploy Public API Gateway
on:
push:
branches:
- dev
- staging
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
RAILWAY_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'prod' || github.ref == 'refs/heads/staging' && 'staging' || 'dev' }}
DEPLOY_URL: ${{ github.ref == 'refs/heads/main' && 'https://api.blackroad.systems' || github.ref == 'refs/heads/staging' && 'https://staging.api.blackroad.systems' || 'https://dev.api.blackroad.systems' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Deploy to Railway
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
GIT_COMMIT: ${{ github.sha }}
run: |
railway login --token "$RAILWAY_TOKEN"
railway up --service public-api --project blackroad-core --environment "$RAILWAY_ENVIRONMENT" --detached
- name: Health check
run: |
curl --fail --retry 3 --retry-delay 5 "$DEPLOY_URL/health"
curl --fail --retry 3 --retry-delay 5 "$DEPLOY_URL/v1/health"

View File

@@ -36,3 +36,81 @@ Sample tasks:
* Route coverage expansion * Route coverage expansion
* Agent protocol endpoints * Agent protocol endpoints
* Pocket OS API handler * Pocket OS API handler
---
## Repository overview
This repository implements the BlackRoad public API gateway using FastAPI. It provides:
- `/health` and `/version` liveness + build metadata.
- Versioned `/v1` surface with API key authentication by default.
- Proxy-style routes for core and agents upstreams with shared error handling.
- Consistent configuration via environment variables.
Project structure:
```
app/
config.py # Environment-driven settings
main.py # FastAPI entrypoint
errors.py # Common error helpers
clients/ # Upstream HTTP clients
middleware/ # Request ID, auth, and error handling middleware
routes/ # Root and versioned routers
```
## Environment variables
| Variable | Required | Description |
| --- | --- | --- |
| `NODE_ENV` | No (default `development`) | Deployment environment label. |
| `PUBLIC_API_URL` | Yes | External URL for this gateway. |
| `CORE_API_URL` | Yes | Upstream Core backend base URL. |
| `AGENTS_API_URL` | No | Upstream Agents API base URL. |
| `API_KEYS` | Yes | Comma-separated list of API keys authorized for `/v1` routes. |
| `RATE_LIMIT_WINDOW` | No | Optional rate limit window (seconds). |
| `RATE_LIMIT_MAX` | No | Optional rate limit max requests per window. |
| `GIT_COMMIT` | No | Commit SHA used for `/version`. |
| `BUILD_TIME` | No | Build timestamp used for `/version`. |
## Railway deployment
- **Railway project**: `blackroad-core`
- **Service name**: `public-api`
### Commands
- **Build**: `pip install -r requirements.txt`
- **Start**: `uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}`
### Environment mappings
- **dev**
- `PUBLIC_API_URL` = dev Railway URL or `https://dev.api.blackroad.systems`
- `CORE_API_URL` = core dev Railway URL
- `AGENTS_API_URL` = agents dev Railway URL
- **staging**
- `PUBLIC_API_URL` = `https://staging.api.blackroad.systems`
- `CORE_API_URL` = `https://staging.core.blackroad.systems`
- `AGENTS_API_URL` = `https://staging.agents.blackroad.systems`
- **prod**
- `PUBLIC_API_URL` = `https://api.blackroad.systems`
- `CORE_API_URL` = `https://core.blackroad.systems`
- `AGENTS_API_URL` = `https://agents.blackroad.systems`
### DNS (Cloudflare)
- `api.blackroad.systems` → CNAME `public-api-prod.up.railway.app` (proxied)
- `staging.api.blackroad.systems` → CNAME `public-api-staging.up.railway.app` (proxied)
- `dev.api.blackroad.systems` → CNAME `public-api-dev.up.railway.app` (proxied)
### CI/CD
GitHub Actions workflow `.github/workflows/deploy-api.yml` deploys to Railway:
- `dev` branch → Railway `dev`
- `staging` branch → Railway `staging`
- `main` branch → Railway `prod`
After deployment, the workflow runs health checks against `/health` and `/v1/health`.

2
app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

0
app/clients/__init__.py Normal file
View File

85
app/clients/upstream.py Normal file
View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from typing import Any, Dict, Optional
import httpx
from app.errors import UpstreamError
DEFAULT_TIMEOUT = 10.0
class UpstreamClient:
"""
A client for making HTTP requests to an upstream service.
Attributes:
name (str): The name of the upstream service.
base_url (Optional[str]): The base URL of the upstream service.
"""
def __init__(self, name: str, base_url: Optional[str]):
"""
Initialize an UpstreamClient.
Args:
name (str): The name of the upstream service.
base_url (Optional[str]): The base URL of the upstream service.
"""
self.name = name
self.base_url = base_url.rstrip("/") if base_url else None
async def request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
"""
Make an HTTP request to the upstream service.
Args:
method (str): The HTTP method (e.g., "GET", "POST").
path (str): The path to append to the base URL.
**kwargs: Additional arguments to pass to httpx.AsyncClient.request.
Common options include 'params', 'json', 'headers', etc.
'timeout' (float, optional): Request timeout in seconds (default: 10.0).
Returns:
Dict[str, Any]: The response data. If the response is JSON, returns the parsed JSON.
Otherwise, returns a dict with 'status_code' and 'content'.
Raises:
UpstreamError: If the upstream is not configured, times out, returns an error status,
or if the request fails for any reason.
"""
if not self.base_url:
raise UpstreamError(source=self.name, message=f"{self.name} upstream not configured", status_code=502)
url = f"{self.base_url}/{path.lstrip('/')}"
timeout = kwargs.pop("timeout", DEFAULT_TIMEOUT)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.request(method, url, **kwargs)
response.raise_for_status()
if response.headers.get("content-type", "").startswith("application/json"):
return response.json()
return {
"status_code": response.status_code,
"content": response.text,
"content_type": response.headers.get("content-type", ""),
}
except httpx.TimeoutException as exc:
raise UpstreamError(self.name, message=f"{self.name} upstream timeout", details={"path": path}) from exc
except httpx.HTTPStatusError as exc:
raise UpstreamError(
self.name,
status_code=exc.response.status_code,
message=f"{self.name} upstream returned {exc.response.status_code}",
details={"path": path},
)
except httpx.HTTPError as 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)

49
app/config.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import datetime
from functools import lru_cache
from typing import List, Optional
from pydantic import AnyHttpUrl, Field, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application configuration loaded from environment variables."""
env: str = Field("development", alias="NODE_ENV")
public_api_url: AnyHttpUrl = Field(..., alias="PUBLIC_API_URL")
core_api_url: AnyHttpUrl = Field(..., alias="CORE_API_URL")
agents_api_url: Optional[AnyHttpUrl] = Field(None, alias="AGENTS_API_URL")
api_keys: List[str] = Field(default_factory=list, alias="API_KEYS")
app_version: str = "0.1.0"
build_time: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
commit: Optional[str] = Field(None, alias="GIT_COMMIT")
model_config = {
"case_sensitive": False,
"populate_by_name": True,
}
@field_validator("api_keys", mode="before")
@classmethod
def split_keys(cls, value: Optional[str]) -> List[str]:
if value in (None, ""):
return []
if isinstance(value, str):
return [key.strip() for key in value.split(",") if key.strip()]
return value
@property
def core_configured(self) -> bool:
return bool(self.core_api_url)
@property
def agents_configured(self) -> bool:
return bool(self.agents_api_url)
@lru_cache()
def get_settings() -> Settings:
return Settings()

26
app/errors.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import HTTPException
class UpstreamError(HTTPException):
def __init__(self, source: str, status_code: int = 502, message: str = "Upstream service error", details: Optional[Dict[str, Any]] = None):
self.source = source
self.details = details or {}
super().__init__(status_code=status_code, detail=message)
def build_error_response(code: str, message: str, request_id: Optional[str], details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
response: Dict[str, Any] = {
"error": {
"code": code,
"message": message,
}
}
if details:
response["error"]["details"] = details
if request_id:
response["error"]["requestId"] = request_id
return response

29
app/main.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.middleware.errors import ErrorHandlerMiddleware
from app.middleware.request_id import RequestIdMiddleware
from app.routes import root
from app.routes.v1.router import router as v1_router
app = FastAPI(title="BlackRoad Public API Gateway", version="0.1.0")
app.add_middleware(ErrorHandlerMiddleware)
app.add_middleware(RequestIdMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
app.include_router(root.router)
app.include_router(v1_router)
@app.get("/", include_in_schema=False)
async def index():
return {"message": "BlackRoad public API gateway", "docs": "/docs"}

View File

29
app/middleware/auth.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import List, Optional
from fastapi import Depends, Header, HTTPException, status
from app.config import get_settings
def get_api_keys() -> List[str]:
return get_settings().api_keys
def api_key_auth(
x_api_key: Optional[str] = Header(None, alias="x-api-key"),
authorization: Optional[str] = Header(None),
api_keys: List[str] = Depends(get_api_keys),
):
if not api_keys:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key required")
provided_key = x_api_key
if not provided_key and authorization and authorization.startswith("Bearer "):
provided_key = authorization.split(" ", 1)[1]
if provided_key in api_keys:
return True
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")

30
app/middleware/errors.py Normal file
View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette import status
from starlette.middleware.base import BaseHTTPMiddleware
from app.errors import UpstreamError, build_error_response
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
try:
return await call_next(request)
except UpstreamError as exc:
payload = build_error_response(
code="UPSTREAM_ERROR",
message=exc.detail,
details={"source": exc.source, **exc.details},
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
payload = build_error_response(
code="INTERNAL_ERROR",
message=str(exc) or "Internal server error",
details=None,
request_id=getattr(request.state, "request_id", None),
)
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=payload)

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
import uuid
from typing import Callable
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable):
request.state.request_id = str(uuid.uuid4())
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
return response

0
app/routes/__init__.py Normal file
View File

30
app/routes/root.py Normal file
View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict
from fastapi import APIRouter, Depends
from app.config import Settings, get_settings
router = APIRouter()
@router.get("/health", summary="Gateway health check")
async def health(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
return {
"status": "ok",
"timestamp": datetime.utcnow().isoformat(),
"environment": settings.env,
}
@router.get("/version", summary="Gateway version info")
async def version(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
return {
"service": "public-api",
"appVersion": settings.app_version,
"commit": settings.commit,
"buildTime": settings.build_time,
"environment": settings.env,
}

View File

17
app/routes/v1/agents.py Normal file
View File

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

19
app/routes/v1/core.py Normal file
View File

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

23
app/routes/v1/health.py Normal file
View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict
from fastapi import APIRouter, Depends
from app.config import Settings, get_settings
router = APIRouter()
@router.get("/health", summary="Versioned health check")
async def health(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
return {
"status": "ok",
"timestamp": datetime.utcnow().isoformat(),
"environment": settings.env,
"upstreams": {
"coreConfigured": settings.core_configured,
"agentsConfigured": settings.agents_configured,
},
}

10
app/routes/v1/router.py Normal file
View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter
from app.routes.v1 import agents, core, health
router = APIRouter(prefix="/v1")
router.include_router(health.router)
router.include_router(core.router)
router.include_router(agents.router)

20
railway.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://railway.app/railway.schema.json",
"name": "public-api",
"project": "blackroad-core",
"service": {
"name": "public-api",
"envVar": "PORT",
"startCommand": "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}",
"buildCommand": "pip install -r requirements.txt"
},
"variables": [
"NODE_ENV",
"PUBLIC_API_URL",
"CORE_API_URL",
"AGENTS_API_URL",
"API_KEYS",
"RATE_LIMIT_WINDOW",
"RATE_LIMIT_MAX"
]
}

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic>=2.6.0
pydantic-settings>=2.2.1