Class-Based Views#
Class-based views are the core of FastAPI-Restly. They make REST scaffolding subclassable, keep shared behavior in one place, and let you override one method without rewriting the route.
When to use what#
View is a general route-organization layer, not just scaffolding for CRUD:
You’re building |
Reach for |
|---|---|
One simple standalone endpoint |
A plain FastAPI route — no Restly needed |
A group of related non-CRUD endpoints — login/auth flows, webhook receivers, RPC-style actions, composite-key resources |
|
A database-backed CRUD resource |
|
CRUD plus custom actions — publish, vote, bulk operations |
|
The rest of this page explains the machinery behind all four rows.
What is a class-based view?#
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 (CBV) groups related endpoints 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[UserRead]:
users = await self.session.scalars(select(User))
return [UserRead.model_validate(user) for user in users]
@fr.post("")
async def create_user(self, payload: UserCreate) -> UserRead:
user = User(**payload.model_dump())
self.session.add(user)
await self.session.flush()
return UserRead.model_validate(user)
Declare dependencies, prefix, tags, and metadata 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. They get repetitive in larger codebases:
Repetition. The same
Depends(get_session), the same auth dependency, the same response config — all duplicated across every related endpoint.Scattering. Endpoints that conceptually belong together (everything about users, everything about invoices) live as separate top-level functions. 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’re attributes on
self.
A CBV solves all three with one tool: the class itself.
The FastAPI-Restly model#
class View:
prefix: ClassVar[str]
tags: ClassVar[Iterable[str] | None] = None
dependencies: ClassVar[Iterable[Any] | None] = None
responses: ClassVar[dict[int, Any]] = {}
@classmethod
def before_include_view(cls): ...
That is the base class: FastAPI router metadata plus one pre-registration hook.
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, instantiates a per-request copy of the view, 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.
True subclassing#
The naive way to add CBV support to FastAPI is a class decorator that mutates the class on definition:
@cbv(router)
class UserView:
@router.get("/users")
async def list_users(self): ...
That works for a single class. It falls apart the moment you try to subclass:
Routes are registered on
routeragainstUserView. Overridelist_userson a subclass — the registered handler still calls the original.Re-decorate the subclass with
@cbv(router)and you get duplicate routes.Decorate the subclass on a different router and only the subclass’s directly-decorated methods register; the parent’s routes don’t follow.
FastAPI-Restly avoids this by deferring registration:
class AdminUserView(UserView):
async def list_users(self):
# filter to soft-deleted users
...
fr.include_view(admin_app, AdminUserView)
When include_view runs, it walks AdminUserView.__mro__, finds inherited
route metadata, and registers handlers against AdminUserView. Your override
runs. The same view can be included on multiple routers.
That is what “true class-based views” means in this framework. You can:
Define an abstract parent that supplies handlers but is never registered.
Subclass a working view to specialise it for a different prefix, a different role, or a different audience.
Mix in behaviour through multiple inheritance — see the share-behaviour guide.
One base view for the whole app#
The payoff of true subclassing, and the simplest big win: declare your app’s
request context once, on a bare View, and subclass it everywhere. No
CRUD 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 — and the same base
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 win: 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#
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
Viewis the bare CBV primitive. Use it for non-CRUD endpoints: auth flows, custom RPC, file uploads, or composite-key resources.BaseRestViewextendsViewwithmodel,schema, the auto-generated create/update schemas (schema_create/schema_update), query-modifier configuration, and helper methods liketo_response()andto_response_schema(). The concrete CRUD methods live onRestView/AsyncRestView;BaseRestViewis an abstract scaffold with no endpoints of its own.RestViewandAsyncRestViewprovide the concrete sync and async implementations of the CRUD endpoints. One of these is what you usually subclass. They assume a single scalar resource id for the generated/{id}routes; composite primary keys are not supported by the default CRUD view contract. For legacy tables with composite keys, subclassViewdirectly and define routes that match your API shape.
The public method surface is classified in the
API reference. Each CRUD verb is split
into three tiers: <verb>_endpoint (HTTP contract), handle_<verb>
(authorization + commit bracket), and <verb> (domain operation). Cross-cutting
override points include build_query, authorize, hooks, and to_response.
Override a single tier#
AsyncRestView and RestView split every CRUD verb into three tiers — the
route shell (wire contract), the request 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 model, both request
lifecycles, and the override decision table live in
How Overrides Work: The Three Tiers; task-shaped
recipes in Override CRUD Behavior.
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, ornames one of FastAPI’s bare-injectable special types (
Request,Response,BackgroundTasks,WebSocket).
This matches FastAPI function parameters. A plain annotation like
model: type[Foo] is only a type hint.
from fastapi import Request
from typing import Annotated
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: plain annotation, just a type hint.
model: type[User]
The shipped AsyncSessionDep / SessionDep aliases carry Depends, so
they keep working unchanged. The request attribute on BaseRestView
relies on the special-type rule. Any custom dependency declared on a view
class must use the Annotated[X, Depends(...)] form unless it’s one of
the bare-injectable types.
This 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 don’t 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:
...
Three related endpoints, one shared prefix and tag, one place for auth dependencies. No model, no schema, 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. Don’t reach for them just for the sake of structure.
Cross-references#
How Overrides Work: The Three Tiers — the tier model behind every CRUD verb, both request lifecycles, and the override decision table.
Override Endpoints — every tier on
AsyncRestView/RestView, with call-chain diagrams.Share Behaviour with Base Views — patterns for multi-tenant scoping, role-based filtering, and shared mixins.
API Reference — full
View,BaseRestView,RestView,AsyncRestViewsignatures and class attributes.