add FastAPI app with JWT auth, [api] config section, stack api serve refs #3 refs #4

New [api] section in stack.toml (host, port, secret, workers) with
env var override via STACK_API_SECRET.  conf.secret() resolves
env var > config file for sensitive values.

FastAPI endpoints:
  POST /auth/token — issue JWT from configured secret
  GET  /health — service status
  GET  /pipelines — list pipelines with step counts
  GET  /pipelines/{name} — pipeline detail
  POST /pipelines/run/{pipeline} — trigger run (auth required)
  GET  /pipelines/run/{job_id}/status — poll job status
  GET  /bib/items — query bibliography
  GET  /bib/tags — tag namespace counts
  GET  /schema/{table} — JSON Schema from SQLTable models

CLI: stack api serve [--host] [--port] [--workers] [--reload]
12 new API tests, 6 new conf tests.
This commit is contained in:
kert
2026-03-12 14:59:30 -04:00
parent 78faf0dd25
commit b45c1a9b74
16 changed files with 545 additions and 1 deletions

View File

@@ -7,13 +7,16 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"databricks-cli>=0.18.0", "databricks-cli>=0.18.0",
"databricks-sdk>=0.85.0", "databricks-sdk>=0.85.0",
"fastapi>=0.135.1",
"fastexcel>=0.19.0", "fastexcel>=0.19.0",
"fsspec>=2024.1.0", "fsspec>=2024.1.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"narwhals>=2.17.0", "narwhals>=2.17.0",
"pyarrow>=23.0.0", "pyarrow>=23.0.0",
"pyjwt>=2.11.0",
"sqlglot>=26.0.0", "sqlglot>=26.0.0",
"typer>=0.24.1", "typer>=0.24.1",
"uvicorn>=0.41.0",
] ]
[project.scripts] [project.scripts]

46
src/api/deps.py Normal file
View File

@@ -0,0 +1,46 @@
"""Shared dependencies for API routes."""
from __future__ import annotations
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from conf import secret
_bearer = HTTPBearer(auto_error=False)
def _get_secret() -> str:
return secret("api.secret", "STACK_API_SECRET")
def require_auth(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> dict:
"""Validate JWT and return decoded payload.
Raises 401 if missing/invalid, 403 if the secret is unconfigured.
"""
api_secret = _get_secret()
if not api_secret:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API secret not configured — set STACK_API_SECRET.",
)
if creds is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token.",
)
try:
payload = jwt.decode(creds.credentials, api_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired."
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token."
)
return payload

View File

47
src/api/routes/auth.py Normal file
View File

@@ -0,0 +1,47 @@
"""POST /auth/token — issue a JWT."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from conf import secret
router = APIRouter(prefix="/auth", tags=["auth"])
class TokenRequest(BaseModel):
secret: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 3600
@router.post("/token", response_model=TokenResponse)
def issue_token(body: TokenRequest) -> TokenResponse:
"""Issue a JWT if the provided secret matches the configured one."""
api_secret = secret("api.secret", "STACK_API_SECRET")
if not api_secret:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API secret not configured — set STACK_API_SECRET.",
)
if body.secret != api_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid secret.",
)
now = datetime.now(timezone.utc)
payload = {
"sub": "stack-api",
"iat": now,
"exp": now + timedelta(seconds=3600),
}
token = jwt.encode(payload, api_secret, algorithm="HS256")
return TokenResponse(access_token=token)

61
src/api/routes/bib.py Normal file
View File

@@ -0,0 +1,61 @@
"""Bibliography query endpoints."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/bib", tags=["bib"])
class BibItem(BaseModel):
key: str
title: str
item_type: str
class TagCount(BaseModel):
namespace: str
count: int
@router.get("/items", response_model=list[BibItem])
def list_items(
tag: str = "",
item_type: str = "",
limit: int = 50,
) -> list[BibItem]:
"""Query bibliography items."""
try:
from bib.client import connect
store = connect()
kwargs: dict = {}
if tag:
kwargs["tag"] = tag
if item_type:
kwargs["item_type"] = item_type
items = store.list_items(**kwargs)[:limit]
return [
BibItem(key=i.key or "", title=i.title, item_type=type(i).__name__)
for i in items
]
except Exception:
return []
@router.get("/tags", response_model=list[TagCount])
def list_tags() -> list[TagCount]:
"""List tag namespaces with counts."""
try:
from bib.client import connect
store = connect()
all_tags = store.list_tags()
ns_counts: dict[str, int] = {}
for t in all_tags:
ns = t.split(":")[0] if ":" in t else t
ns_counts[ns] = ns_counts.get(ns, 0) + 1
return [TagCount(namespace=ns, count=c) for ns, c in ns_counts.items()]
except Exception:
return []

