Customizing RestView#

RestView and AsyncRestView define complete CRUD endpoints. Because views are class-based, a subclass changes an endpoint’s behavior by overriding the method that controls it: stamp a field server-side, archive instead of delete, or hide soft-deleted rows. This page first explains how each view handles a request, then works through the override points and recipes that follow from that structure.

Using RestView covers the default CRUD contract and its class configuration. This page starts where those defaults stop. A plain View defines no CRUD endpoints. Views covers the class mechanics shared by all views.

Note

To add routes rather than change existing 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 commit bracket
            └─ 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_action_commit, then the commit itself, then after_action_commit. It returns the domain object, so custom routes can reuse it; only the delete handler returns nothing. The handler is final: custom routes call it, and a view class that defines one fails at class definition. Every CRUD route therefore runs authorize and the commit bracket. The handler normally owns the commit. To move it to an outer block, see Commit several writes together.

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 surrounding commit bracket.

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 surrounding commit bracket commits after this method returns:

import fastapi_restly as fr
from sqlalchemy.orm import Mapped

from .auth import hash_password


class User(fr.IDBase):
    email: Mapped[str]
    password: Mapped[str]  # stores the hash. UserView.create writes it


class UserSchema(fr.IDSchema):
    email: str
    password: fr.WriteOnly[str]  # accepted on input, never serialized


@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User
    schema = UserSchema

    async def create(self, schema_obj):
        obj = await self.make_new_object(schema_obj)
        obj.password = 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. The wire field and the column share a name because make_new_object passes every writable schema field to the model constructor: the plaintext lands in password first, and the override replaces it with the hash before the flush. fr.WriteOnly keeps the field out of every response.

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)   # build the ORM object (final)
            │    └─ save_object(obj)              # flush + refresh (no commit)
            ├─ before_action_commit("create", new=obj)
            ├─ commit                             # the framework owns this
            └─ after_action_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_action_commit, commit, and after_action_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
            │    └─ the view scope   # VISIBILITY: soft-delete, role, 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 applies the view scope (Scopes), replaceable 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.

get_many works the same way: the scope establishes visibility, apply_list_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)

create / update / delete

business method

One commit over several writes

shared_write_action_commit in a custom route

commit bracket

A lookup by another key (slug, email)

handle_get_one with a predicate, in a custom route (below)

handler

A list of a subset (one project’s tasks)

handle_get_many with where=, in a custom route (Scopes)

handler

The HTTP contract (status, signature)

<verb>_endpoint

endpoint method

Read scope / row visibility

scope (Scopes)

read extension point

Filter / sort / pagination grammar

apply_list_params

read extension point

The list total

count

read extension point

Authorization / policy

authorize (override to gate)

handler hook

Server-stamped fields (audit/tenant)

a column default on the model (below)

model layer

A side effect inside the commit (outbox/audit)

before_action_commit

transaction hook

A side effect after the commit (email/webhook)

after_action_commit

transaction hook

The response shape

to_response

response boundary

Start at the business method. Move a side effect that depends on the commit to a transaction hook, 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 hashed a password. Any server-owned field follows the same shape, reading request context through self:

from typing import Annotated
from fastapi import Depends

    current_user: Annotated[User, Depends(get_current_user)]

    async def create(self, schema_obj):
        obj = await self.make_new_object(schema_obj)
        obj.created_by = self.current_user.id
        return await self.save_object(obj)

current_user is a class-level dependency, injected per request like any other (Dependency injection on class attributes). 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 the view scope and the loader options derived from the response class. If one endpoint needs an extra relationship, delegate to super() so the scoped load and its 404 stay intact, then load the extra attribute explicitly. An override declares the scope parameter and passes it on; the handlers always forward it:

    async def get_one(self, id, *, scope=None):
        obj = await super().get_one(id, scope=scope)   # scoped load + 404
        await obj.awaitable_attrs.audit_log
        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(). Use a scope for row visibility. Put joins and ordering for the list in apply_list_params. Put eager loading in get_relationship_loader_options.

    async def get_many(self, list_params, *, scope=None):
        result = await super().get_many(list_params, scope=scope)
        for obj in result.objects:
            obj._display_name = derive_display_name(obj)
        return result

