Skip to content

Working with FastAPI

A transmuter is a Pydantic model, so it drops straight into FastAPI: one definition supplies your request bodies, your responses, your filters, and your pages — no parallel AuthorCreate / AuthorUpdate / AuthorResponse classes to hand-maintain. This page wires the setup Author / Book models into a small app: CRUD with the generated partial schemas, filtering with Criteria, and keyset pagination returning Page.

Partial schemas are experimental

.Create / .Update are still experimental — the generated surface may change.

A session dependency

Yield an arcanus Session per request. Everything below depends on it.

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import create_engine, func, select
from arcanus import Criteria, Cursor, Page
from arcanus.materia.sqlalchemy import Session

engine = create_engine("postgresql+psycopg://localhost/app")
app = FastAPI()

def get_session():
    with Session(engine) as session:        # blesses rows into transmuters on the way out
        yield session

CRUD with partial schemas

Author.Create excludes identity fields; Author.Update makes every non-frozen field optional. shell() turns a validated Create into a transmuter; absorb() applies an Update in place. The response model is the transmuter itself (response_model=Author), so one class drives the create body, the update body, and the response.

@app.post("/authors", response_model=Author, status_code=201)
def create_author(body: Author.Create, session: Session = Depends(get_session)) -> Author:
    author = Author.shell(body)             # body already validated by FastAPI/Pydantic
    session.add(author)
    session.flush()                         # server-generated id synced back automatically
    session.commit()
    return author

@app.get("/authors/{author_id}", response_model=Author)
def get_author(author_id: int, session: Session = Depends(get_session)) -> Author:
    author = session.get(Author, author_id)
    if author is None:
        raise HTTPException(status_code=404, detail="Author not found")
    return author

@app.patch("/authors/{author_id}", response_model=Author)
def update_author(
    author_id: int, body: Author.Update, session: Session = Depends(get_session)
) -> Author:
    author = session.get(Author, author_id)
    if author is None:
        raise HTTPException(status_code=404, detail="Author not found")
    author.absorb(body)                     # only the fields the client actually sent change
    session.commit()
    return author

@app.delete("/authors/{author_id}", status_code=204)
def delete_author(author_id: int, session: Session = Depends(get_session)) -> None:
    author = session.get(Author, author_id)
    if author is not None:
        session.delete(author)
        session.commit()

Invalid bodies never reach your code — FastAPI returns a 422 from the generated schema — and every shape shows up in the OpenAPI docs automatically.

Filtering with Criteria

Type a parameter as Criteria[Author] and clients get rich, validated filtering from a single field. The handler spreads .expressions into a query:

@app.post("/authors/search", response_model=list[Author])
def search_authors(where: Criteria[Author], session: Session = Depends(get_session)) -> list[Author]:
    return list(session.execute(select(Author).where(*where.expressions)).scalars())

A client posts e.g. {"name": {"contains": "Asimov"}} and only valid operators/value types are accepted — see Criteria validation.

Keyset pagination with Page

For large collections, return a Page[Author]items plus total, has_more, and an opaque next_cursor. A small reusable helper works for any transmuter; the client pages by echoing back the cursor (filter, ordering, and limit all travel inside the token):

from typing import TypeVar
from arcanus.base import BaseTransmuter
from arcanus.expression import Expression, Order

T = TypeVar("T", bound=BaseTransmuter)

def paginate(
    session: Session,
    entity: type[T],
    *,
    expressions: tuple[Expression[bool], ...],
    order_bys: tuple[Order, ...],
    size: int,
    cursor: str | None = None,
) -> Page[T]:
    filters, orders, bookmark = expressions, order_bys, None
    if cursor:                                       # continue a previous page
        token = Cursor[entity](cursor)               # validates the opaque token
        filters = token.criteria.expressions if token.criteria else ()
        orders, bookmark = token.order_by.orders, token.bookmark.expression

    rows = session.execute(
        select(entity)
        .where(*filters, *((bookmark,) if bookmark is not None else ()))
        .order_by(*orders)
        .limit(size + 1)                             # +1 row reveals whether more exist
    ).scalars().all()
    items = tuple(rows[:size])
    total = session.execute(
        select(func.count()).select_from(entity).where(*filters)
    ).scalar_one()

    next_cursor = Cursor[entity].from_expressions(
        expressions=filters,
        bookmark=Cursor[entity].bookmark_from_item(items[-1], orders) if items else bookmark,
        order_bys=orders,
        limit=size,
    )
    return Page(items=items, total=total, next_cursor=str(next_cursor), has_more=len(rows) > size)


@app.post("/authors/page", response_model=Page[Author])
def page_authors(
    where: Criteria[Author], cursor: str | None = None, session: Session = Depends(get_session)
) -> Page[Author]:
    return paginate(
        session,
        Author,
        expressions=where.expressions,
        order_bys=(Author["id"].asc(),),
        size=20,
        cursor=cursor,
    )

The first call filters by where; each subsequent call passes the previous response's next_cursor and ignores where (the criteria are carried in the token), giving stable keyset pagination that doesn't drift when rows are inserted. Because the cursor is opaque and self-validating, a malformed token is rejected before any query runs.

Async

Everything above mirrors one-to-one onto AsyncSession — make the dependency and handlers async and await the I/O:

from arcanus.materia.sqlalchemy import AsyncSession

async def get_session():
    async with AsyncSession(async_engine) as session:
        yield session

@app.get("/authors/{author_id}", response_model=Author)
async def get_author(author_id: int, session: AsyncSession = Depends(get_session)) -> Author:
    author = await session.get(Author, author_id)
    if author is None:
        raise HTTPException(status_code=404, detail="Author not found")
    return author

See Lifecycle & Async for the awaiting rules.