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:

  • prefix and dependencies apply to every route of the view. require_login runs before both endpoints.

  • @fr.get and @fr.post only mark a method as a route. They register nothing.

  • fr.include_view registers 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. So self.session is 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

fr.View (Group endpoints that are not CRUD)

CRUD routes for a database model

fr.AsyncRestView or fr.RestView (CRUD Routes)

CRUD routes plus actions such as publish

AsyncRestView or RestView with extra @fr.get or @fr.post methods (Add a custom action route)

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

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 a RestlyMisuseWarning. 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/me next 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 inside Annotated[...]. A Security(...) marker counts too, because it is a kind of Depends.

  • It names one of FastAPI’s special types: Request, Response, BackgroundTasks or WebSocket.

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

Share behavior with base views#

A base view is a view class that you subclass but do not register. Each subclass inherits what the base declares: dependency attributes, dependencies, route methods, overrides and other class attributes. Register only the subclasses.

A base view overrides the same methods as a single view. How a view handles a request explains which method to override for each change. Two shared concerns have their own sections: values that every write path must set, such as an owner id, on Models, and soft delete on Scopes.

A route on a base view names the classes of each subclass with markers, such as ViewResponse; see Name the view’s classes in a custom route.

One base view for the whole app#

A base view for the whole app declares the request context once. Every view in the app subclasses it. It needs no CRUD:

from typing import Annotated

from fastapi import Depends
from sqlalchemy import select

import fastapi_restly as fr


class AppView(fr.View):
    """The base of every view in the app. Never registered."""

    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 MyInvoicesView(AppView):
    prefix = "/my-invoices"

    @fr.get("")
    async def my_invoices(self) -> list[InvoiceSchema]:
        invoices = await self.session.scalars(
            select(Invoice).where(Invoice.user_id == self.current_user.id)
        )
        return [InvoiceSchema.model_validate(invoice) for invoice in invoices]

In plain FastAPI, each function in the app would declare session and current_user as parameters again. Here, each method reads them from self. CRUD views can use the same base:

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

This also helps tests: FastAPI’s app.dependency_overrides replaces get_current_user for every view at once.

Run a dependency on every route#

dependencies lists FastAPI dependencies that run for every route of the view. They give no value to the methods. Use them for a check, such as a login check, or to bind a fr.ContextNamespace such as Current for the request:

from fastapi import Depends


class AuthenticatedView(fr.AsyncRestView):
    """Never registered."""

    dependencies = [
        Depends(require_login),
        Current.depends(user_id=get_current_user_id),
    ]


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


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

Every route of /invoices and /customers now requires a login and binds Current.user_id. Bind from a FastAPI dependency explains the binding. The SaaS example’s AuthenticatedView uses this pattern (app/views.py). To limit every read to the rows of one tenant, see Multi-Tenancy.

When a subclass sets its own dependencies, they run after the base’s, in the same way as nested FastAPI routers. So a subclass cannot remove a check that its base declares. To run its own dependency first, a subclass lists the base’s dependencies again after it: dependencies = [own, *AuthenticatedView.dependencies]. Each dependency still runs only once. responses add up in the same way, and for a status code that both declare, the subclass’s entry wins. Concatenate URL prefixes shows how prefix adds up. Other class attributes, such as tags, replace the base’s value.

Share an authorization check#

A base view can read a dependency attribute in a method that every subclass runs. Here, every caller may read and create, and only the owner of a row may do anything else to it:

from typing import Annotated

from fastapi import Depends


class OwnedView(fr.AsyncRestView):
    """Base for rows that only their owner may change. Never registered."""

    current_user: Annotated[User, Depends(get_current_user)]

    async def authorize(self, action, obj=None, data=None):
        if action in (fr.Action.GET_MANY, fr.Action.GET_ONE, fr.Action.CREATE):
            return
        if obj is None or obj.owner_id != self.current_user.id:
            raise fr.exc.Forbidden("Only the owner can change this")


@fr.include_view(app)
class NoteView(OwnedView):
    prefix = "/notes"
    model = Note


