action_figure 0.1.0 → 0.5.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.
@@ -0,0 +1,932 @@
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 seven response helpers. Six 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
+ | `UnprocessableContent(errors:)` | `422 Unprocessable Content` | Validation failures |
59
+ | `NotFound(errors:)` | `404 Not Found` | Resource not found |
60
+ | `Forbidden(errors:)` | `403 Forbidden` | Authorization failure |
61
+
62
+ `NoContent` is shared across all formatters and is defined in the base `Formatter` module. It returns `{ status: :no_content }` with no JSON body.
63
+
64
+ 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.
65
+
66
+ ## Default Format
67
+
68
+ 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`.
69
+
70
+ ### Success Responses
71
+
72
+ The resource you pass becomes the entire JSON body with no wrapper.
73
+
74
+ **`Ok` -- returning a single resource:**
75
+
76
+ ```ruby
77
+ def call(params:)
78
+ user = User.find(params[:id])
79
+ resource = UserBlueprint.render_as_hash(user)
80
+ Ok(resource:)
81
+ end
82
+ ```
83
+
84
+ ```json
85
+ {
86
+ "id": 1,
87
+ "name": "Jane Doe",
88
+ "email": "jane@example.com"
89
+ }
90
+ ```
91
+
92
+ **`Created` -- returning a new resource:**
93
+
94
+ ```ruby
95
+ def call(params:)
96
+ user = User.create(params)
97
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
98
+
99
+ resource = UserBlueprint.render_as_hash(user)
100
+ Created(resource:)
101
+ end
102
+ ```
103
+
104
+ ```json
105
+ {
106
+ "id": 42,
107
+ "name": "Jane Doe",
108
+ "email": "jane@example.com"
109
+ }
110
+ ```
111
+
112
+ **`Ok` -- with metadata:**
113
+
114
+ When `meta:` is provided, the response wraps the resource under a `"data"` key so that `"meta"` can sit alongside it:
115
+
116
+ ```ruby
117
+ def call(params:)
118
+ users = User.where(active: true).limit(20)
119
+ Ok(resource: users, meta: { next_cursor: "abc123", total: 42 })
120
+ end
121
+ ```
122
+
123
+ ```json
124
+ {
125
+ "data": [
126
+ { "id": 1, "name": "Jane Doe" },
127
+ { "id": 2, "name": "John Smith" }
128
+ ],
129
+ "meta": {
130
+ "next_cursor": "abc123",
131
+ "total": 42
132
+ }
133
+ }
134
+ ```
135
+
136
+ Without `meta:`, the resource is the entire body. With `meta:`, the response becomes `{ "data": resource, "meta": meta }`.
137
+
138
+ **`Accepted` -- with no resource:**
139
+
140
+ ```ruby
141
+ def call(params:)
142
+ OrderFulfillmentJob.perform_later(params[:order_id])
143
+ Accepted()
144
+ end
145
+ ```
146
+
147
+ ```json
148
+ {}
149
+ ```
150
+
151
+ **`Accepted` -- with a resource:**
152
+
153
+ ```ruby
154
+ def call(params:)
155
+ order = Order.find(params[:id])
156
+ order.update(status: "processing")
157
+ Accepted(resource: { order_id: order.id, status: order.status })
158
+ end
159
+ ```
160
+
161
+ ```json
162
+ {
163
+ "order_id": 7,
164
+ "status": "processing"
165
+ }
166
+ ```
167
+
168
+ ### Failure Responses
169
+
170
+ 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`.
171
+
172
+ **`UnprocessableContent` -- validation errors:**
173
+
174
+ ```ruby
175
+ def call(params:)
176
+ user = User.new(params)
177
+ return UnprocessableContent(errors: user.errors.messages) unless user.save
178
+ resource = UserBlueprint.render_as_hash(user)
179
+ Created(resource:)
180
+ end
181
+ ```
182
+
183
+ ```json
184
+ {
185
+ "errors": {
186
+ "email": ["has already been taken"],
187
+ "name": ["can't be blank"]
188
+ }
189
+ }
190
+ ```
191
+
192
+ **`NotFound`:**
193
+
194
+ ```ruby
195
+ def call(params:)
196
+ user = User.find_by(id: params[:id])
197
+ return NotFound(errors: { base: ["User not found"] }) unless user
198
+ resource = UserBlueprint.render_as_hash(user)
199
+ Ok(resource:)
200
+ end
201
+ ```
202
+
203
+ ```json
204
+ {
205
+ "errors": {
206
+ "base": ["User not found"]
207
+ }
208
+ }
209
+ ```
210
+
211
+ **`Forbidden`:**
212
+
213
+ ```ruby
214
+ def call(params:)
215
+ order = Order.find(params[:id])
216
+ return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
217
+ resource = OrderBlueprint.render_as_hash(order)
218
+ Ok(resource:)
219
+ end
220
+ ```
221
+
222
+ ```json
223
+ {
224
+ "errors": {
225
+ "base": ["You do not have access to this order"]
226
+ }
227
+ }
228
+ ```
229
+
230
+ ## JSend Format
231
+
232
+ The JSend formatter wraps responses in the [JSend specification](https://github.com/omniti-labs/jsend) envelope.
233
+
234
+ ### Success Responses
235
+
236
+ Success responses use `"status": "success"` with a `"data"` key containing the resource.
237
+
238
+ **`Ok` -- returning a single resource:**
239
+
240
+ ```ruby
241
+ def call(params:)
242
+ user = User.find(params[:id])
243
+ resource = UserBlueprint.render_as_hash(user)
244
+ Ok(resource:)
245
+ end
246
+ ```
247
+
248
+ ```json
249
+ {
250
+ "status": "success",
251
+ "data": {
252
+ "id": 1,
253
+ "name": "Jane Doe",
254
+ "email": "jane@example.com"
255
+ }
256
+ }
257
+ ```
258
+
259
+ **`Created` -- with metadata:**
260
+
261
+ ```ruby
262
+ def call(params:)
263
+ user = User.create(params)
264
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
265
+
266
+ resource = UserBlueprint.render_as_hash(user)
267
+ Created(resource:, meta: { request_id: "abc-123" })
268
+ end
269
+ ```
270
+
271
+ ```json
272
+ {
273
+ "status": "success",
274
+ "data": {
275
+ "id": 42,
276
+ "name": "Jane Doe",
277
+ "email": "jane@example.com"
278
+ },
279
+ "meta": {
280
+ "request_id": "abc-123"
281
+ }
282
+ }
283
+ ```
284
+
285
+ **`Accepted` -- with no resource:**
286
+
287
+ ```ruby
288
+ def call(params:)
289
+ OrderFulfillmentJob.perform_later(params[:order_id])
290
+ Accepted()
291
+ end
292
+ ```
293
+
294
+ ```json
295
+ {
296
+ "status": "success"
297
+ }
298
+ ```
299
+
300
+ **`Accepted` -- with a resource:**
301
+
302
+ ```ruby
303
+ def call(params:)
304
+ order = Order.find(params[:id])
305
+ order.update(status: "processing")
306
+ Accepted(resource: { order_id: order.id, status: order.status })
307
+ end
308
+ ```
309
+
310
+ ```json
311
+ {
312
+ "status": "success",
313
+ "data": {
314
+ "order_id": 7,
315
+ "status": "processing"
316
+ }
317
+ }
318
+ ```
319
+
320
+ ### Failure Responses
321
+
322
+ 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.
323
+
324
+ **`UnprocessableContent` -- validation errors:**
325
+
326
+ ```ruby
327
+ def call(params:)
328
+ user = User.new(params)
329
+ return UnprocessableContent(errors: user.errors.messages) unless user.save
330
+ resource = UserBlueprint.render_as_hash(user)
331
+ Created(resource:)
332
+ end
333
+ ```
334
+
335
+ ```json
336
+ {
337
+ "status": "fail",
338
+ "data": {
339
+ "email": ["has already been taken"],
340
+ "name": ["can't be blank"]
341
+ }
342
+ }
343
+ ```
344
+
345
+ **`NotFound`:**
346
+
347
+ ```ruby
348
+ def call(params:)
349
+ user = User.find_by(id: params[:id])
350
+ return NotFound(errors: { base: ["User not found"] }) unless user
351
+ resource = UserBlueprint.render_as_hash(user)
352
+ Ok(resource:)
353
+ end
354
+ ```
355
+
356
+ ```json
357
+ {
358
+ "status": "fail",
359
+ "data": {
360
+ "base": ["User not found"]
361
+ }
362
+ }
363
+ ```
364
+
365
+ **`Forbidden`:**
366
+
367
+ ```ruby
368
+ def call(params:)
369
+ order = Order.find(params[:id])
370
+ return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
371
+ resource = OrderBlueprint.render_as_hash(order)
372
+ Ok(resource:)
373
+ end
374
+ ```
375
+
376
+ ```json
377
+ {
378
+ "status": "fail",
379
+ "data": {
380
+ "base": ["You do not have access to this order"]
381
+ }
382
+ }
383
+ ```
384
+
385
+ ## Wrapped Format
386
+
387
+ The Wrapped formatter places every response in a uniform `{ data:, errors:, status: }` envelope. Success responses use `"status": "success"` and failure responses use `"status": "error"`.
388
+
389
+ ### Success Responses
390
+
391
+ **`Ok` -- returning a single resource:**
392
+
393
+ ```ruby
394
+ def call(params:)
395
+ user = User.find(params[:id])
396
+ resource = UserBlueprint.render_as_hash(user)
397
+ Ok(resource:)
398
+ end
399
+ ```
400
+
401
+ ```json
402
+ {
403
+ "data": {
404
+ "id": 1,
405
+ "name": "Jane Doe",
406
+ "email": "jane@example.com"
407
+ },
408
+ "errors": null,
409
+ "status": "success"
410
+ }
411
+ ```
412
+
413
+ **`Created` -- with metadata:**
414
+
415
+ ```ruby
416
+ def call(params:)
417
+ user = User.create(params)
418
+ return UnprocessableContent(errors: user.errors.messages) if user.errors.any?
419
+
420
+ resource = UserBlueprint.render_as_hash(user)
421
+ Created(resource:, meta: { request_id: "abc-123" })
422
+ end
423
+ ```
424
+
425
+ ```json
426
+ {
427
+ "data": {
428
+ "id": 42,
429
+ "name": "Jane Doe",
430
+ "email": "jane@example.com"
431
+ },
432
+ "errors": null,
433
+ "status": "success",
434
+ "meta": {
435
+ "request_id": "abc-123"
436
+ }
437
+ }
438
+ ```
439
+
440
+ **`Accepted` -- with no resource:**
441
+
442
+ ```ruby
443
+ def call(params:)
444
+ OrderFulfillmentJob.perform_later(params[:order_id])
445
+ Accepted()
446
+ end
447
+ ```
448
+
449
+ ```json
450
+ {
451
+ "data": null,
452
+ "errors": null,
453
+ "status": "success"
454
+ }
455
+ ```
456
+
457
+ **`Accepted` -- with a resource:**
458
+
459
+ ```ruby
460
+ def call(params:)
461
+ order = Order.find(params[:id])
462
+ order.update(status: "processing")
463
+ Accepted(resource: { order_id: order.id, status: order.status })
464
+ end
465
+ ```
466
+
467
+ ```json
468
+ {
469
+ "data": {
470
+ "order_id": 7,
471
+ "status": "processing"
472
+ },
473
+ "errors": null,
474
+ "status": "success"
475
+ }
476
+ ```
477
+
478
+ ### Failure Responses
479
+
480
+ Failure responses use `"status": "error"` with the error hash under `"errors"` and `"data"` set to `null`.
481
+
482
+ **`UnprocessableContent` -- validation errors:**
483
+
484
+ ```ruby
485
+ def call(params:)
486
+ user = User.new(params)
487
+ return UnprocessableContent(errors: user.errors.messages) unless user.save
488
+ resource = UserBlueprint.render_as_hash(user)
489
+ Created(resource:)
490
+ end
491
+ ```
492
+
493
+ ```json
494
+ {
495
+ "data": null,
496
+ "errors": {
497
+ "email": ["has already been taken"],
498
+ "name": ["can't be blank"]
499
+ },
500
+ "status": "error"
501
+ }
502
+ ```
503
+
504
+ **`NotFound`:**
505
+
506
+ ```ruby
507
+ def call(params:)
508
+ user = User.find_by(id: params[:id])
509
+ return NotFound(errors: { base: ["User not found"] }) unless user
510
+ resource = UserBlueprint.render_as_hash(user)
511
+ Ok(resource:)
512
+ end
513
+ ```
514
+
515
+ ```json
516
+ {
517
+ "data": null,
518
+ "errors": {
519
+ "base": ["User not found"]
520
+ },
521
+ "status": "error"
522
+ }
523
+ ```
524
+
525
+ **`Forbidden`:**
526
+
527
+ ```ruby
528
+ def call(params:)
529
+ order = Order.find(params[:id])
530
+ return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
531
+ resource = OrderBlueprint.render_as_hash(order)
532
+ Ok(resource:)
533
+ end
534
+ ```
535
+
536
+ ```json
537
+ {
538
+ "data": null,
539
+ "errors": {
540
+ "base": ["You do not have access to this order"]
541
+ },
542
+ "status": "error"
543
+ }
544
+ ```
545
+
546
+ ## JSON:API Format
547
+
548
+ The JSON:API formatter structures responses according to the [JSON:API specification](https://jsonapi.org/).
549
+
550
+ ### Success Responses
551
+
552
+ 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).
553
+
554
+ **`Ok` -- returning a single resource:**
555
+
556
+ ```ruby
557
+ def call(params:)
558
+ user = User.find(params[:id])
559
+ Ok(resource: user)
560
+ end
561
+ ```
562
+
563
+ ```json
564
+ {
565
+ "data": {
566
+ "type": "user",
567
+ "id": "1",
568
+ "attributes": {
569
+ "name": "Jane Doe",
570
+ "email": "jane@example.com",
571
+ "created_at": "2026-01-15T09:30:00Z",
572
+ "updated_at": "2026-03-10T14:22:00Z"
573
+ }
574
+ }
575
+ }
576
+ ```
577
+
578
+ **`Created` -- with metadata:**
579
+
580
+ ```ruby
581
+ def call(params:)
582
+ order = Order.create(params)
583
+ return UnprocessableContent(errors: order.errors.messages) if order.errors.any?
584
+
585
+ Created(resource: order, meta: { total_orders: Order.count })
586
+ end
587
+ ```
588
+
589
+ ```json
590
+ {
591
+ "data": {
592
+ "type": "order",
593
+ "id": "87",
594
+ "attributes": {
595
+ "total": "49.99",
596
+ "status": "pending",
597
+ "created_at": "2026-03-23T12:00:00Z",
598
+ "updated_at": "2026-03-23T12:00:00Z"
599
+ }
600
+ },
601
+ "meta": {
602
+ "total_orders": 12
603
+ }
604
+ }
605
+ ```
606
+
607
+ **`Ok` -- returning a collection:**
608
+
609
+ ```ruby
610
+ def call(params:)
611
+ users = User.where(active: true).limit(2)
612
+ Ok(resource: users)
613
+ end
614
+ ```
615
+
616
+ ```json
617
+ {
618
+ "data": [
619
+ {
620
+ "type": "user",
621
+ "id": "1",
622
+ "attributes": {
623
+ "name": "Jane Doe",
624
+ "email": "jane@example.com",
625
+ "created_at": "2026-01-15T09:30:00Z",
626
+ "updated_at": "2026-03-10T14:22:00Z"
627
+ }
628
+ },
629
+ {
630
+ "type": "user",
631
+ "id": "2",
632
+ "attributes": {
633
+ "name": "John Smith",
634
+ "email": "john@example.com",
635
+ "created_at": "2026-02-20T11:00:00Z",
636
+ "updated_at": "2026-03-18T08:45:00Z"
637
+ }
638
+ }
639
+ ]
640
+ }
641
+ ```
642
+
643
+ **`Accepted` -- with no resource:**
644
+
645
+ ```ruby
646
+ def call(params:)
647
+ OrderFulfillmentJob.perform_later(params[:order_id])
648
+ Accepted()
649
+ end
650
+ ```
651
+
652
+ ```json
653
+ {}
654
+ ```
655
+
656
+ ### Error Responses
657
+
658
+ Error responses use the `"errors"` key with an array of error objects. Each error object contains `status`, `detail`, and `source.pointer`.
659
+
660
+ **`UnprocessableContent` -- validation errors:**
661
+
662
+ ```ruby
663
+ def call(params:)
664
+ user = User.new(params)
665
+ return UnprocessableContent(errors: user.errors.messages) unless user.save
666
+ Created(resource: user)
667
+ end
668
+ ```
669
+
670
+ Given `errors.messages` of `{ email: ["has already been taken"], name: ["can't be blank", "is too short"] }`:
671
+
672
+ ```json
673
+ {
674
+ "errors": [
675
+ {
676
+ "status": "422",
677
+ "detail": "has already been taken",
678
+ "source": { "pointer": "/data/attributes/email" }
679
+ },
680
+ {
681
+ "status": "422",
682
+ "detail": "can't be blank",
683
+ "source": { "pointer": "/data/attributes/name" }
684
+ },
685
+ {
686
+ "status": "422",
687
+ "detail": "is too short",
688
+ "source": { "pointer": "/data/attributes/name" }
689
+ }
690
+ ]
691
+ }
692
+ ```
693
+
694
+ Note that multiple messages on the same field produce **separate error objects**, each with its own `detail`.
695
+
696
+ **`NotFound` -- with `:base` errors:**
697
+
698
+ ```ruby
699
+ def call(params:)
700
+ user = User.find_by(id: params[:id])
701
+ return NotFound(errors: { base: ["User not found"] }) unless user
702
+ Ok(resource: user)
703
+ end
704
+ ```
705
+
706
+ ```json
707
+ {
708
+ "errors": [
709
+ {
710
+ "status": "404",
711
+ "detail": "User not found",
712
+ "source": { "pointer": "/data" }
713
+ }
714
+ ]
715
+ }
716
+ ```
717
+
718
+ Errors keyed under `:base` receive the pointer `"/data"`. Field-level errors receive `"/data/attributes/{field}"`.
719
+
720
+ **`Forbidden`:**
721
+
722
+ ```ruby
723
+ def call(params:)
724
+ order = Order.find(params[:id])
725
+ return Forbidden(errors: { base: ["You do not have access to this order"] }) unless authorized?(order)
726
+ Ok(resource: order)
727
+ end
728
+ ```
729
+
730
+ ```json
731
+ {
732
+ "errors": [
733
+ {
734
+ "status": "403",
735
+ "detail": "You do not have access to this order",
736
+ "source": { "pointer": "/data" }
737
+ }
738
+ ]
739
+ }
740
+ ```
741
+
742
+ ## ActiveRecord Serialization (JSON:API)
743
+
744
+ The JSON:API formatter includes automatic serialization for ActiveRecord objects via the `Resource` class.
745
+
746
+ ### Detection Rules
747
+
748
+ The serializer inspects the object you pass as `resource:` and applies different strategies:
749
+
750
+ | Object type | Behavior |
751
+ |---------------------------------------|-----------------------------------------------------|
752
+ | Responds to `.attributes` and `.class.model_name.element` (e.g., AR model) | Serialized into `{ type, id, attributes }` |
753
+ | `Hash` | Passed through unchanged |
754
+ | Responds to `.each` (e.g., Array, AR::Relation) | Each element serialized individually |
755
+ | Anything else | Passed through unchanged |
756
+
757
+ ### How ActiveRecord Models Are Serialized
758
+
759
+ 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"`:
760
+
761
+ - **`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`).
762
+ - **`id`** is always cast to a string (`"1"`, not `1`), per the JSON:API specification.
763
+ - **`attributes`** contains all model attributes **except** `"id"`, since the id is already a top-level member.
764
+
765
+ ```json
766
+ {
767
+ "type": "user",
768
+ "id": "1",
769
+ "attributes": {
770
+ "name": "Jane Doe",
771
+ "email": "jane@example.com",
772
+ "created_at": "2026-01-15T09:30:00Z",
773
+ "updated_at": "2026-03-10T14:22:00Z"
774
+ }
775
+ }
776
+ ```
777
+
778
+ ### Hash Passthrough
779
+
780
+ 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:
781
+
782
+ ```ruby
783
+ def call(params:)
784
+ user = User.find(params[:id])
785
+ Ok(resource: UserBlueprint.render_as_hash(user))
786
+ end
787
+ ```
788
+
789
+ The hash is placed directly under the `"data"` key with no further transformation.
790
+
791
+ ### Collections
792
+
793
+ 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.
794
+
795
+ ```ruby
796
+ def call(params:)
797
+ orders = Order.where(user_id: params[:user_id]).order(created_at: :desc)
798
+ Ok(resource: orders, meta: { count: orders.size })
799
+ end
800
+ ```
801
+
802
+ ```json
803
+ {
804
+ "data": [
805
+ {
806
+ "type": "order",
807
+ "id": "87",
808
+ "attributes": {
809
+ "total": "49.99",
810
+ "status": "shipped",
811
+ "created_at": "2026-03-20T10:00:00Z",
812
+ "updated_at": "2026-03-22T16:30:00Z"
813
+ }
814
+ },
815
+ {
816
+ "type": "order",
817
+ "id": "63",
818
+ "attributes": {
819
+ "total": "129.00",
820
+ "status": "delivered",
821
+ "created_at": "2026-02-14T08:15:00Z",
822
+ "updated_at": "2026-02-18T11:45:00Z"
823
+ }
824
+ }
825
+ ],
826
+ "meta": {
827
+ "count": 2
828
+ }
829
+ }
830
+ ```
831
+
832
+ ## The `meta:` Keyword
833
+
834
+ 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.
835
+
836
+ Common uses for `meta:`:
837
+
838
+ - **Pagination cursors** for keyset pagination
839
+ - **Result counts** for listing endpoints
840
+ - **Request tracing** identifiers
841
+
842
+ ```ruby
843
+ def call(params:)
844
+ users = User.where("id > ?", params[:after]).limit(20)
845
+ last_user = users.last
846
+
847
+ Ok(
848
+ resource: users,
849
+ meta: {
850
+ next_cursor: last_user&.id,
851
+ count: users.size
852
+ }
853
+ )
854
+ end
855
+ ```
856
+
857
+ **Default output:**
858
+
859
+ ```json
860
+ {
861
+ "data": [
862
+ { "id": 5, "name": "Alice Yu", "email": "alice@example.com" },
863
+ { "id": 6, "name": "Bob Park", "email": "bob@example.com" }
864
+ ],
865
+ "meta": {
866
+ "next_cursor": 6,
867
+ "count": 2
868
+ }
869
+ }
870
+ ```
871
+
872
+ **JSend output:**
873
+
874
+ ```json
875
+ {
876
+ "status": "success",
877
+ "data": [
878
+ { "id": 5, "name": "Alice Yu", "email": "alice@example.com" },
879
+ { "id": 6, "name": "Bob Park", "email": "bob@example.com" }
880
+ ],
881
+ "meta": {
882
+ "next_cursor": 6,
883
+ "count": 2
884
+ }
885
+ }
886
+ ```
887
+
888
+ **Wrapped output:**
889
+
890
+ ```json
891
+ {
892
+ "data": [
893
+ { "id": 5, "name": "Alice Yu", "email": "alice@example.com" },
894
+ { "id": 6, "name": "Bob Park", "email": "bob@example.com" }
895
+ ],
896
+ "errors": null,
897
+ "status": "success",
898
+ "meta": {
899
+ "next_cursor": 6,
900
+ "count": 2
901
+ }
902
+ }
903
+ ```
904
+
905
+ **JSON:API output:**
906
+
907
+ ```json
908
+ {
909
+ "data": [
910
+ {
911
+ "type": "user",
912
+ "id": "5",
913
+ "attributes": {
914
+ "name": "Alice Yu",
915
+ "email": "alice@example.com"
916
+ }
917
+ },
918
+ {
919
+ "type": "user",
920
+ "id": "6",
921
+ "attributes": {
922
+ "name": "Bob Park",
923
+ "email": "bob@example.com"
924
+ }
925
+ }
926
+ ],
927
+ "meta": {
928
+ "next_cursor": 6,
929
+ "count": 2
930
+ }
931
+ }
932
+ ```