Views API#
fastapi_restly.views implements the class-based view layer. RestView
and AsyncRestView define CRUD endpoint methods for a model. View is the
bare primitive for hand-written endpoint groups. include_view() registers
either on a FastAPI app.
Class-based views with default CRUD methods and explicit override tiers.
Every CRUD verb on RestView / AsyncRestView exists at three tiers.
Name the tier that owns your change and override one method:
<verb>_endpoint, the endpoint method: the@route, FastAPI signature,response_model, andto_response. Replace only to change the HTTP contract.handle_<verb>, the handler: runsauthorizeand the commit bracket (before_action_commit-> commit ->after_action_commit). Final: call it from a custom route, never override it.<verb>(get_many,get_one,create,update,delete), the business method: the domain operation, auth-free and commit-free. The usual override point.
Cross-cutting seams: scope (read visibility), authorize (policy),
apply_list_params (URL grammar), to_response (wire shape),
write_action (custom write actions), shared_write_action_commit
(one commit over several writes). Under the verbs sit the final
domain utilities (make_new_object, update_object, save_object):
call them from a verb override, never override them; a server-stamped
field is a column default on the model. View is the bare class-based
primitive for non-CRUD endpoint groups (auth flows, webhooks, RPC).
- class fastapi_restly.views.Action#
Bases:
objectCanonical CRUD action names passed to
authorize/before_action_commit/after_action_commit.This is a constants class, not an
Enum: custom actions and mixins add their own names. Use constants for typo checking at import time.- CREATE = 'create'#
- DELETE = 'delete'#
- GET_MANY = 'get_many'#
- GET_ONE = 'get_one'#
- UPDATE = 'update'#
- class fastapi_restly.views.AsyncReactAdminView#
Bases:
_ReactAdminMixin,AsyncRestViewAsyncRestView that speaks the ra-data-simple-rest wire contract.
Use this instead of AsyncRestView when your frontend is react-admin with ra-data-simple-rest.
- class fastapi_restly.views.AsyncRestView#
Bases:
BaseRestView[ModelT,ResponseSchemaT,CreateSchemaT,UpdateSchemaT,IdT]AsyncRestView creates an async CRUD/REST interface for database objects. Basic usage:
class FooView(AsyncRestView): prefix = "/foo" schema = FooSchema model = Foo
Each verb is three tiers (see “Customizing RestView” in the docs):
<verb>_endpoint: the endpoint method. Owns the HTTP signature,response_model, andto_response. Rarely overridden.handle_<verb>: the handler. Ownsauthorizeand the commit bracket (before_action_commit-> commit ->after_action_commit); returns the domain object. Final: call it from a custom route to get the bracket, never override it.<verb>(get_many/get_one/create/update/delete): the business method. Auth-free, commit-free; the common override point (hash a password, derive a slug, …).
- async after_action_commit(action: str, new: ModelT | None, old: dict[str, Any] | None = None) None#
Post-commit side effect (email, webhook, cache invalidation).
oldenables dirty detection (“notify only if the status changed”).For external effects only: the write is already durable, so mutating
newor the database here is NOT persisted. A mutation tonewalso leaks into this request’s response (which serializesnewafter this hook) while being silently discarded from storage. Do the mutation in the business method orbefore_action_commitinstead.An exception here cannot undo the committed write, yet it fails the request, so a client that retries repeats the write. Catch and log the errors of a best-effort effect here; add an effect that must happen as an outbox row in
before_action_commit.
- apply_list_params(query: Select, list_params: Any) Select#
Apply the list params (filter, sort, page) to
query. Override for a non-default URL grammar; the common case is driven by configuration. The default isfastapi_restly.query.apply_list_params()with the view’s model, response class and pagination.
- async authorize(action: str, obj: ModelT | None = None, data: Any = None) None#
Gate a verb. Called by
handle_<verb>at the right phase: before the write forcreate, and after the scoped load forupdate/delete/get_one(soobjis available for row-level checks).The default is a no-op. Override to enforce policy, raising
fr.exc.Forbidden/fr.exc.NotFoundto reject (actionsays which verb;obj/datacarry the loaded row and the request payload). Row visibility, hiding a row from every caller, belongs in the scope, not here.
- async before_action_commit(action: str, new: ModelT | None, old: dict[str, Any] | None = None) None#
In-transaction side effect (outbox rows, audit rows), committed atomically with the write.
oldis the pre-mutation snapshot dict.
- async count(query: Select) int#
Total for the list, ignoring presentation-layer ordering/pagination.
The stripped query is made
DISTINCTand wrapped as a subquery, so the total is correct across user-provided query shapes, including a query that joins a to-many relationship, whose row fan-out would otherwise inflate the count. Override for estimated counts on huge tables.
- async create(schema_obj: CreateSchemaT) ModelT#
Build a new object from
schema_objand save it.Auth-free and commit-free:
handle_createowns both. The usual create override point (hash a password, derive a slug), written asmake_new_object(), the extra step, thensave_object().
- async create_endpoint(schema_obj: Any) Any#
POST /endpoint method. Overridecreatefor domain logic (it is commit-free; the handler owns the commit),to_responsefor the response shape; replace this method only to change the HTTP contract.
- async delete(obj: ModelT) None#
Remove
objand flush. Does not commit:handle_deletedoes.Override (on the view or on a soft-delete mixin) to flip a timestamp instead of removing the row, without calling
super(). A raw row delete elsewhere isfr.objects.async_delete_object(self.session, obj).
- async delete_endpoint(id: Any) Any#
DELETE /{id}endpoint method. Overridedeletefor domain logic (e.g. soft delete); replace this method only to change the HTTP contract (e.g. return the deleted object instead of 204).
- async get_many(list_params: Any, *, scope: WhereClause | Unscoped | None = None) ListResult[ModelT]#
List the rows the scope allows, filtered and paged by
list_params.Auth-free:
handle_get_manyaddsauthorize. The query is the resolved scope (fr.resolve_scope(self), orscopewhen given) plusapply_list_params(). A paginated view also runscount()fortotal_count; a view without pagination returns every matching row withtotal_count=None. Relationships the response class names are eager-loaded.The handlers always forward
scope=, so an override must declare the parameter and pass it on tosuper(). A route’swhere=arrives insidescope:handle_get_manyfolds it in, and there is no separate parameter to declare.- Parameters:
list_params – the list params (filter, sort, page) as the endpoint receives them.
scope – a clause that replaces the resolved scope for this read;
fr.clauses.UNSCOPEDreads unscoped.
- async get_many_endpoint(list_params: Any) Any#
GET /endpoint method. Overrideget_manyfor domain logic,to_responsefor the response shape; replace this method only to change the HTTP contract.
- async get_one(id: IdT | ColumnElement[bool], *, scope: WhereClause | Unscoped | None = None) ModelT#
Load the one row
idnames, through the scope, or raise 404.Auth-free:
handle_get_oneaddsauthorize; a write action calls this directly and gates its own action. Visibility is the resolved scope (fr.resolve_scope(self), orscopewhen given), so a row outside it is a 404 for every caller. Relationships the response class names are eager-loaded.The handlers always forward
scope=, so an override must declare the parameter and pass it on tosuper().- Parameters:
id – the primary key, or a SQLAlchemy boolean expression that picks the row instead (
get_one(Item.slug == slug)), which is also how a composite key is addressed. A criterion narrows inside the scope; onlyscope=replaces it.scope – a clause that replaces the resolved scope for this read;
fr.clauses.UNSCOPEDreads unscoped.
- Raises:
fastapi_restly.exc.NotFound – no row matches inside the scope. The message names an id, never a predicate.
sqlalchemy.exc.MultipleResultsFound – more than one row matches; an ambiguous key is a bug in the criterion.
TypeError –
idis a Python bool (a comparison on a loaded object, which would render asWHERE true) or an uncalled clause (a scope, not a row identity).NotImplementedError – a plain id on a composite primary key; pass a predicate.
- async get_one_endpoint(id: Any) Any#
GET /{id}endpoint method. Overrideget_onefor domain logic (visibility lives in the scope),to_responsefor the response shape; replace this method only to change the HTTP contract.
- async handle_create(schema_obj: CreateSchemaT) ModelT#
Create handler:
authorize, thecreatebusiness method, commit bracket.Final: override
createfor the domain change,authorizefor the gate, andbefore_action_commit/after_action_commitfor side effects. Call it from a custom create route, and from insideshared_write_action_commit()to share one commit with other writes; it then returns after the flush, before the commit.
- async handle_delete(id: IdT | ColumnElement[bool]) None#
Delete handler: scoped load, then
deletein the commit bracket.Final, like
handle_create(): a soft delete flips a timestamp indelete, and an off-request follow-up runs inafter_action_commit. Insideshared_write_action_commit(), the mutation runs immediately. The after-hook waits for the outermost block to commit.
- async handle_get_many(list_params: Any, *, scope: WhereClause | Unscoped | None = None, where: ColumnElement[bool] | WhereClause | Unscoped | None = None) ListResult[ModelT]#
List handler:
authorizethen theget_manybusiness method.Final, like every handler: override
get_manyfor the query andauthorizefor the gate. Call it from a custom list route.- Parameters:
scope – a clause that replaces the view scope for this read, so a custom route can list another surface of the same model (a trash list);
fr.clauses.UNSCOPEDreads past it. Forwarded toget_many.where – a SQLAlchemy boolean expression or a clause that narrows this read inside the scope, so a nested list keeps the visibility rules (
where=Task.project_id == id). It is ANDed into the scope asfr.all_ofwould, andget_manyreceives the result asscope. The page andtotal_countboth apply it.
- Raises:
TypeError –
whereis a Python bool (a comparison on a loaded object), or neither an expression nor a clause.
- async handle_get_one(id: IdT | ColumnElement[bool], *, scope: WhereClause | Unscoped | None = None) ModelT#
Retrieve handler: scoped load (404 by visibility) then read-auth.
Final: override
get_onefor the load andauthorizefor the gate. Call it from a custom read route as “load with scope + 404 + read-auth”. A write action instead loads withget_one(id, scope=...)and gates only its own action, the wayhandle_updateandhandle_deletedo.- Parameters:
id – the primary key, or a SQLAlchemy boolean expression that picks the row instead, so a natural-key route is this handler with
Item.slug == slug. Forwarded toget_one.scope – a clause that replaces the view scope for this read, so a restore route can load the row the view scope hides. Forwarded to
get_one.
- async handle_update(id: IdT | ColumnElement[bool], schema_obj: UpdateSchemaT) ModelT#
Update handler: scoped load, then
updatein the commit bracket.Final, like
handle_create():updatereceives the loaded object, so the load, the 404, andauthorizestay here. Insideshared_write_action_commit()it returns before the commit.
- async make_new_object(schema_obj: CreateSchemaT) ModelT#
Construct a new ORM object from
schema_objand add it to the session. Does not flush;save_object()does.Final: the view-bound spelling of
fr.objects.async_make_new_object, passing the view’s model.schema_obj’s own schema decides what is written, not the view’s schema. A server-stamped field (an audit id, a tenant id) is a column default on the model, which covers every write path; a value derived from the payload goes in acreateoverride, after this call.
- async save_object(obj: ModelT) ModelT#
Flush the session and refresh
objfrom the database, eager-loading the relationships the response class names. Does not commit;handle_<verb>owns the commit.Final: a side effect per write belongs in
before_action_commit/after_action_commit(or a session event, to see the bulk paths too), and the reload strategy isget_relationship_loader_options.The refresh leaves relationships unloaded, so without the eager load the serializer would reach them one lazy query at a time, which on an async session is not slow but fatal: a lazy load in the endpoint coroutine has no greenlet to suspend into and raises
MissingGreenlet. Reads apply the same options inget_one/get_many.
- session: _AsyncSessionDependency object at 0x7f6868f759d0>, use_cache=True, scope=function)]#
Share one commit across write actions on this session.
The outermost block commits once, then runs the queued
after_action_commithooks.write_actionand the write handlers still authorize, snapshot, mutate, runbefore_action_commit, and flush. They return uncommitted objects inside the block:async with self.shared_write_action_commit(): for schema_obj in items: await self.handle_create(schema_obj)
Serialize returned objects and run code that depends on an after-hook only after the block exits. Nested blocks on the same session share the commit. An exception escaping a deferred-commit block aborts the shared commit, including when an enclosing deferred-commit block catches that exception. Rollback belongs to the session owner.
After-hooks run in queue order and stop on the first exception.
newis the live object after all writes, whileoldis each action’s snapshot. Directsession.commit()calls raiseRuntimeError. An async write action needs an async outermost block; a sync one joins either.
- async update(obj: ModelT, schema_obj: UpdateSchemaT) ModelT#
Apply
schema_objto the loadedobjand save it.Auth-free and commit-free:
handle_updateloadsobjthroughget_one, gates, and commits. The usual update override point, written asupdate_object(), the extra step, thensave_object().
- async update_endpoint(id: Any, schema_obj: Any) Any#
PATCH /{id}endpoint method. Overrideupdatefor domain logic,to_responsefor the response shape; replace this method only to change the HTTP contract.
- async update_object(obj: ModelT, schema_obj: UpdateSchemaT) ModelT#
Apply writable fields from
schema_objtoobj. Does not flush.Final, like
make_new_object(): anupdated_bystamp is the column’sonupdateon the model; payload-derived values go in anupdateoverride, after this call.
- write_action(action: str, *, obj: ~typing.Any = <object object>, data: ~typing.Any = None)#
Run a custom write action through the commit bracket.
Use this for non-CRUD actions such as publish or change-password:
async with self.write_action("publish", obj=article): # in-place article.status = "published"
For create-shaped actions, omit
objand setw.objbefore exit:async with self.write_action("create", data=req) as w: w.obj = await self.make_new_object(req)
Pass
obj=Nonefor writes with no single object. Exceptions skip the commit. Insideshared_write_action_commit(), the outermost block owns the commit and the after-hooks, so this bracket returns after its flush but before either one.
- class fastapi_restly.views.BaseRestView#
Bases:
View,Generic[ModelT,ResponseSchemaT,CreateSchemaT,UpdateSchemaT,IdT]Base class for RestView implementations.
This class contains the common functionality shared between AsyncRestView and RestView, including schema definitions, model configuration, and common CRUD operation logic.
- classmethod before_include_view()#
Apply type annotations needed for FastAPI, before creating an APIRouter from this view and registering it.
This function can be overridden to further tweak the endpoints before they are added to FastAPI.
- extra_query_params: ClassVar[Iterable[str]] = ()#
Extra query-parameter keys to allow on list routes beyond those derived from the response class. Use this when a view reads a custom parameter from
self.request(e.g.?verbose=true). Without this, the strict unknown-key guard rejects the request with 422. A key that the route declares, on the endpoint method or in a dependency, or anAPIKeyQuerykey, needs no entry.
- get_relationship_loader_options() list[Any]#
Loader options for the relationships the response class names.
Returns recursive
selectinload(...)options derived fromschema_response, applied on reads (get_one/get_many) and on the post-write reload insave_object. Override to eager-load relationships the response class does not name on both paths; append tosuper().get_relationship_loader_options()to keep the derived loads. See the “Relationship Loading and Async” how-to.
- id_type: ClassVar[type[Any] | None] = None#
The type of the
{id}path parameter on the default routes.None(the default) takes the Python type of the model’s primary key, and a composite key getsint. Set a type to override it. Built-in item routes use Starlette’sintoruuidpath converter for those types. Integer paths accept non-negative digits. An unmatched value falls through to another route or returns 404, instead of 422.
- pagination: ClassVar[NumberedPagination | NoPagination | None] = NumberedPagination(default_page_size=50, max_page_size=1000, max_page=None, page_query_param='page', page_size_query_param='page_size', envelope=<class 'fastapi_restly._pagination.PaginatedEnvelope'>)#
How list endpoints paginate. The default
NumberedPaginationtakespage/page_sizequery parameters, runs the count query, and wraps the list in itsenvelope(PaginatedEnvelope:dataplustotal_count/page/page_size/total_pages). ANoPaginationreturns every matching row with no count, in itsenvelope;Noneis short forNoPagination(), a plainEnvelope(dataonly). A view inherits it, so a project base view sets it once; a view that differs changes one setting withreplace()on the shared settings. For a shape no envelope model can express, such as a header, replaceget_many_endpointwith a matchingresponse_model(seeAsyncReactAdminView).
- responses: ClassVar[dict[int | str, dict[str, Any]]] = {404: {'description': 'Not found'}}#
OpenAPI responses documented on every route. Each class in the hierarchy adds its own, and a subclass’s entry for a status code wins.
- schema_list_params: ClassVar[type[BaseModel]]#
The list params (filter, sort, page) as a pydantic model, generated from
schema_responseandmodelbyderive_schema_list_params(). Any route method on the view that declares alist_paramsparameter takes it: typed for FastAPI and OpenAPI, and guarded against unknown keys likeGET /, so a custom list route (a trash route naming its own scope) reads the same list params. The route can take other query parameters beside it.
- schema_response: ClassVar[type[BaseModel]]#
The class of everything that goes out: the single responses, the items of the list response, and the fields the list params filter and sort on. The relationships it names are the ones the view loads. When a view sets none, Restly builds it with
derive_schema_response(): the view’s schema without itsWriteOnlyfields, named<Resource>Response. A class that a view sets is used as it is.
- scope: ClassVar[WhereClause | Unscoped | None] = None#
The clause every read on this view applies: list, count, and retrieve (a row outside it is 404).
None(the default) falls back to the model’s declareddefault_scope;fr.clauses.UNSCOPEDreads unscoped despite that default. Declaring a scope replaces the default, it does not stack on it; compose the replacement from the same leaves (ItemClauses.trashedcontaining the tenant clausevisiblecontains). A rule that must hold under every scope is a session-levelwith_loader_criteria, not a view concern. See the Scopes guide.
- snapshot(obj: Any) dict[str, Any]#
Frozen capture of an object’s already-loaded column values, passed as
oldtobefore_action_commit/after_action_commitfor dirty detection. Override to change whatoldcaptures (e.g. include a relationship’s prior state); the default delegates tosnapshot().
- to_list_response(list_result: ListResult[ModelT]) Any#
Build the list response body: an instance of the envelope model.
The view fills its
pagination’senvelope: by default aPaginatedEnvelope(dataplustotal_count/page/page_size/total_pages), or anEnvelope(dataonly) for a view without pagination. The route’sresponse_modelis the same envelope, fixed frompaginationat registration. To change the shape, set the pagination’senvelope. For aContent-Rangeheader, replaceget_many_endpointwith a matchingresponse_model(asAsyncReactAdminViewdoes), not by overriding this method alone, which would fail response validation.The page and page size come from
list_result.list_params.
- to_response(result: Any, shape: ResponseShape = ResponseShape.SINGLE) Any#
Endpoint-method response boundary.
shapeselects the wire form, and with it whatresultis: one object forSINGLE(to_single_response()), aListResultforLIST(to_list_response()), and nothing forEMPTY. A plain Python list is not a validresult.shapeis not the write-action name. Override for envelopes or shape-wide status behavior; per-endpoint projections belong in the endpoint method.
- to_single_response(obj: ModelT | ResponseSchemaT) ResponseSchemaT#
Serialize one ORM object to the view’s
schema_response.By default the response class is the view’s schema without its WriteOnly fields, named like
UserResponse. An override may return an instance of another model, such asschema. Only the fields of the response class go out. Restly copies the values of an instance of a subclass of the response class, and ofschemawhen Restly derived the response class, without validating them again. It validates any other model into the response class from its attributes.The view loads only the relationships that the response class names. An override that builds
schemafrom the ORM object also reads the relationships of its WriteOnly fields. On an async view that fails withMissingGreenlet: add them inget_relationship_loader_options(), or build the response class instead.The ORM path below validates through the response class, so a view’s schema that declares a WriteOnly field the ORM object doesn’t carry (e.g.
passwordbacked by apassword_hashcolumn) doesn’t fail response validation.
- class fastapi_restly.views.Envelope(*, data: Sequence[DataT])#
Bases:
BaseModel,Generic[DataT]List response wrapper:
{"data": [...]}.The default
envelopeofNoPagination, for a view that returns every row. A paginated view uses its pagination’senvelope,PaginatedEnvelopeby default.- model_config: ClassVar[ConfigDict] = {}#
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class fastapi_restly.views.ListResult(objects: Sequence[ModelT], total_count: int | None = None, list_params: Any = None)#
Bases:
Generic[ModelT]Result returned by
get_manybefore HTTP response formatting.total_countisNonefor a list without pagination, which does not run the count query.list_paramsholds the list params thatget_manyreceived, soBaseRestView.to_list_response()can read the page from them.
- class fastapi_restly.views.NoPagination(*, envelope: type[BaseModel] = Envelope)#
Bases:
objectNo pagination: a list returns every matching row and runs no count query.
pagination = Noneis the short form ofNoPagination(). Set an instance to choose the list response model:class TagView(fr.AsyncRestView): pagination = fr.NoPagination(envelope=DataCount)
envelopeworks as forNumberedPagination, and Restly fills it by name fromdataandtotal_count, the number of rows. A genericpydantic.RootModelover the items whose before-validator returnsdatamakes the list a bare JSON array.
- class fastapi_restly.views.NumberedPagination(*, default_page_size: int = 50, max_page_size: int = 1000, max_page: int | None = None, page_query_param: str = 'page', page_size_query_param: str = 'page_size', envelope: type[BaseModel] = PaginatedEnvelope)#
Bases:
objectPage-number pagination:
?page=2&page_size=50.Set an instance as a view’s
pagination. Views inherit it, so a project base view sets it once, and a view that differs changes one setting withreplace():APP_PAGINATION = fr.NumberedPagination( page_size_query_param="size", max_page_size=100 ) class AppView(fr.AsyncRestView): pagination = APP_PAGINATION class LogView(AppView): pagination = APP_PAGINATION.replace( default_page_size=10, max_page_size=10 )
envelopeis the list response model: a generic Pydantic model with one type parameter, which each view fills with its response schema. Restly fills its fields by name fromdata,total_count,page,page_sizeandtotal_pages. Leave a field out to drop it from the response, and rename one with analiasor analias_generator. Amodel_validator(mode="before")receives those values as a dict and can reshape them, for example to nest the metadata. Creating the settings builds an empty page from the envelope, so a required field Restly cannot fill fails at startup instead of on a request. An envelope keeps the defaultextrasetting: Restly passes it every page value and it keeps the fields it declares.
- class fastapi_restly.views.PaginatedEnvelope(*, data: Sequence[DataT], total_count: int, page: int, page_size: int, total_pages: int)#
Bases:
EnvelopePaginated list response:
dataplus pagination metadata.The default
envelopeofNumberedPagination.- model_config: ClassVar[ConfigDict] = {}#
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class fastapi_restly.views.ReactAdminView#
Bases:
_ReactAdminMixin,RestViewRestView that speaks the ra-data-simple-rest wire contract.
Use this instead of RestView when your frontend is react-admin with ra-data-simple-rest.
- class fastapi_restly.views.ResponseShape(*values)#
-
The wire shape an endpoint method asks
BaseRestView.to_response()to produce.This is separate from write-action names such as
"publish". Endpoint methods choose one of these three response shapes; custom actions remain an open string namespace.- EMPTY = 'empty'#
- LIST = 'list'#
- SINGLE = 'single'#
- class fastapi_restly.views.RestView#
Bases:
BaseRestView[ModelT,ResponseSchemaT,CreateSchemaT,UpdateSchemaT,IdT]RestView creates a sync CRUD/REST interface for database objects. Basic usage:
class FooView(RestView): prefix = "/foo" schema = FooSchema model = Foo
Each verb is three tiers (see “Customizing RestView” in the docs):
<verb>_endpoint: the endpoint method. Owns the HTTP signature,response_model, andto_response. Rarely overridden.handle_<verb>: the handler. Ownsauthorizeand the commit bracket (before_action_commit-> commit ->after_action_commit); returns the domain object. Final: call it from a custom route to get the bracket, never override it.<verb>(get_many/get_one/create/update/delete): the business method. Auth-free, commit-free; the common override point (hash a password, derive a slug, …).
- after_action_commit(action: str, new: ModelT | None, old: dict[str, Any] | None = None) None#
Post-commit side effect (email, webhook, cache invalidation).
oldenables dirty detection (“notify only if the status changed”).For external effects only: the write is already durable, so mutating
newor the database here is NOT persisted. A mutation tonewalso leaks into this request’s response (which serializesnewafter this hook) while being silently discarded from storage. Do the mutation in the business method orbefore_action_commitinstead.An exception here cannot undo the committed write, yet it fails the request, so a client that retries repeats the write. Catch and log the errors of a best-effort effect here; add an effect that must happen as an outbox row in
before_action_commit.
- apply_list_params(query: Select, list_params: Any) Select#
Apply the list params (filter, sort, page) to
query. Override for a non-default URL grammar; the common case is driven by configuration. The default isfastapi_restly.query.apply_list_params()with the view’s model, response class and pagination.
- authorize(action: str, obj: ModelT | None = None, data: Any = None) None#
Gate a verb. Called by
handle_<verb>at the right phase: before the write forcreate, and after the scoped load forupdate/delete/get_one(soobjis available for row-level checks).The default is a no-op. Override to enforce policy, raising
fr.exc.Forbidden/fr.exc.NotFoundto reject (actionsays which verb;obj/datacarry the loaded row and the request payload). Row visibility, hiding a row from every caller, belongs in the scope, not here.
- before_action_commit(action: str, new: ModelT | None, old: dict[str, Any] | None = None) None#
In-transaction side effect (outbox rows, audit rows), committed atomically with the write.
oldis the pre-mutation snapshot dict.
- count(query: Select) int#
Total for the list, ignoring presentation-layer ordering/pagination.
The stripped query is made
DISTINCTand wrapped as a subquery, so the total is correct across user-provided query shapes, including a query that joins a to-many relationship, whose row fan-out would otherwise inflate the count. Override for estimated counts on huge tables.
- create(schema_obj: CreateSchemaT) ModelT#
Build a new object from
schema_objand save it.Auth-free and commit-free:
handle_createowns both. The usual create override point (hash a password, derive a slug), written asmake_new_object(), the extra step, thensave_object().
- create_endpoint(schema_obj: Any) Any#
POST /endpoint method. Overridecreatefor domain logic (it is commit-free; the handler owns the commit),to_responsefor the response shape; replace this method only to change the HTTP contract.
- delete(obj: ModelT) None#
Remove
objand flush. Does not commit:handle_deletedoes.Override (on the view or on a soft-delete mixin) to flip a timestamp instead of removing the row, without calling
super(). A raw row delete elsewhere isfr.objects.delete_object(self.session, obj).
- delete_endpoint(id: Any) Any#
DELETE /{id}endpoint method. Overridedeletefor domain logic (e.g. soft delete); replace this method only to change the HTTP contract (e.g. return the deleted object instead of 204).
- get_many(list_params: Any, *, scope: WhereClause | Unscoped | None = None) ListResult[ModelT]#
List the rows the scope allows, filtered and paged by
list_params.Auth-free:
handle_get_manyaddsauthorize. The query is the resolved scope (fr.resolve_scope(self), orscopewhen given) plusapply_list_params(). A paginated view also runscount()fortotal_count; a view without pagination returns every matching row withtotal_count=None. Relationships the response class names are eager-loaded.The handlers always forward
scope=, so an override must declare the parameter and pass it on tosuper(). A route’swhere=arrives insidescope:handle_get_manyfolds it in, and there is no separate parameter to declare.- Parameters:
list_params – the list params (filter, sort, page) as the endpoint receives them.
scope – a clause that replaces the resolved scope for this read;
fr.clauses.UNSCOPEDreads unscoped.
- get_many_endpoint(list_params: Any) Any#
GET /endpoint method. Overrideget_manyfor domain logic,to_responsefor the response shape; replace this method only to change the HTTP contract.
- get_one(id: IdT | ColumnElement[bool], *, scope: WhereClause | Unscoped | None = None) ModelT#
Load the one row
idnames, through the scope, or raise 404.Auth-free:
handle_get_oneaddsauthorize; a write action calls this directly and gates its own action. Visibility is the resolved scope (fr.resolve_scope(self), orscopewhen given), so a row outside it is a 404 for every caller. Relationships the response class names are eager-loaded.The handlers always forward
scope=, so an override must declare the parameter and pass it on tosuper().- Parameters:
id – the primary key, or a SQLAlchemy boolean expression that picks the row instead (
get_one(Item.slug == slug)), which is also how a composite key is addressed. A criterion narrows inside the scope; onlyscope=replaces it.scope – a clause that replaces the resolved scope for this read;
fr.clauses.UNSCOPEDreads unscoped.
- Raises:
fastapi_restly.exc.NotFound – no row matches inside the scope. The message names an id, never a predicate.
sqlalchemy.exc.MultipleResultsFound – more than one row matches; an ambiguous key is a bug in the criterion.
TypeError –
idis a Python bool (a comparison on a loaded object, which would render asWHERE true) or an uncalled clause (a scope, not a row identity).NotImplementedError – a plain id on a composite primary key; pass a predicate.
- get_one_endpoint(id: Any) Any#
GET /{id}endpoint method. Overrideget_onefor domain logic (visibility lives in the scope),to_responsefor the response shape; replace this method only to change the HTTP contract.
- handle_create(schema_obj: CreateSchemaT) ModelT#
Create handler:
authorize, thecreatebusiness method, commit bracket.Final: override
createfor the domain change,authorizefor the gate, andbefore_action_commit/after_action_commitfor side effects. Call it from a custom create route, and from insideshared_write_action_commit()to share one commit with other writes; it then returns after the flush, before the commit.
- handle_delete(id: IdT | ColumnElement[bool]) None#
Delete handler: scoped load, then
deletein the commit bracket.Final, like
handle_create(): a soft delete flips a timestamp indelete, and an off-request follow-up runs inafter_action_commit. Insideshared_write_action_commit(), the mutation runs immediately. The after-hook waits for the outermost block to commit.
- handle_get_many(list_params: Any, *, scope: WhereClause | Unscoped | None = None, where: ColumnElement[bool] | WhereClause | Unscoped | None = None) ListResult[ModelT]#
List handler:
authorizethen theget_manybusiness method.Final, like every handler: override
get_manyfor the query andauthorizefor the gate. Call it from a custom list route.- Parameters:
scope – a clause that replaces the view scope for this read, so a custom route can list another surface of the same model (a trash list);
fr.clauses.UNSCOPEDreads past it. Forwarded toget_many.where – a SQLAlchemy boolean expression or a clause that narrows this read inside the scope, so a nested list keeps the visibility rules (
where=Task.project_id == id). It is ANDed into the scope asfr.all_ofwould, andget_manyreceives the result asscope. The page andtotal_countboth apply it.
- Raises:
TypeError –
whereis a Python bool (a comparison on a loaded object), or neither an expression nor a clause.
- handle_get_one(id: IdT | ColumnElement[bool], *, scope: WhereClause | Unscoped | None = None) ModelT#
Retrieve handler: scoped load (404 by visibility) then read-auth.
Final: override
get_onefor the load andauthorizefor the gate. Call it from a custom read route as “load with scope + 404 + read-auth”. A write action instead loads withget_one(id, scope=...)and gates only its own action, the wayhandle_updateandhandle_deletedo.- Parameters:
id – the primary key, or a SQLAlchemy boolean expression that picks the row instead, so a natural-key route is this handler with
Item.slug == slug. Forwarded toget_one.scope – a clause that replaces the view scope for this read, so a restore route can load the row the view scope hides. Forwarded to
get_one.
- handle_update(id: IdT | ColumnElement[bool], schema_obj: UpdateSchemaT) ModelT#
Update handler: scoped load, then
updatein the commit bracket.Final, like
handle_create():updatereceives the loaded object, so the load, the 404, andauthorizestay here. Insideshared_write_action_commit()it returns before the commit.
- make_new_object(schema_obj: CreateSchemaT) ModelT#
Construct a new ORM object from
schema_objand add it to the session. Does not flush;save_object()does.Final: the view-bound spelling of
fr.objects.make_new_object, passing the view’s model.schema_obj’s own schema decides what is written, not the view’s schema. A server-stamped field (an audit id, a tenant id) is a column default on the model, which covers every write path; a value derived from the payload goes in acreateoverride, after this call.
- save_object(obj: ModelT) ModelT#
Flush the session and refresh
objfrom the database, eager-loading the relationships the response class names. Does not commit;handle_<verb>owns the commit.Final: a side effect per write belongs in
before_action_commit/after_action_commit(or a session event, to see the bulk paths too), and the reload strategy isget_relationship_loader_options.The refresh leaves relationships unloaded, so without the eager load the serializer would reach them one lazy query at a time. Reads apply the same options in
get_one/get_many.
- session: _SyncSessionDependency object at 0x7f6868f75940>, use_cache=True, scope=function)]#
Share one commit across write actions on this session.
The outermost block commits once, then runs the queued
after_action_commithooks.write_actionand the write handlers still authorize, snapshot, mutate, runbefore_action_commit, and flush. They return uncommitted objects inside the block:with self.shared_write_action_commit(): for schema_obj in items: self.handle_create(schema_obj)
Serialize returned objects and run code that depends on an after-hook only after the block exits. Nested blocks on the same session share the commit. An exception escaping a deferred-commit block aborts the shared commit, including when an enclosing deferred-commit block catches that exception. Rollback belongs to the session owner.
After-hooks run in queue order and stop on the first exception.
newis the live object after all writes, whileoldis each action’s snapshot. Directsession.commit()calls raiseRuntimeError. An async write action needs an async outermost block; a sync one joins either.
- update(obj: ModelT, schema_obj: UpdateSchemaT) ModelT#
Apply
schema_objto the loadedobjand save it.Auth-free and commit-free:
handle_updateloadsobjthroughget_one, gates, and commits. The usual update override point, written asupdate_object(), the extra step, thensave_object().
- update_endpoint(id: Any, schema_obj: Any) Any#
PATCH /{id}endpoint method. Overrideupdatefor domain logic,to_responsefor the response shape; replace this method only to change the HTTP contract.
- update_object(obj: ModelT, schema_obj: UpdateSchemaT) ModelT#
Apply writable fields from
schema_objtoobj. Does not flush.Final, like
make_new_object(): anupdated_bystamp is the column’sonupdateon the model; payload-derived values go in anupdateoverride, after this call.
- write_action(action: str, *, obj: ~typing.Any = <object object>, data: ~typing.Any = None)#
Run a custom write action through the commit bracket.
Use this for non-CRUD actions such as publish or change-password:
with self.write_action("publish", obj=article): # in-place article.status = "published"
For create-shaped actions, omit
objand setw.objbefore exit:with self.write_action("create", data=req) as w: w.obj = self.make_new_object(req)
Pass
obj=Nonefor writes with no single object. Exceptions skip the commit. Insideshared_write_action_commit(), the outermost block owns the commit and the after-hooks, so this bracket returns after its flush but before either one.
- class fastapi_restly.views.View#
Bases:
objectClass-based view primitive for FastAPI.
Group related endpoints on a class, share dependencies and metadata via class attributes, and let subclasses override individual handlers. Routes are bound at
include_view()time, not at class-definition time, so subclassing works the way Python developers expect: override a method on a subclass and the override is what runs.Most users will subclass
RestVieworAsyncRestView, which extendViewwith CRUD scaffolding. UseViewdirectly for grouped non-CRUD endpoints (auth flows, custom RPC routes, etc.).- classmethod before_include_view() None#
Run by
include_view()once per class, before its routes are registered. A no-op here; override to adjust route methods first.
- dependencies: ClassVar[Any] = None#
FastAPI dependencies run for every route, without injecting a result. Each class in the hierarchy adds its own, base first, so a subclass cannot drop a base’s guard. A subclass that lists a base’s entry again runs it where it lists it.
- prefix: ClassVar[str]#
The URL prefix of every route. Each class in the hierarchy that sets one adds a segment, base first.
- responses: ClassVar[dict[int | str, dict[str, Any]]] = {}#
OpenAPI responses documented on every route. Each class in the hierarchy adds its own, and a subclass’s entry for a status code wins.
- route_options: ClassVar[Mapping[str | ViewRoute, Mapping[str, Any]]] = {}#
FastAPI route keyword arguments keyed by endpoint method name or
ViewRoute. These override decorator metadata without replacing the endpoint method. A subclass inherits this mapping unless it declares its own, which replaces it.
- class fastapi_restly.views.ViewRoute(*values)#
-
Default CRUD route names that can be referenced by view options.
Values are the endpoint method names so
exclude_routescan drop them.- CREATE = 'create_endpoint'#
- DELETE = 'delete_endpoint'#
- GET_MANY = 'get_many_endpoint'#
- GET_ONE = 'get_one_endpoint'#
- UPDATE = 'update_endpoint'#
- async fastapi_restly.views.async_run_write_action(host: AsyncWriteHost, action: str, *, obj: Any = None, data: Any = None, mutate: Callable[[], Awaitable[T]]) T#
Run
mutateinside the async commit bracket and return its result.
- fastapi_restly.views.delete(path: str, **api_route_kwargs: Any) Callable[[...], Any]#
Decorator to mark a View method as a DELETE endpoint.
Equivalent to:
@route(path, methods=["DELETE"], status_code=204, ... )
- fastapi_restly.views.get(path: str, **api_route_kwargs: Any) Callable[[...], Any]#
Decorator to mark a View method as a GET endpoint.
Equivalent to:
@route(path, methods=["GET"], status_code=200, ... )
- fastapi_restly.views.include_view(parent_router: APIRouter | FastAPI, view_cls: V | None = None) V | Callable[[V], V]#
Add a View class’s routes to a FastAPI app or APIRouter.
Prefer the direct call form from your app/router composition layer:
include_view(app, MyView)
For small apps, it can also be used as a decorator:
@include_view(app) class MyView(AsyncRestView): ...
Registering a view on several parents mounts its routes on each; calling
include_viewagain with a parent the view is already registered on is a no-op.
- fastapi_restly.views.patch(path: str, **api_route_kwargs: Any) Callable[[...], Any]#
Decorator to mark a View method as a PATCH endpoint.
Equivalent to:
@route(path, methods=["PATCH"], status_code=200, ... )
- fastapi_restly.views.post(path: str, **api_route_kwargs: Any) Callable[[...], Any]#
Decorator to mark a View method as a POST endpoint.
Equivalent to:
@route(path, methods=["POST"], status_code=201, ... )
- fastapi_restly.views.put(path: str, **api_route_kwargs: Any) Callable[[...], Any]#
Decorator to mark a View method as a PUT endpoint.
Equivalent to:
@route(path, methods=["PUT"], status_code=200, ... )
- fastapi_restly.views.resolve_scope(target: type[Any] | BaseRestView[Any, Any, Any, Any, Any]) WhereClause | Unscoped#
The scope a read applies, resolved down the ladder.
Pass a view, class or instance, for the visibility that view’s reads apply: its declared
scope, the model’sdefault_scopewhen the view declares none, andfr.clauses.UNSCOPEDwhen neither does. Pass a mapped model class for the model rung alone, which is what every reference check applies. The two answers differ wherever a view declares a scope, so code off the request path that wants what the API shows passes the view.The result is a clause or
fr.clauses.UNSCOPED, neverNone, so it composes without a branch:query = fr.apply_clauses( select(Task).where(Task.project_id == id), fr.resolve_scope(TaskView), )
Every read resolves here, so a route that applies the result sees the same rows as
get_oneandget_many. It resolves the scope and does not apply it: applying stays with the framework, and there is no apply-side override point.- Parameters:
target – a view class or instance, or a mapped model class.
- Raises:
RestlyConfigurationError – if the declared scope is not a clause.
TypeError – if
targetis neither a view nor a mapped model.
- fastapi_restly.views.route(path: str, **api_route_kwargs: Any) Callable[[...], Any]#
Decorator to mark a View method as an endpoint. The path and api_route_kwargs are passed into APIRouter.add_api_route(), see for example: https://fastapi.tiangolo.com/reference/apirouter/#fastapi.APIRouter.get
Endpoints methods are later added as routes to the FastAPI app using include_view()
- fastapi_restly.views.run_write_action(host: WriteHost, action: str, *, obj: Any = None, data: Any = None, mutate: Callable[[], T]) T#
Sync variant of
async_run_write_action().
See also
Views & CRUD introduces the class-based view concept and hierarchy, and Customizing RestView explains the three-tier override model and collects the task-shaped override recipes.