Skip to content

Commit 84d2675

Browse files
authored
Merge pull request #28 from fastapi-startkit/docs/jsonapi-resource
docs(jsonapi): add JSON:API Resources documentation page
2 parents b15ce1c + 806110f commit 84d2675

2 files changed

Lines changed: 305 additions & 0 deletions

File tree

.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export default defineConfig({
193193
{ text: 'Storage', link: '/docs/storage' },
194194
{ text: 'Process', link: '/docs/process' },
195195
{ text: 'Broadcasting', link: '/docs/broadcasting' },
196+
{ text: 'JSON:API Resources', link: '/docs/jsonapi' },
196197
]
197198
},
198199
{

docs/jsonapi.md

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
---
2+
title: JSON:API Resources
3+
description: Build JSON:API-compliant responses with JsonResource — automatic serialization, hidden fields, pagination meta, fluent chain API for fields and includes.
4+
---
5+
6+
# JSON:API Resources
7+
8+
FastAPI Startkit ships a first-class **JSON:API** layer built around the `JsonResource` generic base class. It handles type derivation, auto-serialization, hidden fields, relationship side-loading, sparse fieldsets, and paginator meta — all with zero boilerplate.
9+
10+
## Installation
11+
12+
The JSON:API module requires no extra dependencies beyond the core package.
13+
14+
```bash
15+
pip install fastapi-startkit
16+
```
17+
18+
## Quick Start
19+
20+
```python
21+
from fastapi_startkit.jsonapi import JsonResource
22+
23+
class PostResource(JsonResource["Post"]):
24+
pass # type="posts", attributes from Post.serialize() automatically
25+
```
26+
27+
Return the resource directly from a FastAPI endpoint — `?include=` and `?fields[*]=` query params are parsed and applied automatically:
28+
29+
```python
30+
@app.get("/api/posts/{id}")
31+
async def get_post(id: int):
32+
post = await Post.find_or_fail(id)
33+
return PostResource(post)
34+
```
35+
36+
```json
37+
{
38+
"data": {
39+
"type": "posts",
40+
"id": "1",
41+
"attributes": {
42+
"title": "Hello World",
43+
"body": "..."
44+
}
45+
}
46+
}
47+
```
48+
49+
## Fluent Chain API
50+
51+
Use `.include()` and `.fields()` to control what gets serialized.
52+
53+
`.fields()` takes plain field names for the primary resource, and `"type.field"` dotted specs for related resources:
54+
55+
```python
56+
# Sideload a relationship
57+
return PostResource(post).include("author")
58+
59+
# Sparse fieldsets — plain names restrict this resource's attributes
60+
return PostResource(post).fields("title", "created_at")
61+
62+
# Dotted names restrict a related type's attributes
63+
# mirrors ?fields[posts]=title,created_at&fields[users]=name&include=author
64+
return (
65+
PostResource(post)
66+
.include("author")
67+
.fields("title", "created_at", "users.name")
68+
)
69+
70+
# Manual serialization to a dict
71+
doc = PostResource(post).include("author").fields("title", "users.name").serialize()
72+
```
73+
74+
The same chain API works on collections:
75+
76+
```python
77+
return PostResource.collection(posts).include("author").fields("title", "users.name")
78+
```
79+
80+
When the resource is returned directly from a FastAPI endpoint **without** calling chain methods, `?include=` and `?fields[*]=` query params are parsed from the live request automatically. The chain API and automatic query-string parsing are equivalent — use whichever fits your endpoint.
81+
82+
## Auto-Type Derivation
83+
84+
The `type` field is derived from the class name via `inflection.tableize()`:
85+
86+
| Class name | Derived type |
87+
|---|---|
88+
| `PostResource` | `"posts"` |
89+
| `UserResource` | `"users"` |
90+
| `AgentResource` | `"agents"` |
91+
| `UserProfileResource` | `"user_profiles"` |
92+
93+
Override `type` to use a custom value:
94+
95+
```python
96+
class PostResource(JsonResource[Post]):
97+
type = "articles"
98+
```
99+
100+
## Auto-Serialization
101+
102+
`to_attributes()` calls `model.serialize()` and exposes all returned fields. Only fields listed in `hidden` are excluded.
103+
104+
```python
105+
class PostResource(JsonResource[Post]):
106+
pass # all model fields are included in data.attributes
107+
```
108+
109+
### Hiding Sensitive Fields
110+
111+
```python
112+
class UserResource(JsonResource[User]):
113+
hidden = ["password", "remember_token", "api_key"]
114+
```
115+
116+
To also hide `id`, add it explicitly:
117+
118+
```python
119+
class UserResource(JsonResource[User]):
120+
hidden = ["id", "password"]
121+
```
122+
123+
## Collections
124+
125+
```python
126+
@app.get("/api/posts")
127+
async def list_posts():
128+
posts = await Post.all()
129+
return PostResource.collection(posts)
130+
```
131+
132+
```json
133+
{
134+
"data": [
135+
{ "type": "posts", "id": "1", "attributes": { "title": "Hello" } },
136+
{ "type": "posts", "id": "2", "attributes": { "title": "World" } }
137+
]
138+
}
139+
```
140+
141+
### Paginated Collections
142+
143+
Pass a `LengthAwarePaginator` or `SimplePaginator` — pagination meta is added automatically:
144+
145+
```python
146+
@app.get("/api/posts")
147+
async def list_posts(page: int = 1):
148+
posts = await Post.paginate(15, page)
149+
return PostResource.collection(posts)
150+
```
151+
152+
```json
153+
{
154+
"data": [...],
155+
"meta": {
156+
"total": 42,
157+
"per_page": 15,
158+
"current_page": 1,
159+
"last_page": 3,
160+
"next_page": 2,
161+
"previous_page": null
162+
}
163+
}
164+
```
165+
166+
## Extra Envelope Keys — `with_()`
167+
168+
Override `with_()` to merge extra top-level keys into the document:
169+
170+
```python
171+
class ArticleResource(JsonResource[Article]):
172+
def with_(self):
173+
return {
174+
"jsonapi": {"version": "1.0"},
175+
"meta": {"generated_at": "2026-01-01"},
176+
}
177+
```
178+
179+
`with_()` is applied last, so its keys take precedence over `to_links()` / `to_meta()`.
180+
181+
## Relationships
182+
183+
`to_relationships()` returns a plain dict with two intended forms:
184+
185+
```python
186+
class PostResource(JsonResource[Post]):
187+
def to_relationships(self):
188+
return {
189+
# Class reference → always a single resource.
190+
# Framework reads self.model.author and wraps it with UserResource.
191+
# Omitted automatically when model.author is None.
192+
"author": UserResource,
193+
194+
# Lambda → for has-many / collections and any custom logic.
195+
# Call ResourceClass.collection() inside the lambda.
196+
"comments": lambda: CommentResource.collection(self.model.comments),
197+
198+
# Explicit instance — full control when needed.
199+
"tag": TagResource(self.model.primary_tag),
200+
}
201+
```
202+
203+
The key name drives the lookup for the class-reference form (`"author"``self.model.author`).
204+
205+
Sideload with `.include()`:
206+
207+
```python
208+
return PostResource(post).include("author")
209+
```
210+
211+
```json
212+
{
213+
"data": {
214+
"type": "posts",
215+
"id": "1",
216+
"attributes": { "title": "Hello" },
217+
"relationships": {
218+
"author": { "data": { "type": "users", "id": "5" } }
219+
}
220+
},
221+
"included": [
222+
{ "type": "users", "id": "5", "attributes": { "name": "Alice" } }
223+
]
224+
}
225+
```
226+
227+
Nested dot-notation is supported: `.include("author.company")`.
228+
229+
## Sparse Fieldsets
230+
231+
Pass plain field names for the primary resource, and `"type.field"` to restrict a related resource's attributes:
232+
233+
```python
234+
# GET /api/posts?fields[posts]=title,created_at&fields[users]=name&include=author
235+
return PostResource(post).include("author").fields("title", "created_at", "users.name")
236+
```
237+
238+
When returning resources directly (without chain methods), `?fields[posts]=title,created_at` in the URL is applied automatically.
239+
240+
## Overridable Hooks
241+
242+
| Method | Purpose |
243+
|---|---|
244+
| `to_attributes()` | `{name: value}` dict of resource attributes |
245+
| `to_relationships()` | `{name: JsonResource}` dict of related resources |
246+
| `to_links()` | Top-level `links` dict |
247+
| `to_meta()` | Top-level `meta` dict |
248+
| `with_()` | Extra top-level envelope keys merged last |
249+
250+
## Query-Param Helpers
251+
252+
```python
253+
from fastapi_startkit.jsonapi import parse_include, parse_fields
254+
255+
# ?include=author,comments -> ["author", "comments"]
256+
include = parse_include(request.query_params.get("include"))
257+
258+
# ?fields[posts]=title,body&fields[users]=name -> {"posts": ["title", "body"], "users": ["name"]}
259+
fields = parse_fields(dict(request.query_params))
260+
```
261+
262+
## Full Example
263+
264+
```python
265+
from fastapi import Query, Request
266+
from fastapi_startkit.jsonapi import JsonResource
267+
268+
class PostResource(JsonResource[Post]):
269+
hidden = ["internal_notes"]
270+
271+
def to_relationships(self):
272+
author = getattr(self.model, "author", None)
273+
if author is None:
274+
return None
275+
return {"author": UserResource(author)}
276+
277+
def with_(self):
278+
return {"jsonapi": {"version": "1.0"}}
279+
280+
281+
class UserResource(JsonResource[User]):
282+
hidden = ["password"]
283+
284+
285+
# Automatic query-string parsing — client controls fields and includes
286+
@app.get("/api/posts/{id}")
287+
async def get_post(id: int):
288+
post = await Post.find_or_fail(id)
289+
return PostResource(post)
290+
291+
292+
# Server-controlled restrictions via chain API
293+
@app.get("/api/posts/{id}/summary")
294+
async def get_post_summary(id: int):
295+
post = await Post.find_or_fail(id)
296+
return PostResource(post).fields("title", "created_at")
297+
298+
299+
# Paginated collection with include + field restriction
300+
@app.get("/api/posts")
301+
async def list_posts(page: int = 1):
302+
posts = await Post.paginate(15, page)
303+
return PostResource.collection(posts).include("author").fields("title", "users.name")
304+
```

0 commit comments

Comments
 (0)