@fr.include_view(app)
class AlbumView(OwnedView):
    prefix = "/albums"
    model = Album

NoteView and AlbumView now run the same check. The owner check also covers update, delete and every custom action, such as an archive route, so a new action is never open to every caller. Authorization explains authorize. The blog example’s AuthoredView shares one authorize between posts and comments in the same way (blog/main.py).

The view only reads the caller here. To store the caller on a row, such as the owner id, use a column default on the model, as Stamp the fields the server owns shows.

Share a business method#

A base view can override a business method once for all its subclasses. Here, two resources store a password hash:

class PasswordHashingView(fr.AsyncRestView):
    """Base for resources that store a password hash. Never registered."""

    async def create(self, data):
        obj = await super().create(data)
        obj.password = hash_password(data.password)
        return obj

    async def update(self, obj, data):
        obj = await super().update(obj, data)
        if "password" in data.model_fields_set:
            obj.password = hash_password(data.password)
        return obj


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


@fr.include_view(app)
class ServiceAccountView(PasswordHashingView):
    prefix = "/service-accounts"
    model = ServiceAccount
    schema = ServiceAccountSchema

Here, only the HTTP routes receive a plain password, so the hash belongs in create and update. The generated update body accepts password too, so without the update override a PATCH request would store the new password without a hash. Hash a password explains these overrides for one view.

A subclass can override the same method again. Its version then replaces the base’s version. To run the base’s code too, call super(), as in any Python class.

Run code after the commit#

A base view can also share a transaction hook:

class NotifyingView(fr.AsyncRestView):
    async def after_action_commit(self, action, new, old=None):
        if action == fr.Action.CREATE:
            await notify_created(new)

Every subclass of NotifyingView calls notify_created after a create is committed. This is also true when a custom route groups several writes with shared_write_action_commit(): the hook then waits until the outermost block commits. Run code before or after the commit explains both hooks, and When after_action_commit raises explains what happens when the hook fails.

Inherit routes#

Every registered subclass inherits the custom routes of its base:

class FieldsRouteView(fr.AsyncRestView):
    @fr.get("/fields")
    async def fields(self) -> list[str]:
        return list(self.schema_response.model_fields)


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

GET /users/fields now exists next to the CRUD routes of UserView. A subclass also inherits exclude_routes, so a base view can leave out the same CRUD routes in every subclass; see Exclude CRUD routes.

Concatenate URL prefixes#

When a base view sets prefix, the prefix of each subclass is added after it. So you declare a shared URL namespace once:

class ApiV1(fr.AsyncRestView):
    prefix = "/api/v1"


@fr.include_view(app)
class UserView(ApiV1):
    prefix = "/users"     # becomes /api/v1/users
    model = User


@fr.include_view(app)
class OrderView(ApiV1):
    prefix = "/orders"    # becomes /api/v1/orders
    model = Order

This works for any number of levels:

class AdminBase(fr.AsyncRestView):
    prefix = "/admin"


class V2Base(AdminBase):
    prefix = "/v2"


@fr.include_view(app)
class ReportView(V2Base):
    prefix = "/reports"   # becomes /admin/v2/reports
    model = Report

A prefix can also contain a path parameter, such as /projects/{project_id}. Nest a resource under its parent builds child resources in this way.

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:

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

  2. It checks that no route method has the name of a business method.

  3. It gives each route method a unique name, such as userview_get_one_endpoint. It makes the self parameter a dependency on the view class, Depends(UserView), so FastAPI creates a new view for each request.

  4. It calls before_include_view(). On a CRUD view, this builds the schemas that the view does not declare and the schema_list_params. It sets the item path from id_type, fills in the types of the endpoint methods, and removes the route marker of each excluded route.

  5. It turns the dependency attributes into keyword parameters of the view’s __init__. FastAPI then resolves them, and the new instance stores them.

  6. It builds an APIRouter. The prefix joins the prefixes of all classes in the hierarchy, base first. Their dependencies and responses add 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#