Read scope: the view scope + authorize#

The read lifecycle above split read access into two independent concerns; each has its own override point:

  • Visibility, meaning which rows exist at all for this caller, lives in the view scope: a query clause declared as the model’s default_scope or the view’s scope. Scopes owns that topic.

  • Policy, meaning whether this caller may perform the action, lives in authorize, which the handler calls.

The scope: filter every read at once#

The following view scopes every read to rows owned by the requesting user:

class Current(fr.ContextNamespace):
    user_id: fr.ContextParam[int]

@fr.include_view(app)
class DocumentView(fr.AsyncRestView):
    prefix = "/documents"
    model = Document
    schema = DocumentSchema
    scope = fr.where_clause(Document.owner_id == Current.user_id)

with Current.depends(...) binding user_id per request. get_many (list and count) and get_one both apply the scope, so one declaration covers:

  • the listed page,

  • the pagination total (count counts the same scoped query),

  • and single-row fetches: a row hidden from the list returns 404 from GET /{id} as well, with no extra code.

Because handle_update and handle_delete load through get_one first, they inherit the same visibility check. get_one stays auth-free even though it 404s on hidden rows: visibility comes from the query, and custom routes that call get_one(id) get the same scope.

Scopes covers composing clauses, the model-wide default_scope, and per-read scope replacement. Overriding build_query() is removed. A view that still defines it fails at class definition with a pointer to Migrating from build_query.

authorize: gate the action#

authorize(action, obj=None, data=None) runs inside handle_<verb>: before create and get_many, and after the object is loaded for get_one / update / delete. Override it to enforce policy:

from typing import Annotated
from fastapi import Depends

@fr.include_view(app)
class InvoiceView(fr.AsyncRestView):
    prefix = "/invoices"
    model = Invoice
    schema = InvoiceSchema
    current_user: Annotated[User, Depends(get_current_user)]

    async def authorize(self, action, obj=None, data=None):
        if action in ("create", "update", "delete") and not self.current_user.is_staff:
            raise fr.exc.Forbidden()
        if action == "update" and obj.posted:
            raise fr.exc.Forbidden("Posted invoices are immutable")

action is the verb, obj is the loaded row, and data is the validated request payload. Authentication itself is yours to wire; Restly calls authorize and maps fr.exc.Forbidden / fr.exc.NotFound to HTTP responses.

Visibility belongs in the scope, not here: raising from authorize produces a 403, whereas a row outside the scope produces a 404.

Transaction hooks: before_action_commit / after_action_commit#

The write handlers call two hooks around the commit:

old is a snapshot dict of the object’s column values before the mutation (see snapshot), which enables dirty detection:

    async def after_action_commit(self, action, new, old=None):
        if action == "update" and old["status"] != new.status:
            await notify_status_change(new.id, new.status)

On a delete, new is None and old holds the removed row’s columns. A delete that finishes off-request marks the row in the delete business method, then enqueues the real delete once the mark is durable:

    async def delete(self, obj):
        obj.status = "pending_deletion"
        await self.session.flush()  # no super(): the row stays

    async def after_action_commit(self, action, new, old=None):
        if action == "delete":
            await enqueue_async_delete(old["id"])  # the real delete runs off-request

When after_action_commit raises#

The write is already committed, so an exception from the hook cannot undo it. The request still fails, and a client that retries repeats the write. For a best-effort effect, catch and log the error in the hook:

    async def after_action_commit(self, action, new, old=None):
        if action == "create":
            try:
                await send_confirmation_email(new.id)
            except Exception:  # the order is saved; don't fail the request
                logger.exception("Confirmation email failed for order %s", new.id)

An effect that must happen belongs in an outbox row written in before_action_commit, which a worker delivers and retries. Make delivery idempotent, since a retry can repeat it. FastAPI’s BackgroundTasks is no substitute: a crash loses the task, and nothing retries it.

