Patterns#

This page collects common scenarios and gives the idiomatic Restly answer to each. Entries are short on purpose: each states the problem, shows the recommended shape in one example, and links to the page that owns the depth. Every example on this page runs against the current release.

Nested resources (/projects/{id}/tasks)#

To expose child rows under a parent, model the child as a flat resource and filter by its foreign key; the filter parameter is generated automatically for MustExist fields:

class TaskRead(fr.IDSchema):
    title: str
    project_id: fr.MustExist[int, Project]


@fr.include_view(app)
class TaskView(fr.AsyncRestView):
    prefix = "/tasks"
    model = Task
    schema = TaskRead

Clients then scope the list through the query string:

GET /tasks/?project_id=17        # all tasks of one project
GET /tasks/?project_id__in=1,2   # tasks of several projects

When the nested URL is part of your API contract, add a custom route on the parent view, so that parent scoping and 404 behavior come from the parent’s read path:

import sqlalchemy as sa

class ProjectView(fr.AsyncRestView):
    prefix = "/projects"
    model = Project
    schema = ProjectRead

    @fr.get("/{id}/tasks", response_model=list[TaskRead])
    async def list_tasks(self, id: int):
        project = await self.handle_get_one(id)  # scoping, 404, and read-auth
        query = sa.select(Task).where(Task.project_id == project.id)
        tasks = (await self.session.scalars(query)).all()
        return [TaskRead.model_validate(t, from_attributes=True) for t in tasks]

The filter grammar, including foreign-key filtering, is documented in Filter, Sort, and Paginate Lists; custom routes are covered in Customize RestView.

A different schema for the list endpoint#

There is no schema_list attribute. A different list shape is an HTTP-contract change, so it belongs in the endpoint method: replace get_many_endpoint with your own response_model and serialize through the slimmer schema. Filtering, sorting, and pagination parameters keep working:

class UserSummary(fr.IDSchema):
    name: str


class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User
    schema = UserRead  # detail routes keep the full schema

    @fr.get("/", response_model=list[UserSummary])
    async def get_many_endpoint(self, query_params):
        result = await self.handle_get_many(query_params)
        return [
            UserSummary.model_validate(u, from_attributes=True)
            for u in result.objects
        ]

Replacing an endpoint method is the outermost override; see Replace an endpoint method to change the HTTP contract.

Restore a soft-deleted row#

Soft delete hides rows in build_query, so every generated read returns 404 for them, including the read your restore action needs. The restore route therefore makes a deliberately unscoped query, then mutates inside write_action so that authorization and the commit bracket still run:

class ItemView(fr.AsyncRestView):
    prefix = "/items"
    model = Item
    schema = ItemRead

    def build_query(self):
        return super().build_query().where(self.model.deleted_at.is_(None))

    async def delete(self, obj):
        obj.deleted_at = datetime.now(timezone.utc)

    @fr.post("/{id}/restore", response_model=ItemRead, status_code=200)
    async def restore(self, id: int):
        # The framework's read path calls build_query() with no arguments,
        # so the bypass is an explicit query here, visibly on purpose.
        query = sa.select(self.model).where(self.model.id == id)
        obj = (await self.session.scalars(query)).one_or_none()
        if obj is None:
            raise fr.exc.NotFound(f"Item {id!r} not found")
        async with self.write_action("restore", obj=obj):
            obj.deleted_at = None
        return self.to_response(obj)

Soft delete itself is covered as a one-off override in Customize RestView and as a reusable mixin in Compose Views with Mixins, which also discusses the admin bypass.

Receive a webhook (inbound)#

An inbound webhook receiver is not CRUD, so use a bare fr.View with the raw Request. Verify the signature before parsing, and commit explicitly: the framework’s auto-commit bracket only wraps RestView handlers, so a bare View route owns its commit (the same contract as fr.open_async_session()).

from fastapi import Request

@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(kind=event["type"], data=payload.decode()))
        await self.session.commit()  # a bare View owns its commit

For outbound webhooks (calling someone else after a write), use the after_commit hook instead; see Customize RestView.

The decision between View and RestView is covered in When to use View directly.

An app-wide base view#

Declare session, current_user, and the rest of your request context once on a bare View base; every endpoint group (CRUD or not) subclasses it and reads from self. This pattern is owned by One base view for the whole app in Class-Based Views.

Login and other auth flows#

An AuthView with /login, /refresh, and /logout routes is the worked example in When to use View directly, which owns this pattern.

Custom action routes (POST /{id}/publish)#

Reuse handle_<verb> when the action is CRUD under another URL; use write_action("publish", ...) when the action has its own identity. The full walkthrough is Add a custom action route in Customize RestView, which owns this pattern.

Tenant scoping#

A TenantScopedMixin filters every read through build_query and stamps writes cooperatively; the pattern is owned by TenantScopedMixin in Compose Views with Mixins. The single-base-class variant is in Share Behaviour with Base Views.