Endpoints and routers¶
This page exposes the TaskAction from Actions and CRUD over HTTP, mounts it in the app, and tests it. It follows the same shape MSFLib modules use for their own routers, so what you build here is how you will mount msflib-account, msflib-workspaces and the rest.
A request-scoped dependency¶
The router needs to know who is calling, to fill owner on create and to scope every read. In a real app that comes from msflib-auth (see Authentication). To keep this Quick Start self-contained, a header stands in for it:
# app/deps.py
from fastapi import Header
def get_owner(x_owner: str = Header(default="anonymous")) -> str:
return x_owner
Warning
A client-supplied header is not authentication. It is here only so the example has a caller. Replace get_owner with a dependency that resolves the current account from a verified token.
The router factory¶
MSFLib routers are factory functions. The factory takes the dependencies it needs as arguments and returns an APIRouter, instead of importing a global session or user. The same router code then works with any engine and any auth setup, and tests can pass fakes.
# app/router.py
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from msflib.api import create_enhanced_router
from sqlmodel import Session
from .actions import task_action
from .models import TaskCreate, TaskRead, TaskUpdate
def task_router(*, get_session, get_owner, create_type=TaskCreate, update_type=TaskUpdate):
api_router = APIRouter(prefix="/tasks", tags=["tasks"])
router = create_enhanced_router(api_router)
@router.get("/", response_model=list[TaskRead])
def list_tasks(
session: Session = Depends(get_session),
owner: str = Depends(get_owner),
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
) -> Any:
return task_action.get_multi_by_all(session, offset=offset, limit=limit, owner=owner)
@router.post(
"/",
response_model=TaskRead,
status_code=201,
validation_types={"data": create_type},
)
def create_task(
data: Any,
session: Session = Depends(get_session),
owner: str = Depends(get_owner),
) -> Any:
return task_action.create(session, data=data, update={"owner": owner})
@router.get("/{task_id}", response_model=TaskRead)
def read_task(
task_id: int,
session: Session = Depends(get_session),
owner: str = Depends(get_owner),
) -> Any:
task = task_action.get_by_all(session, id=task_id, owner=owner)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch(
"/{task_id}",
response_model=TaskRead,
validation_types={"data": update_type},
)
def update_task(
task_id: int,
data: Any,
session: Session = Depends(get_session),
owner: str = Depends(get_owner),
) -> Any:
task = task_action.get_by_all(session, id=task_id, owner=owner)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task_action.update(session, model=task, data=data)
@router.delete("/{task_id}", response_model=TaskRead)
def delete_task(
task_id: int,
session: Session = Depends(get_session),
owner: str = Depends(get_owner),
) -> Any:
task = task_action.get_by_all(session, id=task_id, owner=owner)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task_action.delete(session, task.id)
return api_router
Things worth noting:
- Scoping is in the query, not the check. Every handler looks the row up with
id=task_id, owner=owner. A task owned by someone else is indistinguishable from one that does not exist, and both give 404. response_model=TaskReadcontrols what leaves the API, whatever the action returns.create_enhanced_routerandvalidation_types.create_enhanced_router(api_router)wraps the router so each HTTP-method decorator acceptsvalidation_types={"param": SomeModel}. At registration time the named parameter's annotation is replaced with that model, and FastAPI validates the request body against it. The handler body still receives the validated object as a plain argument. This is what makes the request schema a parameter of the factory (create_type,update_type) rather than something hard-coded: a host app can mount the same router with a stricter or extended schema. Parameters not listed, including everyDepends(...), are left as they are. The wrapper delegates everything else to the underlying router, which is why the factory returnsapi_router, the originalAPIRouter, forinclude_router.- You do not need
create_enhanced_routerfor your own routes. In an app where the schema never varies, annotatedata: TaskCreateand use a plainAPIRouter. Use it when you write routers meant to be reused with substitutable schemas, as MSFLib modules do. - The
data: Anyparameter with no default must come before parameters that have defaults, which is why it is listed first.
Substitute a schema¶
Because the schema is an argument, a caller can tighten it without touching the router:
from pydantic import Field
from app.models import TaskCreate
class StrictTaskCreate(TaskCreate):
title: str = Field(min_length=3)
router = task_router(get_session=get_session, get_owner=get_owner, create_type=StrictTaskCreate)
A POST with {"title": "ab"} now returns 422 and {"title": "abc"} returns 201. The action receives the validated object as data exactly as before. Overriding models covers the wider pattern.
Mount it¶
# app/main.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from msflib.db.migrations import migrate
from msflib.eventbus import bind_app_emitter
from .db import engine, get_session
from .deps import get_owner
from .migration import MIGRATION
from .router import task_router
from .settings import settings
core = settings.scope("CORE")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
migrate(engine, MIGRATION)
yield
app = FastAPI(
title=core.PROJECT_NAME,
openapi_url=f"{core.API_V1_STR}/openapi.json",
lifespan=lifespan,
)
app_emitter = bind_app_emitter(app)
@app.get(f"{core.API_V1_STR}/health")
def health() -> dict[str, str]:
return {"status": "ok", "project": core.PROJECT_NAME}
app.include_router(
task_router(get_session=get_session, get_owner=get_owner),
prefix=core.API_V1_STR,
)
The prefix comes from settings (/api/v1 by default), and the router adds its own /tasks. Mounting a module router is the same call with the module's factory and the dependencies it documents. Mounting routes covers prefixes, tags and several routers together.
Run it¶
uvicorn app.main:app --reload
curl -X POST localhost:8000/api/v1/tasks/ \
-H 'content-type: application/json' -H 'X-Owner: ann' \
-d '{"title": "write docs", "priority": "high"}'
# {"id":1,"title":"write docs","done":false,"priority":"high","owner":"ann"}
curl localhost:8000/api/v1/tasks/ -H 'X-Owner: ann'
# [{"id":1,"title":"write docs","done":false,"priority":"high","owner":"ann"}]
curl localhost:8000/api/v1/tasks/ -H 'X-Owner: bob'
# []
/docs now lists the five task routes, and shows TaskCreate as the request body of the POST.
Test it¶
FastAPI's TestClient drives the app in-process. Replace the session dependency with one bound to a throwaway in-memory database, and the tests never touch tasks.db:
# tests/test_tasks.py
import pytest
from fastapi.testclient import TestClient
from msflib.db.sqlite import enable_savepoints
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
from app.actions import task_action
from app.db import get_session
from app.main import app
from app.models import TaskCreate
@pytest.fixture
def engine():
engine = enable_savepoints(
create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
)
SQLModel.metadata.create_all(engine)
return engine
@pytest.fixture
def client(engine):
def override_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_session] = override_session
yield TestClient(app)
app.dependency_overrides.clear()
def test_owner_scoping(client):
body = {"title": "write docs"}
created = client.post("/api/v1/tasks/", json=body, headers={"X-Owner": "ann"})
assert created.status_code == 201
assert created.json()["owner"] == "ann"
assert client.get("/api/v1/tasks/", headers={"X-Owner": "ann"}).json() != []
assert client.get("/api/v1/tasks/", headers={"X-Owner": "bob"}).json() == []
def test_action_directly(engine):
with Session(engine) as session:
task = task_action.create(
session, data=TaskCreate(title="from a test"), update={"owner": "ann"}
)
assert task_action.get(session, task.id).title == "from a test"
python -m pytest tests
Two details. StaticPool makes every connection share the one in-memory database; without it each thread gets its own empty database, and TestClient runs the app in a different thread from your test. And dependency_overrides is keyed by the exact get_session function that the router was built with, so import it from app.db, not a copy. The TestClient here is not used as a context manager, so the app's lifespan (and migrate) does not run; the fixture creates the tables itself.
Troubleshooting¶
422 on every write, with data reported as a missing query parameter. The decorator has no validation_types={"data": ...}, so data: Any is treated as a query parameter. Passing validation_types to a plain APIRouter decorator raises TypeError: ... unexpected keyword argument 'validation_types'; register the route on the wrapper returned by create_enhanced_router.
sqlite3.OperationalError: no such table: task in tests. The test engine has no tables. Call SQLModel.metadata.create_all(engine) after importing the models, as the fixture does.
Tests see data from an earlier test. The engine fixture creates a new in-memory database per test. If you made the engine module-level, drop and recreate the tables in the fixture instead.
The override has no effect. The override key must be the same get_session object given to task_router.
Every request returns 404 for rows you just created. The X-Owner header differs between the create and the read. Reads are scoped to the owner.
Where next¶
You now have the full loop: settings, engine, models, tables, actions, routes and tests, on the core package alone. From here:
- Modules: add accounts, workspaces and the rest.
- Authentication: replace the header with a real identity dependency.
- Subclassing actions and Overriding models: customise what modules ship.
- Seeding: load initial data from
initial_data.