Server-stamped fields: column defaults on the model#

A field the server owns (an audit id, a tenant id) is a column default that reads a bound fr.ContextNamespace member. It fires on every write path, whichever view or helper built the row, so nothing on the view has to run:

class Article(fr.TimestampsMixin, fr.IDBase):
    title: Mapped[str]
    created_by_id: Mapped[int | None] = mapped_column(
        ForeignKey("user.id"),
        default_factory=lambda: None,
        insert_default=lambda: Current.user_id(),
    )

default_factory makes the constructor argument optional without reading the context. insert_default reads it when the row is inserted. SQLAlchemy 2.1 rejects combining default with insert_default.

A payload value wins over a default, so keep the field fr.ReadOnly on the schema and out of any explicit write schema. Stamping from the view instead, in create, only covers that verb: a create override does not call super(), and the free fr.objects helpers never see the view. See Compose Views with Mixins for the tenant, audit, and soft-delete pieces the SaaS example ships.

A derivation that needs more than a value, a slug from the title or a denormalised counter, is a SQLAlchemy before_insert mapper event on the same model:

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 three utilities. Call them from your create / update overrides; they are final, and a view class that defines one, itself or through a mixin, fails at class definition. A server-stamped field is a column default on the model; a per-write side effect is a transaction hook.

Method

What it does

self.make_new_object(schema_obj)

Constructs a new ORM object from the schema, resolving references and skipping the fields the schema marks read-only, and adds it to the session. Does not flush.

self.update_object(obj, schema_obj)

Applies writable fields onto an existing object, resolving references. Does not flush.

self.save_object(obj)

Flushes and refreshes obj, then eager-loads the relationships the response class names. Does not commit.

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)
    user.password = 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 hooks change behavior while preserving the default CRUD route’s HTTP contract. 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 = ProductSchema

    @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_single_response(obj).model_dump(mode="json")
        await self.handle_delete(id)               # authorize + delete + commit
        return serialized

At view registration, a directly defined endpoint method replaces the inherited method with the same name. The other inherited endpoint methods remain unchanged.

Registration fills in the view’s types for annotations that are missing or Any. id gets id_type, schema_obj gets schema_create or schema_update, and the return annotation gets the route’s default response model. An annotation you write is kept, so schema_obj: ProductReplace validates the request body against ProductReplace. list_params is the exception: it always gets the view’s schema_list_params.

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#

The default endpoint methods return through self.to_response(result, shape), where shape is SINGLE, LIST, or EMPTY. The shape tells what result is: one object for SINGLE, a ListResult for LIST, and None for EMPTY. to_response passes them on to to_single_response and to_list_response. Override it for envelopes or shape-wide response behavior:

    def to_response(self, result, shape=fr.ResponseShape.SINGLE):
        if shape is fr.ResponseShape.SINGLE:
            return {"data": self.to_single_response(result)}
        return super().to_response(result, shape)

If this changes a default CRUD route’s HTTP contract, also replace that endpoint method and set a matching response_model. Otherwise, FastAPI validates and documents the response with the original response model. 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_single_response(obj).model_dump_json(),
            media_type="application/json",
            status_code=201,
            headers={"Location": f"{self.prefix}/{obj.id}"},
        )

For object serialization, to_single_response(obj) builds the view’s response class, normalizes relationship ids, and validates through Pydantic. By default the response class is the view’s schema without its WriteOnly fields. Override it for a different projection or a faster trusted path:

    def to_single_response(self, obj: User):
        return self.schema_response.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. Build the response class, not the view’s schema: Restly sends an instance of the response class as it is, and converts an instance of any other class first.

Replace the list endpoint method#

Replace get_many_endpoint when the list response contract changes, for example custom headers. Keep the list_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 = ProductSchema

    @fr.get("/")
    async def get_many_endpoint(self, list_params):
        result = await self.handle_get_many(list_params)
        serialized = [
            self.to_single_response(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)},
        )

Share a replacement across views with a mixin#

