How-To: Override CRUD Behavior and Add Custom Endpoints#
FastAPI-Restly generates five standard CRUD endpoints for every view, but real applications always need to bend the rules: inject extra fields, restrict which rows a user may see, run side effects, or expose non-CRUD operations. This guide walks through every layer of the override system, from the highest-level hooks down to raw session access.
Every concrete view class must be decorated with @fr.include_view(app) (or
an equivalent APIRouter), otherwise no routes are registered.
How the hook chain works#
Understanding the call chain helps you pick the right layer to override.
Read path#
GET /{id}
└─ get() ← FastAPI endpoint (avoid overriding)
└─ on_get(id) ← business-logic hook — override here
GET /
└─ index() ← FastAPI endpoint (avoid overriding)
└─ on_list(query_params) ← business-logic hook — override here
└─ count_index(query_params) ← only called when include_pagination_metadata = True
Write path#
POST /
└─ post() ← FastAPI endpoint (avoid overriding)
└─ on_create(schema_obj) ← high-level hook — override here to add fields
└─ make_new_object(...) ← low-level hook — override to change construction
└─ save_object(obj) ← low-level hook — override to add side effects
PATCH /{id}
└─ patch()
└─ on_update(id, schema_obj)
└─ on_get(id) ← reuses the same 404 logic
└─ update_object(obj, schema_obj)
└─ save_object(obj)
DELETE /{id}
└─ delete()
└─ on_delete(id)
└─ on_get(id)
└─ delete_object(obj)
General rule: prefer overriding on_* hooks for business logic and
make_new_object / update_object / save_object / delete_object for
lower-level structural changes. Override the raw endpoint methods (get,
post, etc.) only when you need to change the HTTP contract itself (status
code, response shape).
Override an on_* hook#
Each on_* hook maps one-to-one to a generated endpoint. Override the
one you want to change; the others keep their default implementations.
on_create — inject server-side fields at creation#
import fastapi_restly as fr
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
async def on_create(self, schema_obj):
obj = await self.make_new_object(schema_obj)
obj.created_by = self.request.state.user_id # set from request context
return await self.save_object(obj)
self.request is the live FastAPI Request. self.session is the injected
async SQLAlchemy session. Both are available in every hook.
on_update — run validation before saving#
async def on_update(self, id, schema_obj):
obj = await self.on_get(id) # raises 404 if missing
if obj.locked:
raise fastapi.HTTPException(409, "Cannot update a locked record")
return await self.update_object(obj, schema_obj)
on_delete — require a confirmation header#
async def on_delete(self, id):
if self.request.headers.get("X-Confirm-Delete") != "yes":
raise fastapi.HTTPException(400, "Missing X-Confirm-Delete: yes header")
return await super().on_delete(id)
Using super() here keeps the existing 404-checking and deletion logic intact.
on_get — eager-load extra relationships#
The default on_get relies on the view’s schema to decide which
relationships to load. If you need something extra just for one endpoint,
override and load it manually:
from sqlalchemy.orm import selectinload
async def on_get(self, id):
obj = await self.session.get(
self.model, id,
options=[selectinload(User.audit_log)]
)
if obj is None:
raise fastapi.HTTPException(404)
return obj
Scope-filter the list endpoint#
The most common real-world override: restrict GET / to rows owned by the
current user. You need to override both on_list and count_index
so that pagination totals stay accurate.
import sqlalchemy as sa
@fr.include_view(app)
class DocumentView(fr.AsyncRestView):
prefix = "/documents"
model = Document
schema = DocumentSchema
include_pagination_metadata = True
def _owner_query(self):
user_id = self.request.state.user_id
return sa.select(Document).where(Document.owner_id == user_id)
async def on_list(self, query_params, query=None):
return await super().on_list(query_params, query=self._owner_query())
async def count_index(self, query_params):
from sqlalchemy import func, select
filtered = self._owner_query()
count_q = select(func.count()).select_from(filtered.subquery())
return int(await self.session.scalar(count_q) or 0)
on_list accepts an optional query argument — a SQLAlchemy Select
statement. Pass it to restrict the result set before query modifiers (filters,
sorting, pagination) are applied on top.
Override low-level object hooks#
When the change you need applies to all writes (both create and update), it
is cleaner to override the low-level hooks rather than duplicate logic in
on_create and on_update.
save_object — send a notification after every write#
async def save_object(self, obj):
obj = await super().save_object(obj)
await notify_subscribers(obj.id) # your async side-effect
return obj
make_new_object — set a default field on creation only#
async def make_new_object(self, schema_obj):
obj = await super().make_new_object(schema_obj)
obj.tenant_id = self.request.state.tenant_id
return obj
update_object — prevent certain fields from being changed#
async def update_object(self, obj, schema_obj):
# Ignore any attempt to change `owner_id` via PATCH
schema_obj.owner_id = None
return await super().update_object(obj, schema_obj)
delete_object — implement soft-delete#
Instead of removing the row, mark it as archived:
async def delete_object(self, obj):
obj.deleted_at = datetime.utcnow()
await self.session.flush()
# Do NOT call super() — that would remove the row.
Extend rather than replace with super()#
For most overrides, calling super() and tweaking the result is less error-prone
than re-implementing the hook from scratch. The pattern is consistent across all
hooks:
async def on_list(self, query_params, query=None):
objs = await super().on_list(query_params, query)
# Annotate each object with a computed field before serialization
for obj in objs:
obj._display_name = f"{obj.first_name} {obj.last_name}"
return objs
Raise HTTP errors from hooks#
All on_* hooks run inside a request context, so you can raise
fastapi.HTTPException at any point:
import fastapi
async def on_create(self, schema_obj):
if not self.request.state.user.is_admin:
raise fastapi.HTTPException(403, "Admin access required")
return await super().on_create(schema_obj)
Add a custom read route#
Use @fr.get to expose computed or summarised data alongside the generated
endpoints:
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
@fr.get("/{id}/summary")
async def summary(self, id: int):
user = await self.on_get(id) # raises 404 automatically
return {
"id": user.id,
"display_name": f"{user.first_name} {user.last_name}",
"email": user.email,
}
on_get returns the raw ORM object, so you can access all model
attributes directly.
Add a custom action route#
Use @fr.post (or @fr.patch, @fr.delete) for explicit state-change
actions such as archive, publish, or recalculate:
@fr.include_view(app)
class OrderView(fr.AsyncRestView):
prefix = "/orders"
model = Order
schema = OrderSchema
@fr.post("/{id}/archive", status_code=202)
async def archive(self, id: int):
order = await self.on_get(id)
if order.archived:
raise fastapi.HTTPException(409, "Already archived")
order.archived = True
order = await self.save_object(order)
return {"id": order.id, "archived": order.archived}
Exclude generated routes#
Set exclude_routes to suppress specific generated endpoints:
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
exclude_routes = ("delete", "patch")
Valid values are: "index", "get", "post", "patch", "delete". Any
other string raises AttributeError at startup.
Choosing between @fr.route and the shorthand decorators#
Prefer @fr.get, @fr.post, @fr.put, @fr.patch, and @fr.delete for
most endpoints — they set the HTTP method automatically, and @fr.get (200),
@fr.post (201), and @fr.delete (204) also set default status codes.
@fr.put and @fr.patch do not set a default; FastAPI uses 200.
Use @fr.route(path, methods=[...], ...) only when you need full manual
control over route options — for example, to register a single path under
multiple HTTP methods, or to set a non-standard response code:
@fr.route("/{id}/thumbnail", methods=["GET", "HEAD"], status_code=200)
async def thumbnail(self, id: int):
...
What is available on self#
Inside any hook or custom route method, the following attributes are always available:
Attribute |
Type |
Description |
|---|---|---|
|
|
The current database session |
|
|
The live HTTP request |
|
|
The SQLAlchemy model class |
|
|
The Pydantic response schema |
Any class-level Annotated dependency you declare on the view (e.g. a current
user) is also injected and available as an instance attribute.