Request Lifecycle & Hooks#
Every CRUD route of a RestView or
AsyncRestView runs its steps
in a fixed order. A write route checks that the caller may act, makes the
change, commits it and builds the response. A read route also checks the
caller, but it only loads rows, so it has no commit. Each step except the
commit is a method on the view. To change a route, you override the method of
one step. A custom route calls the same methods, so it runs the same steps.
CRUD Routes lists the default routes and their settings. Views covers what every view shares: registration, dependencies and inheritance.
How a view handles a request#
Take POST /, the create route. FastAPI calls
create_endpoint, the
endpoint method. It calls
handle_create, the
handler. The handler runs the steps in this order:
POST /
└─ create_endpoint(data) # endpoint method: the HTTP contract
├─ handle_create(data) # handler: runs the steps (final)
│ ├─ authorize("create", data=data)
│ ├─ check the references in data # an id that does not exist: 404
│ ├─ create(data) # business method: builds the object;
│ │ # nothing is written yet
│ ├─ save: flush, refresh obj, load it with the loader options
│ ├─ before_action_commit("create", new=obj, old=None)
│ ├─ commit
│ └─ after_action_commit("create", new=obj, old=None)
└─ to_response(obj)
The highlighted lines are the methods that you can override in your view.
Every CRUD route has these three methods. The names follow one pattern for
all five actions: update_endpoint calls handle_update, which calls
update, and so on. A read has a load in the place of the business method.
The endpoint method,
<verb>_endpoint, is the method that FastAPI calls. It owns the HTTP contract: the route decorator, the parameters, theresponse_modeland the call toto_response. Replace it only to change that contract, such as the status code. See Change the status code, headers or response of one route.The handler,
handle_<verb>, runs the steps. It callsauthorize, checks the references in the data, and then calls the business method. On a write, it ends with the commit bracket: it saves the object, runsbefore_action_commit, commits, then runsafter_action_commit. It returns the object. Only the delete handler returns nothing. A handler is final: a view class that defines one fails when Python creates the class. A custom route calls a handler to run the same steps.The business method of a write is the bare verb:
create,updateordelete. It makes the change: it builds the object or applies the input to it, and returns it. It does not check permissions, does not flush and does not commit. So when your override runs,authorizeand the reference checks have already passed. Nothing is written while it runs. The save after it writes the change, and then the commit follows.A read has a load:
get_oneorget_many. A load is final, as a handler is. You change it through the scope, the list params, the loader options and two small hooks. See A read.
Details
Update and delete have the same steps, with a load first. Their handlers load
the row through get_one, so a
row that the caller cannot see is a 404. Then they call authorize with the
loaded row, check the references in the data, and take a
snapshot of it as old.
The business method runs next, then the commit bracket. The delete business
method only marks the row for deletion.
The row that an action is about is its subject: the object that create
or update returns, or the row that delete gets. old is the subject
before the action, and new is the subject after it. So the hooks can see
what changed. After a delete, the row is gone and new is None. A soft
delete keeps the row, so new is the row.
The save is the step that session.flush() and session.refresh() do in
plain SQLAlchemy. It flushes the session. Then it refreshes the subject, and
every other row of the view’s model that the action wrote. It loads them
with the view’s
loader options, as a read does: the
relationships that the response shows, and any value that you add there. So
the hooks and the response see the saved rows. Rows of other models are
written, but not refreshed.
The business method runs under SQLAlchemy’s
session.no_autoflush.
So a query inside it does not write a half-built object, also on a
RestView, whose session flushes
before each query everywhere else.
A read: visibility, then policy#
A read has no commit bracket and no business method. Its load is final. Each step of the load comes from a declaration or from a small hook:
GET /{id}
└─ get_one_endpoint(id) # endpoint method
├─ handle_get_one(id) # handler
│ ├─ get_one(id) # the load (final)
│ │ ├─ select() # select(model), the scope, the loader options
│ │ │ # VISIBILITY: a hidden row is a 404 for every caller
│ │ ├─ .where(where_id(id)) # hook: the primary key by default
│ │ └─ no row: raise not_found(id) # hook: a 404 by default
│ └─ authorize("get_one", obj=obj) # POLICY: checks the loaded row
└─ to_response(obj)
└─ to_single_response(obj) # the response of the row
GET /
└─ get_many_endpoint(list_params)
├─ handle_get_many(list_params)
│ ├─ authorize("get_many") # POLICY: no row yet
│ └─ get_many(list_params) # the load (final)
│ ├─ select() # the same start as get_one
│ ├─ apply_list_params(stmt, list_params) # filters, sort, page
│ └─ count(stmt) # on a paginated view
└─ to_response(result, LIST)
└─ to_single_response(row) # for each row
The highlighted lines are the methods that you can override in your view.
Visibility. The load starts from
select(): the model with the view’s scope. A row outside the scope is a 404 for every caller, as if the row did not exist. See Scopes.Policy. The handler calls
authorize. For one row, it gets the loaded row, and a refusal is a 403. See Authorization.
PATCH /{id}, DELETE /{id} and your action routes load their row with the
same get_one. So the scope,
where_id and
not_found apply to them
too.
Details
The loads are final: a view class that defines get_one, get_many or
select fails when Python creates the class. The message names the place
for each change. An override of a load could leave out the scope, or the
scope= that a route asked for, and nothing would tell you. With final
loads, the scope always applies.
select() also adds the options of
get_loader_options,
so the rows are ready for the response. A custom read starts from it too, as
in self.select().where(Item.price > 10). In a count, a subquery or a select
of some columns, SQLAlchemy leaves the loader options out.
Which method do I override#
I want to change… |
Use |
|---|---|
What an action does on the HTTP routes: hash a password, check a rule |
|
A value that every write path must set: a slug, the author |
a column default or a mapper event on the model (Models) |
Who may perform an action |
|
Which rows a caller can see |
|
How the item routes find their row: a slug, a composite key |
|
The error for a row that does not exist |
|
A value on each row of the response |
|
What each row loads: a relationship, a SQL expression |
|
A read from another source: a cache, a service |
the endpoint method, |
The keys that a list has |
|
A list filter that the generated keys do not give |
|
How the list params filter, sort and page |
|
The list total |
|
A side effect inside the commit: an outbox or audit row, or other work that needs the new id |
|
A side effect after the commit: an email, a webhook |
|
The response shape of every route |
|
The HTTP contract of one route: status, headers, parameters |
|
A path that is not CRUD: a lookup, an action such as publish |
a method with |
A lookup by another key, such as a slug or an email |
In a custom route, call |
A list of a subset, such as one project’s tasks |
In a custom route, call |
One action that changes several rows, such as a new order of the rows |
a |
One commit over several writes |
|
The same change on several views |
a base view or a mixin (Views) |
Start at the business method of a write, or at the declarations of a read. Move a side effect that depends on the commit to a hook. Replace the endpoint method only when the HTTP contract changes.
Change what an action does#
Override the business method of the action:
create,
update or
delete. Override only the
one you need. The handler still checks permissions and commits around it.
The reads have no business method; see
Add a value to each row.
Before you set a value in create or update, ask which write paths must set
it, as Set a value on every write path explains:
A value that every write path must set belongs on the model, as a column default or a mapper event.
A value that only the HTTP routes set belongs in
createorupdate.
The common cases:
Hash a password: in
create. The plain password arrives in the request body, and only the route has it. A script that also gets plain passwords calls the same function: see Run the same logic in a script or a worker.Make a slug from the title: on the model. Every new row needs a slug, whichever path writes it.
Keep a total on a parent row up to date, such as the story points of a project: on the model. The total is wrong as soon as one write path skips it.
Send an event about a change: in a transaction hook, for an event about an action of the view. An event that every write path must send needs a SQLAlchemy session event instead.
Hash a password in create#
import fastapi_restly as fr
from sqlalchemy.orm import Mapped
from .auth import hash_password
from .models import Base
class User(fr.IDMixin, Base):
email: Mapped[str]
password: Mapped[str] # stores the hash. UserView.create writes it
class UserSchema(fr.IDSchema):
email: str
password: fr.WriteOnly[str] # accepted on input, never in a response
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
async def create(self, data):
user = await super().create(data)
user.password = hash_password(data.password)
return user
handle_create still calls
authorize, saves and commits. The override only changes the object before
the save. The schema field and the column have the same name, because the
default create passes every writable schema field that the client sent to
the model’s constructor. So the plain password goes into password first, and
the override replaces it with the hash. The save comes after create
returns, so the database only gets the hash. fr.WriteOnly keeps the field
out of every response.
The generated update body takes password too. Without more code,
PATCH /users/{id} stores a new password as plain text. So hash it in
update as well:
async def update(self, obj, data):
user = await super().update(obj, data)
if "password" in data.model_fields_set:
user.password = hash_password(data.password)
return user
The default update writes only the fields that the request sends, so the
check uses the same set. The other way is an
explicit update schema without password, with a
separate route that changes the password. The SaaS example does this: its
change-password route checks the current password first
(app/users/views.py).
The new object has no id in create#
create returns the object before the save. So inside create, the new
object has no primary key yet. The values that the database or a mapper event
sets at the flush, such as a slug or a tenant id, are not there either. Code
that reads them gets None, not an error.
Most code that needs these values runs after the save. An outbox row goes in
before_action_commit, which gets the saved object.
When create itself needs the id, flush, as in plain SQLAlchemy:
async def create(self, data):
report = await super().create(data)
await self.session.flush() # report.id is set now
report.file_path = f"reports/{report.id}.pdf"
return report
The flush writes the row, and the save writes the path with an UPDATE.
Details
Nothing is written before the save, so a query inside
createdoes not see the new object. A check such as “is this email taken?” aftersuper().create()finds only the rows that are already in the database, as it should. Restly runs the business method undersession.no_autoflushfor this. Without it, a sync session would write the half-built object before the query.For the same reason, a query in a business method or a
write_actionblock does not see any of your unflushed changes. A loop that looks a row up and adds it when it is missing, or a count of rows against a limit, must callflush()before the query.A
tryor awitharoundsuper().create()catches no error of the INSERT, because the INSERT runs at the save, aftercreatereturns. To catch it increate, flush inside thetry.“Nothing is written” is about the objects in the session. A statement that you run yourself, such as
await self.session.execute(update(Product).values(...)), goes to the database at once. It is part of the same transaction, so the commit of the action commits it, and an error rolls it back.Often you do not need the id at all. Link the objects with a relationship, such as
line.order = order, and the flush copies the key.
Refuse a change in update#
update receives the row that
handle_update has
already loaded through the scope, not the id. A rule that needs the stored row
or the database goes here:
async def update(self, obj, data):
if obj.locked:
raise fr.exc.Conflict("This record is locked")
return await super().update(obj, data)
A rule about who may make the change goes in
authorize. A rule about one field’s value is a Pydantic
validator on the schema: see Validation.
Errors lists the exceptions to raise.
Add a value to each row#
A value that needs no query, such as whether the caller may edit the row,
goes in
to_single_response.
Every response of the view passes there: one row, each row of a list, the
row after a write, and a custom route that returns the row:
def to_single_response(self, obj):
response = super().to_single_response(obj)
return response.model_copy(update={"can_edit": obj.owner_id == Current.user_id()})
Current.user_id() is the id of the caller
(Request Context). The view’s schema declares the
field:
can_edit: fr.ReadOnly[bool | None] = None
It is not a column, so ReadOnly keeps it out of the create and update
bodies.
A value that needs a query, such as the number of tasks of a project, is SQL
on the model. A column_property loads with the row, in the same SELECT. So
a list of 50 projects still runs one query for the page:
from sqlalchemy import func, select
from sqlalchemy.orm import column_property
Project.task_count = column_property(
select(func.count(Task.id))
.where(Task.project_id == Project.id)
.correlate_except(Task)
.scalar_subquery()
)
Declare task_count: fr.ReadOnly[int] = 0 in the view’s schema, as for
can_edit. The save after a write loads the value again, so the create and
update responses show it too. The list gets filters and a sort key for it,
such as ?sort=-task_count; see Filter a list. The SaaS
example sets two task counts on its
projects this way
(app/tasks/models.py),
and can_edit in ProjectView.to_single_response
(app/projects/views.py).
Other changes to a list have their own place. A scope hides
rows.
apply_list_params
changes how the list params filter, sort and page
(Filtering, Sorting & Pagination).
get_loader_options
loads more for each row (Relationships).
Details
The column_property is set after both classes, because the expression names
Task. Place it in the module of Task when that module imports Project.
For type checkers, declare it on the class under if TYPE_CHECKING:, as
task_count: Mapped[int] = mapped_column(init=False). Every SELECT of the
model computes a column_property, also session.get(). The list count does
not: it counts the primary keys.
A value that only one view shows can be a query_expression() on the model
instead. The view fills it with with_expression(...) in
get_loader_options.
Every read of the view applies the loader options, and so does the save after
a write. So the value is there in every response of the view, also after
POST. Another view of the model, or session.get(), leaves it unloaded.
A custom route that names another class in response_model= skips
to_single_response: FastAPI builds the response from the object, so the
value is missing. Declare the view’s response instead, as
The response of a custom route explains.
For a value from another service, or from a cache, replace the endpoint method. It calls the handler, gets the values for the whole page in one call, and builds the response:
from fastapi_restly.views import ViewListResponse
@fr.get("/")
async def get_many_endpoint(self, list_params) -> ViewListResponse:
result = await self.handle_get_many(list_params)
ratings = await rating_service.for_ids([p.id for p in result.objects])
for project in result.objects:
project.rating = ratings.get(project.id)
return result
A read that does not come from the database at all replaces the endpoint
method in the same way, and calls authorize itself.
Build and save objects#
A write business method changes objects in the session and returns the
object. The handler saves what it returns. The defaults use the functions in
fr.objects:
Business method |
Default |
|---|---|
|
|
|
|
|
|
None of them flushes or commits. After the business method, the commit
bracket saves the object that it returns. It adds the object to the session
when it is new, flushes, refreshes the object and loads it with the view’s
loader options. Then it runs before_action_commit, commits and runs
after_action_commit.
So an override calls super(), and changes the object before or after that
call. To build the object in another way, build it yourself and return it:
async def create(self, data):
# this model's constructor takes the values in another form
return Product.from_catalog_entry(data.catalog_entry)
The handler has already checked the references in data
(fr.IDRef and fr.MustExist, see Relationships). So an object that you build yourself
keeps those rules, and the bracket adds and saves it.
Details
The functions in fr.objects are for another
model and for code outside a view:
async_make_new_object,
async_update_object,
async_save_object and
async_delete_object, and
their sync versions. See
The same operations outside a view. They do not
call authorize or the hooks. async_make_new_object and
async_update_object check the references too. In a CRUD route the handler
has checked them first, and the second check does not query the database
again. async_save_object refreshes the object, but it does not load the
relationships that the view’s response class names.
The save writes every change in the session. It refreshes the object that the
business method returns, and every other row of the view’s model that the
action wrote. A row of another model that the method changes, such as a line
of an order, is written but not refreshed. When the change set a foreign key
column of such a row, such as line.product_id, the save unloads that
relationship. So a response that nests the line loads the new product, not
the old one. A business method that you call yourself, outside a handler or a
write_action block, writes nothing: the handler does the save.
Up to 0.11, views had the methods make_new_object, update_object and
save_object. They are removed. A view class that defines one fails when
Python creates the class, with a message that names the replacement.
Run the same logic in a script or a worker#
While the view is the only caller, keep the logic in its create or update.
There it has self.session, self.request and the view’s dependencies.
When a script or a worker must run the same logic, move it into a plain function and call it from both:
from fastapi_restly.objects import async_make_new_object, async_save_object
def set_password(user: User, raw_password: str) -> None:
user.password = hash_password(raw_password)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
async def create(self, data):
user = await super().create(data)
set_password(user, data.password)
return user
async def import_user(session, payload) -> User:
user = await async_make_new_object(session, User, payload)
set_password(user, payload.password)
await async_save_object(session, user)
await session.commit()
return user
The script has no handler, so it saves and commits itself. Do not move logic out of the view before there is a second caller.
A value that every write path must set does not need this. Put it on the model (Models), and every path gets it.
What commits, and when#
Restly commits for you, once for each write action. The handler of a write
route, or a write_action
block in a custom route, runs before_action_commit, commits, and then runs
after_action_commit. Your business methods and hooks do not commit.
The session that Restly opens for a request does not commit when the request
ends. It rolls back and closes. So a custom route that writes must commit
through a handler or a write_action block. A route on a plain
View has neither, so it commits the
session itself. When a request ends with changes
that are not committed, Restly warns with
RestlyUncommittedChangesWarning.
To commit several writes at once, see Commit several writes together.
Details
The warning is on by default. To turn it off for the app, pass
warn_on_uncommitted=False to fr.configure.
A route that rolls back on purpose, such as a dry run, can turn it off for one
request with session.info["_fr_suppress_uncommitted"] = True.
A session generator of your own decides how a session is made and closed. It does not change who commits, unless it commits itself, which it must not do; see Build sessions with your own generator.
When a write action raises#
When a write action raises, Restly rolls back what the action changed, and the session stays usable. So a route can catch the error of one write and go on:
from fastapi import HTTPException
@fr.post("/bulk", status_code=201)
async def bulk_create(self, items: list[OrderCreate]) -> list[ViewResponse]:
orders = []
for item in items:
try:
orders.append(await self.handle_create(item))
except HTTPException: # also fr.exc.Conflict, for a duplicate value
continue
return orders
Each order is committed on its own. On fr.RestView, use the sync handlers.
To save all orders in one commit, see
Commit several writes together.
Details
Each outermost write action runs in a savepoint, SQLAlchemy’s
session.begin_nested(). The
savepoint begins after authorize, the reference check and the snapshot, and
it ends at the commit. When the action raises before the commit, Restly rolls
back to the savepoint. This undoes only what the action changed. Rows that
the action did not change stay loaded, so async code can still read them.
Rows that it changed, such as the row of an update, are expired. In async
code, call await session.refresh(obj) before you read such a row. Inside a
shared_write_action_commit() block, or inside the block of another action,
an action has no savepoint of its own; see
Commit several writes together.
A savepoint begins with a flush of what is pending in the session, as
begin_nested() always does. So an object that a route adds to the session
before the write action is written when the action begins, and an error in
the action does not undo it. This is also true for a new object that a route
appends to a collection of a loaded row. Give a new object to write_action
as obj, without session.add(), and connect it to loaded rows inside the
block: the action then adds it inside its savepoint.
An error from after_action_commit does not undo the order: the order is
saved. See When after_action_commit raises.
When the commit itself fails, for example on a deferred constraint, Restly
rolls back the whole transaction. This also expires the rows that were loaded
before. On SQLite, Restly sends BEGIN before the savepoint. With the default
settings of the driver, the end of the savepoint would commit already
otherwise.
The savepoint costs two more statements for each write: SAVEPOINT and
RELEASE SAVEPOINT. On SQLite, BEGIN is a third.
SQLAlchemy calls the listeners of the session for a savepoint too:
after_begin when it begins, before_commit and after_commit when it ends,
and after_rollback when it rolls back. So before_commit and after_commit
run twice for each outermost write action: when the savepoint ends, and at the
commit. The first time is before the commit, which can still fail. A listener
that must run only at the commit returns early when
session.in_nested_transaction() is true. Work that must wait for the commit,
such as an email, belongs in after_action_commit. On SQLite, the savepoint
can also make database is locked come sooner; see
Turn on WAL for SQLite.
Run code before or after the commit#
Every write action calls two hooks around its commit:
before_action_commit(action, new, old=None)runs inside the transaction, before the commit. What it writes is committed together with the change, or not at all. Use it for outbox rows and audit rows.after_action_commit(action, new, old=None)runs after the commit, when the change is saved. Use it for email, webhooks and cache invalidation.
The hooks run for the CRUD writes and for every write_action block. Reads do
not call them, and neither do the fr.objects functions. The arguments are:
action: the name of the action, such as"create", or the name that a custom route giveswrite_action. See Action names.new: the row that the action is about, after the save. It isNonewhen the action removed the row, as a delete does, or when the action has no single row. On a session that expires on commit,snapshot(new)inafter_action_commitis empty, and Restly warns; see Session factory defaults.old: a dict of that row’s column values before the action, fromsnapshot. It holds only the columns that were loaded. It isNonewhen the row is new, as on a create, or when the action has no single row.
So old and new tell what happened: a new row has no old, a removed row
has no new, and a changed row has both. This is the same for the CRUD
handlers and for every write_action block. So a hook can check what changed
without a list of routes:
async def after_action_commit(self, action, new, old=None):
if old and new and old["status"] != new.status: # PATCH, archive, ...
await notify_status_change(new.id, new.status)
Do not change new or the database in after_action_commit. The commit is
done, so the change is not saved. A change to new still shows in the
response of this request, which hides the problem. Make the change in the
business method or in before_action_commit.
Details
After a delete, new is None and old holds the columns of the removed
row. A soft delete keeps the row, so new is the row
with its new timestamp. A delete that must finish outside the request can
mark the row in delete in the same way. After the commit,
after_action_commit starts the real delete:
async def delete(self, obj):
obj.status = "pending_deletion" # no super(): the row stays
async def after_action_commit(self, action, new, old=None):
if action == self.Action.DELETE:
await enqueue_async_delete(new.id) # a worker deletes the row
old comes from the action, not from the database. A create always gives
old=None, also when your create returns a row that exists already, such
as a tag with the same name. A hook that reads old is None as “a new row”
is wrong on that path. To give the hooks the stored values, look the row up
in a custom route, and change it in a write_action block with the row as
obj.
Code that a route runs after its write_action block also runs after the
commit, but only when the block commits itself. Inside
shared_write_action_commit(), or inside the
block of another action, that code runs before the commit. A hook runs after
the commit in both cases. So work that reads the committed row, such as
starting a job, goes in after_action_commit.
Restly finds new from the state of the row after the flush, as SQLAlchemy
reports it. A row that the action deleted with session.delete() or with an
ORM statement such as delete(Post) is gone. A Core delete() on the table
does not tell the session, so the refresh of the row fails with SQLAlchemy’s
InvalidRequestError. Delete the row that obj names through the ORM.
Run the work after the response#
Work in after_action_commit, such as sending an email, makes the client
wait for it. To answer first, give the work to FastAPI’s
BackgroundTasks. Declare it as a
class attribute of the view, and
add the task in the hook:
from fastapi import BackgroundTasks
class OrderView(fr.AsyncRestView):
...
background_tasks: BackgroundTasks
async def after_action_commit(self, action, new, old=None):
if action == self.Action.CREATE:
self.background_tasks.add_task(send_confirmation_email, new.id)
FastAPI runs the task after it sends the response. The order is committed
then, so a task that opens its own session finds it. A task queue works the
same way: enqueue the job in after_action_commit, with the id of the row.
Do not enqueue it in before_action_commit: a worker can start the job
before the commit, when other sessions cannot see the row yet, and the commit
can still fail. A task that fails does not undo the write; see the next
section.
When after_action_commit raises#
The write is already committed, so an exception from the hook cannot undo it. The request still fails, and a client that tries again repeats the write. For an effect that may fail without harm, catch and log the error in the hook:
async def after_action_commit(self, action, new, old=None):
if action == self.Action.CREATE:
try:
await send_confirmation_email(new.id)
except Exception: # the order is saved; don't fail the request
logger.exception("Confirmation email failed for order %s", new.id)
An effect that must happen belongs in an outbox row, written in
before_action_commit. A worker then delivers it and tries again when it
fails. Make the delivery safe to repeat, because a retry can deliver it twice.
FastAPI’s BackgroundTasks does not replace an outbox: a crash loses the
task, and nothing tries it again.
Change the status code or headers of one route#
Business methods and hooks keep the HTTP contract of the CRUD route. Replace the endpoint method when the contract itself must change: the status code, the headers or the parameters. To change the shape of the response body, such as an envelope around one object, see Change the shape of one route on Responses. It replaces the endpoint method in the same way.
Define a method with the same name, such as delete_endpoint, and give it a
route decorator. Most replacements call the handler and only change the
response:
@fr.include_view(app)
class ProductView(fr.AsyncRestView):
prefix = "/products"
model = Product
schema = ProductSchema
@fr.delete("/{id}", status_code=200)
async def delete_endpoint(self, id: int):
obj = await self.get_one(id) # load through the scope, or 404
response = self.to_response(obj) # the row as GET /{id} sends it
await self.handle_delete(id) # authorize, delete, commit
return response
The default DELETE /{id} answers 204 No Content. This version answers
200 with the deleted row, as React Admin’s ra-data-simple-rest expects
(React Admin). It builds the response before the row is
gone. to_response
gives an object of the response class, so only its fields go out, as from
GET /{id}.
Details
When Restly registers the view, an endpoint method that the class defines replaces the inherited method with the same name. The other inherited endpoint methods stay.
The built-in endpoint methods get the view’s types from markers, such as
get_one_endpoint(self, id: ViewID) -> ViewResponse. A replacement gets the
view’s types only where it uses the markers too; see
Name the view’s classes in a custom route. A
parameter or a return without an annotation gets no type. Restly keeps an
annotation that you write, so data: ProductReplace validates the
request body against ProductReplace. list_params is the exception: it
always gets the view’s
schema_list_params.
Change the list route#
Replace
get_many_endpoint
when the contract of the list changes, for example to send the total in a
header. Keep the list_params parameter to keep Restly’s
filter, sort and page parameters:
import fastapi
import json
from fastapi_restly.views import ViewResponse
@fr.include_view(app)
class ProductView(fr.AsyncRestView):
prefix = "/products"
model = Product
schema = ProductSchema
@fr.get("/", response_model=list[ViewResponse])
async def get_many_endpoint(self, list_params):
result = await self.handle_get_many(list_params)
serialized = [
self.to_response(obj).model_dump(mode="json", by_alias=True)
for obj in result.objects
]
return fastapi.Response(
content=json.dumps(serialized),
media_type="application/json",
headers={"X-Total-Count": str(result.total_count)},
)
The route sends a plain list, not the default envelope, so it names its
response_model: a list of the view’s response class. Without it, OpenAPI
shows no schema for the response. The route builds the body itself, so it
dumps each row by_alias=True, as FastAPI does for a response model.
Name the view’s classes in a custom route
explains the markers, such as ViewResponse.
Add a custom read route#
A CRUD view can have more routes beside its CRUD routes. Declare a method with
@fr.get, as on any view
(Views). To load one row, call
handle_get_one. It
loads the row through the scope, so a row that the caller cannot see is a
404. Then it calls authorize("get_one", obj=row), as GET /{id} does.
get_one alone also loads
through the scope, but it does not call authorize.
A read of your own starts from
self.select(): the view’s
model with its scope and its loader options. So the rows are the ones that
the view shows, and each row loads what the response needs:
@fr.get("/recent")
async def recent(self) -> list[ViewResponse]:
await self.authorize(fr.Action.GET_MANY)
stmt = self.select().order_by(Post.created_at.desc()).limit(5)
return list(await self.session.scalars(stmt))
ViewResponse from fastapi_restly.views stands for the view’s response
class. The route declares a list of it, so Restly passes each row through
to_single_response, as the list route does; see
The response of a custom route. The route builds
its own read, so it calls authorize itself.
The SaaS example’s GET /users/me returns the caller’s own row:
@fr.get("/me")
async def get_current_user(self) -> ViewResponse:
"""Get current user's profile."""
return await self.handle_get_one(Current.user_id())
Current.user_id() is the id of the caller
(Request Context).
Details
ViewResponse stands for the view’s response class, UserResponse, so
OpenAPI shows the same type as for GET /users/{id}, and the row goes
through the view’s response step
(The response of a custom route). The
route /me works beside /{id}, because the item routes match only an
integer id (Item paths and static routes).
The handlers return the ORM object, so a route can read every attribute of
the model, also the ones that the response leaves out. In the
same example,
GET /projects/{id}/stats calls handle_get_one(id), and then counts the
project’s tasks.
Look a row up by another key#
handle_get_one and
get_one take a SQLAlchemy
boolean expression in place of the id. The expression picks the row instead
of the primary key. Everything else about the load stays the same: the view’s
scope, the loader options, and the 404.
@fr.get("/by-email/{email}")
async def get_by_email(self, email: str) -> ViewResponse:
return await self.handle_get_one(User.email == email)
When every item route takes the other key, override
where_id instead. It
gets the id from the path and returns the WHERE that picks the row. Set
id_type to the type of
the value in the path:
from sqlalchemy import ColumnElement
@fr.include_view(app)
class RecipeView(fr.AsyncRestView):
prefix = "/recipes"
model = Recipe
schema = RecipeSchema
id_type = str
def where_id(self, id: str) -> ColumnElement[bool]:
return Recipe.slug == id
Now GET, PATCH and DELETE /recipes/{slug} find the recipe by its slug,
and so does self.get_one(slug) in your own routes. To change the error for
a row that does not exist, override
not_found. It returns
the exception that get_one raises:
def not_found(self, id: str | None) -> Exception:
return fr.exc.NotFound(f"No recipe has the slug {id!r}")
Details
where_id is a plain def, also on an async view: it builds SQL and runs
no query. not_found may be async def on an async view, and Restly awaits
it. When a route loads by an expression, not_found gets None, because the
404 body is no place for SQL. A hook that returns the wrong kind of value
raises TypeError.
Combine columns with sqlalchemy.and_(...). This is how a route reaches a
row of a model with a composite primary key. A plain id cannot name such a
row, so a view of such a model must override where_id to read the parts
from the path, such as "3-7". Set id_type = str too: without it, the
path takes only an integer, and /seats/3-7 is a 404 before where_id
runs. Then the default item routes work for the model:
class SeatView(fr.AsyncRestView):
prefix = "/seats"
model = Seat
id_type = str # the id in the path is "3-7"
def where_id(self, id: str) -> ColumnElement[bool]:
row, _, number = id.partition("-")
if not (row.isdigit() and number.isdigit()):
raise self.not_found(id)
return sqlalchemy.and_(Seat.row == int(row), Seat.number == int(number))
A view that keeps the default where_id fails when it registers, unless it
leaves the item routes out with
include_routes or
exclude_routes.
The expression narrows the read inside the scope. It never reaches past it,
so a row that the view hides stays a 404 under any key. Only
scope changes what the view
sees. When more than one row matches, Restly raises SQLAlchemy’s
MultipleResultsFound and does not return the first row: a key that matches
several rows is a bug in the expression.
The expression, and the one that where_id returns, compares columns of the
view’s model. A column of another table raises TypeError, because SQL would join every row of that table and
could pick a wrong row. For a condition on a related row, use .has() or
.any(), such as Book.shelf.has(Shelf.code == code).
handle_update and
handle_delete take the
same expression. So a custom PATCH /users/by-email/{email} route that calls
handle_update(User.email == email, data) runs the same steps as
PATCH /users/{id}. The SaaS example reads and updates projects by slug in
app/projects/views.py,
next to the routes by id. That is why it passes an expression, and does not
override where_id.
Add a custom action route#
An action changes state: publish, archive, recalculate. Declare it with
@fr.post, or with
@fr.patch or
@fr.delete. Most actions take one of
four forms.
Change one row in a write_action block. Load the row with
get_one. Then change it inside
write_action, under the
name of the action. obj names the row that the action is about. The blog
example publishes a post:
class Action(fr.Action):
PUBLISH = "publish"
@fr.post("/{id}/publish", status_code=200)
async def publish(self, id: int) -> Post:
post = await self.get_one(id)
async with self.write_action(self.Action.PUBLISH, obj=post):
if post.published:
raise fr.exc.Conflict("Already published")
post.published = True
return post
The view’s Action class lists its actions: the five CRUD actions of
fr.Action, and PUBLISH. See
Action names.
When the block starts, Restly calls authorize("publish", obj=post) and takes
the snapshot of the row. With data=, it also checks the references in the
data, as a CRUD write does. When the block ends, Restly saves, as after a
business method: it flushes, refreshes post and loads it with the view’s
loader options. Then it runs before_action_commit, commits, and runs
after_action_commit, each with the action name "publish". So the block
only changes the row. If the block raises, as it does here for a post that is
already published, nothing is committed. The route loads with get_one, not
with handle_get_one, because write_action authorizes the action itself.
Authorize a custom action shows how authorize
handles a new name.
The SaaS example uses this form for the task changes POST /tasks/{id}/start,
complete and reopen
(app/tasks/views.py),
and for POST /users/{id}/change-password
(app/users/views.py).
Run a CRUD action under another path. When the action is a create or an
update with another path or another body, call the handler,
handle_create or
handle_update. The
route then runs every step of that CRUD action, with authorize and the
commit. The SaaS example’s PATCH /users/me updates the caller’s own row:
@fr.patch("/me")
async def update_current_user(self, request: UpdateMeRequest) -> ViewResponse:
return await self.handle_update(Current.user_id(), request)
UpdateMeRequest has only name and email, so this route cannot change
other fields. The default update writes the fields of the schema it
receives, and only the fields that the client sent. A change to the update
business method applies here too.
Create in a block of your own. Some creates need more than create does,
such as a parent row and its child rows. Build the new object, and pass it to
write_action as obj:
@fr.post("/with-lines", status_code=201)
async def create_with_lines(self, data: OrderWithLines) -> ViewResponse:
order = Order(customer_id=data.customer_id)
async with self.write_action(self.Action.CREATE, obj=order, data=data):
order.lines = [OrderLine(sku=line.sku) for line in data.lines]
return order
The order is not in the database yet, so authorize gets obj=None, as on
POST /, and old is None. Pass the request as data=: then
authorize gets it, so a create rule that reads the payload works here too.
Restly checks the references in data before the block, as the create
handler does. Then it adds the order to the session. When the block ends, it
saves the order with the lines that its relationship holds, and the hooks
receive the order as new.
The route builds the order before the block, so before authorize and the
reference check. That is safe, because building an object writes nothing. But
set a reference to a loaded row by its key, as customer_id= above, or set it
inside the block. With Order(customer=customer) before the block, the loaded
customer holds the new order in its orders. The action begins with a flush,
and SQLAlchemy then warns that the order is not in the session. A check of the
built object, such as a rule about its lines, goes inside the block, where
authorize has already passed.
Change several rows in one action. Pass obj=None: the action has no
single row. When the block ends, Restly saves every row of the view’s model
that the block changed. It reloads them together, with the view’s loader
options, so each row comes back as the database has it:
class Action(fr.Action):
REORDER = "reorder"
@fr.put("/positions")
async def reorder(self, items: list[CookbookPosition]) -> list[ViewResponse]:
cookbooks = []
async with self.write_action(self.Action.REORDER, obj=None, data=items):
for item in items:
cookbook = await self.get_one(item.id)
cookbooks.append(await self.update(cookbook, item))
return cookbooks
authorize gets obj=None, once, and the hooks get new=None and
old=None. The block calls the business method update, so a change to it
applies here too. Restly checks the references in each schema of the list.
The reload runs one query for each 500 rows, also for rows that the route
does not return.
Details
A block that needs the id of the new row before it ends, for rows that refer
to it by a plain id, calls await self.session.flush(). Restly has added the
new object to the session, so the flush inserts it. The SaaS example’s
POST /uploads does this for an upload and its lines, and
POST /projects/{id}/clone does it for a project and its copied tasks
(app/uploads/views.py,
app/projects/views.py).
Rows of another model, such as the lines, are written but not refreshed.
When each row of several needs its own authorize call and its own hooks,
call the handler for each row inside
shared_write_action_commit() instead. Or call
authorize("update", obj=cookbook, data=item) in the loop yourself.
A block does not take a list as obj. Inside the block, nothing is written
until it ends, so a query there does not see your changes. When the query
must see them, call session.flush() first.
After the block, obj has the relationships of the view’s loader options,
and the ones that the model loads eagerly, such as with lazy="selectin".
The save unloads the rest, so a changed foreign key never shows the old
related row. This holds also for a relationship that the
route loaded before the block. On an async view, read such a relationship
after the block with await obj.awaitable_attrs.<name>, or add it to
get_loader_options.
Restly checks the references in data, also in the schemas nested in it and
in each item of a list; see Check that the referenced row exists.
The routes above declare ViewResponse, or a list of it. So Restly sends
the rows that they return through the view’s response step, and OpenAPI
shows the same types as for the CRUD routes; see
The response of a custom route. A route
that builds a schema with model_construct() skips validation, so its
reference fields need the right form: see
Build references in a custom route.
Commit several writes together#
A handler or a write_action block commits on its own, unless it runs
inside another write_action block, which then owns the commit. To commit
several of them at once, put them inside a
shared_write_action_commit()
block. The outermost block commits once. Then the queued after_action_commit
hooks run:
@fr.post("/bulk", status_code=201)
async def bulk_create(self, items: list[OrderCreate]) -> list[ViewResponse]:
async with self.shared_write_action_commit():
orders = [await self.handle_create(item) for item in items]
return orders
Inside the block, each action still calls authorize, takes the snapshot,
makes its change and runs before_action_commit. Then it flushes and returns.
Its changes are not committed yet, and its after_action_commit has not run.
So code after a handler call inside the block runs before the commit. Code
that needs the commit, or an after-hook that has run, goes after the block,
or in after_action_commit. The route above returns the orders after the
block, so Restly builds their responses after the commit. On fr.RestView, use
with and the sync handlers. The method has the same name.
Details
The block can also hold the handlers of other views, for an operation on more
than one model. Each create runs the steps of its own view: authorize, the
reference checks, the save and the hooks. The SaaS example creates a label and
attaches it to a task:
@fr.post("/create-and-attach", response_model=TaskLabelResponse, status_code=201)
async def create_and_attach(
self, request: CreateAndAttachLabelRequest
) -> TaskLabel:
"""Sibling-creation: build a Label *and* a TaskLabel in one request.
Two creates, each through the handler of its own view, inside one
``shared_write_action_commit()``. So each runs its own steps:
``authorize``, the reference checks, the save and the hooks. The
label's handler saves the label, so the link gets its id. The Label
lands in the organization the request acts in: ``organization_id`` is
the model's stamp, not an argument here. The link's ``authorize``
refuses a task that the member is not assigned to (403), and its
reference check refuses a foreign or deleted task (404). Then nothing
is committed, the label included.
"""
labels = LabelView(session=self.session, request=self.request)
async with self.shared_write_action_commit():
label = await labels.handle_create(
LabelView.schema_create(name=request.label_name, color=request.color)
)
link = await self.handle_create(
self.schema_create(task_id=request.task_id, label_id=label.id)
)
return link
The label’s handler saves the label, so the link gets its id. The label, the
link and the rows that their before_action_commit hooks write are committed
together. If any part fails, none of them is committed. Code that only
belongs to one action, such as copied child rows, goes in that action’s
write_action block instead, as the clone route does.
Blocks inside each other share the commit when their views share a session.
A write_action block counts as such a block, so a handler that you call
inside it joins its commit. The handler saves, so it also writes the changes
of the outer block so far: set the required values of the block’s objects
before you call it.
An exception that leaves a shared_write_action_commit() block cancels the
shared commit and drops the queued hooks. This also happens when an outer
block catches the exception. For a write action that raises inside the
block, see the savepoint below.
The outermost block runs in a savepoint of its own. When an exception leaves
it, Restly rolls back to that savepoint: what the block changed is undone, and
the session can write again. A direct session.commit() inside the block
raises RuntimeError, because the outermost block owns the commit.
When some rows may fail and the others must still be saved, put SQLAlchemy’s
session.begin_nested() around each handler call. Catch the error outside
that savepoint:
from sqlalchemy.exc import IntegrityError
async with self.shared_write_action_commit():
for item in items:
try:
async with self.session.begin_nested():
await self.handle_create(item)
except IntegrityError:
continue
The savepoint holds the row’s change and the rows that its
before_action_commit wrote. So a failed row rolls back both, and its queued
after-hook is dropped. Without the savepoint, a row that failed after its
change began, such as a create that refuses after super().create(), would
leave a half-done change in the session. So when the route catches such an
error without a savepoint, the commit raises RuntimeError, and nothing is
committed. An error before the change, from authorize or the reference
check, changed nothing, and the commit goes on. Inside the shared block, the row’s error stays a raw
IntegrityError, so the route can catch it. An IntegrityError that escapes
the shared block, or that its commit raises, becomes a 409; see
Unique values and other database conflicts: 409. After the shared commit, the after-hooks of the saved
rows run in order. They stop at the first exception.
The SaaS example runs both forms. POST /tasks/bulk, /tasks/bulk-delete
and /tasks/import-csv put a savepoint around each row inside one
shared_write_action_commit() block
(app/tasks/views.py).
POST /task-labels/create-and-attach commits a label and its link together
(app/labels/views.py).
What is available on self#
Inside every method and custom route, you can use these attributes:
Attribute |
Type |
Description |
|---|---|---|
|
The database session of the request |
|
|
The HTTP request |
|
the model class |
The SQLAlchemy model of the view |
|
|
The view’s schema |
A class attribute that you declare with Annotated[..., Depends(...)], such
as the current user, is also set on self for each request. See
Declare dependencies as class attributes.
Internals#
The CRUD handlers and write_action share one private implementation of the
commit bracket. It calls authorize, checks the references in data, takes
the snapshot, and runs the change under session.no_autoflush. Then it
saves: it flushes and refreshes the subject. Then one reload with the view’s
loader options covers the subject and the other written rows of the view’s
model. It does not apply the scope, so a row that the action moved out of it,
such as a soft-deleted row, still comes back. Then it runs
before_action_commit, the commit and after_action_commit. The outermost
bracket on a session owns the commit. So inside a write_action or a
shared_write_action_commit() block, a bracket flushes instead of
committing and queues its after-hook for the outermost block. A handler
hands the object that create or update returns to the bracket as the
subject. write_action gets its subject from obj. The outermost bracket
opens a savepoint after the snapshot, and an exception rolls back to it; see
When a write action raises.
See also#
Authorization: who may perform each action.
Scopes: which rows a view can see.
Views: registration, dependencies and base views.
Models: values that every write path must set.
View Reference: every view method, with its signature.