If several views need the same changed contract, put the replacement in a mixin. Python’s MRO ensures the mixin’s version is picked up before the standard one:

class DeleteReturnsObjectMixin:
    @fr.delete("/{id}", status_code=200)
    async def delete_endpoint(self, id):
        obj = await self.get_one(id)
        serialized = self.to_single_response(obj).model_dump(mode="json")
        await self.handle_delete(id)
        return serialized


@fr.include_view(app)
class ProductView(DeleteReturnsObjectMixin, fr.AsyncRestView):
    prefix = "/products"
    model = Product
    schema = ProductSchema

React Admin views use this same pattern: they replace get_many_endpoint for the ra-data-simple-rest wire contract and keep the standard verbs and handlers.

Add a custom read route#

A CRUD view can add routes alongside its inherited CRUD routes. 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 = UserSchema

    @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.

Look a row up by another key#

id is the primary key by default, and a SQLAlchemy boolean expression replaces it. Everything else about the load is unchanged: the view’s scope, the schema’s loader options, and the same 404.

    @fr.get("/by-email/{email}")
    async def get_by_email(self, email: str) -> UserSchema:
        user = await self.handle_get_one(User.email == email)
        return self.to_response(user)

Combine columns with sqlalchemy.and_(...). That is also how a model with a composite primary key is addressed, since an id cannot name one of its rows; passing one raises NotImplementedError.

The criterion narrows inside the scope and never past it, so a row the view hides stays a 404 under any key, and scope remains the only way to change what the view sees. More than one match raises SQLAlchemy’s MultipleResultsFound instead of serving the first row: an ambiguous key is a bug in the criterion.

handle_update and handle_delete take the same identity, so PATCH /users/by-email/{email} runs the full commit bracket against the row that criterion picks.

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 get_one(id), then run the mutation inside self.write_action under a custom action name; the bracket authorizes that action itself, the same way handle_update / handle_delete gate only their own action:

@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.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_single_response(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.

Commit several writes together#

The outermost shared_write_action_commit() block commits once, then the queued after_action_commit hooks run. Use it when several handlers or custom actions should share that commit:

    @fr.post("/bulk", status_code=201)
    async def bulk_create(self, items: list[OrderCreate]):
        async with self.shared_write_action_commit():
            orders = [await self.handle_create(item) for item in items]
        return [self.to_response(order) for order in orders]

The block can also combine a handler with related model writes. This is useful when one logical operation spans more than one model:

    async with self.shared_write_action_commit():
        project = await self.handle_create(project_data)
        self.session.add_all(
            Task(project_id=project.id, title=task.title)
            for task in source_tasks
        )
    return self.to_response(project)

The new project, its before-hook writes, and the copied tasks commit together. If any part fails, none of them commit.

Each action still authorizes, snapshots, mutates, and runs before_action_commit. It then flushes and returns while its changes remain uncommitted. Its after_action_commit has not run yet. Build responses and run code that depends on an after-hook after the outer block, as in the example. On fr.RestView, use with and synchronous handlers under the same method name.

Nested blocks share the commit when their views share a session. An exception escaping a deferred-commit block discards its shared queue and prevents that commit, even if an enclosing deferred-commit block catches the exception. The session owner still handles rollback. A direct session.commit() inside the block raises RuntimeError because the outermost block owns the commit.

For partial success, put SQLAlchemy’s session.begin_nested() outside each inner commit bracket and catch the row exception outside its savepoint:

    from sqlalchemy.exc import IntegrityError

    async with self.shared_write_action_commit():
        for item in items:
            try:
                async with self.session.begin_nested():
                    await self.handle_create(item)
            except IntegrityError:
                continue

The savepoint contains the row’s mutation and before-hook writes, so a failed row rolls both back. Its queued after-hook is discarded. Surviving after-hooks run in order after the shared commit and stop on the first exception.

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.current_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 CRUD routes#

Set exclude_routes to prevent selected CRUD endpoint methods from being registered as routes:

@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

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[pydantic.BaseModel]

The view’s schema, a Pydantic model

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#