Overriding Handlers (Custom LIST and POST)¶
Every CRUD handler is a regular method, so subclassing the viewset is the canonical way to add filtering, ordering, validation, conflict handling, and more.
Available handlers¶
| Handler | Logical method | HTTP | Path | Default behavior |
|---|---|---|---|---|
list() |
LIST |
GET |
/ |
Paginated list with limit / offset |
get_element() |
GET |
GET |
/{id} |
Retrieve by ID |
create_element() |
POST |
POST |
/ |
Create from request body |
update_element() |
PUT / PATCH |
PUT / PATCH |
/{id} |
Full or partial update |
delete_element() |
DELETE |
DELETE |
/{id} |
Delete by ID |
Complete example: search + ordering + conflict handling¶
This example subclasses AsyncBaseViewset and overrides both list (case-insensitive search + simple ordering) and create_element (input normalization + mapping IntegrityError to 409).
from typing import List, Optional
from fastapi import Body, HTTPException, status
from fastapi import Depends
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import Column, DateTime, Integer, String, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_viewsets import AsyncBaseViewset
from fastapi_viewsets.db_conf import Base, get_async_session
class Item(Base):
"""Item model with timestamps and a unique name."""
__tablename__ = "items_custom"
id = Column(Integer, primary_key=True)
name = Column(String(255), nullable=False, unique=True, index=True)
description = Column(String(1024), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class ItemSchema(BaseModel):
"""Single Pydantic v2 schema reused as request and response model.
Server-controlled fields (``id``, ``created_at``) are optional so
the same schema can be used for POST/PATCH bodies and responses
— ``register()`` patches the body annotation to ``response_model``.
"""
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
id: Optional[int] = None
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=1024)
created_at: Optional[object] = None # datetime in real code
class ItemsViewSet(AsyncBaseViewset):
"""Custom async viewset that overrides LIST and POST."""
async def list( # type: ignore[override]
self,
limit: int = 20,
offset: int = 0,
search: Optional[str] = None,
order_by: str = "-created_at",
token: Optional[str] = Depends(lambda: None),
) -> List[ItemSchema]:
"""Custom LIST: case-insensitive search + whitelist ordering.
Query: ``GET /items?search=foo&order_by=-name&limit=10``.
"""
session: AsyncSession = self.db_session()
try:
stmt = select(self.model)
if search:
stmt = stmt.where(self.model.name.ilike(f"%{search}%"))
# "-name" → desc, "name" → asc; whitelist allowed columns.
field, desc = (order_by[1:], True) if order_by.startswith("-") else (order_by, False)
column = {"name": self.model.name, "created_at": self.model.created_at}.get(field)
if column is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported order_by")
stmt = stmt.order_by(column.desc() if desc else column.asc())
stmt = stmt.offset(offset).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [ItemSchema.model_validate(row) for row in rows]
finally:
await session.close()
async def create_element( # type: ignore[override]
self,
item: ItemSchema = Body(...),
token: Optional[str] = Depends(lambda: None),
) -> ItemSchema:
"""Custom POST: normalize, persist, map IntegrityError to 409."""
# Pydantic v2 dump; ``str_strip_whitespace`` already trimmed strings.
payload = item.model_dump(exclude_unset=True, exclude={"id", "created_at"})
session: AsyncSession = self.db_session()
try:
obj = self.model(**payload)
session.add(obj)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Item '{payload.get('name')}' already exists",
) from exc
await session.refresh(obj)
return ItemSchema.model_validate(obj)
finally:
await session.close()
items = ItemsViewSet(
endpoint="/items",
model=Item,
response_model=ItemSchema,
db_session=get_async_session,
tags=["items"],
)
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
Key points when overriding¶
Keep method names and the item body parameter¶
register() introspects list, get_element, create_element, update_element, delete_element. It also rewrites the item.__annotation__ to response_model so the OpenAPI body schema stays consistent — use the same schema for request and response, or pre-validate inside the handler.
Adding new query parameters is fine¶
search, order_by, filters, etc. — FastAPI picks them up automatically from the function signature.
Manage your own session lifecycle¶
In overrides, manage sessions with try/finally + await session.close(), or use a FastAPI dependency with yield.
Sync viewsets¶
For sync apps, the same pattern applies to BaseViewset — just drop the async/await and use Session instead of AsyncSession.
What register() does internally¶
When you call register():
- Sorts methods —
LISTis registered beforeGETso FastAPI doesn't match the collection path against/{id}. - Patches annotations — if the handler has an
itemparameter andresponse_modelis set, the annotation is rewritten to your schema for OpenAPI consistency. - Handles PUT vs PATCH — both map to
update_element, butPATCHpassespartial=True(usingmodel_dump(exclude_unset=True)). - Applies OAuth2 — if
protected_methodsincludes a method andoauth_protectis provided, the token dependency is wired viafunctools.partial. - Adds routes —
add_api_route()is called with the computed endpoint path, response model, tags, and HTTP method.
Next steps¶
- Authentication — OAuth2 on selected operations
- Custom Routes — adding endpoints alongside CRUD
- API Reference — full class and method documentation