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.
@@ -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
+ ```