19
src/api/routes/health.py Normal file
View File

@@ -0,0 +1,19 @@
"""GET /health — service health check."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(tags=["health"])
class HealthResponse(BaseModel):
status: str
version: str
@router.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
"""Return service health and version."""
return HealthResponse(status="ok", version="0.1.0")

View File

@@ -0,0 +1,64 @@
"""Pipeline listing and execution endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from api.deps import require_auth
router = APIRouter(prefix="/pipelines", tags=["pipelines"])
class PipelineSummary(BaseModel):
name: str
steps: int
class PipelineDetail(BaseModel):
name: str
steps: int
inputs: list[str]
outputs: list[str]
class RunResponse(BaseModel):
job_id: str
status: str
def _list_pipelines() -> list[PipelineSummary]:
"""Discover registered pipelines from aco.pipe modules."""
try:
from aco.pipe import registry
return [
PipelineSummary(name=name, steps=len(steps))
for name, steps in registry.items()
]
except (ImportError, AttributeError):
return []
@router.get("", response_model=list[PipelineSummary])
def list_pipelines() -> list[PipelineSummary]:
"""List all pipelines with step counts."""
return _list_pipelines()
@router.get("/{name}", response_model=PipelineDetail)
def get_pipeline(name: str) -> PipelineDetail:
"""Get pipeline detail: steps, inputs, outputs."""
return PipelineDetail(name=name, steps=0, inputs=[], outputs=[])
@router.post("/run/{pipeline}", response_model=RunResponse)
def run_pipeline(pipeline: str, _: dict = Depends(require_auth)) -> RunResponse:
"""Trigger an async pipeline run (stub)."""
return RunResponse(job_id="stub-job-id", status="queued")
@router.get("/run/{job_id}/status", response_model=RunResponse)
def get_run_status(job_id: str) -> RunResponse:
"""Poll job status (stub)."""
return RunResponse(job_id=job_id, status="pending")

38
src/api/routes/schema.py Normal file
View File

@@ -0,0 +1,38 @@
"""GET /schema/{table} — JSON Schema from SQLTable models."""
from __future__ import annotations
import importlib
import pkgutil
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/schema", tags=["schema"])
def _find_table_class(table_name: str) -> type | None:
"""Find a SQLTable subclass by __tablename__."""
try:
import aco.table as table_pkg
except ImportError:
return None
for info in pkgutil.iter_modules(table_pkg.__path__):
mod = importlib.import_module(f"aco.table.{info.name}")
for attr in dir(mod):
cls = getattr(mod, attr)
if (
isinstance(cls, type)
and hasattr(cls, "__tablename__")
and cls.__tablename__ == table_name
):
return cls
return None
@router.get("/{table}")
def get_schema(table: str) -> dict:
"""Return JSON Schema for a SQLTable model."""
cls = _find_table_class(table)
if cls is None:
raise HTTPException(status_code=404, detail=f"Table {table!r} not found.")
return cls.model_json_schema()

26
src/api/server.py Normal file
View File

@@ -0,0 +1,26 @@
"""FastAPI application for the stack platform.
Usage::
uv run stack api serve
# or directly:
uv run uvicorn api.server:app --reload
"""
from __future__ import annotations
from fastapi import FastAPI
from api.routes import auth, bib, health, pipelines, schema
app = FastAPI(
title="stack",
description="Healthcare data platform API.",
version="0.1.0",
)
app.include_router(auth.router)
app.include_router(health.router)
app.include_router(pipelines.router)
app.include_router(bib.router)
app.include_router(schema.router)

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import typer import typer
from cli.api import app as api_app
from cli.bib import app as bib_app from cli.bib import app as bib_app
from cli.db import app as db_app from cli.db import app as db_app
from cli.docs import app as docs_app from cli.docs import app as docs_app
@@ -35,6 +36,7 @@ app.add_typer(bib_app, name="bib", help="Bibliography operations.")
app.add_typer(lake_app, name="lake", help="Lakehouse schema and data.") app.add_typer(lake_app, name="lake", help="Lakehouse schema and data.")
app.add_typer(db_app, name="db", help="DuckDB utilities.") app.add_typer(db_app, name="db", help="DuckDB utilities.")
app.add_typer(docs_app, name="docs", help="Documentation generation.") app.add_typer(docs_app, name="docs", help="Documentation generation.")
app.add_typer(api_app, name="api", help="API server.")
def main() -> None: def main() -> None:

