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

self.session

AsyncSession

The current database session

self.request

fastapi.Request

The live HTTP request

self.model

type[DeclarativeBase]

The SQLAlchemy model class

self.schema

type[BaseSchema]

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.