JSON:API Resources
When you build an API that follows the JSON:API specification, every response has to follow the same document structure. Writing that by hand for every endpoint gets old fast. JsonResource does it for you, turning a model into a JSON:API document. It derives the resource type from the class name, serializes the model's attributes, and handles include, sparse fieldsets, and pagination meta.
Installation
There's nothing extra to install; the JSON:API module ships with the core package:
uv add fastapi-startkitQuick Start
To create a resource, extend JsonResource with your model. For most models, that's all it takes:
from fastapi_startkit.jsonapi import JsonResource
class PostResource(JsonResource["Post"]):
pass # type="posts", attributes from Post.serialize() automaticallyThen return the resource from an endpoint. The ?include= and ?fields[*]= query parameters are applied for you:
@app.get("/api/posts/{id}")
async def get_post(id: int):
post = await Post.find_or_fail(id)
return PostResource(post){
"data": {
"type": "posts",
"id": "1",
"attributes": {
"title": "Hello World",
"body": "..."
}
}
}Fluent Chain API
When you want to decide on the server what gets serialized, use .include() and .fields(). .fields() takes plain names for the primary resource and "type.field" for related resources:
# Sideload a relationship
return PostResource(post).include("author")
# Sparse fieldsets — plain names restrict this resource's attributes
return PostResource(post).fields("title", "created_at")
# Dotted names restrict a related type's attributes
# mirrors ?fields[posts]=title,created_at&fields[users]=name&include=author
return (
PostResource(post)
.include("author")
.fields("title", "created_at", "users.name")
)
# Manual serialization to a dict
doc = PostResource(post).include("author").fields("title", "users.name").serialize()The same chain API works on collections:
return PostResource.collection(posts).include("author").fields("title", "users.name")If you return a resource without calling these methods, the ?include= and ?fields[*]= query parameters from the request are used instead. Either way you get the same result. The only difference is who decides: the chain fixes the output on the server, while the query string lets the client choose.
Auto-Type Derivation
You rarely need to set the type field yourself. It is derived from the class name using inflection.tableize():
| Class name | Derived type |
|---|---|
PostResource | "posts" |
UserResource | "users" |
AgentResource | "agents" |
UserProfileResource | "user_profiles" |
If you'd like a different value, override type:
class PostResource(JsonResource[Post]):
type = "articles"Auto-Serialization
to_attributes() returns everything from model.serialize() except the fields listed in hidden:
class PostResource(JsonResource[Post]):
pass # all model fields are included in data.attributesHiding Sensitive Fields
You'll usually want to keep things like password hashes and tokens out of your responses. List them in hidden:
class UserResource(JsonResource[User]):
hidden = ["password", "remember_token", "api_key"]hidden only filters attributes; the top-level data.id is always present. To keep id out of attributes, list it:
class UserResource(JsonResource[User]):
hidden = ["id", "password"]Collections
To return a list of models, call collection() instead of creating a single resource:
@app.get("/api/posts")
async def list_posts():
posts = await Post.all()
return PostResource.collection(posts){
"data": [
{ "type": "posts", "id": "1", "attributes": { "title": "Hello" } },
{ "type": "posts", "id": "2", "attributes": { "title": "World" } }
]
}Paginated Collections
When you pass a LengthAwarePaginator or SimplePaginator, the pagination meta is added for you. A SimplePaginator has no total or last_page:
@app.get("/api/posts")
async def list_posts(page: int = 1):
posts = await Post.paginate(15, page)
return PostResource.collection(posts){
"data": [...],
"meta": {
"total": 42,
"count": 15,
"per_page": 15,
"current_page": 1,
"last_page": 3,
"next_page": 2,
"previous_page": null
}
}Extra Envelope Keys — with_()
To merge extra top-level keys into the document, override with_():
class ArticleResource(JsonResource[Article]):
def with_(self):
return {
"jsonapi": {"version": "1.0"},
"meta": {"generated_at": "2026-01-01"},
}with_() is applied last, so its keys take precedence over to_links() / to_meta().
Relationships
To describe a resource's relationships, override to_relationships(). It returns a dict, and each value can be a resource class, a lambda, or a resource instance:
class PostResource(JsonResource[Post]):
def to_relationships(self):
return {
# Class reference → always a single resource.
# Framework reads self.model.author and wraps it with UserResource.
# Omitted automatically when model.author is None.
"author": UserResource,
# Lambda → for has-many / collections and any custom logic.
# Call ResourceClass.collection() inside the lambda.
"comments": lambda: CommentResource.collection(self.model.comments),
# Explicit instance — full control when needed.
"tag": TagResource(self.model.primary_tag),
}The key name drives the lookup for the class-reference form ("author" → self.model.author).
To sideload related resources, use .include():
return PostResource(post).include("author"){
"data": {
"type": "posts",
"id": "1",
"attributes": { "title": "Hello" },
"relationships": {
"author": { "data": { "type": "users", "id": "5" } }
}
},
"included": [
{ "type": "users", "id": "5", "attributes": { "name": "Alice" } }
]
}For nested relationships, include the parent too: .include("author", "author.company"). "author.company" on its own includes nothing.
Sparse Fieldsets
Sparse fieldsets let you trim the attributes in a response. Pass plain field names for the primary resource, and "type.field" to restrict a related resource's attributes:
# GET /api/posts?fields[posts]=title,created_at&fields[users]=name&include=author
return PostResource(post).include("author").fields("title", "created_at", "users.name")If you skip the chain methods, ?fields[posts]=title,created_at in the URL does the same thing.
Overridable Hooks
These are the methods you can override to shape the document:
| Method | Purpose |
|---|---|
to_attributes() | {name: value} dict of resource attributes |
to_relationships() | {name: JsonResource} dict of related resources |
to_links() | Top-level links dict |
to_meta() | Top-level meta dict |
with_() | Extra top-level envelope keys merged last |
Query-Param Helpers
If you need to read the include and fields query parameters yourself, the module exports two small helpers:
from fastapi_startkit.jsonapi import parse_include, parse_fields
# ?include=author,comments -> ["author", "comments"]
include = parse_include(request.query_params.get("include"))
# ?fields[posts]=title,body&fields[users]=name -> {"posts": ["title", "body"], "users": ["name"]}
fields = parse_fields(dict(request.query_params))Full Example
Here's how the pieces fit together:
from fastapi import Query, Request
from fastapi_startkit.jsonapi import JsonResource
class PostResource(JsonResource[Post]):
hidden = ["internal_notes"]
def to_relationships(self):
author = getattr(self.model, "author", None)
if author is None:
return None
return {"author": UserResource(author)}
def with_(self):
return {"jsonapi": {"version": "1.0"}}
class UserResource(JsonResource[User]):
hidden = ["password"]
# Automatic query-string parsing — client controls fields and includes
@app.get("/api/posts/{id}")
async def get_post(id: int):
post = await Post.find_or_fail(id)
return PostResource(post)
# Server-controlled restrictions via chain API
@app.get("/api/posts/{id}/summary")
async def get_post_summary(id: int):
post = await Post.find_or_fail(id)
return PostResource(post).fields("title", "created_at")
# Paginated collection with include + field restriction
@app.get("/api/posts")
async def list_posts(page: int = 1):
posts = await Post.paginate(15, page)
return PostResource.collection(posts).include("author").fields("title", "users.name")