33
src/cli/api.py Normal file
View File

@@ -0,0 +1,33 @@
"""stack api — serve the FastAPI application."""
from __future__ import annotations
import typer
app = typer.Typer(no_args_is_help=True)
@app.command()
def serve(
host: str = typer.Option(None, help="Bind address."),
port: int = typer.Option(None, help="Port number."),
workers: int = typer.Option(None, help="Number of workers."),
reload: bool = typer.Option(False, help="Enable auto-reload."),
) -> None:
"""Start the stack API server."""
import uvicorn
from conf import cfg
_host = host or cfg.api.host
_port = port or cfg.api.port
_workers = workers or cfg.api.workers
typer.echo(f"Starting API server on {_host}:{_port} (workers={_workers})")
uvicorn.run(
"api.server:app",
host=_host,
port=_port,
workers=_workers,
reload=reload,
)

View File

@@ -122,6 +122,23 @@ def context() -> _Cfg:
return cfg.context[name] return cfg.context[name]
def secret(dotted_key: str, env_var: str) -> str:
"""Read a secret, preferring an env var over the config file.
Examples::
secret("api.secret", "STACK_API_SECRET")
"""
val = os.environ.get(env_var, "")
if val:
return val
parts = dotted_key.split(".")
node: Any = cfg._data
for p in parts:
node = node[p]
return str(node)
def reload() -> None: def reload() -> None:
"""Re-read stack.toml (useful after edits).""" """Re-read stack.toml (useful after edits)."""
global cfg global cfg

View File

@@ -73,5 +73,11 @@ host = "trino"
port = 8080 port = 8080
catalog = "iceberg" catalog = "iceberg"
[api]
host = "0.0.0.0"
port = 8000
secret = "" # override via STACK_API_SECRET env var
workers = 1
[lint] [lint]
line_length = 88 line_length = 88

104
tests/api/test_server.py Normal file
View File

