Nested Resources#
A nested resource is a child resource served under its parent in the URL,
such as /projects/{project_id}/tasks. In Restly it is an ordinary
AsyncRestView with three
additions: a path parameter in its prefix, a dependency that looks up the
parent, and a scope that
limits the rows to that parent. Every CRUD route keeps working, including
paging, filters and sort.
A flat view filtered by the foreign key, GET /tasks?project_id=17, needs no
extra code; see Foreign-key filtering. Nest the
view when the nested URL is part of your API.
One level#
This example serves the tasks of a project at
/projects/{project_id}/tasks:
import sqlalchemy as sa
from fastapi import FastAPI
from sqlalchemy.orm import Mapped, mapped_column
import fastapi_restly as fr
class Project(fr.IDBase):
name: Mapped[str]
archived: Mapped[bool] = mapped_column(default=False)
class Task(fr.IDBase):
title: Mapped[str]
project_id: Mapped[int] = mapped_column(sa.ForeignKey("project.id"), init=False)
class ProjectSchema(fr.IDSchema):
name: str
class TaskSchema(fr.IDSchema):
title: str
project_id: fr.ReadOnly[int]
class Parent(fr.ContextNamespace):
project_id: fr.ContextParam[int]
app = FastAPI()
fr.configure(app, async_database_url="sqlite+aiosqlite:///app.db")
@fr.include_view(app)
class ProjectView(fr.AsyncRestView):
prefix = "/projects"
model = Project
schema = ProjectSchema
scope = fr.where_clause(Project.archived.is_(False))
async def project_from_path(project_id: int, session: fr.AsyncSessionDep) -> int:
query = fr.apply_clauses(
sa.select(Project.id).where(Project.id == project_id),
fr.resolve_scope(ProjectView),
)
found = await session.scalar(query)
if found is None:
raise fr.exc.NotFound(f"Project with id {project_id} was not found")
return found
@fr.include_view(app)
class ProjectTaskView(fr.AsyncRestView):
prefix = "/projects/{project_id}/tasks"
model = Task
schema = TaskSchema
dependencies = [Parent.depends(project_id=project_from_path)]
scope = fr.all_of(
fr.resolve_scope(Task),
fr.where_clause(Task.project_id == Parent.project_id),
)
async def create(self, schema_obj):
task = await self.make_new_object(schema_obj)
task.project_id = Parent.project_id()
return await self.save_object(task)
ProjectTaskView differs from a flat view in these places:
prefixcontains{project_id}, so every route of the view sits under a project.project_from_pathreadsproject_idfrom the path. FastAPI checks that it is an integer and lists it in OpenAPI on every route. The function answers404when the project does not exist orProjectViewhides it.Parent.dependsbinds the project id for the request.Parentis afr.ContextNamespace, likeCurrentin Current context. OneParentserves every nested view in the app.scopelimits every read to the tasks of that project: the list, its total count, get one, update and delete. A task of another project answers404. A view’s scope replaces the model’s default scope (View scopes), sofr.resolve_scope(Task)adds it back.createsets the project id; see Create a child.
The routes then answer like this:
GET /projects/1/tasks?sort=-title the tasks of project 1, by title, descending
POST /projects/1/tasks a new task in project 1
GET /projects/2/tasks/7 404 when task 7 is in another project
GET /projects/99/tasks 404 when project 99 does not exist
GET /projects/x/tasks 422, x is not an integer
Create a child#
The project id comes from the path, not from the request body. TaskSchema
marks project_id as fr.ReadOnly,
so the generated create and update schemas leave it out. The create
override sets it from Parent after
make_new_object
builds the task.
Declare the foreign key with init=False. make_new_object builds the task
from the request body, which has no project_id. Without init=False the
model requires it there, and the create answers 500.
Keep the parent id out of every write schema of the nested view. If
TaskSchema declared a writable project_id, the generated update schema would
include it. An explicit write schema writes every
field it declares. Either way, a PATCH could move the task to a project the
URL never named, past the lookup.
Set the parent id in the view, not in a column default that reads
Parent.project_id. With such a default, creating a task anywhere else fails,
for example in a flat view or a script, because nothing binds Parent there.
Which parents a client can reach#
The lookup decides which parents a client can reach. project_from_path
applies the scope of ProjectView through
fr.resolve_scope, so a project
that GET /projects/{id} hides also answers 404 here, with all of its tasks.
In the example, that is an archived project.
Without this check, POST /projects/{project_id}/tasks creates a task in any
project whose id a client can guess.
The lookup does not run the
authorize method of
ProjectView. Put rules about which rows a user may see in a scope, so that
both views apply them. The nested view still runs its own authorize on each
of its routes.
Deeper nesting#
For more levels, build a chain of base views, one per level. Each base adds
its segment to prefix and its lookup to dependencies. Both add up from
base to subclass; see Concatenate URL prefixes. This
example serves tasks at /companies/{company_id}/projects/{project_id}/tasks.
It is a separate app. It uses the imports, app, Task and TaskSchema of the
first example, and its own Project, Parent and lookups:
from typing import Annotated
from fastapi import Depends
class Company(fr.IDBase):
name: Mapped[str]
class Project(fr.IDBase):
name: Mapped[str]
company_id: Mapped[int] = mapped_column(sa.ForeignKey("company.id"), init=False)
class Parent(fr.ContextNamespace):
company_id: fr.ContextParam[int]
project_id: fr.ContextParam[int]
async def company_from_path(company_id: int, session: fr.AsyncSessionDep) -> int:
query = fr.apply_clauses(
sa.select(Company.id).where(Company.id == company_id),
fr.resolve_scope(Company),
)
found = await session.scalar(query)
if found is None:
raise fr.exc.NotFound(f"Company with id {company_id} was not found")
return found
async def project_from_path(
project_id: int,
company_id: Annotated[int, Depends(company_from_path)],
session: fr.AsyncSessionDep,
) -> int:
query = fr.apply_clauses(
sa.select(Project.id).where(
Project.id == project_id, Project.company_id == company_id
),
fr.resolve_scope(Project),
)
found = await session.scalar(query)
if found is None:
raise fr.exc.NotFound(f"Project with id {project_id} was not found")
return found
class CompanyScoped(fr.AsyncRestView):
prefix = "/companies/{company_id}"
dependencies = [Parent.depends(company_id=company_from_path)]
class ProjectScoped(CompanyScoped):
prefix = "/projects/{project_id}"
dependencies = [Parent.depends(project_id=project_from_path)]
@fr.include_view(app)
class ProjectTaskView(ProjectScoped):
prefix = "/tasks"
model = Task
schema = TaskSchema
scope = fr.all_of(
fr.resolve_scope(Task),
fr.where_clause(Task.project_id == Parent.project_id),
)
async def create(self, schema_obj):
task = await self.make_new_object(schema_obj)
task.project_id = Parent.project_id()
return await self.save_object(task)
project_from_path gets the company id from company_from_path as a FastAPI
sub-dependency. FastAPI runs the company lookup first, once per request, and
answers 404 for an unknown company. The project query also checks that the
project belongs to that company, so /companies/2/projects/1/tasks answers
404 when project 1 is in company 1.
Each lookup applies the scope of its parent, as in
Which parents a client can reach. This example has
no views for companies or projects, so it passes the models. When a view
serves the parent, such as a ProjectView, pass the view instead, so the
nested routes hide what that view hides.
Do not read Parent.company_id() in project_from_path instead. When the
company id in the path is not an integer, FastAPI still runs the project
lookup, Parent.company_id is not bound, and the request answers 500
instead of 422.
Let each base inherit the one above it, as ProjectScoped inherits
CompanyScoped. Two mixins side by side,
class ProjectTaskView(CompanyMixin, ProjectMixin, fr.AsyncRestView), add their
segments in reverse order: /projects/{project_id}/companies/{company_id}/tasks.
Things to know#
Name the path segment after the parent, such as
{project_id}. The item routes already use{id}, and FastAPI raises an error for the duplicate when you include the view.A model can have a flat view and nested views at the same time, such as a
TaskViewat/tasksnext toProjectTaskView. Both appear under the “Tasks” tag in OpenAPI unless you settags. Watch two things:Each view applies its own scope. Rows that the scope of
TaskViewhides, such as another user’s tasks, still show under the nested URL. To hide them there too, usefr.resolve_scope(TaskView)instead offr.resolve_scope(Task)in the scope ofProjectTaskView.TaskViewcannot create tasks withTaskSchema, becauseproject_idis read-only there. GiveTaskViewits ownschema_create, afr.BaseSchemawithproject_id: fr.MustExist[int, Project]. Keepproject_idread-only inTaskSchema; see Create a child.
OpenAPI marks a field that refers to a task, such as
fr.MustExist[int, Task], withx-resource-ref. Its value comes from the prefix of the last view ofTaskthat you include. Include the flat view after the nested one, so the value istasks. With only a nested view, the value isprojects/{project_id}/tasks, which a client cannot use as a resource name.A sync
fr.RestViewworks the same way. Write the lookups as plain functions that takefr.SessionDep, and thecreateoverride as a plaindefwithoutawait.Each level adds one query to every request.
See also#
Current context: binding a value for each request.
Scopes: what a view’s scope covers, and how it replaces the model’s default scope.
Share Behaviour with Base Views: how
prefix,dependenciesandresponsesadd up from base to subclass.