Skip to content

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 (or value() if callable) when condition, else default. A field left at the default MISSING is stripped from the payload entirely, not serialized as null.
  • when_not_none(value)value if not None, else MISSING.
  • when_loaded(relation, cb=None) — the eager-loaded relation's value (or cb(value) if given), or MISSING if relation wasn'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}_count value when it was loaded via Model.with_count(...), else MISSING. 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, else MISSING. Pass accessor if you renamed the pivot via as_pivot.
  • merge_when(condition, mapping)mapping when condition, else {}; spread it into to_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:

PostResource.collection(posts).to_payload()
# {"data": [{"id": 1, ...}, {"id": 2, ...}]}

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_loaded on 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. JsonResource itself 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 wrap clashing with additional() 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 & Serializationto_dict(), the plainer serialization a resource builds on.
  • Relationships — eager loading, which drives what a resource can include.
  • Pagination — a paginator passed to .collection() contributes links/meta.
  • Validation — the request-side path resources deliberately leave alone.