API Resources¶
A model's to_dict() is fine for a quick endpoint, but a public API usually needs to shape the
output differently from the storage schema: hide internal columns, rename keys, conditionally
include a relation only when it's eager-loaded, or add pagination metadata. JsonResource (
the API-resources capability) is that transform layer — declare the shape once, reuse it everywhere
the model is serialized.
Defining a resource¶
from typing import Any
from arvel.database import JsonResource
class PostResource(JsonResource[Post]):
def to_array(self, request: Any | None = None) -> dict:
return {
"id": self.resource.id,
"title": self.resource.title,
"author": self.when_loaded("author", lambda a: {"name": a.name}),
}
self.resource is the wrapped model (or any value — a resource doesn't require a Model). Return
a route handler a resource directly and the HTTP kernel serializes it for you:
async def show(request, post: Post):
return PostResource(post) # -> {"data": {"id": ..., "title": ..., ...}}
Conditional fields¶
when(condition, value, default=MISSING)—value(orvalue()if callable) whencondition, elsedefault. A field left at the defaultMISSINGis stripped from the payload entirely, not serialized asnull.when_not_none(value)—valueif notNone, elseMISSING.when_loaded(relation, cb=None)— the eager-loaded relation's value (orcb(value)if given), orMISSINGifrelationwasn't eager-loaded (Model.with_(...)). This reads the model's loaded-relation bookkeeping directly — it never triggers a lazy query, so a resource is safe to serialize outside a request/DB context.when_counted(relation, cb=None)— the{relation}_countvalue when it was loaded viaModel.with_count(...), elseMISSING. Reads the loaded count directly; never triggers a query.when_pivot_loaded(cb=None, accessor="pivot")— the pivot row's data when a many-to-many relation carries one, elseMISSING. Passaccessorif you renamed the pivot viaas_pivot.merge_when(condition, mapping)—mappingwhencondition, else{}; spread it intoto_array's returned dict:{**self.merge_when(is_admin, {"email": ...}), "id": ...}.
class PostResource(JsonResource[Post]):
def to_array(self, request=None):
return {
"id": self.resource.id,
"author": self.when_loaded("author", lambda a: {"name": a.name}),
**self.merge_when(self.resource.body is not None, {"body": self.resource.body}),
}
fetched = await Post.find(1) # "author" not eager-loaded
PostResource(fetched).to_payload() # {"data": {"id": 1}} — no "author" key at all
fetched = (await Post.with_("author").get())[0]
PostResource(fetched).to_payload() # {"data": {"id": 1, "author": {"name": "Ada"}}}
Wrapping and metadata¶
to_payload(request=None) is the final JSON-safe dict: to_array() with MISSING fields
stripped, wrapped under the class's wrap key ("data" by default — set wrap = None to disable
wrapping entirely; never double-wrapped), plus anything attached via .additional(...):
PostResource(post).additional({"version": "v1"}).to_payload()
# {"data": {...}, "version": "v1"}
class Unwrapped(JsonResource[Post]):
wrap = None
def to_array(self, request=None):
return {"id": self.resource.id}
Unwrapped(post).to_payload() # {"id": 1} — no "data" nesting
Collections and pagination¶
YourResource.collection(models) returns a ResourceCollection — maps the resource over every
item, wrapped under the same key the singular resource would use:
Pass a paginator (Builder.paginate()/simple_paginate()) instead of a plain list and the
collection's data is still mapped through the resource, with meta/links alongside it —
the paginated-resource response shape (distinct from a bare paginator's own to_dict(),
which flattens those fields):
page = await Post.paginate(per_page=10)
PostResource.collection(page).to_payload()
# {
# "data": [{"id": 1, ...}, ...],
# "links": {"first": "...", "last": "...", "prev": None, "next": "..."},
# "meta": {"current_page": 1, "from": 1, "last_page": 3, "path": "...", "per_page": 10,
# "to": 10, "total": 25},
# }
Common mistakes & gotchas¶
when_loadedon a relation you forgot to eager-load. It won't lazily fetch it — the field is just omitted. If it's always missing, check the handler actually called.with_("relation").- Returning a resource from a non-HTTP context expecting a dict.
JsonResourceitself isn't a dict; call.to_payload()explicitly outside the HTTP kernel's automatic conversion (e.g. inside a queued job or a CLI command). - A custom
wrapclashing withadditional()keys.additional({"data": ...})would collide with the wrap key — pick a different top-level name for extra metadata.
JSON:API documents¶
For an API that speaks the JSON:API media type, extend JsonApiResource instead. Declare the
resource type and (optionally) its related resource classes; the document shape, relationship
linkage, ?include=, and ?fields[<type>]= handling come with it:
from arvel.database.resources import JsonApiResource
class AuthorResource(JsonApiResource[Author]):
resource_type = "authors"
class PostResource(JsonApiResource[Post]):
resource_type = "posts"
relationships = {"author": AuthorResource}
Return one from a handler and the response is a compliant document served as
application/vnd.api+json:
async def show(request, post_id: int):
post = await Post.query().with_("author").find_or_fail(post_id)
return PostResource(post)
# GET /posts/7?include=author&fields[posts]=title
# {
# "data": {"type": "posts", "id": "7",
# "attributes": {"title": "Hello"},
# "relationships": {"author": {"data": {"type": "authors", "id": "3"}}}},
# "included": [{"type": "authors", "id": "3", "attributes": {"name": "Ada"}}],
# }
The rules mirror when_loaded: relationship linkage renders only for relations you actually
eager-loaded (never a lazy fetch), ?include= adds the full objects under included, and sparse
fieldsets drop unknown names silently. Compound (dot-path) includes recurse — ?include=author.comments
returns the author and its comments, deduplicated across the document; an unknown segment at any
depth is ignored, and a relation that wasn't eager-loaded contributes nothing (still no lazy fetch).
PostResource.collection(models_or_paginator) builds the collection document — a paginator
contributes JSON:API links and meta. Validation failures return the spec's error objects
(errors[].source.pointer → /data/attributes/<field>) when the client's Accept asks for
the JSON:API media type. Write-side document parsing (POST/PATCH request bodies) is not
implemented — request validation stays the framework's normal typed/FormRequest path.
See also¶
- Casts & Serialization —
to_dict(), the plainer serialization a resource builds on. - Relationships — eager loading, which drives what a resource can include.
- Pagination — a paginator passed to
.collection()contributeslinks/meta. - Validation — the request-side path resources deliberately leave alone.