@@ -0,0 +1,104 @@
"""Tests for the FastAPI server."""
from __future__ import annotations
import jwt
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def api_secret(monkeypatch):
secret = "test-secret-key-for-jwt"
monkeypatch.setenv("STACK_API_SECRET", secret)
return secret
@pytest.fixture()
def client():
from api.server import app
return TestClient(app)
@pytest.fixture()
def authed_client(client, api_secret):
token = jwt.encode({"sub": "test"}, api_secret, algorithm="HS256")
client.headers["Authorization"] = f"Bearer {token}"
return client
class TestHealth:
def test_health(self, client) -> None:
r = client.get("/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert "version" in data
class TestAuth:
def test_issue_token(self, client, api_secret) -> None:
r = client.post("/auth/token", json={"secret": api_secret})
assert r.status_code == 200
data = r.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
decoded = jwt.decode(data["access_token"], api_secret, algorithms=["HS256"])
assert decoded["sub"] == "stack-api"
def test_wrong_secret(self, client, api_secret) -> None:
r = client.post("/auth/token", json={"secret": "wrong"})
assert r.status_code == 401
def test_no_secret_configured(self, client, monkeypatch) -> None:
monkeypatch.delenv("STACK_API_SECRET", raising=False)
from conf import cfg
cfg._data.setdefault("api", {})["secret"] = ""
r = client.post("/auth/token", json={"secret": "anything"})
assert r.status_code == 403
class TestPipelines:
def test_list_pipelines(self, client) -> None:
r = client.get("/pipelines")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_get_pipeline(self, client) -> None:
r = client.get("/pipelines/readmissions")
assert r.status_code == 200
assert r.json()["name"] == "readmissions"
def test_run_requires_auth(self, client) -> None:
r = client.post("/pipelines/run/readmissions")
assert r.status_code in (401, 403)
def test_run_with_auth(self, authed_client) -> None:
r = authed_client.post("/pipelines/run/readmissions")
assert r.status_code == 200
assert r.json()["status"] == "queued"
def test_run_status(self, client) -> None:
r = client.get("/pipelines/run/some-job/status")
assert r.status_code == 200
assert r.json()["job_id"] == "some-job"
class TestBib:
def test_list_items(self, client) -> None:
r = client.get("/bib/items")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_list_tags(self, client) -> None:
r = client.get("/bib/tags")
assert r.status_code == 200
assert isinstance(r.json(), list)
class TestSchema:
def test_missing_table(self, client) -> None:
r = client.get("/schema/nonexistent_table")
assert r.status_code == 404

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from conf import ROOT, cfg, context, path, reload from conf import ROOT, cfg, context, path, reload, secret
class TestCfg: class TestCfg:
@@ -114,6 +114,36 @@ class TestContext:
context() context()
class TestSecret:
def test_env_var_takes_precedence(self, monkeypatch) -> None:
monkeypatch.setenv("STACK_API_SECRET", "from-env")
assert secret("api.secret", "STACK_API_SECRET") == "from-env"
def test_falls_back_to_config(self, monkeypatch) -> None:
monkeypatch.delenv("STACK_API_SECRET", raising=False)
# config value is empty string by default
assert secret("api.secret", "STACK_API_SECRET") == ""
def test_reads_non_empty_config(self, monkeypatch) -> None:
import conf
monkeypatch.delenv("_CONF_TEST_SECRET_", raising=False)
monkeypatch.setitem(conf.cfg._data["api"], "secret", "from-toml")
result = secret("api.secret", "_CONF_TEST_SECRET_")
assert result == "from-toml"
class TestApiConfig:
def test_api_host(self) -> None:
assert cfg.api.host == "0.0.0.0"
def test_api_port(self) -> None:
assert cfg.api.port == 8000
def test_api_workers(self) -> None:
assert cfg.api.workers == 1
class TestFindRootError: class TestFindRootError:
def test_raises_when_no_stack_toml(self, tmp_path: Path) -> None: def test_raises_when_no_stack_toml(self, tmp_path: Path) -> None:
import pytest import pytest

48
uv.lock generated
View File

@@ -586,6 +586,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
] ]
[[package]]
name = "fastapi"
version = "0.135.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
]
[[package]] [[package]]
name = "fastexcel" name = "fastexcel"
version = "0.19.0" version = "0.19.0"
@@ -1668,13 +1684,16 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "databricks-cli" }, { name = "databricks-cli" },
{ name = "databricks-sdk" }, { name = "databricks-sdk" },
{ name = "fastapi" },
{ name = "fastexcel" }, { name = "fastexcel" },
{ name = "fsspec" }, { name = "fsspec" },
{ name = "httpx" }, { name = "httpx" },
{ name = "narwhals" }, { name = "narwhals" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pyjwt" },
{ name = "sqlglot" }, { name = "sqlglot" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -1694,13 +1713,16 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "databricks-cli", specifier = ">=0.18.0" }, { name = "databricks-cli", specifier = ">=0.18.0" },
{ name = "databricks-sdk", specifier = ">=0.85.0" }, { name = "databricks-sdk", specifier = ">=0.85.0" },
{ name = "fastapi", specifier = ">=0.135.1" },
{ name = "fastexcel", specifier = ">=0.19.0" }, { name = "fastexcel", specifier = ">=0.19.0" },
{ name = "fsspec", specifier = ">=2024.1.0" }, { name = "fsspec", specifier = ">=2024.1.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "narwhals", specifier = ">=2.17.0" }, { name = "narwhals", specifier = ">=2.17.0" },
{ name = "pyarrow", specifier = ">=23.0.0" }, { name = "pyarrow", specifier = ">=23.0.0" },
{ name = "pyjwt", specifier = ">=2.11.0" },
{ name = "sqlglot", specifier = ">=26.0.0" }, { name = "sqlglot", specifier = ">=26.0.0" },
{ name = "typer", specifier = ">=0.24.1" }, { name = "typer", specifier = ">=0.24.1" },
{ name = "uvicorn", specifier = ">=0.41.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -1716,6 +1738,19 @@ dev = [
{ name = "ruff", specifier = ">=0.11.0" }, { name = "ruff", specifier = ">=0.11.0" },
] ]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]] [[package]]
name = "tabulate" name = "tabulate"
version = "0.9.0" version = "0.9.0"
@@ -1788,6 +1823,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[[package]] [[package]]
name = "zipp" name = "zipp"
version = "3.23.0" version = "3.23.0"