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/{project_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 TaskSchema(fr.IDSchema):
    title: str
    project_id: fr.MustExist[int, Project]


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

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, serve the child view under the parent instead: a path parameter in its prefix, a lookup that answers 404 for an unknown or hidden parent, and a scope that keeps that parent’s rows. Every CRUD route keeps paging, filters and sort. Nested Resources shows the full example, how to create children, and deeper nesting.

The filter grammar, including foreign-key filtering, is documented in Filter, Sort, and Paginate Lists; custom routes are covered in Customizing 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 = UserSchema  # detail routes keep the full schema

    @fr.get("/", response_model=list[UserSummary])
    async def get_many_endpoint(self, list_params):
        result = await self.handle_get_many(list_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#

The soft-delete scope hides deleted rows from every default read, including the read your restore action needs. The restore route therefore reads through the complementary clause, then mutates inside write_action so that authorization and the commit bracket still run:

is_deleted = fr.where_clause(Item.deleted_at.is_not(None))
ItemResponse = fr.schemas.derive_schema_response(ItemSchema)

class ItemView(fr.AsyncRestView):
    prefix = "/items"
    model = Item
    schema = ItemSchema
    scope = fr.none_of(is_deleted)

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

    @fr.post("/{id}/restore", response_model=ItemResponse, status_code=200)
    async def restore(self, id: int):
        # Reads through the complement of the view scope: only a deleted
        # row can be restored, and the bypass is visible on purpose.
        obj = await self.get_one(id, scope=is_deleted)
        async with self.write_action("restore", obj=obj):
            obj.deleted_at = None
        return self.to_response(obj)

The restore route names the view’s response class, so OpenAPI shows ItemResponse, as for the CRUD routes; see A custom route names the same classes.

scope= replaces the view scope for that read; it does not narrow it. Keep rules that a restore must never skip, such as tenant isolation, at the session level (Tenant scoping). A route names its own scope owns the details.

Soft delete itself is covered as a one-off override in Customizing 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_action_commit hook instead, or an outbox row for a webhook that must arrive. See When after_action_commit raises.

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. One base view for the whole app owns this pattern.

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. Add a custom action route provides the full walkthrough.

Tenant scoping#

A session-level with_loader_criteria rule filters every ORM SELECT, including reads that replace a model’s default scope. The model stamps its tenant column on every write. Tenant row scoping owns the complete pattern.