Views#
A view is a Python class that groups related FastAPI endpoints. The class declares once what its endpoints share, such as a URL prefix and dependencies. Its methods are the endpoints. A subclass inherits all of this, and it can override any method.
How a view works#
In plain FastAPI, an endpoint is a function, and each function declares what it needs:
@app.get("/users", dependencies=[Depends(require_login)])
async def list_users(session: AsyncSession = Depends(get_session)):
...
@app.post("/users", dependencies=[Depends(require_login)])
async def create_user(payload: UserCreate, session: AsyncSession = Depends(get_session)):
...
A view declares the same endpoints as methods of one class:
import fastapi_restly as fr
from fastapi import Depends
from sqlalchemy import select
class UserView(fr.View):
prefix = "/users"
dependencies = [Depends(require_login)]
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.commit()
return UserSchema.model_validate(user)
fr.include_view(app, UserView)
The parts work together like this:
prefixanddependenciesapply to every route of the view.require_loginruns before both endpoints.@fr.getand@fr.postonly mark a method as a route. They register nothing.fr.include_viewregisters the routes on the app. It reads the class at that moment, together with the routes that the class inherits.For each request, FastAPI creates a new instance of the view. FastAPI resolves each dependency attribute, such as
session, and the instance gets the value before the method runs. Soself.sessionis the session of this request.The methods are normal Python methods. They can call other methods and read class attributes through
self.
Because the routes bind when you register the class, a subclass gets the routes of its base. When the subclass overrides a method, its own version runs.
Choose a view class#
A view helps when endpoints share something: dependencies, OpenAPI metadata, or a value such as the current user. For one endpoint that shares nothing, a plain FastAPI function is simpler.
You are building |
Use |
|---|---|
One endpoint that shares nothing with others |
A plain FastAPI function, with no Restly view |
A group of endpoints that are not CRUD: login, webhooks, RPC-style actions, resources with a composite key |
|
CRUD routes for a database model |
|
CRUD routes plus actions such as publish |
|
Choose async or sync explains the choice between
AsyncRestView and RestView. All view classes follow the rules on this page
for routes, registration, dependencies and inheritance.
The view classes#
The CRUD views build on View. Each class
adds to the one above it:
View ← routes, prefix, dependencies; no CRUD
└── BaseRestView ← model, schemas, list and response helpers; no routes
├── RestView ← sync CRUD routes
│ └── ReactAdminView ← + the ra-data-simple-rest contract
└── AsyncRestView ← async CRUD routes
└── AsyncReactAdminView ← + the ra-data-simple-rest contract
Viewis the base of every view. Use it directly for endpoints that are not CRUD.BaseRestViewholds the configuration and the helpers that the CRUD views share. It has no routes. Import it fromfastapi_restly.views; it is not in thefrnamespace.RestViewandAsyncRestViewadd the sync and async CRUD routes. CRUD Routes describes them.ReactAdminViewandAsyncReactAdminViewserve the contract that React Admin expects; see React Admin.
View Reference lists every method of these classes.
Declare routes#
A route is a method with a route decorator. Use
@fr.get,
@fr.post,
@fr.put,
@fr.patch or
@fr.delete. Each one sets the HTTP
method and a default status code. Endpoint decorators
lists the status codes. Pass status_code= to use another one.
The path is relative to the view’s prefix. With prefix = "/users",
@fr.get("/{id}/summary") serves /users/{id}/summary, and @fr.get("")
serves /users itself.
On a CRUD view, a custom route usually loads and writes through the view’s handlers. Add a custom read route and Add a custom action route show how.
Details
Every keyword argument other than status_code= goes to FastAPI’s
APIRouter.add_api_route(), such as response_model=, dependencies=,
responses= and tags=. So a route on a view has the same options as a
FastAPI route.
To serve one path with more than one HTTP method, use
@fr.route and list the methods. It sets
no method and no status code for you:
@fr.route("/{id}/thumbnail", methods=["GET", "HEAD"], status_code=200)
async def thumbnail(self, id: int):
...
A route method must not have the name of a view setting, such as tags or
prefix, or on a CRUD view model or pagination: the method would
replace the setting. On a CRUD view, it must not have the name of a
business method either: create, update or delete. Both fail with
RestlyConfigurationError when Python creates the class, also for a route
of a mixin. A plain View has no business methods, so these three names are
free there. A CRUD view class that defines get_one, get_many or select
fails when Python creates it too, because the loads are final. Give the
route another name; its path stays the same. To replace a CRUD route,
override its endpoint method, such as create_endpoint; see
Change the status code or headers of one route.
Register a view#
Call fr.include_view with the app
and the view class:
fr.include_view(app, UserView)
The first argument is a FastAPI app or an APIRouter. In a larger app, define
each view in its own module, and do not register it there. Register all views
in the module that builds the app. Importing a view module then has no side
effects. Compose in one place shows this layout.
In a small app or a single file, use include_view as a class decorator. It
registers the class when Python defines it:
@fr.include_view(app)
class UserView(fr.View):
...
Both forms register the same routes.
Details
The routes bind when you register the view, not when you declare the
methods. include_view reads the class at that moment: its own route
methods and the ones it inherits, and the prefix and dependencies of every
class in its hierarchy. On a CRUD view, it also builds each schema that the
view does not declare. Internals lists the
steps.
Some rules for registration:
Register only the classes that serve routes. A base view that you only subclass stays unregistered; see Share behavior with base views.
You can register one view on more than one parent, such as a public app and an admin router. Each parent gets the routes.
Registering a view again on the same parent does nothing. With
fr.configure(warn_on_misuse=True), Restly warns about it with aRestlyMisuseWarning. It usually means that a module was imported twice, or that the decorator and a direct call both registered the view.FastAPI tries the routes in the order you added them. For a route such as
/users/menext to the CRUD routes, see Item paths and static routes.
Declare dependencies as class attributes#
A class attribute on a view is a FastAPI dependency only when its annotation does one of these:
It carries a
Depends(...)marker insideAnnotated[...]. ASecurity(...)marker counts too, because it is a kind ofDepends.It names one of FastAPI’s special types:
Request,Response,BackgroundTasksorWebSocket.
Every other annotation is only a type hint. This is different from a function parameter: there, FastAPI reads a plain annotation as a query or body parameter. This view shows each case:
from typing import Annotated
from fastapi import Depends, Request
class UserView(fr.AsyncRestView):
# Dependency: AsyncSessionDep is Annotated[AsyncSession, Depends(...)].
session: fr.AsyncSessionDep
# Dependency: Request is one of FastAPI's special types.
request: Request
# Dependency: an explicit Depends marker.
current_user: Annotated[User, Depends(get_current_user)]
# Not a dependency: a plain type is only a hint.
reviewer: User
The view instance gets each dependency attribute before any method runs.
So endpoint methods, business methods, authorize and the
commit hooks can all read self.current_user. An attribute that is not a
dependency is never set from the request: reading self.reviewer raises
AttributeError.
Details
fr.AsyncSessionDep and
fr.SessionDep carry a Depends
marker. The request attribute of
BaseRestView is a dependency
because of the special-type rule.
Put a request parameter, such as a query parameter or a header, on the
endpoint method that reads it. A class attribute with a Path(), Query(),
Header(), Cookie(), Body(), Form() or File() marker raises
RestlyConfigurationError
when you register the view, because FastAPI would never set it. When every
route of the view needs the same path segment or header, read it in a
dependency. Nest a resource under its parent does this
for a path segment.
A plain annotation never replaces a dependency annotation. A mixin or a
subclass can declare current_user: User for type checkers, and the
attribute keeps the Depends marker of the base. So a mixin can declare what
it needs from its view without breaking the view’s dependencies.
Declare what a mixin needs from its view#
A mixin is a class that you list before the view class in the bases, as in
class ProjectView(SoftDeleteMixin, fr.AsyncRestView). When its methods read
attributes of the view, declare them for type checkers with an annotation
without a value. Such an annotation adds nothing to the class, so it hides
nothing:
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
class SoftDeleteMixin:
session: AsyncSession
async def delete(self, obj: Any) -> None:
obj.deleted_at = datetime.now(timezone.utc)
self.session.add(DeletionLog(row_id=obj.id)) # a model of your own
When the shared code calls methods of the view, such as get_one or a
handler, write a base view instead of a mixin. A base view subclasses the
view class, so it has the real methods, with their types:
from fastapi_restly.views import ViewID, ViewResponse
class ArchiveView(fr.AsyncRestView):
"""Base for views with an archive route. Never registered."""
class Action(fr.Action):
ARCHIVE = "archive"
@fr.post("/{id}/archive", status_code=200)
async def archive(self, id: ViewID) -> ViewResponse:
obj = await self.get_one(id)
async with self.write_action(self.Action.ARCHIVE, obj=obj):
obj.archived = True
return obj
@fr.include_view(app)
class ProjectView(ArchiveView):
prefix = "/projects"
model = Project
Share behavior with base views shows more of them.
Details
Do not declare the view’s methods in a mixin, not even as stubs. A stub
method is a real method, and Python finds it before the view’s method, so
await self.update(obj, data) returns None. Under if TYPE_CHECKING:,
Python does not add the stub to the class, but type checkers still find it
before the view’s method. A stub such as
async def update(self, obj: Any, data: Any) -> Any then hides the typed
update in every view that uses the mixin. A stub of a load or a handler,
such as get_one or handle_get_many, makes the view class fail when
Python creates it. Under if TYPE_CHECKING:, mypy reports it as an
error.
To keep the model’s type in each subclass, make the base view generic in the
model. The TrashView of the SaaS example does this, and it binds the model
to the model mixin that has the deleted_at column
(app/views.py).
Group endpoints that are not CRUD#
Use a plain View for endpoints that do
not fit the CRUD shape: login routes, webhook receivers, RPC-style actions. A
View has no model, no schema and no CRUD routes. It gives the endpoints one
prefix, one tag and one place for shared dependencies.
A View route that writes to the database must commit, because only the
CRUD views commit for you; see What commits, and when.
Login, refresh and logout routes#
@fr.include_view(app)
class AuthView(fr.View):
prefix = "/auth"
tags = ["auth"]
session: fr.AsyncSessionDep
@fr.post("/login", status_code=200)
async def login(self, credentials: LoginRequest) -> Token:
...
@fr.post("/refresh", status_code=200)
async def refresh(self, body: RefreshRequest) -> Token:
...
@fr.post("/logout", status_code=204)
async def logout(self) -> None:
...
@fr.post answers 201 by default. A login creates no resource, so these
routes set their own status code. The methods are FastAPI endpoints, so they
can use FastAPI’s security tools like any other endpoint. To make the
logged-in user available to other views, bind it for each request; see
Bind from a FastAPI dependency.
Receive a webhook#
A receiver for incoming webhooks is not CRUD, so use a plain View and read
the raw Request. Check the signature before you parse the body, and commit
yourself:
import json
from fastapi import Request
from sqlalchemy.exc import IntegrityError
@fr.include_view(app)
class PaymentWebhookView(fr.View):
prefix = "/webhooks"
session: fr.AsyncSessionDep
@fr.post("/payments", status_code=204)
async def receive_payment_event(self, request: Request):
payload = await request.body()
verify_signature(payload, request.headers.get("X-Signature"))
event = json.loads(payload)
self.session.add(
PaymentEvent(event_id=event["id"], kind=event["type"], data=payload.decode())
)
try:
await self.session.commit() # a plain View commits itself
except IntegrityError: # the same event again: event_id is unique
await self.session.rollback()
A route that commits itself must also handle its own database errors.
Senders often send the same event twice, so here a unique event_id column
refuses the second copy, and the route answers 204 again. Restly does not
turn an IntegrityError here into a 409, because the route is outside a
Restly write; see
Unique values and other database conflicts: 409.
To call another service after a write, use the
after_action_commit
hook of a CRUD view instead. For a call that must not get lost, write an
outbox row in before_action_commit; see
When after_action_commit raises.
Internals#
include_view() connects a view
class to FastAPI. FastAPI builds routes from function signatures, so most of
its work is to give the route methods the right signatures. If the view is
already registered on the same parent, it does nothing. Otherwise, it runs
these steps:
It copies each route method that the class inherits onto the class itself. Restly can then change the signature of the copy without changing the base or other subclasses.
It checks that no route method has the name of a business method.
It gives each route method a unique name, such as
userview_get_one_endpoint. It makes theselfparameter a dependency on the view class,Depends(UserView), so FastAPI creates a new view for each request.It calls
before_include_view(). On a CRUD view, this builds the schemas that the view does not declare and theschema_list_params. It sets the item path fromid_type, fills in the types of the endpoint methods, and removes the route marker of each excluded route.It turns the dependency attributes into keyword parameters of the view’s
__init__. FastAPI then resolves them, and the new instance stores them.It builds an
APIRouter. The prefix joins the prefixes of all classes in the hierarchy, base first. Theirdependenciesandresponsesadd up in the same order. It adds each route method with its decorator arguments, and then includes the router in the parent.
Steps 1 to 5 run once for each class. A second registration on another parent only builds a new router.
before_include_view() is a class method that you can override to change
the route methods before registration. On a CRUD view, call super() in it.
See also#
CRUD Routes: the default CRUD routes and their configuration.
Request Lifecycle & Hooks: how a view handles a request, and which method to override.
View Reference: every class attribute and method of
View,BaseRestView,RestViewandAsyncRestView.