CRUD Routes#
AsyncRestView and
RestView give one SQLAlchemy model
five CRUD routes. A subclass sets the URL prefix and the model, and it can
also set the schemas. include_view() registers the routes:
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
UserView now serves requests to list, create, get, update and delete users
under /users. Build a Blog API shows the app, the database
configuration and the model around such a view, in one file that runs.
include_view also works as a direct call; see
Register a view.
Each route runs an endpoint method, a final handler, and a business method or, for a read, a final load. To change what a route does, you override the endpoint method or the business method, or set what the load reads; Which method do I override maps each change to its method.
Choose async or sync#
Use AsyncRestView for a new
application, unless the application already uses synchronous SQLAlchemy. It
gets an AsyncSession through
fr.AsyncSessionDep, and the
methods you override are async def methods.
Use RestView with synchronous
SQLAlchemy sessions, or with libraries that only work synchronously. It gets
a Session through fr.SessionDep,
and the methods you override are plain def methods.
Apart from this, the two classes are the same: the same routes, the same
configuration and the same methods to override. You choose for each view, so
one application can use both. Database & Configuration shows
how to configure the sessions. To give one view another session, see
Use another database on one view.
The examples on this page use AsyncRestView.
The default routes#
For prefix = "/users", include_view registers these routes:
Request |
Endpoint method |
Input |
Response |
Status |
|---|---|---|---|---|
|
Filter, sort and page parameters |
A page of objects in a |
|
|
|
|
|
|
|
|
The id in the path |
|
|
|
|
|
|
|
|
|
The id in the path |
Empty body |
|
The main path of the list is /users, without a slash at the end. Restly
also accepts /users/, as a route that OpenAPI does not show.
GET /users takes the filter, sort and page parameters that
Filtering, Sorting & Pagination defines, and it is paginated by
default.
Responses shows the response bodies and how to
change the data envelope.
A row that does not exist returns 404, and so does a row that the view’s
scope hides. Each write commits before Restly builds the
response, and a read does not commit; see
What commits, and when.
Important
Restly does not authenticate requests, and by default it allows every action
on every route. Add authentication with FastAPI dependencies; see
Run a dependency on every route. Then
decide per action who may do what in
authorize(); see
Authorization.
Item paths and static routes#
The paths of the built-in GET, PATCH and DELETE routes for one row
follow id_type. By
default, Restly takes it from the model’s primary key. The PUT route of a
React Admin view uses the same path.
ID type |
Registered item path |
Matching values |
|---|---|---|
|
|
Digits only, including |
|
|
UUIDs that Starlette’s converter accepts |
Any other type |
|
Any single path segment, then FastAPI validation |
With an integer or UUID item path, a static route such as /users/me still
works when you register it after the view.
An untyped item path, such as the path for string ids, also matches
/users/me. Register a static route like that before the item route.
Details
A value that does not match the converter returns 404, unless another
route matches it. So a negative integer id does not match, even when the row
exists. OpenAPI still shows the path as /users/{id}, with an integer or UUID
parameter.
A route that you declare yourself keeps the path you give it. Restly adds
the converter only to a path parameter annotated
ViewID; see
Name the view’s classes in a custom route. To
accept negative integer ids, replace each item endpoint you need with an
untyped path such as @fr.get("/{id}"), and annotate its id parameter as
int; see
Change the status code or headers of one route.
Set the model and the schemas#
A CRUD view sets two class attributes:
prefix: the URL prefix of every route of the view.model: the SQLAlchemy mapped class that the routes read and write.
Normal models built on SQLAlchemy’s DeclarativeBase work. Restly’s model
defaults, fr.ModelDefaults, are
optional; see Models.
If the view does not declare schema, Restly generates the view’s
schema from model when you register the view. From the view’s schema, it
derives the request bodies and the response class:
schema_create,
schema_update and
schema_response.
Generate the schema or write it shows when to write the
schema yourself, and
Hide a field from requests or from responses
shows which fields each class takes.
Configure a CRUD view#
This table maps each need to its setting:
You need |
Configuration |
|---|---|
Stable response fields |
Declare |
Other fields or validation on create or update |
Declare |
Another type for the |
Set |
Fewer routes, such as a read-only view |
Set |
Hide rows from every read |
|
No pagination |
Set |
Other page limits, parameter names or list envelope |
Set |
Fewer filter or sort keys on a list |
Set |
A list filter of your own |
Decorate a method with |
A query key of your own next to the list params, not a filter |
Declare it on the endpoint method or in a dependency (Accept extra query parameters) |
OpenAPI metadata or dependencies on every route |
Set |
A route name, summary or operation ID |
Override the endpoint method with a decorator of your own (OpenAPI) |
Another session for one view |
Override the |
View Reference lists the type and the default of each attribute.
Exclude CRUD routes#
Set exclude_routes
to leave out some of the CRUD routes:
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
exclude_routes = [fr.ViewRoute.DELETE, fr.ViewRoute.UPDATE]
UserView now serves only GET /users, POST /users and GET /users/{id}.
Subclasses inherit exclude_routes. To make several views read-only, set it
on a base view:
class ReadOnlyView(fr.AsyncRestView):
exclude_routes = (fr.ViewRoute.CREATE, fr.ViewRoute.UPDATE, fr.ViewRoute.DELETE)
@fr.include_view(app)
class ProductView(ReadOnlyView):
prefix = "/products"
model = Product
ProductView only serves GET /products and GET /products/{id}.
A subclass that sets its own exclude_routes replaces the list of its base. It
does not add to it. So repeat the entries of the base, or the subclass serves
the write routes again:
exclude_routes = (*ReadOnlyView.exclude_routes, fr.ViewRoute.GET_ONE).
exclude_routes leaves out only the routes that it names. So a subclass also
serves the custom routes of its base, such as POST /tags/merge, and also a
write route that someone adds to the base later. For a read-only copy of a
view with custom routes, set
include_routes
instead. It names the routes to serve, and the view leaves out every other
route:
@fr.include_view(app)
class TagView(fr.AsyncRestView):
prefix = "/tags"
model = Tag
@fr.post("/merge")
async def merge(self, source_id: int, target_id: int) -> None: ...
@fr.include_view(public_router) # an APIRouter(prefix="/public")
class PublicTagView(TagView):
include_routes = (fr.ViewRoute.GET_MANY, fr.ViewRoute.GET_ONE)
PublicTagView serves only GET /public/tags and GET /public/tags/{id},
also when TagView gets more routes later.
Details
Each entry of include_routes and exclude_routes is a
fr.ViewRoute value, GET_MANY,
GET_ONE, CREATE, UPDATE or DELETE, or the name of a route method,
such as "delete_endpoint" or "merge". A name that is not a route of the
view raises AttributeError when you register the view.
The default of include_routes, ("*",), is every route. A view serves a
route when include_routes has it and exclude_routes does not. A subclass
that sets its own include_routes replaces the list of its base.
The other way to make a read-only copy is a base view with the shared
settings, subclassed twice. Then the copy does not inherit the custom routes
of TagView:
class TagBase(fr.AsyncRestView):
model = Tag
@fr.include_view(app)
class TagView(TagBase):
prefix = "/tags"
@fr.post("/merge")
async def merge(self, source_id: int, target_id: int) -> None: ...
@fr.include_view(app)
class PublicTagView(TagBase):
prefix = "/public/tags"
exclude_routes = (fr.ViewRoute.CREATE, fr.ViewRoute.UPDATE, fr.ViewRoute.DELETE)
PublicTagView serves only the two read routes, also when TagView gets more
routes later. But both views serve a route that TagBase gets later.
Nest a resource under its parent#
A nested resource is a child resource that the URL places under its parent,
such as /projects/{project_id}/tasks. In Restly, it is a normal
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
pages, filters and sorting.
A flat view that filters on the foreign key, GET /tasks?project_id=17,
needs no extra code; see Filter by a foreign key.
Nest the view when the nested URL is part of your API.
Nest 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 DeclarativeBase, Mapped, mapped_column
import fastapi_restly as fr
class Base(fr.ModelDefaults, DeclarativeBase):
pass
class Project(fr.IDMixin, Base):
name: Mapped[str]
archived: Mapped[bool] = mapped_column(default=False)
class Task(fr.IDMixin, Base):
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:
stmt = fr.apply_clauses(
sa.select(Project.id).where(Project.id == project_id),
fr.resolve_scope(ProjectView),
)
found = await session.scalar(stmt)
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, data):
task = await super().create(data)
task.project_id = Parent.project_id()
return task
ProjectTaskView differs from a flat view in these places:
prefixcontains{project_id}, so every route of the view is under a project. Name the path parameter after the parent, as here. The item routes already use{id}, and FastAPI raises an error for a second{id}when you register the view.project_from_pathreadsproject_idfrom the path. FastAPI checks that it is an integer, and OpenAPI lists it on every route. The function returns404when the project does not exist or whenProjectViewhides it.Parent.dependsbinds the project id for the request.Parentis afr.ContextNamespace, likeCurrentin Request 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 returns404. A view’s scope replaces the model’s default scope (Give one view other rows), sofr.resolve_scope(Task)adds the default scope 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
Details
A sync fr.RestView works in the same
way. Write the lookups as plain functions that take
fr.SessionDep, and write the create
override as a plain def without await.
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 super().create() builds the task. The
INSERT has the id, because Restly saves after create returns.
Declare the foreign key with init=False. super().create() builds the task
from the request body, which has no project_id. Without init=False, the
model requires project_id in its constructor, and the create returns 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. In both cases, a PATCH request could move the task
to a project that the URL never named, and the lookup would not check it.
Details
Set the parent id in the view, not in a column default that reads
Parent.project_id. Only the nested route knows the parent, so the value
belongs in create; see Values every write path sets
for the difference. 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 returns 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.
Nest more than one level#
For more levels, build a chain of base views, one for each 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, Base, 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.IDMixin, Base):
name: Mapped[str]
class Project(fr.IDMixin, Base):
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:
stmt = fr.apply_clauses(
sa.select(Company.id).where(Company.id == company_id),
fr.resolve_scope(Company),
)
found = await session.scalar(stmt)
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:
stmt = 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(stmt)
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, data):
task = await super().create(data)
task.project_id = Parent.project_id()
return 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
returns 404 for an unknown company. The project query also checks that the
project belongs to that company. So /companies/2/projects/1/tasks returns
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. The nested
routes then hide what that view hides.
Details
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 then not bound, and the request returns 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.
Each level adds one query to every request.
A flat view next to the nested one#
A model can have a flat view and nested views at the same time, such as a
TaskView at /tasks next to ProjectTaskView. Watch these points:
Each view applies its own scope. Rows that the scope of
TaskViewhides, such as the tasks of other users, 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.Both views appear under the “Tasks” tag in OpenAPI, unless you set
tags.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 register. Register 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.
What the default routes do not do#
The default CRUD routes have these limits:
A row is identified by one primary key column. The
{id}path parameter takes the Python type of the key, so a UUID or string key needs no configuration. Reference fields such asfr.MustExist[UUID, Model]accept these keys too. For a composite key, setid_type = strand overridewhere_idto read the parts of the key from the path; see Look a row up by another key. Withoutwhere_id, a view of such a model fails when it registers, unless it leaves out the item routes.Responses can contain nested objects, and lists can filter on relationships. Request bodies with nested objects are not supported in general. Use
MustExist,IDReforIDSchemato refer to other rows, or change the payload in a business method; see Relationships.The update route uses
PATCH, notPUT. Add aPUTroute only when the client needs one.
See also#
Build a Blog API: build and run a first resource.
Request Lifecycle & Hooks: change what a route does.
Schemas & Validation: the fields that the routes accept and return.
Views: registration, shared dependencies and base views.
View Reference: the exact signatures and defaults.