Patterns#
Common scenarios and the idiomatic Restly answer to each. Entries are short on purpose: the problem, the blessed shape, one example, and a link to the page that owns the depth. Every example on this page runs against the current release.
Nested resources (/projects/{id}/tasks)#
Model the child as a flat resource and filter by its foreign key — the
filter parameter is generated automatically for IDRef fields:
class TaskRead(fr.IDSchema):
title: str
project_id: fr.IDRef[Project]
@fr.include_view(app)
class TaskView(fr.AsyncRestView):
prefix = "/tasks"
model = Task
schema = TaskRead
GET /tasks/?project_id=17 # all tasks of one project
GET /tasks/?project_id__in=1,2 # of several
When the nested URL is part of your API contract, add a custom route on the parent view, so 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) # scope + 404 + 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]
Depth: Filter, Sort, and Paginate Lists (the
filter grammar, including #foreign-key-filtering) and
Override CRUD Behavior (custom routes).
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 route shell: 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
]
Depth: Override CRUD Behavior → Tier 1.
Restore a soft-deleted row#
Soft delete hides rows in build_query, so every generated read 404s on them —
including the read your restore action needs. The restore route therefore
makes a deliberately unscoped query, then mutates inside write_action so
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)
Depth: soft delete itself is owned by Override CRUD Behavior (one-off) and Compose Views with Mixins (reusable mixin + the admin-bypass discussion).
Receive a webhook (inbound)#
An inbound webhook receiver is not CRUD — 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
How Overrides Work.)
An app-wide base view#
Owned by Class-Based Views → One base view for the whole
app — 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.
Login and other auth flows#
Owned by Class-Based Views → When to use View
directly — an AuthView
with /login, /refresh, and /logout routes is the worked example.
Custom action routes (POST /{id}/publish)#
Owned by How Overrides Work → Worked example: a custom action
route — reuse
handle_<verb> when the action is CRUD under another URL; use
write_action("publish", ...) when it has its own identity.
Tenant scoping#
Owned by Compose Views with Mixins —
a TenantScopedMixin filters every read through build_query and stamps
writes cooperatively. The single-base-class variant is in
Share Behaviour with Base Views.