Handlers and lifecycle¶
A handler owns one resource. It receives a request, loads what the request refers to, runs the query pipeline, and calls the HTTP method. Every part is a method you can override.
RESTHandler is the base for non-database resources and adds OpenAPI support.
The backend handlers (PWRESTHandler, SARESTHandler, MongoRESTHandler)
extend RESTBase and implement the storage parts. For a custom in-memory
resource, subclass RESTHandler and implement prepare_collection.
Binding routes¶
@api.route
class Pets(PWRESTHandler):
class Meta:
model = Pet
Without a path, api.route binds two routes from Meta.name:
/{name}for the collection/{name}/{id}for one resource
With explicit paths, only those paths are bound:
@api.route("/pets", "/pets/{id}")
class Pets(PWRESTHandler):
class Meta:
model = Pet
The detail route must use {id}. The default prepare_resource reads that
parameter, and the backend handlers use it to look up the record. A different
name such as {pet} will leave resource as None and turn detail requests
into collection requests.
api.route also accepts plain functions. They are not resources and get no
query handling:
@api.route("/health")
async def health(request):
return {"status": "ok"}
What runs on a request¶
RESTBase.__call__ is the entry point. It runs in this order:
authorize(request)
prepare_collection(request)
prepare_resource(request)
|
+-- GET collection -> filter -> sort -> paginate -> get
+-- GET detail -> get(resource=...)
+-- POST/PUT/DELETE -> the matching method with resource
+-- custom route -> the decorated method with resource
GET on the collection is the only path that runs filters, sorting, and
pagination. Detail requests and writes receive the loaded resource directly.
Custom routes always dispatch straight to the method, so they do not inherit
where, sort, or pagination.
The handler instance is created per request, so self.auth and
self.collection hold that request's state. self.filters and self.sorting
are filled when the collection pipeline runs.
Methods and status codes¶
| Request | Method called | Notes |
|---|---|---|
GET /name |
get(request) |
filters, sorts, paginates |
GET /name/{id} |
get(request, resource=...) |
404 when missing |
POST /name |
post(request, resource=None) |
body may be an object or an array |
POST /name/{id} |
post(request, resource=...) |
updates the resource |
PUT /name/{id} |
put(request, resource=...) |
partial update |
PUT /name |
put(request, resource=None) |
404 |
DELETE /name/{id} |
delete(request, resource=...) |
removes one |
DELETE /name |
delete(request, resource=None) |
body is an array of ids |
A create returns 200, not 201. The default allowed methods are get,
post, put, and delete. Other methods return 405 until you add them:
class Pets(PWRESTHandler):
methods = ("get", "post", "put", "delete", "patch")
async def patch(self, request, *, resource=None):
...
methods is also used when the routes are bound, so adding a method to the
tuple without defining it is a runtime error on the first matching request.
Hooks¶
Override these methods to change behavior. The signatures are the ones the base class calls.
Loading¶
async def authorize(self, request) -> Any
async def prepare_collection(self, request) -> collection
async def prepare_resource(self, request) -> resource | None
authorize proxies to API.authorize and raises APIError.UNAUTHORIZED when
the callback returns a falsy value. The result is stored in self.auth.
prepare_collection is abstract. It returns the query object the backend
understands: a Peewee query, a SQLAlchemy Select, a MongoChain, or any
iterable for RESTHandler.
prepare_resource reads request["path_params"]["id"]. The default returns the
raw string; backend handlers resolve it and raise 404 when the record does not
exist. It runs for collection requests too, where it returns None.
Query pipeline¶
async def filter(self, request, collection) -> tuple[collection, dict]
async def sort(self, request, collection) -> tuple[collection, dict]
async def paginate(self, request, *, limit, offset) -> tuple[collection, int | None]
filter and sort delegate to Meta.filters and Meta.sorting and return the
parsed parameters alongside the collection. paginate returns the page and the
total count, or None when the count was skipped. paginate is only called
when Meta.limit is non-zero.
Pagination parameters are prepared by:
def paginate_prepare_params(self, request) -> tuple[int, int]
def paginate_prepare_headers(self, limit, offset, total) -> dict
The first clamps limit to [limit_min, limit_max] and turns a negative
offset into 0. The second builds x-limit, x-offset, and x-total.
Serialization¶
def get_schema(self, request, *, resource=None, **options) -> marshmallow.Schema
async def load_data(self, request) -> dict | list
async def load(self, request, resource=None, **options) -> resource | list
async def dump(self, request, data, *, many=False) -> dict | list
get_schema applies schema_only and schema_exclude from the query string
and instantiates Meta.Schema. load_data reads the request body and turns
invalid JSON into a 400. load validates the body with the schema and uses
partial=True when a resource is present. dump serializes with the same
schema, so field selection affects responses and request bodies equally.
Persistence¶
async def save(self, request, resource, *, update=False) -> resource
async def save_many(self, request, data, *, update=False) -> list
async def remove(self, request, resource=None) -> Any
save is abstract in RESTBase and implemented by each backend. save_many
loops over save by default. Override it when the database supports a real bulk
insert.
remove receives the loaded resource for detail deletes and None for
collection deletes. In the second case it reads the JSON array of ids from the
request body.
Batch operations¶
POST accepts an array:
curl -X POST http://localhost:8000/api/pet \
-H 'content-type: application/json' \
-d '[{"name": "Muffin"}, {"name": "Cookie"}]'
The body is validated with many=True, saved through save_many, and returned
as an array. DELETE on the collection takes an array of ids:
curl -X DELETE http://localhost:8000/api/pet \
-H 'content-type: application/json' \
-d '["1", "2"]'
Peewee returns the deleted ids. SQLAlchemy and Mongo return null. The methods
are sequential by default; wrap them in a transaction in save_many or
remove if a partial batch must not be visible.
Custom routes¶
Decorate a method with the handler's route:
class Pets(PWRESTHandler):
class Meta:
model = Pet
@PWRESTHandler.route("/pet/{id}/adopt", methods="post")
async def adopt(self, request, *, resource: Pet):
"""Mark a pet as adopted."""
resource.status = "sold"
await resource.save()
return await self.dump(request, resource)
The method is bound to the API router and added to the OpenAPI spec. It receives
resource when the path contains {id}. Custom routes bypass the collection
pipeline, so call self.filter, self.sort, or self.paginate yourself if you
need them.
route accepts multiple paths and standard router options:
@PWRESTHandler.route("/pet/featured", "/pet/popular", methods="get")
async def featured(self, request, *, resource=None):
...
Overriding CRUD¶
The simplest way to change behavior is to override a hook, not the HTTP method. For example, scope every query to the current user:
async def prepare_collection(self, request):
return super().prepare_collection(request).where(Pet.owner == self.auth)
Override get, post, put, or delete only when the response shape must
change:
async def post(self, request, *, resource=None):
response = await super().post(request, resource=resource)
if resource is None:
response.status_code = 201
return response
Backend get implementations return serialized data, not a Response, so
wrap them yourself if you need to change headers.
Backend delete implementations differ. In PWRESTHandler, delete calls
remove, so overriding remove is enough. In SARESTHandler, delete is an
alias to remove created at class definition; overriding remove in a subclass
does not change delete. In MongoRESTHandler, delete is the implementation.
When in doubt, override delete as well.
Typing¶
Handlers are generic over the resource and collection types:
class Pets(PWRESTHandler[Pet]):
class Meta:
model = Pet
This types the resource argument and self.collection for type checkers and
gives Meta the backend-specific fields. The generic is optional at runtime.