action_figure 0.1.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +1 -1
- data/README.md +219 -16
- data/docs/actions.md +495 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +185 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +1088 -0
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +272 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +200 -0
- data/lib/action_figure/format_registry.rb +40 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +49 -0
- data/lib/action_figure/formatters/jsend.rb +49 -0
- data/lib/action_figure/formatters/json_api/resource.rb +30 -0
- data/lib/action_figure/formatters/json_api.rb +65 -0
- data/lib/action_figure/formatters/wrapped.rb +49 -0
- data/lib/action_figure/testing/minitest.rb +58 -0
- data/lib/action_figure/testing/rspec.rb +44 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +68 -0
- data/sig/action_figure.rbs +157 -1
- metadata +26 -5
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
# Response Formatters
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
ActionFigure action classes return **render-ready hashes** from their response helpers. Each hash contains `:json` and `:status` keys that you pass directly to `render` in your controller:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class UsersController < ApplicationController
|
|
9
|
+
def create
|
|
10
|
+
render Users::CreateAction.call(params:)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The **formatter** determines the shape of the JSON envelope wrapping your data. ActionFigure ships with four built-in formatters: Default, JSend, JSON:API, and Wrapped.
|
|
16
|
+
|
|
17
|
+
## Choosing a Format
|
|
18
|
+
|
|
19
|
+
You select a formatter when you include ActionFigure in your action class:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Explicit Default (Rails-style)
|
|
23
|
+
class Users::CreateAction
|
|
24
|
+
include ActionFigure[:default]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Explicit JSend
|
|
28
|
+
class Users::CreateAction
|
|
29
|
+
include ActionFigure[:jsend]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Explicit JSON:API
|
|
33
|
+
class Users::CreateAction
|
|
34
|
+
include ActionFigure[:jsonapi]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Explicit Wrapped
|
|
38
|
+
class Users::CreateAction
|
|
39
|
+
include ActionFigure[:wrapped]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Uses the configured default (Default unless changed)
|
|
43
|
+
class Users::CreateAction
|
|
44
|
+
include ActionFigure
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Response Helpers
|
|
49
|
+
|
|
50
|
+
Every formatter implements the same nine response helpers. Eight return a hash with `:json` and `:status` keys. `NoContent` returns only `:status`.
|
|
51
|
+
|
|
52
|
+
| Helper | HTTP Status | When to Use |
|
|
53
|
+
|---------------------------------|--------------------------|--------------------------------------------------|
|
|
54
|
+
| `Ok(resource:, meta: nil)` | `200 OK` | Successful read or update |
|
|
55
|
+
| `Created(resource:, meta: nil)` | `201 Created` | Successful resource creation |
|
|
56
|
+
| `Accepted(resource: nil, meta: nil)` | `202 Accepted` | Request accepted for background processing |
|
|
57
|
+
| `NoContent()` | `204 No Content` | Successful delete or action with no response body|
|
|
58
|
+
| `PaymentRequired(errors:)` | `402 Payment Required` | Business billing or quota constraint |
|
|
59
|
+
| `Forbidden(errors:)` | `403 Forbidden` | Authorization failure |
|
|
60
|
+
| `NotFound(errors:)` | `404 Not Found` | Resource not found |
|
|
61
|
+
| `Conflict(errors:)` | `409 Conflict` | Resource state conflict or duplicate |
|
|
62
|
+
| `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures |
|
|
63
|
+
|
|
64
|
+
`NoContent` is shared across all formatters and is defined in the base `Formatter` module. It returns `{ status: :no_content }` with no JSON body.
|
|
65
|
+
|
|
66
|
+
ActionFigure provides helpers for the status codes most commonly returned by action logic. General request-level concerns like authentication (`401 Unauthorized`) and malformed requests (`400 Bad Request`) are typically handled by controller-level middleware, `before_action` filters, or framework error handling rather than inside individual action classes.
|
|
67
|
+
|
|
68
|
+
## Default Format
|
|
69
|
+
|
|
70
|
+
The default formatter produces Rails-style responses: the resource is the top-level JSON on success, and errors live under an `"errors"` key on failure. This is the configured default format — bare `include ActionFigure` uses it unless you change `config.format`.
|
|
71
|
+
|
|
72
|
+
### Success Responses
|
|
73
|
+
|
|
74
|
+
The resource you pass becomes the entire JSON body with no wrapper.
|
|
75
|
+
|
|
76
|
+
**`Ok` -- returning a single resource:**
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
def call(params:)
|
|
80
|
+
user = User.find(params[:id])
|
|
81
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
82
|
+
Ok(resource:)
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"id": 1,
|
|
89
|
+
"name": "Jane Doe",
|
|
90
|
+
"email": "jane@example.com"
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**`Created` -- returning a new resource:**
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
def call(params:)
|
|
98
|
+
user = User.create(params)
|
|
99
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
100
|
+
|
|
101
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
102
|
+
Created(resource:)
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"id": 42,
|
|
109
|
+
"name": "Jane Doe",
|
|
110
|
+
"email": "jane@example.com"
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**`Ok` -- with metadata:**
|
|
115
|
+
|
|
116
|
+
When `meta:` is provided, the response wraps the resource under a `"data"` key so that `"meta"` can sit alongside it:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
def call(params:)
|
|
120
|
+
users = User.where(active: true).limit(20)
|
|
121
|
+
Ok(resource: users, meta: { next_cursor: "abc123", total: 42 })
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"data": [
|
|
128
|
+
{ "id": 1, "name": "Jane Doe" },
|
|
129
|
+
{ "id": 2, "name": "John Smith" }
|
|
130
|
+
],
|
|
131
|
+
"meta": {
|
|
132
|
+
"next_cursor": "abc123",
|
|
133
|
+
"total": 42
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Without `meta:`, the resource is the entire body. With `meta:`, the response becomes `{ "data": resource, "meta": meta }`.
|
|
139
|
+
|
|
140
|
+
**`Accepted` -- with no resource:**
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
def call(params:)
|
|
144
|
+
OrderFulfillmentJob.perform_later(params[:order_id])
|
|
145
|
+
Accepted()
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**`Accepted` -- with a resource:**
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
def call(params:)
|
|
157
|
+
order = Order.find(params[:id])
|
|
158
|
+
order.update(status: "processing")
|
|
159
|
+
Accepted(resource: { order_id: order.id, status: order.status })
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"order_id": 7,
|
|
166
|
+
"status": "processing"
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Failure Responses
|
|
171
|
+
|
|
172
|
+
Failure responses place the error hash under an `"errors"` key. The `errors:` argument expects a hash where keys are field names and values are arrays of error messages — the same shape as `ActiveModel::Errors#messages`.
|
|
173
|
+
|
|
174
|
+
**`UnprocessableContent` -- validation errors:**
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
def call(params:)
|
|
178
|
+
user = User.new(params)
|
|
179
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.save
|
|
180
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
181
|
+
Created(resource:)
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"errors": {
|
|
188
|
+
"email": ["has already been taken"],
|
|
189
|
+
"name": ["can't be blank"]
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**`NotFound`:**
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
def call(params:)
|
|
198
|
+
user = User.find_by(id: params[:id])
|
|
199
|
+
return NotFound(errors: { base: ["User not found"] }) unless user
|
|
200
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
201
|
+
Ok(resource:)
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"errors": {
|
|
208
|
+
"base": ["User not found"]
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**`Forbidden`:**
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
def call(params:)
|
|
217
|
+
order = Order.find(params[:id])
|
|
218
|
+
return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
|
|
219
|
+
resource = OrderBlueprint.render_as_hash(order)
|
|
220
|
+
Ok(resource:)
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{
|
|
226
|
+
"errors": {
|
|
227
|
+
"base": ["You do not have access to this order"]
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**`Conflict`:**
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
def call(params:)
|
|
236
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
237
|
+
user = User.create(params)
|
|
238
|
+
Created(resource: user)
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"errors": {
|
|
245
|
+
"email": ["already registered"]
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**`PaymentRequired`:**
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
def call(params:, current_user:)
|
|
254
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
255
|
+
Ok(resource: Dashboard.for(current_user))
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"errors": {
|
|
262
|
+
"base": ["subscription expired"]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## JSend Format
|
|
268
|
+
|
|
269
|
+
The JSend formatter wraps responses in the [JSend specification](https://github.com/omniti-labs/jsend) envelope.
|
|
270
|
+
|
|
271
|
+
### Success Responses
|
|
272
|
+
|
|
273
|
+
Success responses use `"status": "success"` with a `"data"` key containing the resource.
|
|
274
|
+
|
|
275
|
+
**`Ok` -- returning a single resource:**
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
def call(params:)
|
|
279
|
+
user = User.find(params[:id])
|
|
280
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
281
|
+
Ok(resource:)
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```json
|
|
286
|
+
{
|
|
287
|
+
"status": "success",
|
|
288
|
+
"data": {
|
|
289
|
+
"id": 1,
|
|
290
|
+
"name": "Jane Doe",
|
|
291
|
+
"email": "jane@example.com"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**`Created` -- with metadata:**
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
def call(params:)
|
|
300
|
+
user = User.create(params)
|
|
301
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
302
|
+
|
|
303
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
304
|
+
Created(resource:, meta: { request_id: "abc-123" })
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
```json
|
|
309
|
+
{
|
|
310
|
+
"status": "success",
|
|
311
|
+
"data": {
|
|
312
|
+
"id": 42,
|
|
313
|
+
"name": "Jane Doe",
|
|
314
|
+
"email": "jane@example.com"
|
|
315
|
+
},
|
|
316
|
+
"meta": {
|
|
317
|
+
"request_id": "abc-123"
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**`Accepted` -- with no resource:**
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
def call(params:)
|
|
326
|
+
OrderFulfillmentJob.perform_later(params[:order_id])
|
|
327
|
+
Accepted()
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{
|
|
333
|
+
"status": "success"
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**`Accepted` -- with a resource:**
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
def call(params:)
|
|
341
|
+
order = Order.find(params[:id])
|
|
342
|
+
order.update(status: "processing")
|
|
343
|
+
Accepted(resource: { order_id: order.id, status: order.status })
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```json
|
|
348
|
+
{
|
|
349
|
+
"status": "success",
|
|
350
|
+
"data": {
|
|
351
|
+
"order_id": 7,
|
|
352
|
+
"status": "processing"
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Failure Responses
|
|
358
|
+
|
|
359
|
+
Failure responses use `"status": "fail"` with a `"data"` key containing the error hash. The `errors:` argument expects a hash where keys are field names and values are arrays of error messages.
|
|
360
|
+
|
|
361
|
+
**`UnprocessableContent` -- validation errors:**
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
def call(params:)
|
|
365
|
+
user = User.new(params)
|
|
366
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.save
|
|
367
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
368
|
+
Created(resource:)
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```json
|
|
373
|
+
{
|
|
374
|
+
"status": "fail",
|
|
375
|
+
"data": {
|
|
376
|
+
"email": ["has already been taken"],
|
|
377
|
+
"name": ["can't be blank"]
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**`NotFound`:**
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
def call(params:)
|
|
386
|
+
user = User.find_by(id: params[:id])
|
|
387
|
+
return NotFound(errors: { base: ["User not found"] }) unless user
|
|
388
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
389
|
+
Ok(resource:)
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```json
|
|
394
|
+
{
|
|
395
|
+
"status": "fail",
|
|
396
|
+
"data": {
|
|
397
|
+
"base": ["User not found"]
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**`Forbidden`:**
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
def call(params:)
|
|
406
|
+
order = Order.find(params[:id])
|
|
407
|
+
return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
|
|
408
|
+
resource = OrderBlueprint.render_as_hash(order)
|
|
409
|
+
Ok(resource:)
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```json
|
|
414
|
+
{
|
|
415
|
+
"status": "fail",
|
|
416
|
+
"data": {
|
|
417
|
+
"base": ["You do not have access to this order"]
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**`Conflict`:**
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
def call(params:)
|
|
426
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
427
|
+
user = User.create(params)
|
|
428
|
+
Created(resource: user)
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"status": "fail",
|
|
435
|
+
"data": {
|
|
436
|
+
"email": ["already registered"]
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**`PaymentRequired`:**
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
def call(params:, current_user:)
|
|
445
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
446
|
+
Ok(resource: Dashboard.for(current_user))
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
```json
|
|
451
|
+
{
|
|
452
|
+
"status": "fail",
|
|
453
|
+
"data": {
|
|
454
|
+
"base": ["subscription expired"]
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Wrapped Format
|
|
460
|
+
|
|
461
|
+
The Wrapped formatter places every response in a uniform `{ data:, errors:, status: }` envelope. Success responses use `"status": "success"` and failure responses use `"status": "error"`.
|
|
462
|
+
|
|
463
|
+
### Success Responses
|
|
464
|
+
|
|
465
|
+
**`Ok` -- returning a single resource:**
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
def call(params:)
|
|
469
|
+
user = User.find(params[:id])
|
|
470
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
471
|
+
Ok(resource:)
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
```json
|
|
476
|
+
{
|
|
477
|
+
"data": {
|
|
478
|
+
"id": 1,
|
|
479
|
+
"name": "Jane Doe",
|
|
480
|
+
"email": "jane@example.com"
|
|
481
|
+
},
|
|
482
|
+
"errors": null,
|
|
483
|
+
"status": "success"
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
**`Created` -- with metadata:**
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
def call(params:)
|
|
491
|
+
user = User.create(params)
|
|
492
|
+
return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
|
|
493
|
+
|
|
494
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
495
|
+
Created(resource:, meta: { request_id: "abc-123" })
|
|
496
|
+
end
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
```json
|
|
500
|
+
{
|
|
501
|
+
"data": {
|
|
502
|
+
"id": 42,
|
|
503
|
+
"name": "Jane Doe",
|
|
504
|
+
"email": "jane@example.com"
|
|
505
|
+
},
|
|
506
|
+
"errors": null,
|
|
507
|
+
"status": "success",
|
|
508
|
+
"meta": {
|
|
509
|
+
"request_id": "abc-123"
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
**`Accepted` -- with no resource:**
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
def call(params:)
|
|
518
|
+
OrderFulfillmentJob.perform_later(params[:order_id])
|
|
519
|
+
Accepted()
|
|
520
|
+
end
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
```json
|
|
524
|
+
{
|
|
525
|
+
"data": null,
|
|
526
|
+
"errors": null,
|
|
527
|
+
"status": "success"
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**`Accepted` -- with a resource:**
|
|
532
|
+
|
|
533
|
+
```ruby
|
|
534
|
+
def call(params:)
|
|
535
|
+
order = Order.find(params[:id])
|
|
536
|
+
order.update(status: "processing")
|
|
537
|
+
Accepted(resource: { order_id: order.id, status: order.status })
|
|
538
|
+
end
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
```json
|
|
542
|
+
{
|
|
543
|
+
"data": {
|
|
544
|
+
"order_id": 7,
|
|
545
|
+
"status": "processing"
|
|
546
|
+
},
|
|
547
|
+
"errors": null,
|
|
548
|
+
"status": "success"
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Failure Responses
|
|
553
|
+
|
|
554
|
+
Failure responses use `"status": "error"` with the error hash under `"errors"` and `"data"` set to `null`.
|
|
555
|
+
|
|
556
|
+
**`UnprocessableContent` -- validation errors:**
|
|
557
|
+
|
|
558
|
+
```ruby
|
|
559
|
+
def call(params:)
|
|
560
|
+
user = User.new(params)
|
|
561
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.save
|
|
562
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
563
|
+
Created(resource:)
|
|
564
|
+
end
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
```json
|
|
568
|
+
{
|
|
569
|
+
"data": null,
|
|
570
|
+
"errors": {
|
|
571
|
+
"email": ["has already been taken"],
|
|
572
|
+
"name": ["can't be blank"]
|
|
573
|
+
},
|
|
574
|
+
"status": "error"
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
**`NotFound`:**
|
|
579
|
+
|
|
580
|
+
```ruby
|
|
581
|
+
def call(params:)
|
|
582
|
+
user = User.find_by(id: params[:id])
|
|
583
|
+
return NotFound(errors: { base: ["User not found"] }) unless user
|
|
584
|
+
resource = UserBlueprint.render_as_hash(user)
|
|
585
|
+
Ok(resource:)
|
|
586
|
+
end
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
```json
|
|
590
|
+
{
|
|
591
|
+
"data": null,
|
|
592
|
+
"errors": {
|
|
593
|
+
"base": ["User not found"]
|
|
594
|
+
},
|
|
595
|
+
"status": "error"
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**`Forbidden`:**
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
def call(params:)
|
|
603
|
+
order = Order.find(params[:id])
|
|
604
|
+
return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
|
|
605
|
+
resource = OrderBlueprint.render_as_hash(order)
|
|
606
|
+
Ok(resource:)
|
|
607
|
+
end
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
```json
|
|
611
|
+
{
|
|
612
|
+
"data": null,
|
|
613
|
+
"errors": {
|
|
614
|
+
"base": ["You do not have access to this order"]
|
|
615
|
+
},
|
|
616
|
+
"status": "error"
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**`Conflict`:**
|
|
621
|
+
|
|
622
|
+
```ruby
|
|
623
|
+
def call(params:)
|
|
624
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
625
|
+
user = User.create(params)
|
|
626
|
+
Created(resource: user)
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
```json
|
|
631
|
+
{
|
|
632
|
+
"data": null,
|
|
633
|
+
"errors": {
|
|
634
|
+
"email": ["already registered"]
|
|
635
|
+
},
|
|
636
|
+
"status": "error"
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
**`PaymentRequired`:**
|
|
641
|
+
|
|
642
|
+
```ruby
|
|
643
|
+
def call(params:, current_user:)
|
|
644
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
645
|
+
Ok(resource: Dashboard.for(current_user))
|
|
646
|
+
end
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
```json
|
|
650
|
+
{
|
|
651
|
+
"data": null,
|
|
652
|
+
"errors": {
|
|
653
|
+
"base": ["subscription expired"]
|
|
654
|
+
},
|
|
655
|
+
"status": "error"
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
## JSON:API Format
|
|
660
|
+
|
|
661
|
+
The JSON:API formatter structures responses according to the [JSON:API specification](https://jsonapi.org/).
|
|
662
|
+
|
|
663
|
+
### Success Responses
|
|
664
|
+
|
|
665
|
+
Success responses place the resource under a `"data"` key. ActiveRecord objects are automatically serialized into the `type` / `id` / `attributes` structure (see [ActiveRecord Serialization](#activerecord-serialization-jsonapi) below).
|
|
666
|
+
|
|
667
|
+
**`Ok` -- returning a single resource:**
|
|
668
|
+
|
|
669
|
+
```ruby
|
|
670
|
+
def call(params:)
|
|
671
|
+
user = User.find(params[:id])
|
|
672
|
+
Ok(resource: user)
|
|
673
|
+
end
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
```json
|
|
677
|
+
{
|
|
678
|
+
"data": {
|
|
679
|
+
"type": "user",
|
|
680
|
+
"id": "1",
|
|
681
|
+
"attributes": {
|
|
682
|
+
"name": "Jane Doe",
|
|
683
|
+
"email": "jane@example.com",
|
|
684
|
+
"created_at": "2026-01-15T09:30:00Z",
|
|
685
|
+
"updated_at": "2026-03-10T14:22:00Z"
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
**`Created` -- with metadata:**
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
def call(params:)
|
|
695
|
+
order = Order.create(params)
|
|
696
|
+
return UnprocessableContent(errors: order.errors.messages) if order.errors.any?
|
|
697
|
+
|
|
698
|
+
Created(resource: order, meta: { total_orders: Order.count })
|
|
699
|
+
end
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
```json
|
|
703
|
+
{
|
|
704
|
+
"data": {
|
|
705
|
+
"type": "order",
|
|
706
|
+
"id": "87",
|
|
707
|
+
"attributes": {
|
|
708
|
+
"total": "49.99",
|
|
709
|
+
"status": "pending",
|
|
710
|
+
"created_at": "2026-03-23T12:00:00Z",
|
|
711
|
+
"updated_at": "2026-03-23T12:00:00Z"
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
"meta": {
|
|
715
|
+
"total_orders": 12
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
**`Ok` -- returning a collection:**
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
def call(params:)
|
|
724
|
+
users = User.where(active: true).limit(2)
|
|
725
|
+
Ok(resource: users)
|
|
726
|
+
end
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
```json
|
|
730
|
+
{
|
|
731
|
+
"data": [
|
|
732
|
+
{
|
|
733
|
+
"type": "user",
|
|
734
|
+
"id": "1",
|
|
735
|
+
"attributes": {
|
|
736
|
+
"name": "Jane Doe",
|
|
737
|
+
"email": "jane@example.com",
|
|
738
|
+
"created_at": "2026-01-15T09:30:00Z",
|
|
739
|
+
"updated_at": "2026-03-10T14:22:00Z"
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
"type": "user",
|
|
744
|
+
"id": "2",
|
|
745
|
+
"attributes": {
|
|
746
|
+
"name": "John Smith",
|
|
747
|
+
"email": "john@example.com",
|
|
748
|
+
"created_at": "2026-02-20T11:00:00Z",
|
|
749
|
+
"updated_at": "2026-03-18T08:45:00Z"
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
]
|
|
753
|
+
}
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
**`Accepted` -- with no resource:**
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
def call(params:)
|
|
760
|
+
OrderFulfillmentJob.perform_later(params[:order_id])
|
|
761
|
+
Accepted()
|
|
762
|
+
end
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
```json
|
|
766
|
+
{}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
### Error Responses
|
|
770
|
+
|
|
771
|
+
Error responses use the `"errors"` key with an array of error objects. Each error object contains `status`, `detail`, and `source.pointer`.
|
|
772
|
+
|
|
773
|
+
**`UnprocessableContent` -- validation errors:**
|
|
774
|
+
|
|
775
|
+
```ruby
|
|
776
|
+
def call(params:)
|
|
777
|
+
user = User.new(params)
|
|
778
|
+
return UnprocessableContent(errors: user.errors.messages) unless user.save
|
|
779
|
+
Created(resource: user)
|
|
780
|
+
end
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
Given `errors.messages` of `{ email: ["has already been taken"], name: ["can't be blank", "is too short"] }`:
|
|
784
|
+
|
|
785
|
+
```json
|
|
786
|
+
{
|
|
787
|
+
"errors": [
|
|
788
|
+
{
|
|
789
|
+
"status": "422",
|
|
790
|
+
"detail": "has already been taken",
|
|
791
|
+
"source": { "pointer": "/data/attributes/email" }
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
"status": "422",
|
|
795
|
+
"detail": "can't be blank",
|
|
796
|
+
"source": { "pointer": "/data/attributes/name" }
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
"status": "422",
|
|
800
|
+
"detail": "is too short",
|
|
801
|
+
"source": { "pointer": "/data/attributes/name" }
|
|
802
|
+
}
|
|
803
|
+
]
|
|
804
|
+
}
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Note that multiple messages on the same field produce **separate error objects**, each with its own `detail`.
|
|
808
|
+
|
|
809
|
+
**`NotFound` -- with `:base` errors:**
|
|
810
|
+
|
|
811
|
+
```ruby
|
|
812
|
+
def call(params:)
|
|
813
|
+
user = User.find_by(id: params[:id])
|
|
814
|
+
return NotFound(errors: { base: ["User not found"] }) unless user
|
|
815
|
+
Ok(resource: user)
|
|
816
|
+
end
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
```json
|
|
820
|
+
{
|
|
821
|
+
"errors": [
|
|
822
|
+
{
|
|
823
|
+
"status": "404",
|
|
824
|
+
"detail": "User not found",
|
|
825
|
+
"source": { "pointer": "/data" }
|
|
826
|
+
}
|
|
827
|
+
]
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
Errors keyed under `:base` receive the pointer `"/data"`. Field-level errors receive `"/data/attributes/{field}"`.
|
|
832
|
+
|
|
833
|
+
**`Forbidden`:**
|
|
834
|
+
|
|
835
|
+
```ruby
|
|
836
|
+
def call(params:)
|
|
837
|
+
order = Order.find(params[:id])
|
|
838
|
+
return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
|
|
839
|
+
Ok(resource: order)
|
|
840
|
+
end
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
```json
|
|
844
|
+
{
|
|
845
|
+
"errors": [
|
|
846
|
+
{
|
|
847
|
+
"status": "403",
|
|
848
|
+
"detail": "You do not have access to this order",
|
|
849
|
+
"source": { "pointer": "/data" }
|
|
850
|
+
}
|
|
851
|
+
]
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
**`Conflict`:**
|
|
856
|
+
|
|
857
|
+
```ruby
|
|
858
|
+
def call(params:)
|
|
859
|
+
return Conflict(errors: { email: ["already registered"] }) if User.exists?(email: params[:email])
|
|
860
|
+
user = User.create(params)
|
|
861
|
+
Created(resource: user)
|
|
862
|
+
end
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
```json
|
|
866
|
+
{
|
|
867
|
+
"errors": [
|
|
868
|
+
{
|
|
869
|
+
"status": "409",
|
|
870
|
+
"detail": "already registered",
|
|
871
|
+
"source": { "pointer": "/data/attributes/email" }
|
|
872
|
+
}
|
|
873
|
+
]
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
**`PaymentRequired`:**
|
|
878
|
+
|
|
879
|
+
```ruby
|
|
880
|
+
def call(params:, current_user:)
|
|
881
|
+
return PaymentRequired(errors: { base: ["subscription expired"] }) if current_user.subscription_expired?
|
|
882
|
+
Ok(resource: Dashboard.for(current_user))
|
|
883
|
+
end
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
```json
|
|
887
|
+
{
|
|
888
|
+
"errors": [
|
|
889
|
+
{
|
|
890
|
+
"status": "402",
|
|
891
|
+
"detail": "subscription expired",
|
|
892
|
+
"source": { "pointer": "/data" }
|
|
893
|
+
}
|
|
894
|
+
]
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
## ActiveRecord Serialization (JSON:API)
|
|
899
|
+
|
|
900
|
+
The JSON:API formatter includes automatic serialization for ActiveRecord objects via the `Resource` class.
|
|
901
|
+
|
|
902
|
+
### Detection Rules
|
|
903
|
+
|
|
904
|
+
The serializer inspects the object you pass as `resource:` and applies different strategies:
|
|
905
|
+
|
|
906
|
+
| Object type | Behavior |
|
|
907
|
+
|---------------------------------------|-----------------------------------------------------|
|
|
908
|
+
| Responds to `.attributes` and `.class.model_name.element` (e.g., AR model) | Serialized into `{ type, id, attributes }` |
|
|
909
|
+
| `Hash` | Passed through unchanged |
|
|
910
|
+
| Responds to `.each` (e.g., Array, AR::Relation) | Each element serialized individually |
|
|
911
|
+
| Anything else | Passed through unchanged |
|
|
912
|
+
|
|
913
|
+
### How ActiveRecord Models Are Serialized
|
|
914
|
+
|
|
915
|
+
The serializer uses the ActiveModel `model_name` API to determine the resource type. Given a `User` record with `id: 1, name: "Jane Doe", email: "jane@example.com"`:
|
|
916
|
+
|
|
917
|
+
- **`type`** is derived from `resource.class.model_name.element`, producing the singular, snake_case model name (e.g., `"user"` for `User`, `"line_item"` for `LineItem`).
|
|
918
|
+
- **`id`** is always cast to a string (`"1"`, not `1`), per the JSON:API specification.
|
|
919
|
+
- **`attributes`** contains all model attributes **except** `"id"`, since the id is already a top-level member.
|
|
920
|
+
|
|
921
|
+
```json
|
|
922
|
+
{
|
|
923
|
+
"type": "user",
|
|
924
|
+
"id": "1",
|
|
925
|
+
"attributes": {
|
|
926
|
+
"name": "Jane Doe",
|
|
927
|
+
"email": "jane@example.com",
|
|
928
|
+
"created_at": "2026-01-15T09:30:00Z",
|
|
929
|
+
"updated_at": "2026-03-10T14:22:00Z"
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
### Hash Passthrough
|
|
935
|
+
|
|
936
|
+
When you pass a `Hash` as the resource, the JSON:API formatter returns it unchanged. This is useful when you are using a dedicated serialization library like Blueprinter or Alba and want to control the shape yourself:
|
|
937
|
+
|
|
938
|
+
```ruby
|
|
939
|
+
def call(params:)
|
|
940
|
+
user = User.find(params[:id])
|
|
941
|
+
Ok(resource: UserBlueprint.render_as_hash(user))
|
|
942
|
+
end
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
The hash is placed directly under the `"data"` key with no further transformation.
|
|
946
|
+
|
|
947
|
+
### Collections
|
|
948
|
+
|
|
949
|
+
Arrays and ActiveRecord::Relations are mapped element-by-element. Each element goes through the same detection rules described above, so a collection of AR models produces an array of `{ type, id, attributes }` objects.
|
|
950
|
+
|
|
951
|
+
```ruby
|
|
952
|
+
def call(params:)
|
|
953
|
+
orders = Order.where(user_id: params[:user_id]).order(created_at: :desc)
|
|
954
|
+
Ok(resource: orders, meta: { count: orders.size })
|
|
955
|
+
end
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
```json
|
|
959
|
+
{
|
|
960
|
+
"data": [
|
|
961
|
+
{
|
|
962
|
+
"type": "order",
|
|
963
|
+
"id": "87",
|
|
964
|
+
"attributes": {
|
|
965
|
+
"total": "49.99",
|
|
966
|
+
"status": "shipped",
|
|
967
|
+
"created_at": "2026-03-20T10:00:00Z",
|
|
968
|
+
"updated_at": "2026-03-22T16:30:00Z"
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
"type": "order",
|
|
973
|
+
"id": "63",
|
|
974
|
+
"attributes": {
|
|
975
|
+
"total": "129.00",
|
|
976
|
+
"status": "delivered",
|
|
977
|
+
"created_at": "2026-02-14T08:15:00Z",
|
|
978
|
+
"updated_at": "2026-02-18T11:45:00Z"
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
],
|
|
982
|
+
"meta": {
|
|
983
|
+
"count": 2
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
## The `meta:` Keyword
|
|
989
|
+
|
|
990
|
+
The `meta:` keyword argument is available on `Ok`, `Created`, and `Accepted`. It accepts any hash, which is included as a top-level `"meta"` key in all four formatters. When `meta:` is `nil` (the default), the key is omitted entirely from the response. In the default formatter, providing `meta:` wraps the response in `{ "data": resource, "meta": meta }` — without `meta:`, the resource is the entire body.
|
|
991
|
+
|
|
992
|
+
Common uses for `meta:`:
|
|
993
|
+
|
|
994
|
+
- **Pagination cursors** for keyset pagination
|
|
995
|
+
- **Result counts** for listing endpoints
|
|
996
|
+
- **Request tracing** identifiers
|
|
997
|
+
|
|
998
|
+
```ruby
|
|
999
|
+
def call(params:)
|
|
1000
|
+
users = User.where("id > ?", params[:after]).limit(20)
|
|
1001
|
+
last_user = users.last
|
|
1002
|
+
|
|
1003
|
+
Ok(
|
|
1004
|
+
resource: users,
|
|
1005
|
+
meta: {
|
|
1006
|
+
next_cursor: last_user&.id,
|
|
1007
|
+
count: users.size
|
|
1008
|
+
}
|
|
1009
|
+
)
|
|
1010
|
+
end
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
**Default output:**
|
|
1014
|
+
|
|
1015
|
+
```json
|
|
1016
|
+
{
|
|
1017
|
+
"data": [
|
|
1018
|
+
{ "id": 5, "name": "Alice Yu", "email": "alice@example.com" },
|
|
1019
|
+
{ "id": 6, "name": "Bob Park", "email": "bob@example.com" }
|
|
1020
|
+
],
|
|
1021
|
+
"meta": {
|
|
1022
|
+
"next_cursor": 6,
|
|
1023
|
+
"count": 2
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
**JSend output:**
|
|
1029
|
+
|
|
1030
|
+
```json
|
|
1031
|
+
{
|
|
1032
|
+
"status": "success",
|
|
1033
|
+
"data": [
|
|
1034
|
+
{ "id": 5, "name": "Alice Yu", "email": "alice@example.com" },
|
|
1035
|
+
{ "id": 6, "name": "Bob Park", "email": "bob@example.com" }
|
|
1036
|
+
],
|
|
1037
|
+
"meta": {
|
|
1038
|
+
"next_cursor": 6,
|
|
1039
|
+
"count": 2
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
**Wrapped output:**
|
|
1045
|
+
|
|
1046
|
+
```json
|
|
1047
|
+
{
|
|
1048
|
+
"data": [
|
|
1049
|
+
{ "id": 5, "name": "Alice Yu", "email": "alice@example.com" },
|
|
1050
|
+
{ "id": 6, "name": "Bob Park", "email": "bob@example.com" }
|
|
1051
|
+
],
|
|
1052
|
+
"errors": null,
|
|
1053
|
+
"status": "success",
|
|
1054
|
+
"meta": {
|
|
1055
|
+
"next_cursor": 6,
|
|
1056
|
+
"count": 2
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
**JSON:API output:**
|
|
1062
|
+
|
|
1063
|
+
```json
|
|
1064
|
+
{
|
|
1065
|
+
"data": [
|
|
1066
|
+
{
|
|
1067
|
+
"type": "user",
|
|
1068
|
+
"id": "5",
|
|
1069
|
+
"attributes": {
|
|
1070
|
+
"name": "Alice Yu",
|
|
1071
|
+
"email": "alice@example.com"
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
"type": "user",
|
|
1076
|
+
"id": "6",
|
|
1077
|
+
"attributes": {
|
|
1078
|
+
"name": "Bob Park",
|
|
1079
|
+
"email": "bob@example.com"
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
],
|
|
1083
|
+
"meta": {
|
|
1084
|
+
"next_cursor": 6,
|
|
1085
|
+
"count": 2
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
```
|