- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications. - Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks. - Adjusted tests to reflect the new program structure and ensure proper functionality. - Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
441 lines
10 KiB
Markdown
441 lines
10 KiB
Markdown
# Web APIs in Castle
|
|
|
|
How to build Python web APIs as castle service components. Based on the
|
|
patterns used in [wild-cloud/api](https://github.com/civilsociety-dev/wild-cloud)
|
|
and existing castle services (central-context, notification-bridge, event-bus).
|
|
|
|
## Stack
|
|
|
|
| Layer | Choice |
|
|
|-------|--------|
|
|
| **Framework** | FastAPI |
|
|
| **Server** | uvicorn |
|
|
| **Config** | pydantic-settings (env vars) |
|
|
| **Validation** | Pydantic models |
|
|
| **HTTP client** | httpx (async) |
|
|
| **Testing** | pytest + FastAPI TestClient |
|
|
| **Python** | 3.13+ for services |
|
|
|
|
## Project layout
|
|
|
|
```
|
|
my-service/
|
|
├── src/my_service/
|
|
│ ├── __init__.py # Package version
|
|
│ ├── main.py # FastAPI app, lifespan, entry point
|
|
│ ├── config.py # pydantic-settings
|
|
│ ├── models.py # Request/response Pydantic models
|
|
│ ├── routes.py # APIRouter with endpoints
|
|
│ └── storage.py # Domain logic (no FastAPI imports)
|
|
├── tests/
|
|
│ ├── conftest.py # Fixtures (client, temp dirs)
|
|
│ ├── test_api.py # Endpoint integration tests
|
|
│ └── test_storage.py # Domain unit tests
|
|
├── pyproject.toml
|
|
└── CLAUDE.md
|
|
```
|
|
|
|
Separation of concerns: routes handle HTTP, storage/core handles logic,
|
|
models define schemas. Domain code never imports FastAPI.
|
|
|
|
## pyproject.toml
|
|
|
|
```toml
|
|
[project]
|
|
name = "my-service"
|
|
version = "0.1.0"
|
|
description = "Does something useful"
|
|
requires-python = ">=3.13"
|
|
dependencies = [
|
|
"fastapi>=0.115.0",
|
|
"uvicorn>=0.34.0",
|
|
"pydantic-settings>=2.0.0",
|
|
]
|
|
|
|
[project.scripts]
|
|
my-service = "my_service.main:run"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["src/my_service"]
|
|
|
|
[dependency-groups]
|
|
dev = [
|
|
"pytest>=8.0.0",
|
|
"httpx>=0.28.0",
|
|
]
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Use pydantic-settings with an env prefix matching the service name:
|
|
|
|
```python
|
|
# config.py
|
|
from pathlib import Path
|
|
from pydantic_settings import BaseSettings
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
data_dir: Path = Path("./data")
|
|
host: str = "0.0.0.0"
|
|
port: int = 9001
|
|
|
|
model_config = {
|
|
"env_prefix": "MY_SERVICE_",
|
|
"env_file": ".env",
|
|
}
|
|
|
|
def ensure_data_dir(self) -> None:
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
settings = Settings()
|
|
```
|
|
|
|
Castle passes config via env vars in castle.yaml:
|
|
|
|
```yaml
|
|
programs:
|
|
my-service:
|
|
description: Does something useful
|
|
source: components/my-service
|
|
|
|
services:
|
|
my-service:
|
|
component: my-service
|
|
run:
|
|
runner: python
|
|
tool: my-service
|
|
expose:
|
|
http:
|
|
internal: { port: 9001 }
|
|
health_path: /health
|
|
proxy:
|
|
caddy: { path_prefix: /my-service }
|
|
manage:
|
|
systemd: {}
|
|
```
|
|
|
|
Convention-based env vars (`MY_SERVICE_DATA_DIR`, `MY_SERVICE_PORT`) are
|
|
generated automatically by `castle deploy`. Only non-convention values
|
|
need `defaults.env`:
|
|
|
|
```yaml
|
|
defaults:
|
|
env:
|
|
CENTRAL_CONTEXT_URL: http://localhost:9001
|
|
```
|
|
|
|
## Application entry point
|
|
|
|
```python
|
|
# main.py
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI
|
|
|
|
from my_service.config import settings
|
|
from my_service.routes import router
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
settings.ensure_data_dir()
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="my-service",
|
|
description="Does something useful",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.include_router(router)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
def run() -> None:
|
|
uvicorn.run(
|
|
"my_service.main:app",
|
|
host=settings.host,
|
|
port=settings.port,
|
|
reload=False,
|
|
)
|
|
```
|
|
|
|
For services with async resources (HTTP clients, connections), initialize
|
|
them in the lifespan and clean up after yield:
|
|
|
|
```python
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
settings.ensure_data_dir()
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
app.state.http_client = client
|
|
yield
|
|
```
|
|
|
|
## Routes
|
|
|
|
Use APIRouter with a prefix and tags. Map domain exceptions to HTTP status codes.
|
|
|
|
```python
|
|
# routes.py
|
|
from fastapi import APIRouter, HTTPException, status
|
|
|
|
from my_service.models import ItemCreate, ItemResponse
|
|
from my_service.storage import (
|
|
ItemExistsError,
|
|
ItemNotFoundError,
|
|
create_item,
|
|
get_item,
|
|
list_items,
|
|
)
|
|
|
|
router = APIRouter(prefix="/items", tags=["items"])
|
|
|
|
|
|
@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
|
|
def create(request: ItemCreate) -> ItemResponse:
|
|
try:
|
|
return create_item(request)
|
|
except ItemExistsError as e:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
|
|
|
|
|
@router.get("/{item_id}", response_model=ItemResponse)
|
|
def get(item_id: str) -> ItemResponse:
|
|
try:
|
|
return get_item(item_id)
|
|
except ItemNotFoundError:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
|
|
|
|
@router.get("", response_model=list[ItemResponse])
|
|
def list_all() -> list[ItemResponse]:
|
|
return list_items()
|
|
```
|
|
|
|
## Request/response models
|
|
|
|
Separate create models (what the client sends) from response models (what
|
|
comes back). Use inheritance to avoid repetition.
|
|
|
|
```python
|
|
# models.py
|
|
from datetime import datetime
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class ItemCreate(BaseModel):
|
|
name: str = Field(..., description="Item name")
|
|
content: str = Field(..., description="Item content")
|
|
description: str | None = Field(default=None)
|
|
|
|
|
|
class ItemResponse(BaseModel):
|
|
name: str
|
|
description: str | None
|
|
created_at: datetime
|
|
size_bytes: int
|
|
checksum: str
|
|
|
|
|
|
class ItemWithBody(ItemResponse):
|
|
content: str
|
|
```
|
|
|
|
## Error handling
|
|
|
|
Define domain exceptions in the storage/core layer. Map them to HTTP status
|
|
codes in the route layer.
|
|
|
|
```python
|
|
# storage.py
|
|
class StorageError(Exception):
|
|
pass
|
|
|
|
class ItemExistsError(StorageError):
|
|
pass
|
|
|
|
class ItemNotFoundError(StorageError):
|
|
pass
|
|
|
|
class InvalidNameError(StorageError):
|
|
pass
|
|
```
|
|
|
|
Mapping convention:
|
|
|
|
| Exception | HTTP Status |
|
|
|-----------|-------------|
|
|
| `NotFoundError` | 404 |
|
|
| `ExistsError` / conflict | 409 |
|
|
| `InvalidError` / bad input | 400 |
|
|
| Unexpected | 500 (FastAPI default) |
|
|
|
|
## Storage
|
|
|
|
Castle services use filesystem storage with JSON metadata sidecars:
|
|
|
|
```
|
|
/data/castle/my-service/
|
|
└── bucket/
|
|
├── item-name
|
|
└── item-name.meta.json
|
|
```
|
|
|
|
```python
|
|
# storage.py
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from my_service.config import settings
|
|
from my_service.models import ItemCreate, ItemResponse
|
|
|
|
|
|
def create_item(request: ItemCreate) -> ItemResponse:
|
|
checksum = hashlib.sha256(request.content.encode()).hexdigest()
|
|
path = settings.data_dir / request.name
|
|
meta_path = path.with_suffix(path.suffix + ".meta.json")
|
|
|
|
if path.exists():
|
|
raise ItemExistsError(f"'{request.name}' already exists")
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
now = datetime.now()
|
|
|
|
metadata = ItemResponse(
|
|
name=request.name,
|
|
description=request.description,
|
|
created_at=now,
|
|
size_bytes=len(request.content.encode()),
|
|
checksum=checksum,
|
|
)
|
|
|
|
path.write_text(request.content, encoding="utf-8")
|
|
meta_path.write_text(
|
|
json.dumps(metadata.model_dump(), default=str, indent=2)
|
|
)
|
|
return metadata
|
|
```
|
|
|
|
## Testing
|
|
|
|
### Fixtures
|
|
|
|
```python
|
|
# tests/conftest.py
|
|
from collections.abc import Generator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from my_service import config
|
|
from my_service.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_data_dir(tmp_path: Path) -> Generator[Path, None, None]:
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
original = config.settings.data_dir
|
|
config.settings.data_dir = data_dir
|
|
yield data_dir
|
|
config.settings.data_dir = original
|
|
|
|
|
|
@pytest.fixture
|
|
def client(temp_data_dir: Path) -> Generator[TestClient, None, None]:
|
|
with TestClient(app) as client:
|
|
yield client
|
|
```
|
|
|
|
### Endpoint tests
|
|
|
|
```python
|
|
# tests/test_api.py
|
|
from fastapi import status
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
class TestHealth:
|
|
def test_health(self, client: TestClient) -> None:
|
|
response = client.get("/health")
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
class TestCreateItem:
|
|
def test_create(self, client: TestClient) -> None:
|
|
response = client.post(
|
|
"/items",
|
|
json={"name": "test", "content": "hello"},
|
|
)
|
|
assert response.status_code == status.HTTP_201_CREATED
|
|
data = response.json()
|
|
assert data["name"] == "test"
|
|
assert "checksum" in data
|
|
|
|
def test_duplicate_returns_409(self, client: TestClient) -> None:
|
|
payload = {"name": "dup", "content": "hello"}
|
|
client.post("/items", json=payload)
|
|
response = client.post("/items", json=payload)
|
|
assert response.status_code == status.HTTP_409_CONFLICT
|
|
```
|
|
|
|
### Domain unit tests
|
|
|
|
```python
|
|
# tests/test_storage.py
|
|
from pathlib import Path
|
|
|
|
from my_service.models import ItemCreate
|
|
from my_service.storage import create_item
|
|
|
|
|
|
class TestCreateItem:
|
|
def test_creates_files(self, temp_data_dir: Path) -> None:
|
|
request = ItemCreate(name="test", content="hello")
|
|
metadata = create_item(request)
|
|
|
|
assert metadata.name == "test"
|
|
assert (temp_data_dir / "test").exists()
|
|
assert (temp_data_dir / "test.meta.json").exists()
|
|
```
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
uv sync # Install deps
|
|
uv run my-service # Run service
|
|
uv run pytest tests/ -v # Run tests
|
|
uv run ruff check . # Lint
|
|
uv run ruff format . # Format
|
|
```
|
|
|
|
## Scaffolding
|
|
|
|
`castle create` generates all of this automatically:
|
|
|
|
```bash
|
|
castle create my-service --stack python-fastapi --description "Does something useful"
|
|
```
|
|
|
|
See @docs/component-registry.md for manifest fields, castle.yaml structure,
|
|
and the full service lifecycle (enable, logs, gateway reload).
|