Views & CRUD#

Views group related FastAPI endpoints in Python classes. A view declares shared route configuration and dependencies once. Subclasses inherit or override its endpoint methods.

Choose a view#

You are building

Use

One simple standalone endpoint

A plain FastAPI route, with no Restly view

A group of related non-CRUD endpoints: login/auth flows, webhook receivers, RPC-style actions, composite-key resources

fr.View (when to use View directly)

A database-backed CRUD resource

fr.AsyncRestView / fr.RestView

CRUD plus custom actions such as publish, vote, or bulk operations

fr.AsyncRestView or fr.RestView with extra @fr.get or @fr.post methods (custom actions)

Use AsyncRestView for new applications unless the application already uses synchronous SQLAlchemy. Using RestView defines its CRUD routes, schemas, and configuration. Customizing RestView shows how to override those routes or add custom actions. All three classes use the same registration, dependency injection, and inheritance mechanics.

How class-based views work#

A class-based view (CBV) is a class that groups related endpoints together with the configuration they share. In plain FastAPI, an endpoint is a function:

@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_session)):
    ...

@app.post("/users")
async def create_user(payload: UserCreate, session: AsyncSession = Depends(get_session)):
    ...

A class-based view declares the same endpoints as methods on a class instead:

import fastapi_restly as fr
from fastapi import Depends
from sqlalchemy import select

@fr.include_view(app)
class UserView(fr.View):
    prefix = "/users"
    dependencies = [Depends(require_logged_in)]
    session: fr.AsyncSessionDep

    @fr.get("")
    async def list_users(self) -> list[UserSchema]:
        users = await self.session.scalars(select(User))
        return [UserSchema.model_validate(user) for user in users]

    @fr.post("")
    async def create_user(self, payload: UserCreate) -> UserSchema:
        user = User(**payload.model_dump())
        self.session.add(user)
        await self.session.flush()
        return UserSchema.model_validate(user)

Dependencies, prefix, tags, and metadata are declared once on the class. The session attribute is a FastAPI dependency too, injected per request and available as self.session. Methods are ordinary Python methods, so helpers, class config, and self all work normally.

Why CBVs at all?#

Function endpoints are fine for a few routes, but they become repetitive in larger codebases:

  • Repetition: the same Depends(get_session), the same auth dependency, and the same response config are duplicated across every related endpoint.

  • Scattering: endpoints that conceptually belong together (everything about users, everything about invoices) live as separate top-level functions, so renames, splits, and shared edits become tedious.

  • No natural place for shared state: a request scope often has a few values that every endpoint in a group needs (the current user, a tenant context, a serialised filter). With functions, you pass them through parameters or recompute them; with a CBV, they are attributes on self.

A CBV solves all three with one tool: the class itself.

Declaration and registration#

The base class itself is small: FastAPI router metadata plus one pre-registration hook.

class View:
    prefix: ClassVar[str]
    tags: ClassVar[Iterable[str | Enum] | None] = None
    dependencies: ClassVar[Any] = None
    responses: ClassVar[dict[int | str, dict[str, Any]]] = {}
    route_options: ClassVar[Mapping[str | ViewRoute, Mapping[str, Any]]] = {}

    @classmethod
    def before_include_view(cls): ...

Methods on a View subclass use @fr.get(...), @fr.post(...), or @fr.route(...). Those decorators only store route metadata; registration happens when you call:

fr.include_view(app, UserView)

For larger apps, define classes in view modules and include them from the app/router composition layer. Small apps can use the decorator shortcut:

@fr.include_view(app)
class UserView(fr.View): ...

include_view walks the class’s MRO, collects every method tagged with route metadata, wires each method’s self up as a dependency on the view class (so FastAPI instantiates a fresh view per request), and registers each route on the parent router or app.

Routes are bound at include-time against the class you pass in; they are not bound at decoration time. This is what makes subclassing work.

One base view for the whole app#

An application-wide base view declares your app’s request context once on a bare View. Subclass it everywhere. No CRUD is required:

from typing import Annotated
from fastapi import Depends


class AppView(fr.View):
    """Project base; every endpoint group in the app subclasses this."""

    session: fr.AsyncSessionDep
    current_user: Annotated[User, Depends(get_current_user)]


@fr.include_view(app)
class ProfileView(AppView):
    prefix = "/profile"

    @fr.get("/")
    async def whoami(self) -> dict:
        return {"user": self.current_user.name}


@fr.include_view(app)
class BillingView(AppView):
    prefix = "/billing"

    @fr.post("/checkout")
    async def checkout(self, payload: CheckoutRequest):
        order = Order(user_id=self.current_user.id, **payload.model_dump())
        self.session.add(order)
        await self.session.commit()
        return {"order_id": order.id}

In plain FastAPI, session and current_user would be Depends parameters re-declared on every function in the project. Here they are declared once and read from self in every method of every subclass. The same base also composes under CRUD views, so the whole app shares one context layer:

class AppRestView(AppView, fr.AsyncRestView):
    """CRUD resources get the same session + current_user attributes."""

Testing inherits the benefit: FastAPI’s dependency_overrides applies to the class-level dependencies, so overriding get_current_user reaches self.current_user in every view at once.

