Writing a backend¶
A backend is the part that knows how to talk to one storage system. It does not
implement routing, auth, filters, sorting, pagination headers, validation, or
OpenAPI; RESTHandler already does those. A backend supplies five operations:
| Operation | Question it answers |
|---|---|
prepare_collection |
what is the base query for this request? |
prepare_resource |
which record does {id} refer to? |
paginate |
how do I take a page and count the total? |
save |
how do I create or update a record? |
remove |
how do I delete one record or a batch? |
This guide builds a dict-backed backend. Replace the dict operations with calls to your client; the contract stays the same.
The complete backend¶
from uuid import uuid4
from muffin_rest import APIError, RESTHandler
from muffin_rest.options import RESTOptions
from muffin_rest.sorting import Sort, Sorting
class DictSort(Sort):
async def apply(self, collection, *, desc=False, **ctx):
field = self.meta.get("field", self.name)
return sorted(collection, key=lambda obj: obj[field], reverse=desc)
class DictSorting(Sorting):
MUTATE_CLASS = DictSort
def sort_default(self, collection):
for sort in reversed(self.default):
field = sort.meta.get("field", sort.name)
collection = sorted(
collection,
key=lambda obj: obj[field],
reverse=sort.meta["default"] == "desc",
)
return collection
class DictOptions(RESTOptions):
base_property = "store"
sorting_cls = DictSorting
store: dict[str, dict]
class DictRESTHandler(RESTHandler):
meta_class = DictOptions
async def prepare_collection(self, request):
return list(self.meta.store.values())
async def prepare_resource(self, request):
key = request["path_params"].get("id")
if not key:
return None
for item in self.collection:
if item["id"] == key:
return item
raise APIError.NOT_FOUND("Resource not found")
async def load(self, request, resource=None, **options):
data = await super().load(request, resource=resource, **options)
if resource is not None:
return {**resource, **data}
return data
async def paginate(self, request, *, limit=0, offset=0):
total = len(self.collection) if self.meta.limit_total else None
return self.collection[offset : offset + limit], total
async def save(self, request, resource, *, update=False):
if not update:
resource["id"] = uuid4().hex
self.meta.store[resource["id"]] = resource
return resource
async def remove(self, request, resource=None):
if resource is not None:
self.meta.store.pop(resource["id"], None)
return resource["id"]
ids = await request.data()
if not ids:
raise APIError.NOT_FOUND()
for key in ids:
self.meta.store.pop(key, None)
return ids
Use it like any other handler:
import marshmallow as ma
from muffin import Application
from muffin_rest import API
app = Application("memory")
api = API(app, "/api")
STORE = {}
@api.route
class Items(DictRESTHandler):
class Meta:
store = STORE
schema_fields = {
"id": ma.fields.String(dump_only=True),
"name": ma.fields.String(required=True),
"count": ma.fields.Integer(load_default=0),
}
filters = ("name", "count")
sorting = ("name", ("count", {"default": "desc"}))
limit = 2
Items gets /items and /items/{id}, filtering, sorting, pagination,
validation, and an OpenAPI spec without any more code.
Options¶
meta_class is where a backend declares what Meta means:
class DictOptions(RESTOptions):
base_property = "store"
sorting_cls = DictSorting
store: dict[str, dict]
base_property names the Meta key that makes a handler concrete. With
base_property = "store", RESTOptions.setup runs only when Meta.store is
set. This keeps DictRESTHandler itself abstract. Without it, the base class
would be treated as a concrete handler and get its own name and schema.
Other option hooks:
| Attribute | Use |
|---|---|
filters_cls |
filter factory for the backend query language |
sorting_cls |
sorter factory for the backend query language |
schema_base |
base class for generated schemas |
schema_fields |
fields added to the generated schema |
schema_meta |
extra Meta for the generated schema |
schema_unknown |
Marshmallow unknown policy |
Override setup when the backend needs to derive name, validate a required
option, or cache a client:
class DictOptions(RESTOptions):
base_property = "store"
def setup(self, cls):
self.name = self.name or "items"
super().setup(cls)
Collection and resource¶
prepare_collection returns the object the rest of the pipeline understands.
For a dict it is a list. For a database it is a query, cursor, or chain.
prepare_resource runs on every request. It returns None when the path has no
{id}, and raises APIError.NOT_FOUND when the id does not match. Look it up
through self.collection, not the raw store, so filters and scoping from
prepare_collection still apply:
async def prepare_resource(self, request):
key = request["path_params"].get("id")
if not key:
return None
for item in self.collection:
if item["id"] == key:
return item
raise APIError.NOT_FOUND("Resource not found")
If the id format is invalid, raise APIError.BAD_REQUEST instead. The Peewee
backend does this for values that cannot be converted to the key type.
Pagination¶
paginate is called only when Meta.limit is greater than zero. It receives the
already filtered and sorted self.collection and returns (page, total):
async def paginate(self, request, *, limit=0, offset=0):
total = len(self.collection) if self.meta.limit_total else None
return self.collection[offset : offset + limit], total
Return None for the total when limit_total = False; RESTBase then omits
the x-total header. For a real database, apply the limit and offset in the
query and run a separate count query. If counting is expensive, respect
limit_total and skip it.
Saving¶
save receives validated data, not a raw request body. It must return the
record that will be serialized back:
async def save(self, request, resource, *, update=False):
if not update:
resource["id"] = uuid4().hex
self.meta.store[resource["id"]] = resource
return resource
For updates, the loaded data usually does not contain every field. The base
load does not merge it with the existing record, so override load:
async def load(self, request, resource=None, **options):
data = await super().load(request, resource=resource, **options)
if resource is not None:
return {**resource, **data}
return data
The SQLAlchemy and Mongo backends do this inside their schemas instead; see
Schemas. Either place works, as long as save(update=True)
receives the full record.
save_many loops over save by default. Override it for a real bulk insert or
to wrap the batch in a transaction.
Deleting¶
remove gets the loaded record for a detail request and None for a collection
request. In the second case the body is a JSON array of ids:
async def remove(self, request, resource=None):
if resource is not None:
self.meta.store.pop(resource["id"], None)
return resource["id"]
ids = await request.data()
if not ids:
raise APIError.NOT_FOUND()
for key in ids:
self.meta.store.pop(key, None)
return ids
The return value becomes the JSON response body. Peewee returns ids; SQLAlchemy
and Mongo return null. Pick one and document it, or override delete if the
default response shape does not fit.
Reading the collection¶
RESTBase.get serializes self.collection directly. That works when
prepare_collection returns a list. When it returns a lazy query, override
get to execute it first:
async def get(self, request, *, resource=None):
if resource:
return await self.dump(request, resource)
rows = await self.meta.client.fetch_all(self.collection)
return await self.dump(request, rows, many=True)
The SQLAlchemy, Mongo, and Peewee handlers all override get for this reason.
The same applies to any other operation that needs to materialize the query.
Filters and sorting¶
The generic Filter and Filters work on dicts and objects through
get_value, so the dict backend needs no filter class. The generic Sort uses
getattr, which does not work on dicts, hence DictSort above.
For a real query language, subclass the backend filter and sorter:
from muffin_rest.filters import Filter
class ClientFilter(Filter):
async def filter(self, collection, *ops, **ctx):
return collection.where(self.field, *ops)
from muffin_rest.sorting import Sort
class ClientSort(Sort):
async def apply(self, collection, *, desc=False, **ctx):
return collection.order_by(self.field, desc=desc)
These sketches assume the filter stores its column in self.field; the Peewee
and SQLAlchemy filters do. Then point the options at them:
class ClientOptions(RESTOptions):
filters_cls = ClientFilters
sorting_cls = ClientSorting
Sorting.prepare is the place to clear an existing order before applying the
request order. Sorting.sort_default applies entries marked with
{"default": ...} when the request has no sort parameter. Both are optional;
the defaults work for in-memory collections, but not for dicts (hence the
override in DictSorting).
Schemas and OpenAPI¶
RESTHandler includes OpenAPIMixin, so a backend that subclasses it gets the
spec for free. Meta.name becomes the URL segment and the OpenAPI tag, and the
generated schema becomes the component.
For a model-driven backend, set schema_base and add the model to the schema
Meta:
class ClientOptions(RESTOptions):
schema_base = ClientSchema
def setup_schema_meta(self, _):
return type("Meta", (object,), {
"unknown": self.schema_unknown,
"model": self.model,
})
Override get_schema when the schema needs the loaded record for updates:
def get_schema(self, request, *, resource=None, **options):
return super().get_schema(request, resource=resource, instance=resource, **options)
The Peewee, SQLAlchemy, and Mongo handlers do this. If your load merges the
record itself, you do not need it.
If you subclass RESTBase instead of RESTHandler, add the OpenAPI mixin
explicitly:
from muffin_rest.handler import RESTBase
from muffin_rest.openapi import OpenAPIMixin
class ClientHandler(OpenAPIMixin, RESTBase):
...
Testing¶
Test the backend through the HTTP layer, not only through the client methods:
from asgi_tools.tests import ASGITestClient
async def test_items(app):
client = ASGITestClient(app)
async with client.lifespan():
response = await client.post("/api/items", json={"name": "a"})
assert response.status_code == 200
item = await response.json()
response = await client.get("/api/items")
assert await response.json() == [item]
response = await client.put(f"/api/items/{item['id']}", json={"count": 2})
assert (await response.json())["count"] == 2
response = await client.delete(f"/api/items/{item['id']}")
assert response.status_code == 200
Pin the async library for backend tests:
import pytest
@pytest.fixture(scope="module")
def aiolib():
return "asyncio", {"loop_factory": None}
Cover at least these cases:
- create, list, detail, update, delete
- unknown id returns
404 - invalid body returns
400 - filter, sort, and pagination headers
- batch create and batch delete
- OpenAPI contains the resource paths
Checklist¶
| Piece | Required | Notes |
|---|---|---|
meta_class |
yes | subclass RESTOptions |
base_property |
yes | the Meta key that makes a handler concrete |
prepare_collection |
yes | return the backend query or list |
prepare_resource |
yes | None for collection, 404 for missing |
paginate |
yes | return page and total, or None |
save |
yes | return the record to serialize |
remove |
yes | detail record or batch ids |
load |
when updates merge | merge existing record with validated data |
get |
when collection is lazy | execute the query before dumping |
filters_cls |
when query language differs | otherwise generic filters work |
sorting_cls |
when query language differs | generic sort needs getattr |
schema_base |
for model schemas | setup_schema_meta adds model/table |
| tests | yes | HTTP-level tests catch lifecycle mistakes |
The existing backends are short. Read muffin_rest/sqlalchemy/__init__.py for a
query-based backend and muffin_rest/mongo/__init__.py for a document-based one.