Customize RestView#
A RestView or AsyncRestView generates complete CRUD
endpoints, and sooner or later one of them needs to behave differently: stamp
a field server-side, archive instead of delete, scope every read to the
current tenant. Because the endpoint is generated, making such a change means
overriding a method, and which method depends on the kind of change. So this
page first explains what a RestView does with a request, then works through
the override points and recipes that follow from that structure.
The page covers the generated CRUD machinery only. A plain View
has no generated behavior to override; the class mechanics that all views
share are covered in Class-Based Views.
Note
To add routes rather than change generated ones, declare a method with
@fr.get or @fr.post,
as on any view; recipes
are in Add a custom read route and
Add a custom action route below.
The three tiers#
Take POST /, the create route, and follow the request inward. FastAPI calls
create_endpoint, which calls handle_create, which calls
create. Every CRUD verb is built this way: three nested methods, each
owning one kind of concern.
POST /
└─ create_endpoint(...) 1. the endpoint method: the HTTP contract
└─ handle_create(...) 2. the handler: authorization and the commit
└─ create(...) 3. the business method: the domain change
The rest of this page, and the how-to guides, lean on these three terms.
The endpoint method, <verb>_endpoint, is the method FastAPI routes to.
It owns the HTTP contract: the @route decorator, the FastAPI
signature, response_model, and the final to_response call. Override
it only when the contract itself must change.
The handler, handle_<verb>, owns the request logic in between. It runs
authorize, calls the business method, and, on writes, closes with the
commit bracket: before_commit, then the commit itself, then
after_commit. It returns the domain object, so custom routes can
reuse it; only the delete handler returns nothing. Override it to change
orchestration or timing without re-declaring the route.
The business method is the bare verb: create, update,
delete, get_one, or get_many. It makes the domain change:
build, apply, save. It is deliberately auth-free and commit-free, which is
what makes it the usual override point: your code runs with authorization
already checked and with the commit still owned by the handler.
The method names are regular across all five verbs, so update_endpoint
calls handle_update, which calls update, and so on. The worked example
below leans on the commit split in particular.
Worked example: hash a password on create#
Hashing a password is domain logic, so it belongs in create. The handler
commits after this method returns:
import fastapi_restly as fr
from .auth import hash_password
from .models import User
from .schemas import UserRead
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserRead
async def create(self, schema_obj):
obj = await self.make_new_object(schema_obj)
obj.password_hash = hash_password(schema_obj.password)
return await self.save_object(obj)
handle_create still authorizes and runs the commit bracket. The override only
changes the domain step.
Request lifecycle: a write (create)#
A POST / request flows down through the tiers, and the commit happens at the
bottom of the handler, after your domain logic has run:
POST /
└─ create_endpoint(schema_obj) # endpoint method
└─ handle_create(schema_obj) # handler
├─ authorize("create", data=schema_obj)
├─ create(schema_obj) # business method (your override point)
│ ├─ make_new_object(schema_obj) # override to stamp extra fields
│ └─ save_object(obj) # flush + refresh (no commit)
├─ before_commit("create", new=obj)
├─ commit # the framework owns this
└─ after_commit("create", new=obj) # runs after durability
└─ to_response(obj) # back in the endpoint method
update and delete follow the same shape. Their handlers first load the row
through get_one (so they 404 on a hidden row), authorize against the loaded
row, and take a snapshot(obj) as old; then the business method runs,
followed by the same bracket of before_commit, commit, and
after_commit, with both new and old available for dirty detection.
Recipes for these hooks are collected in
Transaction hooks.
Request lifecycle: a read (get_one)#
Reads have no commit bracket. Read access instead involves two separate concerns, visibility and policy, and each is handled at a different tier:
GET /{id}
└─ get_one_endpoint(id) # endpoint method
└─ handle_get_one(id) # handler
├─ get_one(id) # business method
│ └─ build_query() # VISIBILITY: scope (tenant, soft-delete, row-level)
│ # a hidden row is a clean 404 for every caller
└─ authorize("get_one", obj=obj) # POLICY: read-auth on the loaded row
└─ to_response(obj)
Because get_one routes through build_query, visibility lives in one
place across list, count, and single-row reads: a hidden row returns 404 from
GET /{id}. get_one itself stays auth-free; authorize handles policy.
Scoping recipes are shown in
build_query: scope every read at once.
get_many works the same way: build_query establishes the scope,
apply_query_params applies filtering, sorting, and pagination, and
count produces the total, with authorize("get_many") added by
handle_get_many.
Which method do I override for X?#
The table below maps the change you want to make to the method that owns it:
I want to change… |
Override / configure |
Tier / kind |
|---|---|---|
Domain logic (hash, derive, compute) |
business method |
|
Orchestration, timing, transaction |
|
handler |
The HTTP contract (status, signature) |
|
endpoint method |
Read scope / row visibility |
read extension point |
|
Filter / sort / pagination grammar |
read extension point |
|
The list total |
read extension point |
|
Authorization / policy |
|
handler hook |
Server-stamped fields (audit/tenant) |
|
cooperative stamping |
In-transaction side effects |
transaction hook |
|
Post-commit side effects (email/webhook) |
transaction hook |
|
The response shape |
response boundary |
Start at the business method. Move to handle_<verb> only when timing or
transaction handling must change, and touch the endpoint method only when the
HTTP contract itself changes.
The sections below give recipes for each of these override points.
Override the business methods#
Each business method maps to one domain operation, so override only the one you need. These methods are auth-free and commit-free; the handler adds authorization and commit handling around them.
create: inject server-side fields at creation#
The worked example above stamped
a password_hash; any server-owned field follows the same shape, reading
request context through self:
async def 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 method.
update: run validation before saving#
update receives the already-loaded object (fetched and visibility-scoped by handle_update), not the id:
async def update(self, obj, schema_obj):
if obj.locked:
raise fastapi.HTTPException(409, "Cannot update a locked record")
obj = await self.update_object(obj, schema_obj)
return await self.save_object(obj)
delete: soft-delete instead of removing the row#
delete also receives the loaded object. Flip a timestamp instead of deleting:
async def delete(self, obj):
obj.deleted_at = datetime.now(timezone.utc)
await self.session.flush()
# Do not call super(); that would remove the row.
For reusable soft-delete that also hides rows on read, see SoftDeleteMixin in Compose Views with Mixins.
get_one: eager-load extra relationships#
The default get_one loads through build_query and schema-derived loader options. If one endpoint needs extra eager loading, keep build_query in the query so visibility still applies:
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import selectinload
async def get_one(self, id):
pk = sa_inspect(self.model).primary_key[0]
query = self.build_query().where(pk == id).options(
selectinload(User.audit_log)
)
obj = (await self.session.scalars(query)).first()
if obj is None:
raise fr.exc.NotFound(f"User {id!r} not found")
return obj
Overriding get_one this way applies the extra load to reads only. To
eager-load a relationship on the create/update response as well, override
get_relationship_loader_options
instead; see Relationship Loading and Async.
get_many: decorate results after the query#
For post-query decoration, override get_many and delegate to super(). For filters, joins, or eager loading that apply to every read, prefer build_query.
async def get_many(self, query_params):
result = await super().get_many(query_params)
for obj in result.objects:
obj._display_name = derive_display_name(obj)
return result
Override handle_<verb> for orchestration#
Use handle_<verb> to change orchestration: transaction handling, side-effect timing, or the authorize/load order. The handler owns authorize and the commit bracket.
For server-controlled field stamps, prefer make_new_object / update_object below. Use a handler override when the bracket itself must change:
async def handle_delete(self, id):
obj = await self.get_one(id)
# write_action runs the same bracket the default handle_delete uses:
# authorize("delete", obj), snapshot, the body, then before/after_commit.
async with self.write_action("delete", obj=obj):
obj.status = "pending_deletion"
await self.save_object(obj)
await enqueue_async_delete(obj.id) # actual delete happens off-request
The endpoint method stays untouched, while the handler controls the write bracket.
Transaction hooks: before_commit / after_commit#
For most timing needs, use the hooks instead of overriding the handler:
before_commit(action, new, old=None)runs inside the transaction and is committed atomically with the write. Use it for outbox rows or audit rows.after_commit(action, new, old=None)runs after the write is durable. Use it for email, webhooks, or cache invalidation.
old is a snapshot dict of the object’s column values before the mutation (see snapshot), which enables dirty detection:
async def after_commit(self, action, new, old=None):
if action == "update" and old["status"] != new.status:
await notify_status_change(new.id, new.status)
Cooperative field stamping: override make_new_object / update_object#
For server-controlled field stamps, override make_new_object / update_object cooperatively: call super(), mutate, and return. This composes cleanly through mixins:
async def make_new_object(self, schema_obj):
obj = await super().make_new_object(schema_obj)
obj.tenant_id = self.request.state.tenant_id # stamp the constructed object
return obj
See Compose Views with Mixins for when to use structural stamping versus per-view business logic.
When the derivation should fire on every insert regardless of which view created the row (audit stamps, slug derivation, denormalised counters), prefer a SQLAlchemy before_insert mapper event listener instead:
from sqlalchemy import event
@event.listens_for(Article, "before_insert")
def _set_slug(mapper, connection, target):
target.slug = slugify(target.title)
See SQLAlchemy’s mapper events documentation for the full event API.
Domain utilities: call, don’t override#
The business methods are built from a handful of low-level utilities. Call these from your create / update / delete overrides. save_object and delete_object are never the override point; make_new_object and update_object are overridden only for cooperative field stamping, and called everywhere else.
Method |
What it does |
|---|---|
|
Constructs a new ORM object from the schema and adds it to the session; the cooperative override point for create-time field stamping. Does not flush. |
|
Applies writable fields onto an existing object; the cooperative override point for update-time field stamping. Does not flush. |
|
Flushes and refreshes |
|
Removes |
The same operations are available as free functions for use outside a view (scripts, workers, services): fr.objects.async_make_new_object, async_update_object, async_save_object, async_delete_object, plus their sync counterparts. See Advanced Object Helpers.
An import script, for example, can build and persist an object with the same semantics a view would use:
from fastapi_restly.objects import async_make_new_object, async_save_object
async def import_user(session, payload) -> User:
user = await async_make_new_object(session, User, payload, UserRead)
user.password_hash = hash_password(payload.password)
await async_save_object(session, user)
await session.commit()
return user
Because none of these commit, the same code works inside a view or worker; only the caller owns the transaction.
Replace an endpoint method to change the HTTP contract#
Business methods and handlers change behavior inside a generated route. Replace the endpoint method when the HTTP contract itself must change: response shape, headers, status code, or query-parameter semantics.
To replace a route, define the same endpoint-method name and add a route decorator. Usually, delegate to the handler and only reshape the response:
@fr.include_view(app)
class ProductView(fr.AsyncRestView):
prefix = "/products"
model = Product
schema = ProductRead
@fr.delete("/{id}", status_code=200)
async def delete_endpoint(self, id: int):
obj = await self.get_one(id) # load (scoped, 404)
serialized = self.to_response_schema(obj).model_dump(mode="json")
await self.handle_delete(id) # authorize + delete + commit
return serialized
At view initialization, Restly uses endpoint methods defined directly on the class and skips the matching generated one. Other generated routes remain unchanged.
The default DELETE /{id} returns 204 No Content; this version returns the deleted record, as ra-data-simple-rest expects (see React Admin Integration).
to_response: the one response method#
Generated endpoint methods return through self.to_response(obj_or_list, shape), where shape is SINGLE, LISTING, or EMPTY. Override it for envelopes or shape-wide response behavior:
def to_response(self, obj_or_list, shape=fr.ResponseShape.SINGLE):
if shape is fr.ResponseShape.SINGLE:
return {"data": self.to_response_schema(obj_or_list)}
return super().to_response(obj_or_list, shape)
If this changes a generated route’s HTTP contract, also replace that endpoint method
and set a matching response_model; otherwise FastAPI response validation and
OpenAPI still use the generated schema. See
Response Envelopes and List Metadata for the full pattern.
to_response is keyed on wire shape, not action. It cannot distinguish create from get_one; both are SINGLE. For one verb’s HTTP contract, override that endpoint method:
@fr.post("/")
async def create_endpoint(self, schema_obj):
obj = await self.handle_create(schema_obj)
return fastapi.Response(
content=self.to_response_schema(obj).model_dump_json(),
media_type="application/json",
status_code=201,
headers={"Location": f"{self.prefix}/{obj.id}"},
)
For object serialization, to_response_schema(obj) builds the configured schema, strips WriteOnly fields, normalizes relationship ids, and validates through Pydantic. Override it for a different projection or a faster trusted path:
def to_response_schema(self, obj: User) -> UserRead:
return self.schema.model_construct(
id=obj.id,
name=obj.name,
email=obj.email,
)
model_construct() bypasses validators and required-field checks. Keep the payload aligned with your response contract, and never include WriteOnly fields.
Replace the list endpoint method#
Replace get_many_endpoint when the list response contract changes, for example custom headers. Keep the query_params parameter if you want Restly’s generated filter, sort, and pagination query parameters:
import fastapi
import json
@fr.include_view(app)
class ProductView(fr.AsyncRestView):
prefix = "/products"
model = Product
schema = ProductRead
@fr.get("/")
async def get_many_endpoint(self, query_params):
result = await self.handle_get_many(query_params)
serialized = [
self.to_response_schema(obj).model_dump(mode="json")
for obj in result.objects
]
return fastapi.Response(
content=json.dumps(serialized),
media_type="application/json",
headers={"X-Total-Count": str(result.total_count)},
)
Add a custom read route#
Beyond overriding generated routes, a view can add routes of its own. Use @fr.get for computed read endpoints, calling get_one(id) for a scoped load that 404s on missing rows, or handle_get_one(id) to include read authorization:
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserRead
@fr.get("/{id}/summary")
async def summary(self, id: int):
user = await self.handle_get_one(id) # scoped load + read-auth + 404
return {
"id": user.id,
"display_name": f"{user.first_name} {user.last_name}",
"email": user.email,
}
get_one / handle_get_one return 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 state-change actions such as archive, publish, or recalculate. Two shapes cover most actions.
The first shape brackets the mutation with write_action. Load the object with handle_get_one(id), then run the mutation inside self.write_action under a custom action name:
@fr.include_view(app)
class OrderView(fr.AsyncRestView):
prefix = "/orders"
model = Order
schema = OrderRead
@fr.post("/{id}/archive", status_code=202)
async def archive(self, id: int):
order = await self.handle_get_one(id)
if order.archived:
raise fastapi.HTTPException(409, "Already archived")
async with self.write_action("archive", obj=order):
order.archived = True
return {"id": order.id, "archived": order.archived}
__aenter__ runs authorization and the snapshot; __aexit__ runs the commit
bracket, and a raised exception skips the commit. The action name ("archive")
drives authorization and the hooks.
The second shape runs a full create or update through a handler. If an action is a create or update under another URL, build the input schema and call handle_create / handle_update:
@fr.post("/{id}/duplicate", status_code=201)
async def duplicate(self, id: int):
original = await self.get_one(id)
payload = self.schema_create(name=f"{original.name} (copy)")
new_order = await self.handle_create(payload)
return self.to_response_schema(new_order)
Reusing handle_<verb> inherits authorization and the commit bracket.
For a create-shaped action that should run under its own write_action
bracket instead, deposit the new object on the yielded handle:
async with self.write_action("create", data=schema_obj) as w:
w.obj = await self.make_new_object(schema_obj)
return self.to_response(w.obj)
Internally, write_action and the CRUD handlers share run_write_action.
Relationship references in custom routes#
When a custom route constructs schemas itself (model_construct() skips
validation), IDRef fields need explicit wrapping; the recipe lives in
Work with Foreign Keys and Relationships.
Raise HTTP errors from any method#
Every method runs inside a request context, so you can raise fastapi.HTTPException (or fr.exc.Forbidden / fr.exc.NotFound) at any point:
import fastapi
async def create(self, schema_obj):
if not self.request.state.user.is_admin:
raise fastapi.HTTPException(403, "Admin access required")
return await super().create(schema_obj)
For permission gating specifically, prefer authorize; it runs at the right phase of the handler and keeps the business method auth-free.
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 = [fr.ViewRoute.DELETE, fr.ViewRoute.UPDATE]
Valid values are: fr.ViewRoute.GET_MANY, fr.ViewRoute.GET_ONE, fr.ViewRoute.CREATE, fr.ViewRoute.UPDATE, fr.ViewRoute.DELETE. Endpoint-method names such as "delete_endpoint" are also accepted; 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 apply Restly’s default status codes: @fr.get/@fr.put/@fr.patch use 200, @fr.post uses 201, and @fr.delete uses 204.
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):
...
Both @fr.route and the shorthand decorators pass their keyword arguments through to FastAPI’s route registration. Class-based routes therefore use the same configuration surface as regular FastAPI routes, including response_model=, status_code=, dependencies=, responses=, tags=, and other APIRouter.add_api_route() options.
What is available on self#
Inside any method or custom route, 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 (for example a current user) is also injected and available as an instance attribute; see Dependency injection on class attributes.
See also#
Class-Based Views: why subclassable views make all of this possible.
Compose Views with Mixins: structural stamping and scoping through cooperative mixins.
View Method Surface: the complete classified method list.