The view hierarchy#

The CRUD rows of the opening table are served by a short inheritance chain, in which each layer adds behavior to the one above it:

View                   ← class-based view primitive (no CRUD)
└── BaseRestView       ← CRUD configuration + helpers (no endpoints)
    ├── RestView         ← sync CRUD endpoints
    │   └── ReactAdminView      ← + ra-data-simple-rest contract
    └── AsyncRestView    ← async CRUD endpoints
        └── AsyncReactAdminView ← + ra-data-simple-rest contract
  • View is the bare CBV primitive. Use it for non-CRUD endpoints: auth flows, custom RPC, file uploads, or composite-key resources.

  • BaseRestView adds the model, schema, list, and response helpers shared by the concrete CRUD views. It is an abstract scaffold with no endpoints of its own.

  • RestView and AsyncRestView define the sync and async CRUD endpoint methods. Using RestView owns their default contract, configuration, and limits.

The API reference classifies the public method surface. Customizing RestView explains the three override tiers and cross-cutting override points.

A shared base view for tenant routes#

Tenant routes need the same identity dependency and often share an error envelope. A base view declares the view-level pieces once:

import fastapi_restly as fr

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

class AuthenticatedView(fr.AsyncRestView):
    """Internal base, never registered directly."""
    dependencies = [Current.depends(tenant_id=get_current_tenant_id)]


@fr.include_view(app)
class InvoiceView(AuthenticatedView):
    prefix = "/invoices"
    model = Invoice
    schema = InvoiceSchema


@fr.include_view(app)
class CustomerView(AuthenticatedView):
    prefix = "/customers"
    model = Customer
    schema = CustomerSchema

The dependency binds the current tenant for both views. A session-level with_loader_criteria rule applies that tenant to every ORM read, including reference checks and reads that replace a view scope. Keep scope available for replaceable surfaces such as soft deletion and role visibility. The tenant row scoping recipe contains the session rule, column stamp, and shared view base.

Override a single tier#

AsyncRestView and RestView split every CRUD verb into three tiers: the endpoint method (HTTP contract), the handler (authorization + commit bracket), and the business method (domain logic, auth-free and commit-free). One behavior change therefore means one method override, while routing, authorization, and the commit stay framework-owned. The tier model, both request lifecycles, the override decision table, and task-shaped recipes live in Customizing RestView.

Dependency injection on class attributes#

A class attribute on a view is wired as a FastAPI dependency only when its annotation either:

  • carries an Annotated[..., Depends(...)] marker (Security(...) counts, since it is a kind of Depends), or

  • names one of FastAPI’s bare-injectable special types (Request, Response, BackgroundTasks, WebSocket).

Every other annotation is only a type hint. This differs from a function parameter, where FastAPI reads a plain annotation as a query or body parameter. The following view demonstrates each case:

from typing import Annotated

from fastapi import Depends, Request

class UserView(fr.AsyncRestView):
    # Wired: AsyncSessionDep is Annotated[AsyncSession, Depends(...)].
    session: fr.AsyncSessionDep

    # Wired: Request is one of FastAPI's bare-injectable specials.
    request: Request

    # Wired: explicit Depends marker.
    current_user: Annotated[User, Depends(get_current_user)]

    # NOT wired: a bare type is only a hint.
    reviewer: User

A wired attribute is set on the view instance before any method runs, so endpoint methods, business methods, authorize, and the commit hooks all read it as self.current_user. An attribute that is not wired is never set from the request: reading self.reviewer raises AttributeError.

The shipped AsyncSessionDep / SessionDep aliases carry Depends. The request attribute on BaseRestView relies on the special-type rule.

A request parameter belongs on the endpoint method that reads it. A class attribute with a Path(), Query(), Header(), Cookie(), Body(), Form() or File() marker raises RestlyConfigurationError when the view is registered, because FastAPI would never set it. A path segment or header that every route of the view needs comes through a dependency instead; Nested Resources shows the pattern for a path segment.

A plain annotation never replaces a wired one. A mixin or subclass that declares current_user: User so type checkers know the attribute keeps the base’s Depends wiring. This rule makes mixins safe: a mixin can declare what it expects from its host without shadowing the host’s wiring. See Composing views with mixins for the mixin pattern.

When to use View directly#

View is the right tool when your endpoints do not fit a CRUD shape:

@fr.include_view(app)
class AuthView(fr.View):
    prefix = "/auth"
    tags = ["auth"]

    @fr.post("/login")
    async def login(self, credentials: LoginRequest) -> Token:
        ...

    @fr.post("/refresh")
    async def refresh(self, token: str) -> Token:
        ...

    @fr.post("/logout")
    async def logout(self) -> None:
        ...

The class gives three related endpoints one shared prefix and tag, and a single place for auth dependencies, with no model, no schema, and no CRUD.

When not to use a CBV#

If you have a single one-off endpoint that does not share anything with others, write a plain function endpoint. CBVs pay off when you have shared metadata, shared dependencies, or related endpoints that benefit from being co-located. Do not reach for them just for the sake of structure.

Next steps#