jpie 0.1.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +65 -0
  5. data/.travis.yml +7 -0
  6. data/Gemfile +21 -0
  7. data/Gemfile.lock +312 -0
  8. data/README.md +2159 -0
  9. data/Rakefile +8 -0
  10. data/bin/console +15 -0
  11. data/bin/setup +8 -0
  12. data/jpie.gemspec +31 -0
  13. data/kiln/app/resources/user_message_resource.rb +2 -0
  14. data/lib/jpie.rb +6 -0
  15. data/lib/json_api/active_storage/deserialization.rb +106 -0
  16. data/lib/json_api/active_storage/detection.rb +74 -0
  17. data/lib/json_api/active_storage/serialization.rb +32 -0
  18. data/lib/json_api/configuration.rb +58 -0
  19. data/lib/json_api/controllers/base_controller.rb +26 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
  21. data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
  22. data/lib/json_api/controllers/relationships_controller.rb +504 -0
  23. data/lib/json_api/controllers/resources_controller.rb +6 -0
  24. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  25. data/lib/json_api/railtie.rb +75 -0
  26. data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
  27. data/lib/json_api/resources/resource.rb +238 -0
  28. data/lib/json_api/resources/resource_loader.rb +35 -0
  29. data/lib/json_api/routing.rb +72 -0
  30. data/lib/json_api/serialization/deserializer.rb +362 -0
  31. data/lib/json_api/serialization/serializer.rb +320 -0
  32. data/lib/json_api/support/active_storage_support.rb +85 -0
  33. data/lib/json_api/support/collection_query.rb +406 -0
  34. data/lib/json_api/support/instrumentation.rb +42 -0
  35. data/lib/json_api/support/param_helpers.rb +51 -0
  36. data/lib/json_api/support/relationship_guard.rb +16 -0
  37. data/lib/json_api/support/relationship_helpers.rb +74 -0
  38. data/lib/json_api/support/resource_identifier.rb +87 -0
  39. data/lib/json_api/support/responders.rb +100 -0
  40. data/lib/json_api/support/response_helpers.rb +10 -0
  41. data/lib/json_api/support/sort_parsing.rb +21 -0
  42. data/lib/json_api/support/type_conversion.rb +21 -0
  43. data/lib/json_api/testing/test_helper.rb +76 -0
  44. data/lib/json_api/testing.rb +3 -0
  45. data/lib/json_api/version.rb +5 -0
  46. data/lib/json_api.rb +50 -0
  47. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  48. metadata +128 -0
data/README.md ADDED
@@ -0,0 +1,2159 @@
1
+ # JSONAPI
2
+
3
+ A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API controllers for producing and consuming JSON:API resources.
4
+
5
+ ## Features
6
+
7
+ - JSON:API v1.1 compliant routing and controllers
8
+ - Automatic MIME type registration (`application/vnd.api+json`)
9
+ - Generic resource controller with CRUD operations
10
+ - Built-in serialization and deserialization
11
+ - Support for filtering (explicit filters with column-aware operators), sorting, pagination, sparse fieldsets, and includes
12
+ - Relationship endpoints for managing resource relationships independently
13
+ - Separate creatable and updatable field definitions
14
+ - Configurable pagination options
15
+ - Content negotiation with Accept and Content-Type headers
16
+ - Support for polymorphic and STI relationships
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem 'json_api'
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ $ bundle install
29
+
30
+ Or install it yourself as:
31
+
32
+ $ gem install json_api
33
+
34
+ ## Requirements
35
+
36
+ - Ruby >= 3.4.0
37
+ - Rails >= 8.0.0
38
+
39
+ ## Usage
40
+
41
+ ### Basic Setup
42
+
43
+ Add the gem to your Gemfile and run `bundle install`. The gem will automatically register the JSON:API MIME type and extend Rails routing.
44
+
45
+ ### Routing
46
+
47
+ Use the `jsonapi_resources` DSL in your routes file:
48
+
49
+ ```ruby
50
+ # config/routes.rb
51
+ Rails.application.routes.draw do
52
+ jsonapi_resources :users
53
+ jsonapi_resources :posts
54
+ end
55
+ ```
56
+
57
+ This creates standard RESTful routes (index, show, create, update, destroy) that default to the `json_api/resources` controller and `jsonapi` format.
58
+
59
+ **Important**: Each resource must have a corresponding resource class defined (e.g., `UserResource` for `jsonapi_resources :users`). The routing will fail at boot time if the resource class is missing.
60
+
61
+ ### Resource Definitions
62
+
63
+ Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:
64
+
65
+ ```ruby
66
+ # app/resources/user_resource.rb
67
+ class UserResource < JSONAPI::Resource
68
+ attributes :email, :name, :phone
69
+
70
+ has_many :posts
71
+ has_one :profile
72
+ end
73
+ ```
74
+
75
+ Resource classes must:
76
+
77
+ - Inherit from `JSONAPI::Resource`
78
+ - Be named `<ResourceName>Resource` (e.g., `UserResource` for `users` resource type)
79
+ - Define permitted attributes using `attributes`
80
+ - Define relationships using `has_many`, `has_one`, or `belongs_to`
81
+
82
+ The generic controller uses these resource definitions to:
83
+
84
+ - Validate requested fields (sparse fieldsets)
85
+ - Validate requested includes
86
+ - Control which attributes are exposed in responses
87
+ - Validate filters
88
+ - Control which fields can be created vs updated
89
+
90
+ ### Virtual Attributes
91
+
92
+ Resources can define virtual attributes that don't correspond to database columns. Virtual attributes are useful for computed values, formatted data, or transforming model attributes.
93
+
94
+ #### Defining Virtual Attributes
95
+
96
+ To create a virtual attribute, declare it in `attributes` and implement a getter method:
97
+
98
+ ```ruby
99
+ class UserResource < JSONAPI::Resource
100
+ attributes :name, :email, :full_name
101
+
102
+ # Virtual attribute getter
103
+ def full_name
104
+ "#{resource.name} (#{resource.email})"
105
+ end
106
+ end
107
+ ```
108
+
109
+ The getter method receives the underlying model instance via `resource`. Virtual attributes are serialized just like regular attributes and appear in the response:
110
+
111
+ ```json
112
+ {
113
+ "data": {
114
+ "type": "users",
115
+ "id": "1",
116
+ "attributes": {
117
+ "name": "John Doe",
118
+ "email": "john@example.com",
119
+ "full_name": "John Doe (john@example.com)"
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ #### Overriding Model Attributes
126
+
127
+ Resource getters take precedence over model attributes. If you define a getter for a real database column, it will override the model's attribute value:
128
+
129
+ ```ruby
130
+ class UserResource < JSONAPI::Resource
131
+ attributes :name, :email
132
+
133
+ # Override name attribute
134
+ def name
135
+ resource.name.upcase
136
+ end
137
+ end
138
+ ```
139
+
140
+ #### Virtual Attribute Setters
141
+
142
+ You can define setters for virtual attributes that transform incoming values into real model attributes. This is useful for accepting formatted input that needs to be stored differently:
143
+
144
+ ```ruby
145
+ class UserResource < JSONAPI::Resource
146
+ attributes :name, :email, :display_name
147
+ creatable_fields :name, :email, :display_name
148
+ updatable_fields :name, :display_name
149
+
150
+ def initialize(resource = nil, context = {})
151
+ super
152
+ @transformed_params = {}
153
+ end
154
+
155
+ # Setter that transforms virtual attribute to model attribute
156
+ def display_name=(value)
157
+ @transformed_params["name"] = value.upcase
158
+ end
159
+
160
+ # Getter for the virtual attribute (for serialization)
161
+ def display_name
162
+ resource&.name&.downcase
163
+ end
164
+
165
+ # Return transformed params accumulated by setters
166
+ def transformed_params
167
+ @transformed_params || {}
168
+ end
169
+ end
170
+ ```
171
+
172
+ When a client sends `display_name` in a create or update request, the setter transforms it to `name` (uppercase). The virtual attribute `display_name` is automatically excluded from the final params returned by the deserializer, so it won't be passed to the model.
173
+
174
+ **Important**: You must initialize `@transformed_params` in your `initialize` method if you use setters that modify it.
175
+
176
+ ### Custom Controllers
177
+
178
+ If you need custom behavior, you can override the default controller:
179
+
180
+ ```ruby
181
+ jsonapi_resources :users, controller: "api/users"
182
+ ```
183
+
184
+ ### Controller Actions
185
+
186
+ The default `JSONAPI::ResourcesController` provides:
187
+
188
+ - `GET /users` - List all users (with filtering, sorting, pagination)
189
+ - `GET /users/:id` - Show a user (with includes, sparse fieldsets)
190
+ - `POST /users` - Create a user (with relationships)
191
+ - `PATCH /users/:id` - Update a user (with relationships)
192
+ - `DELETE /users/:id` - Delete a user
193
+
194
+ Additionally, relationship endpoints are available via `JSONAPI::RelationshipsController`:
195
+
196
+ - `GET /users/:id/relationships/:relationship_name` - Show relationship data (with sorting for collections)
197
+ - `PATCH /users/:id/relationships/:relationship_name` - Update relationship linkage
198
+ - `DELETE /users/:id/relationships/:relationship_name` - Remove relationship linkage
199
+
200
+ #### Example Responses
201
+
202
+ **GET /users (Collection Response):**
203
+
204
+ ```json
205
+ {
206
+ "jsonapi": {
207
+ "version": "1.1"
208
+ },
209
+ "data": [
210
+ {
211
+ "type": "users",
212
+ "id": "1",
213
+ "attributes": {
214
+ "name": "John Doe",
215
+ "email": "john@example.com",
216
+ "phone": "555-0100"
217
+ },
218
+ "relationships": {
219
+ "posts": {
220
+ "data": [
221
+ { "type": "posts", "id": "1" },
222
+ { "type": "posts", "id": "2" }
223
+ ],
224
+ "meta": {
225
+ "count": 2
226
+ }
227
+ },
228
+ "profile": {
229
+ "data": null
230
+ }
231
+ },
232
+ "links": {
233
+ "self": "/users/1"
234
+ },
235
+ "meta": {
236
+ "created_at": "2024-01-15T10:30:00Z",
237
+ "updated_at": "2024-01-15T10:30:00Z"
238
+ }
239
+ }
240
+ ]
241
+ }
242
+ ```
243
+
244
+ **GET /users/:id (Single Resource Response):**
245
+
246
+ ```json
247
+ {
248
+ "jsonapi": {
249
+ "version": "1.1"
250
+ },
251
+ "data": {
252
+ "type": "users",
253
+ "id": "1",
254
+ "attributes": {
255
+ "name": "John Doe",
256
+ "email": "john@example.com",
257
+ "phone": "555-0100"
258
+ },
259
+ "relationships": {
260
+ "posts": {
261
+ "data": [
262
+ { "type": "posts", "id": "1" },
263
+ { "type": "posts", "id": "2" }
264
+ ],
265
+ "meta": {
266
+ "count": 2
267
+ }
268
+ },
269
+ "profile": {
270
+ "data": {
271
+ "type": "admin_profiles",
272
+ "id": "1"
273
+ }
274
+ }
275
+ },
276
+ "links": {
277
+ "self": "/users/1"
278
+ },
279
+ "meta": {
280
+ "created_at": "2024-01-15T10:30:00Z",
281
+ "updated_at": "2024-01-15T10:30:00Z"
282
+ }
283
+ }
284
+ }
285
+ ```
286
+
287
+ **POST /users (Create Response):**
288
+
289
+ ```json
290
+ {
291
+ "jsonapi": {
292
+ "version": "1.1"
293
+ },
294
+ "data": {
295
+ "type": "users",
296
+ "id": "2",
297
+ "attributes": {
298
+ "name": "New User",
299
+ "email": "newuser@example.com",
300
+ "phone": "555-0101"
301
+ },
302
+ "relationships": {
303
+ "posts": {
304
+ "data": [],
305
+ "meta": {
306
+ "count": 0
307
+ }
308
+ },
309
+ "profile": {
310
+ "data": null
311
+ }
312
+ },
313
+ "links": {
314
+ "self": "/users/2"
315
+ },
316
+ "meta": {
317
+ "created_at": "2024-01-15T11:00:00Z",
318
+ "updated_at": "2024-01-15T11:00:00Z"
319
+ }
320
+ }
321
+ }
322
+ ```
323
+
324
+ **PATCH /users/:id (Update Response):**
325
+
326
+ ```json
327
+ {
328
+ "jsonapi": {
329
+ "version": "1.1"
330
+ },
331
+ "data": {
332
+ "type": "users",
333
+ "id": "1",
334
+ "attributes": {
335
+ "name": "Updated Name",
336
+ "email": "john@example.com",
337
+ "phone": "555-9999"
338
+ },
339
+ "relationships": {
340
+ "posts": {
341
+ "data": [{ "type": "posts", "id": "1" }],
342
+ "meta": {
343
+ "count": 1
344
+ }
345
+ },
346
+ "profile": {
347
+ "data": null
348
+ }
349
+ },
350
+ "links": {
351
+ "self": "/users/1"
352
+ },
353
+ "meta": {
354
+ "created_at": "2024-01-15T10:30:00Z",
355
+ "updated_at": "2024-01-15T11:15:00Z"
356
+ }
357
+ }
358
+ }
359
+ ```
360
+
361
+ **DELETE /users/:id:**
362
+
363
+ Returns `204 No Content` with an empty response body.
364
+
365
+ ### Query Parameters
366
+
367
+ The controller supports standard JSON:API query parameters:
368
+
369
+ - `filter[name]=John` - Filter resources (must be declared in resource class)
370
+ - `sort=name,-created_at` - Sort resources (ascending by default, prefix with `-` for descending)
371
+ - `page[number]=1&page[size]=25` - Pagination (number and size only)
372
+ - `include=posts,comments` - Include related resources
373
+ - `fields[users]=name,email` - Sparse fieldsets
374
+
375
+ #### Filtering
376
+
377
+ Filtering requires declaring permitted filters in your resource class:
378
+
379
+ ```ruby
380
+ class UserResource < JSONAPI::Resource
381
+ attributes :name, :email, :phone
382
+ filters :name_eq, :name_match, :created_at_gte
383
+ end
384
+ ```
385
+
386
+ For regular filters, the gem applies column-aware operators when you use suffixes:
387
+
388
+ - `_eq`
389
+ - `_match` (string/text; uses ILIKE with sanitized patterns)
390
+ - `_lt`, `_lte`, `_gt`, `_gte` (numeric, date, datetime)
391
+
392
+ If a filter name doesn't match a supported operator but a model scope with that name exists, the scope is called instead. Filters are only applied when declared via `filters`. Invalid filter names return a `400 Bad Request` error.
393
+
394
+ #### Filter Value Formats
395
+
396
+ Filter values can be provided as either strings or arrays, depending on the filter's requirements:
397
+
398
+ **String Format:**
399
+
400
+ - Single value: `filter[name_eq]=John`
401
+ - Comma-separated values: `filter[name_eq]=John,Jane` (parsed as a single string `"John,Jane"`)
402
+
403
+ **Array Format:**
404
+
405
+ - Multiple values: `filter[categories_include][]=security&filter[categories_include][]=governance`
406
+ - Rails parses this as an array: `["security", "governance"]`
407
+
408
+ **When Arrays Are Required:**
409
+
410
+ Some filters require arrays, particularly when using PostgreSQL array operators:
411
+
412
+ ```ruby
413
+ class SelectedControl < ApplicationRecord
414
+ # This scope uses PostgreSQL's ?| operator which requires an array
415
+ scope :categories_include, ->(categories) {
416
+ where("#{table_name}.categories ?| array[:categories]", categories:)
417
+ }
418
+ end
419
+ ```
420
+
421
+ For such scopes, filters must be provided in array format:
422
+
423
+ - ✅ Correct: `filter[categories_include][]=security&filter[categories_include][]=governance`
424
+ - ❌ Incorrect: `filter[categories_include]=security,governance` (parsed as string `"security,governance"`)
425
+
426
+ **How Rails Parses Query Parameters:**
427
+
428
+ - `filter[key]=value` → Rails parses as `{ "filter" => { "key" => "value" } }`
429
+ - `filter[key][]=value1&filter[key][]=value2` → Rails parses as `{ "filter" => { "key" => ["value1", "value2"] } }`
430
+ - `filter[key]=value1,value2` → Rails parses as `{ "filter" => { "key" => "value1,value2" } }` (single string)
431
+
432
+ The `json_api` gem passes filter values directly to model scopes as parsed by Rails. If a scope expects an array (e.g., for PostgreSQL array operators), ensure the filter is sent in array format.
433
+
434
+ #### Filtering through relationships (nested filter hashes)
435
+
436
+ Expose filters on related resources using nested hashes. Relationships declared with `has_one`/`has_many` automatically allow nested filters on the related resource's filters plus primary key filters. Example:
437
+
438
+ ```ruby
439
+ class PostResource < JSONAPI::Resource
440
+ filters :title
441
+ end
442
+
443
+ class User < ApplicationRecord
444
+ # Column filters: filter[user][email]=foo@example.com
445
+ # Scope filters: filter[user][name_search]=Jane
446
+ scope :name_search, ->(query) { where("users.name LIKE ?", "%#{query}%") }
447
+ end
448
+ ```
449
+
450
+ Examples:
451
+
452
+ - `GET /posts?filter[user][id]=123` (joins users and filters on users.id)
453
+ - `GET /posts?filter[user][email]=jane@example.com` (filters on related column)
454
+ - `GET /posts?filter[user][name_search]=Jane` (calls the related scope and merges it)
455
+ - `GET /comments?filter[post][user][email_eq]=john@example.com` (multi-level chain)
456
+
457
+ Nested filter paths must point to either a column on the related model, a class method/scope on that model, or a filter declared on the related resource.
458
+
459
+ Invalid filter fields will return a `400 Bad Request` error:
460
+
461
+ ```json
462
+ {
463
+ "errors": [
464
+ {
465
+ "status": "400",
466
+ "title": "Invalid Filter",
467
+ "detail": "Invalid filters requested: invalid_field"
468
+ }
469
+ ]
470
+ }
471
+ ```
472
+
473
+ ### Writable through relationships
474
+
475
+ By default, has-many-through and has-one-through relationships are writable via JSON:API payloads and relationship endpoints. Set `readonly: true` on the relationship to block writes and return `ParameterNotAllowed`.
476
+
477
+ ```ruby
478
+ class UserResource < JSONAPI::Resource
479
+ has_many :post_comments, readonly: true
480
+ end
481
+ ```
482
+
483
+ - The opt-out applies to both main resource deserialization and relationship controller updates.
484
+ - Use `readonly: true` when the underlying model should not allow assignment (for example, when it lacks `*_ids` setters or manages joins differently).
485
+ - Without the flag, through relationships are writable.
486
+
487
+ #### Pagination
488
+
489
+ Resources can be paginated using the `page[number]` and `page[size]` parameters. Pagination is only available on collection (index) endpoints.
490
+
491
+ Examples:
492
+
493
+ - `GET /users?page[number]=1&page[size]=10` (first page, 10 items per page)
494
+ - `GET /users?page[number]=2&page[size]=10` (second page, 10 items per page)
495
+ - `GET /users?page[number]=1` (first page with default page size)
496
+
497
+ The default page size is 25, and the maximum page size is 100 (configurable via `JSONAPI.configuration`). If a size larger than the maximum is requested, it will be capped at the maximum.
498
+
499
+ **Example Paginated Response:**
500
+
501
+ ```json
502
+ {
503
+ "jsonapi": {
504
+ "version": "1.1"
505
+ },
506
+ "data": [
507
+ {
508
+ "type": "users",
509
+ "id": "1",
510
+ "attributes": {
511
+ "name": "User 1",
512
+ "email": "user1@example.com",
513
+ "phone": "555-0001"
514
+ },
515
+ "links": {
516
+ "self": "/users/1"
517
+ }
518
+ }
519
+ ],
520
+ "links": {
521
+ "self": "/users?page[number]=2&page[size]=5",
522
+ "first": "/users?page[number]=1&page[size]=5",
523
+ "last": "/users?page[number]=3&page[size]=5",
524
+ "prev": "/users?page[number]=1&page[size]=5",
525
+ "next": "/users?page[number]=3&page[size]=5"
526
+ },
527
+ "meta": {
528
+ "total": 15
529
+ }
530
+ }
531
+ ```
532
+
533
+ #### Including Related Resources
534
+
535
+ Use the `include` parameter to include related resources in the response. This is available on both index and show endpoints:
536
+
537
+ ```ruby
538
+ # Include single relationship
539
+ GET /users?include=posts
540
+
541
+ # Include multiple relationships
542
+ GET /users?include=posts,comments
543
+
544
+ # Include nested relationships (two levels)
545
+ GET /users?include=posts.comments
546
+
547
+ # Include deeply nested relationships (arbitrary depth)
548
+ GET /users?include=posts.comments.author
549
+
550
+ # Include multiple nested paths
551
+ GET /users?include=posts.comments,posts.author
552
+
553
+ # Mix single and nested includes
554
+ GET /users?include=posts.comments,notifications
555
+ ```
556
+
557
+ The gem supports arbitrary depth for nested includes. You can chain as many associations as needed (e.g., `posts.comments.author.profile`). Overlapping paths are automatically merged, so `posts.comments` and `posts.comments.author` will correctly include posts, comments, and authors.
558
+
559
+ Invalid include paths will return a `400 Bad Request` error with a JSON:API error response:
560
+
561
+ ```json
562
+ {
563
+ "errors": [
564
+ {
565
+ "status": "400",
566
+ "title": "Invalid Include Path",
567
+ "detail": "Invalid include paths requested: invalid_association"
568
+ }
569
+ ]
570
+ }
571
+ ```
572
+
573
+ Included resources appear in the `included` array at the top level of the response, and relationships reference them using resource identifiers (`type` and `id`).
574
+
575
+ **Example Response with Includes:**
576
+
577
+ ```json
578
+ {
579
+ "jsonapi": {
580
+ "version": "1.1"
581
+ },
582
+ "data": {
583
+ "type": "users",
584
+ "id": "1",
585
+ "attributes": {
586
+ "name": "John Doe",
587
+ "email": "john@example.com",
588
+ "phone": "555-0100"
589
+ },
590
+ "relationships": {
591
+ "posts": {
592
+ "data": [
593
+ { "type": "posts", "id": "1" },
594
+ { "type": "posts", "id": "2" }
595
+ ],
596
+ "meta": {
597
+ "count": 2
598
+ }
599
+ }
600
+ },
601
+ "links": {
602
+ "self": "/users/1"
603
+ }
604
+ },
605
+ "included": [
606
+ {
607
+ "type": "posts",
608
+ "id": "1",
609
+ "attributes": {
610
+ "title": "First Post",
611
+ "body": "Content 1"
612
+ },
613
+ "relationships": {
614
+ "user": {
615
+ "data": {
616
+ "type": "users",
617
+ "id": "1"
618
+ }
619
+ },
620
+ "comments": {
621
+ "data": [],
622
+ "meta": {
623
+ "count": 0
624
+ }
625
+ }
626
+ },
627
+ "links": {
628
+ "self": "/posts/1"
629
+ }
630
+ },
631
+ {
632
+ "type": "posts",
633
+ "id": "2",
634
+ "attributes": {
635
+ "title": "Second Post",
636
+ "body": "Content 2"
637
+ },
638
+ "relationships": {
639
+ "user": {
640
+ "data": {
641
+ "type": "users",
642
+ "id": "1"
643
+ }
644
+ },
645
+ "comments": {
646
+ "data": [],
647
+ "meta": {
648
+ "count": 0
649
+ }
650
+ }
651
+ },
652
+ "links": {
653
+ "self": "/posts/2"
654
+ }
655
+ }
656
+ ]
657
+ }
658
+ ```
659
+
660
+ #### Polymorphic Relationships
661
+
662
+ The gem supports polymorphic associations (both `belongs_to :profile, polymorphic: true` and `has_many :activities, as: :actor`). When including polymorphic relationships, the serializer automatically determines the correct resource type based on the actual class of the related object:
663
+
664
+ ```ruby
665
+ # User belongs_to :profile, polymorphic: true
666
+ # User has_many :activities, as: :actor
667
+
668
+ GET /users?include=profile
669
+ GET /users?include=activities
670
+ GET /users/:id?include=profile,activities
671
+ ```
672
+
673
+ The response will include the correct resource type for each polymorphic association (e.g., `customer_profiles` or `admin_profiles` for a polymorphic `profile` association).
674
+
675
+ **Example Response with Polymorphic Relationship:**
676
+
677
+ ```json
678
+ {
679
+ "jsonapi": {
680
+ "version": "1.1"
681
+ },
682
+ "data": {
683
+ "type": "users",
684
+ "id": "1",
685
+ "attributes": {
686
+ "name": "John Doe",
687
+ "email": "john@example.com",
688
+ "phone": "555-0100"
689
+ },
690
+ "relationships": {
691
+ "profile": {
692
+ "data": {
693
+ "type": "admin_profiles",
694
+ "id": "1"
695
+ }
696
+ }
697
+ },
698
+ "links": {
699
+ "self": "/users/1"
700
+ }
701
+ },
702
+ "included": [
703
+ {
704
+ "type": "admin_profiles",
705
+ "id": "1",
706
+ "attributes": {
707
+ "department": "Engineering",
708
+ "level": "Senior"
709
+ },
710
+ "links": {
711
+ "self": "/admin_profiles/1"
712
+ }
713
+ }
714
+ ]
715
+ }
716
+ ```
717
+
718
+ #### Single Table Inheritance (STI) Support
719
+
720
+ The gem supports Single Table Inheritance (STI) resources and relationships. Subclasses are treated as first-class JSON:API resources with their own types, while sharing the underlying table.
721
+
722
+ ##### Routing
723
+
724
+ To enable STI support, use the `sti` option in your routes configuration. Pass an array of subtype names to generate routes for both the base resource and its subclasses:
725
+
726
+ ```ruby
727
+ # config/routes.rb
728
+ Rails.application.routes.draw do
729
+ # Generates routes for:
730
+ # - /notifications (Base resource)
731
+ # - /email_notifications
732
+ # - /sms_notifications
733
+ jsonapi_resources :notifications, sti: [:email_notifications, :sms_notifications]
734
+ end
735
+ ```
736
+
737
+ ##### Resource Definitions
738
+
739
+ Define a resource class for the base model and each subclass. Subclasses should inherit from the base resource class to share configuration:
740
+
741
+ ```ruby
742
+ # app/resources/notification_resource.rb
743
+ class NotificationResource < JSONAPI::Resource
744
+ attributes :body, :created_at
745
+ has_one :user
746
+ end
747
+
748
+ # app/resources/email_notification_resource.rb
749
+ class EmailNotificationResource < NotificationResource
750
+ attributes :subject, :recipient_email
751
+ end
752
+
753
+ # app/resources/sms_notification_resource.rb
754
+ class SmsNotificationResource < NotificationResource
755
+ attributes :phone_number
756
+ end
757
+ ```
758
+
759
+ ##### Serialization
760
+
761
+ Resources are serialized with their specific type. When querying the base endpoint (e.g., `GET /notifications`), the response will contain a mix of types:
762
+
763
+ ```json
764
+ {
765
+ "data": [
766
+ {
767
+ "type": "email_notifications",
768
+ "id": "1",
769
+ "attributes": {
770
+ "body": "Welcome!",
771
+ "subject": "Hello"
772
+ }
773
+ },
774
+ {
775
+ "type": "sms_notifications",
776
+ "id": "2",
777
+ "attributes": {
778
+ "body": "Code: 1234",
779
+ "phone_number": "555-1234"
780
+ }
781
+ }
782
+ ]
783
+ }
784
+ ```
785
+
786
+ ##### Creating STI Resources
787
+
788
+ To create a specific subclass, send a POST request to either the base endpoint or the specific subclass endpoint, specifying the correct `type` in the payload:
789
+
790
+ ```json
791
+ POST /notifications
792
+ Content-Type: application/vnd.api+json
793
+
794
+ {
795
+ "data": {
796
+ "type": "email_notifications",
797
+ "attributes": {
798
+ "subject": "Important",
799
+ "body": "Please read this.",
800
+ "recipient_email": "user@example.com"
801
+ },
802
+ "relationships": {
803
+ "user": {
804
+ "data": { "type": "users", "id": "1" }
805
+ }
806
+ }
807
+ }
808
+ }
809
+ ```
810
+
811
+ The controller automatically instantiates the correct model class based on the `type` field.
812
+
813
+ #### STI resource DSL inheritance
814
+
815
+ - Subclasses inherit parent DSL (attributes, filters, sortable_fields, relationships, creatable_fields, updatable_fields, class-level `meta`) only when they do **not** declare that DSL themselves.
816
+ - Once a subclass calls a DSL method, it uses only its own declarations; it must opt-in to parent definitions explicitly (e.g., `attributes(*superclass.permitted_attributes, :child_attr)`).
817
+ - Instance-level `meta` methods still inherit via Ruby method lookup; class-level `meta` follows the “silent inherits, declare resets” rule.
818
+ - For sparse fieldsets, expose needed attributes on the subtype by including the parent set when you declare subtype DSL.
819
+
820
+ #### Sorting
821
+
822
+ Sorting is only available on index endpoints (collection endpoints). Use the `sort` parameter to specify one or more fields to sort by:
823
+
824
+ ```ruby
825
+ # Sort by name ascending
826
+ GET /users?sort=name
827
+
828
+ # Sort by name descending (prefix with -)
829
+ GET /users?sort=-name
830
+
831
+ # Sort by multiple fields
832
+ GET /users?sort=name,created_at
833
+
834
+ # Sort by multiple fields with mixed directions
835
+ GET /users?sort=name,-created_at
836
+ ```
837
+
838
+ Invalid sort fields will return a `400 Bad Request` error with a JSON:API error response:
839
+
840
+ ```json
841
+ {
842
+ "errors": [
843
+ {
844
+ "status": "400",
845
+ "title": "Invalid Sort Field",
846
+ "detail": "Invalid sort fields requested: invalid_field"
847
+ }
848
+ ]
849
+ }
850
+ ```
851
+
852
+ The sort parameter is ignored on show endpoints (single resource endpoints). However, relationship endpoints support sorting for collection relationships:
853
+
854
+ ```ruby
855
+ # Sort posts relationship
856
+ GET /users/:id/relationships/posts?sort=title,-created_at
857
+ ```
858
+
859
+ Sorting on relationship endpoints validates against the related model's columns, not the parent resource.
860
+
861
+ #### Virtual Attribute Sorting
862
+
863
+ The gem supports sorting by virtual attributes (attributes that don't correspond to database columns). When sorting by a virtual attribute, the gem:
864
+
865
+ 1. Loads all records into memory
866
+ 2. Sorts them in Ruby using the resource's getter method for the virtual attribute
867
+ 3. Recalculates the total count after sorting
868
+
869
+ You can mix database columns and virtual attributes in the same sort:
870
+
871
+ ```ruby
872
+ # Sort by database column first, then by virtual attribute
873
+ GET /users?sort=name,full_name
874
+ ```
875
+
876
+ **Example:**
877
+
878
+ ```ruby
879
+ class UserResource < JSONAPI::Resource
880
+ attributes :name, :email, :full_name
881
+
882
+ def full_name
883
+ "#{resource.name} (#{resource.email})"
884
+ end
885
+ end
886
+ ```
887
+
888
+ ```ruby
889
+ # Sort by virtual attribute ascending
890
+ GET /users?sort=full_name
891
+
892
+ # Sort by virtual attribute descending
893
+ GET /users?sort=-full_name
894
+
895
+ # Mix database column and virtual attribute
896
+ GET /users?sort=name,full_name
897
+ ```
898
+
899
+ **Performance Note**: Sorting by virtual attributes requires loading all matching records into memory. For large collections, consider using database columns or computed database columns instead.
900
+
901
+ #### Sort-Only Fields
902
+
903
+ You can declare virtual fields that are sortable but not exposed as attributes. This is useful when you want to allow sorting by a computed value without making it readable or writable in the API response.
904
+
905
+ To declare a sort-only field, use `sortable_fields` in your resource class and implement a getter method:
906
+
907
+ ```ruby
908
+ class UserResource < JSONAPI::Resource
909
+ attributes :name, :email
910
+
911
+ # Declare sort-only field
912
+ sortable_fields :posts_count
913
+
914
+ # Implement getter method (same as virtual attribute)
915
+ def posts_count
916
+ resource.posts.size
917
+ end
918
+ end
919
+ ```
920
+
921
+ Sort-only fields:
922
+
923
+ - Can be used in sort parameters: `GET /users?sort=posts_count`
924
+ - Are validated as valid sort fields
925
+ - Are NOT included in serialized attributes
926
+ - Can be mixed with database columns and regular attributes in sort parameters
927
+
928
+ **Example:**
929
+
930
+ ```ruby
931
+ # Sort by sort-only field
932
+ GET /users?sort=posts_count
933
+
934
+ # Mix sort-only field with database column
935
+ GET /users?sort=posts_count,name
936
+ ```
937
+
938
+ **Note**: Sort-only fields still require loading records into memory for sorting, just like virtual attributes. The difference is that sort-only fields won't appear in the response attributes.
939
+
940
+ ### JSON:API Object
941
+
942
+ All responses automatically include a `jsonapi` object indicating JSON:API version compliance:
943
+
944
+ ```json
945
+ {
946
+ "jsonapi": {
947
+ "version": "1.1"
948
+ },
949
+ "data": { ... }
950
+ }
951
+ ```
952
+
953
+ You can customize the jsonapi object via configuration:
954
+
955
+ ```ruby
956
+ # config/initializers/json_api.rb
957
+ JSONAPI.configure do |config|
958
+ config.jsonapi_version = "1.1"
959
+ config.jsonapi_meta = { ext: ["https://jsonapi.org/ext/atomic"] }
960
+ end
961
+ ```
962
+
963
+ ### Meta Information
964
+
965
+ The gem supports meta information at three levels: document-level, resource-level, and relationship-level.
966
+
967
+ #### Document-Level Meta
968
+
969
+ Document-level meta appears at the top level of the response and is typically used for pagination:
970
+
971
+ ```json
972
+ {
973
+ "jsonapi": { "version": "1.1" },
974
+ "data": [ ... ],
975
+ "meta": {
976
+ "total": 100
977
+ }
978
+ }
979
+ ```
980
+
981
+ Pagination automatically includes meta with the total count when pagination is applied.
982
+
983
+ **Example Document-Level Meta:**
984
+
985
+ ```json
986
+ {
987
+ "jsonapi": {
988
+ "version": "1.1"
989
+ },
990
+ "data": [
991
+ {
992
+ "type": "users",
993
+ "id": "1",
994
+ "attributes": {
995
+ "name": "John Doe",
996
+ "email": "john@example.com"
997
+ }
998
+ }
999
+ ],
1000
+ "meta": {
1001
+ "total": 100
1002
+ }
1003
+ }
1004
+ ```
1005
+
1006
+ #### Resource-Level Meta
1007
+
1008
+ Resource-level meta appears within each resource object. By default, the gem automatically includes `created_at` and `updated_at` timestamps in ISO8601 format if the model responds to these methods.
1009
+
1010
+ You can also define custom meta in two ways:
1011
+
1012
+ **Class-level static meta:**
1013
+
1014
+ ```ruby
1015
+ class UserResource < JSONAPI::Resource
1016
+ attributes :email, :name
1017
+
1018
+ meta({ version: "v1", custom: "value" })
1019
+ end
1020
+ ```
1021
+
1022
+ **Instance-level dynamic meta:**
1023
+
1024
+ ```ruby
1025
+ class UserResource < JSONAPI::Resource
1026
+ attributes :email, :name
1027
+
1028
+ def meta
1029
+ {
1030
+ name_length: resource.name.length,
1031
+ custom_field: "value"
1032
+ }
1033
+ end
1034
+ end
1035
+ ```
1036
+
1037
+ The instance method has access to the model instance via `resource`. Custom meta is merged with the default timestamp meta, with custom values taking precedence.
1038
+
1039
+ **Example Resource-Level Meta:**
1040
+
1041
+ ```json
1042
+ {
1043
+ "jsonapi": {
1044
+ "version": "1.1"
1045
+ },
1046
+ "data": {
1047
+ "type": "users",
1048
+ "id": "1",
1049
+ "attributes": {
1050
+ "name": "John Doe",
1051
+ "email": "john@example.com"
1052
+ },
1053
+ "meta": {
1054
+ "created_at": "2024-01-15T10:30:00Z",
1055
+ "updated_at": "2024-01-15T10:30:00Z",
1056
+ "name_length": 8,
1057
+ "custom_field": "value"
1058
+ },
1059
+ "links": {
1060
+ "self": "/users/1"
1061
+ }
1062
+ }
1063
+ }
1064
+ ```
1065
+
1066
+ #### Relationship-Level Meta
1067
+
1068
+ Relationship-level meta appears within relationship objects:
1069
+
1070
+ ```ruby
1071
+ class UserResource < JSONAPI::Resource
1072
+ attributes :email, :name
1073
+
1074
+ has_many :posts, meta: { count: 5, custom: "relationship_meta" }
1075
+ has_one :profile, meta: { type: "polymorphic" }
1076
+ end
1077
+ ```
1078
+
1079
+ The response will include:
1080
+
1081
+ ```json
1082
+ {
1083
+ "jsonapi": {
1084
+ "version": "1.1"
1085
+ },
1086
+ "data": {
1087
+ "type": "users",
1088
+ "id": "1",
1089
+ "attributes": {
1090
+ "name": "John Doe",
1091
+ "email": "john@example.com"
1092
+ },
1093
+ "relationships": {
1094
+ "posts": {
1095
+ "data": [
1096
+ { "type": "posts", "id": "1" },
1097
+ { "type": "posts", "id": "2" }
1098
+ ],
1099
+ "meta": {
1100
+ "count": 5,
1101
+ "custom": "relationship_meta"
1102
+ }
1103
+ },
1104
+ "profile": {
1105
+ "data": {
1106
+ "type": "admin_profiles",
1107
+ "id": "1"
1108
+ },
1109
+ "meta": {
1110
+ "type": "polymorphic"
1111
+ }
1112
+ }
1113
+ },
1114
+ "links": {
1115
+ "self": "/users/1"
1116
+ }
1117
+ }
1118
+ }
1119
+ ```
1120
+
1121
+ ### Configuration
1122
+
1123
+ Configure the gem in an initializer:
1124
+
1125
+ ```ruby
1126
+ # config/initializers/json_api.rb
1127
+ JSONAPI.configure do |config|
1128
+ config.default_page_size = 25
1129
+ config.max_page_size = 100
1130
+ config.jsonapi_version = "1.1"
1131
+ config.jsonapi_meta = nil
1132
+ config.base_controller_class = ActionController::API # Default: ActionController::API
1133
+ end
1134
+ ```
1135
+
1136
+ #### Base Controller Class
1137
+
1138
+ By default, `JSONAPI::BaseController` inherits from `ActionController::API`. You can configure it to inherit from a different base class (e.g., `ActionController::Base` or a custom base controller):
1139
+
1140
+ ```ruby
1141
+ JSONAPI.configure do |config|
1142
+ # Use ActionController::Base instead of ActionController::API
1143
+ config.base_controller_class = ActionController::Base
1144
+
1145
+ # Or use a custom base controller
1146
+ config.base_controller_class = ApplicationController
1147
+ end
1148
+ ```
1149
+
1150
+ This is useful when you need access to features available in `ActionController::Base` that aren't in `ActionController::API`, such as:
1151
+
1152
+ - View rendering helpers
1153
+ - Layout support
1154
+ - Cookie-based sessions
1155
+ - Flash messages
1156
+
1157
+ **Note:** The configuration must be set before the gem's controllers are loaded. Set it in a Rails initializer that loads before `json_api` is required.
1158
+
1159
+ ### Authorization
1160
+
1161
+ The gem provides configurable authorization hooks that allow you to integrate with any authorization library (e.g., Pundit, CanCanCan). Authorization is handled through two hooks:
1162
+
1163
+ - **`authorization_scope`**: Filters collection queries (index actions) to only return records the user is authorized to see
1164
+ - **`authorization_handler`**: Authorizes individual actions (show, create, update, destroy) on specific records
1165
+
1166
+ Both hooks are optional - if not configured, all records are accessible (authorization is bypassed).
1167
+
1168
+ #### Authorization Scope Hook
1169
+
1170
+ The `authorization_scope` hook receives the initial ActiveRecord scope and should return a filtered scope containing only records the user is authorized to access:
1171
+
1172
+ ```ruby
1173
+ JSONAPI.configure do |config|
1174
+ config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
1175
+ # Filter the scope based on authorization logic
1176
+ # For example, only return records belonging to the current user
1177
+ scope.where(user_id: controller.current_user.id)
1178
+ end
1179
+ end
1180
+ ```
1181
+
1182
+ **Parameters:**
1183
+
1184
+ - `controller`: The controller instance (provides access to `current_user`, `params`, etc.)
1185
+ - `scope`: The initial ActiveRecord scope (e.g., `User.all` or preloaded resources)
1186
+ - `action`: The action being performed (`:index`)
1187
+ - `model_class`: The ActiveRecord model class (e.g., `User`)
1188
+
1189
+ **Return value:** An ActiveRecord scope containing only authorized records
1190
+
1191
+ #### Authorization Handler Hook
1192
+
1193
+ The `authorization_handler` hook is called for individual resource actions (show, create, update, destroy) and should raise an exception if the user is not authorized:
1194
+
1195
+ ```ruby
1196
+ JSONAPI.configure do |config|
1197
+ config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
1198
+ # Raise an exception if the user is not authorized
1199
+ unless authorized?(controller.current_user, record, action)
1200
+ raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
1201
+ end
1202
+ end
1203
+ end
1204
+ ```
1205
+
1206
+ **Parameters:**
1207
+
1208
+ - `controller`: The controller instance
1209
+ - `record`: The ActiveRecord record being accessed (for create, this is a new unsaved record)
1210
+ - `action`: The action being performed (`:show`, `:create`, `:update`, or `:destroy`)
1211
+ - `context`: Optional context hash (for relationship actions, includes `relationship:` key)
1212
+
1213
+ **Exceptions:** Raise `JSONAPI::AuthorizationError` to deny access. Your application is responsible for rescuing this error and rendering an appropriate response (e.g., a `403 Forbidden` JSON:API error object).
1214
+
1215
+ #### Pundit Integration Example
1216
+
1217
+ Here's a complete example using Pundit:
1218
+
1219
+ ```ruby
1220
+ # config/initializers/json_api_authorization.rb
1221
+
1222
+ # Include Pundit in JSONAPI::BaseController
1223
+ JSONAPI::BaseController.class_eval do
1224
+ include Pundit::Authorization
1225
+
1226
+ rescue_from JSONAPI::AuthorizationError, with: :render_jsonapi_authorization_error
1227
+
1228
+ # Provide current_user method (override in your application)
1229
+ def current_user
1230
+ # Return the current authenticated user
1231
+ # This is application-specific
1232
+ @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
1233
+ end
1234
+
1235
+ private
1236
+
1237
+ def render_jsonapi_authorization_error(error)
1238
+ detail = error&.message.presence || "You are not authorized to perform this action"
1239
+ render json: {
1240
+ errors: [
1241
+ {
1242
+ status: "403",
1243
+ title: "Forbidden",
1244
+ detail:
1245
+ }
1246
+ ]
1247
+ }, status: :forbidden
1248
+ end
1249
+ end
1250
+
1251
+ # Configure JSON:API authorization hooks using Pundit
1252
+ JSONAPI.configure do |config|
1253
+ # Authorization scope hook - filters collections based on Pundit scopes
1254
+ config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
1255
+ policy_class = Pundit::PolicyFinder.new(model_class).policy
1256
+ policy_scope = policy_class.const_get(:Scope).new(controller.current_user, scope)
1257
+ policy_scope.resolve
1258
+ end
1259
+
1260
+ # Authorization handler hook - authorizes individual actions using Pundit policies
1261
+ # Note: We convert Pundit authorization failures to JSONAPI::AuthorizationError
1262
+ # so the gem can handle them consistently
1263
+ config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
1264
+ policy_class = Pundit::PolicyFinder.new(record).policy
1265
+ policy = policy_class.new(controller.current_user, record)
1266
+
1267
+ action_method = "#{action}?"
1268
+ unless policy.public_send(action_method)
1269
+ raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
1270
+ end
1271
+ end
1272
+ end
1273
+ ```
1274
+
1275
+ **Example Policy:**
1276
+
1277
+ ```ruby
1278
+ # app/policies/user_policy.rb
1279
+ class UserPolicy < ApplicationPolicy
1280
+ def index?
1281
+ true
1282
+ end
1283
+
1284
+ def show?
1285
+ record.public? || user == record
1286
+ end
1287
+
1288
+ def create?
1289
+ user.admin?
1290
+ end
1291
+
1292
+ def update?
1293
+ user.admin? || user == record
1294
+ end
1295
+
1296
+ def destroy?
1297
+ user.admin?
1298
+ end
1299
+
1300
+ class Scope < ApplicationPolicy::Scope
1301
+ def resolve
1302
+ if user.admin?
1303
+ scope.all
1304
+ else
1305
+ scope.where(public: true).or(scope.where(id: user.id))
1306
+ end
1307
+ end
1308
+ end
1309
+ end
1310
+ ```
1311
+
1312
+ #### Relationship Authorization
1313
+
1314
+ Relationship endpoints (show, update, destroy) authorize the parent resource using the `:update` action with a context hash containing the relationship name:
1315
+
1316
+ ```ruby
1317
+ # The authorization_handler receives:
1318
+ {
1319
+ controller: controller_instance,
1320
+ record: parent_resource,
1321
+ action: :update,
1322
+ context: { relationship: :posts }
1323
+ }
1324
+ ```
1325
+
1326
+ This allows you to implement relationship-specific authorization logic in your policies if needed.
1327
+
1328
+ #### Custom Authorization Error Handling
1329
+
1330
+ The gem automatically handles `JSONAPI::AuthorizationError` and `Pundit::NotAuthorizedError` exceptions, rendering a JSON:API compliant `403 Forbidden` response:
1331
+
1332
+ ```json
1333
+ {
1334
+ "jsonapi": {
1335
+ "version": "1.1"
1336
+ },
1337
+ "errors": [
1338
+ {
1339
+ "status": "403",
1340
+ "title": "Forbidden",
1341
+ "detail": "You are not authorized to perform this action"
1342
+ }
1343
+ ]
1344
+ }
1345
+ ```
1346
+
1347
+ You can customize the error message by raising an exception with a specific message:
1348
+
1349
+ ```ruby
1350
+ raise JSONAPI::AuthorizationError, "Not authorized to view this resource"
1351
+ ```
1352
+
1353
+ #### Overriding Authorization
1354
+
1355
+ Authorization hooks can be easily overridden or disabled:
1356
+
1357
+ ```ruby
1358
+ # Disable authorization (allow all access)
1359
+ JSONAPI.configure do |config|
1360
+ config.authorization_scope = nil
1361
+ config.authorization_handler = nil
1362
+ end
1363
+
1364
+ # Use custom authorization logic
1365
+ JSONAPI.configure do |config|
1366
+ config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
1367
+ # Your custom authorization logic here
1368
+ unless MyAuthService.authorized?(controller.current_user, record, action)
1369
+ raise JSONAPI::AuthorizationError, "Access denied"
1370
+ end
1371
+ end
1372
+ end
1373
+ ```
1374
+
1375
+ ### Instrumentation (Rails 8.1+)
1376
+
1377
+ When running on Rails 8.1 or later, the gem automatically emits structured events via `Rails.event` for all CRUD and relationship operations. This enables seamless integration with monitoring and APM platforms like Datadog, AppSignal, New Relic, or Honeycomb.
1378
+
1379
+ #### Resource Events
1380
+
1381
+ The gem emits events for resource lifecycle operations:
1382
+
1383
+ - **`jsonapi.{resource_type}.created`** - Emitted after successful resource creation
1384
+ - **`jsonapi.{resource_type}.updated`** - Emitted after successful resource updates (includes changed fields)
1385
+ - **`jsonapi.{resource_type}.deleted`** - Emitted after successful resource deletion
1386
+
1387
+ **Event Payload Structure:**
1388
+
1389
+ ```ruby
1390
+ {
1391
+ resource_type: "users",
1392
+ resource_id: 123,
1393
+ changes: { "name" => ["old", "new"], "phone" => ["old", "new"] } # Only for updates
1394
+ }
1395
+ ```
1396
+
1397
+ **Example Usage:**
1398
+
1399
+ ```ruby
1400
+ # Subscribe to events
1401
+ class JsonApiEventSubscriber
1402
+ def emit(event)
1403
+ encoded = ActiveSupport::EventReporter.encoder(:json).encode(event)
1404
+ # Forward to your monitoring service
1405
+ MonitoringService.send_event(encoded)
1406
+ end
1407
+ end
1408
+
1409
+ Rails.event.subscribe(JsonApiEventSubscriber.new) if Rails.respond_to?(:event)
1410
+ ```
1411
+
1412
+ #### Relationship Events
1413
+
1414
+ The gem also emits events for relationship operations:
1415
+
1416
+ - **`jsonapi.{resource_type}.relationship.updated`** - Emitted after successful relationship updates
1417
+ - **`jsonapi.{resource_type}.relationship.removed`** - Emitted after successful relationship removals
1418
+
1419
+ **Event Payload Structure:**
1420
+
1421
+ ```ruby
1422
+ {
1423
+ resource_type: "users",
1424
+ resource_id: 123,
1425
+ relationship_name: "posts",
1426
+ related_type: "posts", # Optional
1427
+ related_ids: [456, 789] # Optional
1428
+ }
1429
+ ```
1430
+
1431
+ #### Testing Instrumentation
1432
+
1433
+ Use Rails 8.1's `assert_events_reported` test helper to verify events are emitted:
1434
+
1435
+ ```ruby
1436
+ assert_events_reported([
1437
+ { name: "jsonapi.users.created", payload: { resource_type: "users", resource_id: 123 } },
1438
+ { name: "jsonapi.users.relationship.updated", payload: { relationship_name: "posts" } }
1439
+ ]) do
1440
+ post "/users", params: payload.to_json, headers: jsonapi_headers
1441
+ end
1442
+ ```
1443
+
1444
+ #### Compatibility
1445
+
1446
+ The instrumentation feature is automatically enabled when `Rails.event` is available (Rails 8.1+). On older Rails versions, the gem continues to work normally without emitting events. No configuration is required.
1447
+
1448
+ ### Creatable and Updatable Fields
1449
+
1450
+ By default, all attributes defined with `attributes` are available for both create and update operations. You can restrict which fields can be created or updated separately:
1451
+
1452
+ ```ruby
1453
+ class UserResource < JSONAPI::Resource
1454
+ attributes :name, :email, :phone, :role
1455
+
1456
+ # Only these fields can be set during creation
1457
+ creatable_fields :name, :email, :phone
1458
+
1459
+ # Only these fields can be updated
1460
+ updatable_fields :name, :phone
1461
+ end
1462
+ ```
1463
+
1464
+ If `creatable_fields` or `updatable_fields` are not explicitly defined, the gem defaults to using all `permitted_attributes`. This allows you to:
1465
+
1466
+ - Prevent certain fields from being set during creation (e.g., `role` might be set by the system)
1467
+ - Prevent certain fields from being updated (e.g., `email` might be immutable after creation)
1468
+ - Have different field sets for create vs update operations
1469
+
1470
+ ### Content Negotiation
1471
+
1472
+ The gem enforces JSON:API content negotiation:
1473
+
1474
+ - **Content-Type**: POST, PATCH, and PUT requests must include `Content-Type: application/vnd.api+json` header (returns `415 Unsupported Media Type` if missing)
1475
+ - **Accept**: If an `Accept` header is provided, it must include `application/vnd.api+json` or be `*/*` (returns `406 Not Acceptable` if explicitly set to non-JSON:API types)
1476
+
1477
+ Blank or `*/*` Accept headers are allowed to support browser defaults.
1478
+
1479
+ ### Custom Controllers
1480
+
1481
+ You can inherit from `JSONAPI::BaseController` to create custom controllers:
1482
+
1483
+ ```ruby
1484
+ class UsersController < JsonApi::BaseController
1485
+ def index
1486
+ # Custom implementation
1487
+ end
1488
+ end
1489
+ ```
1490
+
1491
+ The base controller provides helper methods:
1492
+
1493
+ - `jsonapi_params` - Parsed JSON:API parameters
1494
+ - `jsonapi_attributes` - Extracted attributes
1495
+ - `jsonapi_relationships` - Extracted relationships
1496
+ - `parse_include_param` - Parsed include parameter
1497
+ - `parse_fields_param` - Parsed fields parameter
1498
+ - `parse_filter_param` - Parsed filter parameter
1499
+ - `parse_sort_param` - Parsed sort parameter
1500
+ - `parse_page_param` - Parsed page parameter
1501
+
1502
+ ### Serialization
1503
+
1504
+ Use `JSONAPI::Serializer` to serialize resources:
1505
+
1506
+ ```ruby
1507
+ serializer = JSONAPI::Serializer.new(user)
1508
+ serializer.to_hash(include: ["posts"], fields: { users: ["name", "email"] })
1509
+ ```
1510
+
1511
+ ### Deserialization
1512
+
1513
+ Use `JSONAPI::Deserializer` to deserialize JSON:API payloads:
1514
+
1515
+ ```ruby
1516
+ deserializer = JSONAPI::Deserializer.new(params, resource_class: User)
1517
+ deserializer.attributes # => { "name" => "John", "email" => "john@example.com" }
1518
+ deserializer.relationship_ids(:posts) # => ["1", "2"]
1519
+ deserializer.to_params # => { "name" => "John", "post_ids" => ["1", "2"], "profile_id" => "1", "profile_type" => "CustomerProfile" }
1520
+ ```
1521
+
1522
+ The deserializer automatically converts JSON:API relationship format to Rails-friendly params:
1523
+
1524
+ - To-many relationships: `posts` → `post_ids` (array)
1525
+ - To-one relationships: `account` → `account_id`
1526
+ - Polymorphic relationships: `profile` → `profile_id` and `profile_type`
1527
+
1528
+ #### Creating Resources with Relationships
1529
+
1530
+ You can include relationships when creating resources:
1531
+
1532
+ ```ruby
1533
+ # POST /users
1534
+ {
1535
+ "data": {
1536
+ "type": "users",
1537
+ "attributes": {
1538
+ "name": "John Doe",
1539
+ "email": "john@example.com"
1540
+ },
1541
+ "relationships": {
1542
+ "profile": {
1543
+ "data": {
1544
+ "type": "customer_profiles",
1545
+ "id": "1"
1546
+ }
1547
+ },
1548
+ "posts": {
1549
+ "data": [
1550
+ { "type": "posts", "id": "1" },
1551
+ { "type": "posts", "id": "2" }
1552
+ ]
1553
+ }
1554
+ }
1555
+ }
1556
+ }
1557
+ ```
1558
+
1559
+ #### Updating Resources with Relationships
1560
+
1561
+ You can update relationships when updating resources:
1562
+
1563
+ ```ruby
1564
+ # PATCH /users/:id
1565
+ {
1566
+ "data": {
1567
+ "type": "users",
1568
+ "id": "1",
1569
+ "attributes": {
1570
+ "name": "John Doe Updated"
1571
+ },
1572
+ "relationships": {
1573
+ "profile": {
1574
+ "data": {
1575
+ "type": "admin_profiles",
1576
+ "id": "2"
1577
+ }
1578
+ },
1579
+ "posts": {
1580
+ "data": [
1581
+ { "type": "posts", "id": "3" }
1582
+ ]
1583
+ }
1584
+ }
1585
+ }
1586
+ }
1587
+ ```
1588
+
1589
+ #### Clearing Relationships
1590
+
1591
+ To clear a relationship, send `null` for to-one relationships or an empty array for to-many relationships:
1592
+
1593
+ ```ruby
1594
+ # Clear to-one relationship
1595
+ {
1596
+ "data": {
1597
+ "type": "users",
1598
+ "id": "1",
1599
+ "relationships": {
1600
+ "profile": {
1601
+ "data": null
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ # Clear to-many relationship
1608
+ {
1609
+ "data": {
1610
+ "type": "users",
1611
+ "id": "1",
1612
+ "relationships": {
1613
+ "posts": {
1614
+ "data": []
1615
+ }
1616
+ }
1617
+ }
1618
+ }
1619
+ ```
1620
+
1621
+ #### Relationship Validation
1622
+
1623
+ The gem validates relationship data:
1624
+
1625
+ - Missing `type` or `id` in relationship data returns `400 Bad Request`
1626
+ - Invalid relationship type (for non-polymorphic associations) returns `400 Bad Request`
1627
+ - Invalid polymorphic type (class doesn't exist) returns `400 Bad Request`
1628
+ - Attempting to unset linkage that cannot be nullified (e.g., foreign key has NOT NULL constraint) returns `400 Bad Request`
1629
+
1630
+ ### Relationship Endpoints
1631
+
1632
+ The gem provides dedicated endpoints for managing relationships independently of the main resource:
1633
+
1634
+ #### Show Relationship
1635
+
1636
+ ```ruby
1637
+ GET /users/:id/relationships/posts
1638
+ ```
1639
+
1640
+ Returns the relationship data (resource identifiers) with links and meta:
1641
+
1642
+ **To-Many Relationship Response:**
1643
+
1644
+ ```json
1645
+ {
1646
+ "jsonapi": {
1647
+ "version": "1.1"
1648
+ },
1649
+ "data": [
1650
+ { "type": "posts", "id": "1" },
1651
+ { "type": "posts", "id": "2" }
1652
+ ],
1653
+ "links": {
1654
+ "self": "/users/1/relationships/posts",
1655
+ "related": "/users/1/posts"
1656
+ },
1657
+ "meta": {
1658
+ "count": 2
1659
+ }
1660
+ }
1661
+ ```
1662
+
1663
+ **To-One Relationship Response:**
1664
+
1665
+ ```json
1666
+ {
1667
+ "jsonapi": {
1668
+ "version": "1.1"
1669
+ },
1670
+ "data": {
1671
+ "type": "admin_profiles",
1672
+ "id": "1"
1673
+ },
1674
+ "links": {
1675
+ "self": "/users/1/relationships/profile",
1676
+ "related": "/users/1/profile"
1677
+ }
1678
+ }
1679
+ ```
1680
+
1681
+ **Empty To-One Relationship Response:**
1682
+
1683
+ ```json
1684
+ {
1685
+ "jsonapi": {
1686
+ "version": "1.1"
1687
+ },
1688
+ "data": null,
1689
+ "links": {
1690
+ "self": "/users/1/relationships/profile",
1691
+ "related": "/users/1/profile"
1692
+ }
1693
+ }
1694
+ ```
1695
+
1696
+ For collection relationships, you can sort using the `sort` parameter:
1697
+
1698
+ ```ruby
1699
+ GET /users/:id/relationships/posts?sort=title,-created_at
1700
+ ```
1701
+
1702
+ #### Update Relationship
1703
+
1704
+ ```ruby
1705
+ PATCH /users/:id/relationships/posts
1706
+ Content-Type: application/vnd.api+json
1707
+
1708
+ {
1709
+ "data": [
1710
+ { "type": "posts", "id": "3" },
1711
+ { "type": "posts", "id": "4" }
1712
+ ]
1713
+ }
1714
+ ```
1715
+
1716
+ Replaces the entire relationship linkage. For to-one relationships, send a single resource identifier object (or `null` to clear). For to-many relationships, send an array of resource identifiers (or empty array `[]` to clear).
1717
+
1718
+ #### Delete Relationship Linkage
1719
+
1720
+ ```ruby
1721
+ DELETE /users/:id/relationships/posts
1722
+ Content-Type: application/vnd.api+json
1723
+
1724
+ {
1725
+ "data": [
1726
+ { "type": "posts", "id": "1" }
1727
+ ]
1728
+ }
1729
+ ```
1730
+
1731
+ Removes specific resources from a to-many relationship by setting their foreign key to `NULL`. For to-one relationships, send a single resource identifier object to remove the linkage.
1732
+
1733
+ **Important**: Per JSON:API specification, relationship endpoints (`DELETE /users/:id/relationships/posts`) only modify linkage and never destroy resources. The gem attempts to unset the linkage by setting the foreign key to `NULL`. If this operation fails (due to NOT NULL constraints, validations, or other database constraints), a `400 Bad Request` error is returned. To allow relationship removal, ensure the foreign key column allows `NULL` values and any validations permit nullification.
1734
+
1735
+ Error responses follow JSON:API error format:
1736
+
1737
+ **Example Error Response:**
1738
+
1739
+ ```json
1740
+ {
1741
+ "jsonapi": {
1742
+ "version": "1.1"
1743
+ },
1744
+ "errors": [
1745
+ {
1746
+ "status": "400",
1747
+ "title": "Invalid Relationship",
1748
+ "detail": "Invalid relationship type for profile: 'invalid_type' does not correspond to a valid model class",
1749
+ "source": {
1750
+ "pointer": "/data/relationships/profile/data/type"
1751
+ }
1752
+ }
1753
+ ]
1754
+ }
1755
+ ```
1756
+
1757
+ **Example 404 Not Found Error:**
1758
+
1759
+ ```json
1760
+ {
1761
+ "jsonapi": {
1762
+ "version": "1.1"
1763
+ },
1764
+ "errors": [
1765
+ {
1766
+ "status": "404",
1767
+ "title": "Record Not Found",
1768
+ "detail": "Couldn't find User with 'id'=999"
1769
+ }
1770
+ ]
1771
+ }
1772
+ ```
1773
+
1774
+ **Example Validation Error:**
1775
+
1776
+ ```json
1777
+ {
1778
+ "jsonapi": {
1779
+ "version": "1.1"
1780
+ },
1781
+ "errors": [
1782
+ {
1783
+ "status": "422",
1784
+ "title": "Validation Error",
1785
+ "detail": "Email can't be blank",
1786
+ "source": {
1787
+ "pointer": "/data/attributes/email"
1788
+ }
1789
+ },
1790
+ {
1791
+ "status": "422",
1792
+ "title": "Validation Error",
1793
+ "detail": "Name is too short (minimum is 2 characters)",
1794
+ "source": {
1795
+ "pointer": "/data/attributes/name"
1796
+ }
1797
+ }
1798
+ ]
1799
+ }
1800
+ ```
1801
+
1802
+ ## Development
1803
+
1804
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
1805
+
1806
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
1807
+
1808
+ ### Code Organization
1809
+
1810
+ The gem is organized into several directories within `lib/json_api/`:
1811
+
1812
+ - **`controllers/`** - Controller classes (`BaseController`, `ResourcesController`, `RelationshipsController`)
1813
+ - **`resources/`** - Resource DSL and resource loading (`Resource`, `ResourceLoader`, `ActiveStorageBlobResource`)
1814
+ - **`serialization/`** - Serialization and deserialization (`Serializer`, `Deserializer`)
1815
+ - **`support/`** - Shared concerns and utilities:
1816
+ - `ActiveStorageSupport` - Concern for handling ActiveStorage attachments
1817
+ - `CollectionQuery` - Service class for building filtered, sorted, and paginated queries
1818
+ - `RelationshipHelpers` - Utilities for relationship handling
1819
+ - `ParamHelpers` - Parameter parsing utilities
1820
+ - `Responders` - Content negotiation and error rendering
1821
+ - `Instrumentation` - Rails event emission
1822
+
1823
+ This organization makes it easier to understand the gem's structure and locate specific functionality.
1824
+
1825
+ ## Testing
1826
+
1827
+ Run the test suite:
1828
+
1829
+ ```bash
1830
+ bundle exec rspec
1831
+ ```
1832
+
1833
+ ### Test Helper for Request Specs
1834
+
1835
+ The gem provides a test helper module that makes it easy to write request specs with proper JSON:API content negotiation. The helper ensures `as: :jsonapi` works consistently across all HTTP methods.
1836
+
1837
+ **Setup (RSpec):**
1838
+
1839
+ ```ruby
1840
+ # spec/rails_helper.rb or spec/support/json_api.rb
1841
+ require "json_api/testing"
1842
+
1843
+ RSpec.configure do |config|
1844
+ config.include JSONAPI::Testing::TestHelper, type: :request
1845
+ end
1846
+ ```
1847
+
1848
+ **Usage:**
1849
+
1850
+ ```ruby
1851
+ RSpec.describe "Users API", type: :request do
1852
+ let(:headers) { { "Authorization" => "Bearer #{token}" } }
1853
+
1854
+ describe "GET /users" do
1855
+ it "returns users" do
1856
+ # GET requests: Accept header is set, params go to query string
1857
+ get users_path, params: { filter: { active: true } }, headers:, as: :jsonapi
1858
+ expect(response).to have_http_status(:ok)
1859
+ end
1860
+ end
1861
+
1862
+ describe "POST /users" do
1863
+ it "creates a user" do
1864
+ # POST/PATCH/PUT/DELETE: Content-Type and Accept headers are set,
1865
+ # params are JSON-encoded in the request body
1866
+ payload = {
1867
+ data: {
1868
+ type: "users",
1869
+ attributes: { name: "John", email: "john@example.com" }
1870
+ }
1871
+ }
1872
+ post users_path, params: payload, headers:, as: :jsonapi
1873
+ expect(response).to have_http_status(:created)
1874
+ end
1875
+ end
1876
+ end
1877
+ ```
1878
+
1879
+ **Behavior by HTTP method:**
1880
+
1881
+ | Method | Accept Header | Content-Type Header | Params Encoding |
1882
+ | ------ | ------------- | ------------------- | --------------- |
1883
+ | GET | ✅ Set | ❌ Not set | Query string |
1884
+ | POST | ✅ Set | ✅ Set | JSON body |
1885
+ | PATCH | ✅ Set | ✅ Set | JSON body |
1886
+ | PUT | ✅ Set | ✅ Set | JSON body |
1887
+ | DELETE | ✅ Set | ✅ Set | JSON body |
1888
+
1889
+ ### Integration Tests
1890
+
1891
+ The gem includes a dummy Rails app in `spec/dummy` for integration testing. The integration tests verify that the gem works correctly with a real Rails application.
1892
+
1893
+ To run only integration tests:
1894
+
1895
+ ```bash
1896
+ bundle exec rspec spec/integration
1897
+ ```
1898
+
1899
+ The integration tests cover:
1900
+
1901
+ - Full CRUD operations (create, read, update, delete)
1902
+ - JSON:API response format validation using JSON Schema
1903
+ - Sparse fieldsets (fields parameter) for single and multiple fields
1904
+ - Sorting (ascending, descending, multiple fields, invalid fields)
1905
+ - Including related resources (include parameter) with validation
1906
+ - Creating and updating resources with relationships (to-one, to-many, polymorphic)
1907
+ - Relationship validation and error handling
1908
+ - Error handling and validation responses
1909
+ - HTTP status codes
1910
+
1911
+ The dummy app includes a simple `User` model with basic validations and relationships (posts, profile, activities, notifications) to test the full request/response cycle including relationship writes.
1912
+
1913
+ ## ActiveStorage Support
1914
+
1915
+ The gem includes built-in support for serializing and deserializing ActiveStorage attachments through JSON:API.
1916
+
1917
+ ### Serializing Attachments
1918
+
1919
+ When a model has ActiveStorage attachments (`has_one_attached` or `has_many_attached`), you can expose them as relationships in your resource:
1920
+
1921
+ ```ruby
1922
+ class UserResource < JSONAPI::Resource
1923
+ attributes :name, :email
1924
+ has_one :avatar # For has_one_attached :avatar
1925
+ has_many :documents # For has_many_attached :documents
1926
+ end
1927
+ ```
1928
+
1929
+ The serializer will automatically:
1930
+
1931
+ - Include attachment relationships pointing to `active_storage_blobs` resources
1932
+ - Add download links for each blob
1933
+ - Include blob details in the `included` section when requested via `include` parameter
1934
+ - Filter out ActiveStorage attachments from include paths (attachments are loaded on-demand by the serializer, not via ActiveRecord includes)
1935
+
1936
+ Example response:
1937
+
1938
+ ```json
1939
+ {
1940
+ "data": {
1941
+ "type": "users",
1942
+ "id": "1",
1943
+ "attributes": {
1944
+ "name": "John Doe",
1945
+ "email": "john@example.com"
1946
+ },
1947
+ "relationships": {
1948
+ "avatar": {
1949
+ "data": {
1950
+ "type": "active_storage_blobs",
1951
+ "id": "1"
1952
+ }
1953
+ },
1954
+ "documents": {
1955
+ "data": [
1956
+ { "type": "active_storage_blobs", "id": "2" },
1957
+ { "type": "active_storage_blobs", "id": "3" }
1958
+ ]
1959
+ }
1960
+ },
1961
+ "links": {
1962
+ "self": "/users/1"
1963
+ }
1964
+ },
1965
+ "included": [
1966
+ {
1967
+ "type": "active_storage_blobs",
1968
+ "id": "1",
1969
+ "attributes": {
1970
+ "filename": "avatar.jpg",
1971
+ "content_type": "image/jpeg",
1972
+ "byte_size": 102400,
1973
+ "checksum": "abc123..."
1974
+ },
1975
+ "links": {
1976
+ "self": "/active_storage_blobs/1",
1977
+ "download": "/rails/active_storage/blobs/.../avatar.jpg"
1978
+ }
1979
+ }
1980
+ ]
1981
+ }
1982
+ ```
1983
+
1984
+ ### Deserializing Attachments
1985
+
1986
+ When creating or updating resources, clients can attach files by providing signed blob IDs obtained from ActiveStorage direct uploads:
1987
+
1988
+ ```json
1989
+ {
1990
+ "data": {
1991
+ "type": "users",
1992
+ "attributes": {
1993
+ "name": "Jane Doe",
1994
+ "email": "jane@example.com"
1995
+ },
1996
+ "relationships": {
1997
+ "avatar": {
1998
+ "data": {
1999
+ "type": "active_storage_blobs",
2000
+ "id": "eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--..."
2001
+ }
2002
+ },
2003
+ "documents": {
2004
+ "data": [
2005
+ { "type": "active_storage_blobs", "id": "signed-id-1" },
2006
+ { "type": "active_storage_blobs", "id": "signed-id-2" }
2007
+ ]
2008
+ }
2009
+ }
2010
+ }
2011
+ }
2012
+ ```
2013
+
2014
+ The deserializer will:
2015
+
2016
+ - Validate the signed blob IDs
2017
+ - Convert them to blob objects
2018
+ - Set them as parameters that ActiveStorage can attach (e.g., `avatar: blob` or `documents: [blob1, blob2]`)
2019
+
2020
+ #### Detaching Attachments
2021
+
2022
+ To detach (remove) an attachment, send `null` for to-one relationships or an empty array `[]` for to-many relationships:
2023
+
2024
+ ```json
2025
+ {
2026
+ "data": {
2027
+ "type": "users",
2028
+ "id": "1",
2029
+ "relationships": {
2030
+ "avatar": {
2031
+ "data": null
2032
+ },
2033
+ "documents": {
2034
+ "data": []
2035
+ }
2036
+ }
2037
+ }
2038
+ }
2039
+ ```
2040
+
2041
+ By default, this will purge the attachments from the model. For to-one attachments, sending `null` detaches the current attachment. For to-many attachments, sending an empty array `[]` removes all attachments.
2042
+
2043
+ #### Controlling Purge Behavior with `purge_on_nil`
2044
+
2045
+ By default, when you set an attachment relationship to `null` (for `has_one_attached`) or an empty array `[]` (for `has_many_attached`), the gem will purge the existing attachments. You can opt out of this behavior by setting `purge_on_nil: false` in the relationship declaration:
2046
+
2047
+ ```ruby
2048
+ class UserResource < JSONAPI::Resource
2049
+ attributes :name, :email
2050
+
2051
+ # Opt out of purging when set to nil
2052
+ has_one :avatar, purge_on_nil: false
2053
+ has_many :documents, purge_on_nil: false
2054
+ end
2055
+ ```
2056
+
2057
+ When `purge_on_nil: false` is set:
2058
+
2059
+ - Setting a `has_one_attached` relationship to `null` will keep the existing attachment
2060
+ - Setting a `has_many_attached` relationship to an empty array `[]` will keep all existing attachments
2061
+ - Attaching new blobs will still replace existing attachments (this behavior is not affected by `purge_on_nil`)
2062
+
2063
+ **Use cases for `purge_on_nil: false`:**
2064
+
2065
+ - When you want to prevent accidental deletion of attachments
2066
+ - When attachments should only be removed through explicit delete operations
2067
+ - When you need more control over attachment lifecycle management
2068
+
2069
+ **Note:** The default behavior (`purge_on_nil: true`) ensures that setting a relationship to `null` or `[]` actually removes the attachments, which is typically the expected behavior for most use cases.
2070
+
2071
+ #### Append-Only Mode with `append_only`
2072
+
2073
+ For `has_many_attached` relationships, you can enable append-only mode by setting `append_only: true`. In this mode, new blobs are appended to existing attachments rather than replacing them:
2074
+
2075
+ ```ruby
2076
+ class UserResource < JSONAPI::Resource
2077
+ attributes :name, :email
2078
+
2079
+ # Enable append-only mode for documents
2080
+ has_many :documents, append_only: true
2081
+ end
2082
+ ```
2083
+
2084
+ **Behavior when `append_only: true` is set:**
2085
+
2086
+ - **Appending new blobs:** When updating a resource with new blobs in the relationship, they are added to the existing attachments instead of replacing them
2087
+
2088
+ ```json
2089
+ // Existing attachments: [blob1, blob2]
2090
+ // Payload includes: [blob3, blob4]
2091
+ // Result: [blob1, blob2, blob3, blob4]
2092
+ ```
2093
+
2094
+ - **Empty array is a no-op:** Sending an empty array `[]` preserves all existing attachments
2095
+
2096
+ ```json
2097
+ // Existing attachments: [blob1, blob2]
2098
+ // Payload includes: []
2099
+ // Result: [blob1, blob2] (unchanged)
2100
+ ```
2101
+
2102
+ - **Implicit `purge_on_nil: false`:** When `append_only: true` is set, `purge_on_nil` is automatically set to `false` and cannot be overridden
2103
+
2104
+ - **Deletions:** Deletions via PATCH/PUT with empty arrays are not possible. Use the DELETE `/relationships/:name` endpoint if you need to remove specific attachments
2105
+
2106
+ **Important:** `append_only: true` and `purge_on_nil: true` are mutually exclusive. If both are explicitly set, an `ArgumentError` will be raised at resource class definition time:
2107
+
2108
+ ```ruby
2109
+ # This will raise ArgumentError
2110
+ has_many :documents, append_only: true, purge_on_nil: true
2111
+ ```
2112
+
2113
+ **Use cases for `append_only: true`:**
2114
+
2115
+ - When you want to accumulate attachments over time without replacing existing ones
2116
+ - When attachments represent a log or history that should only grow
2117
+ - When you need to prevent accidental replacement of existing attachments
2118
+ - When attachments should only be removed through explicit DELETE operations
2119
+
2120
+ **Note:** `append_only` only applies to `has_many_attached` relationships. For `has_one_attached`, attachments are always replaced regardless of this setting.
2121
+
2122
+ Example usage in a controller:
2123
+
2124
+ ```ruby
2125
+ def create
2126
+ deserializer = JSONAPI::Deserializer.new(params, resource_class: User, action: :create)
2127
+ attrs = deserializer.to_params
2128
+
2129
+ # attrs will include:
2130
+ # { "name" => "Jane Doe", "email" => "jane@example.com", "avatar" => <ActiveStorage::Blob>, "documents" => [<ActiveStorage::Blob>, ...] }
2131
+
2132
+ user = User.create!(attrs)
2133
+ # Attachments are automatically attached via ActiveStorage
2134
+ end
2135
+ ```
2136
+
2137
+ ### Error Handling
2138
+
2139
+ Invalid signed IDs will raise `ActiveSupport::MessageVerifier::InvalidSignature`, which should be handled appropriately in your controllers.
2140
+
2141
+ ### Built-in ActiveStorage Resource
2142
+
2143
+ The gem provides a built-in `JSONAPI::ActiveStorageBlobResource` that automatically serializes ActiveStorage blobs with:
2144
+
2145
+ - `filename` - The original filename
2146
+ - `content_type` - MIME type
2147
+ - `byte_size` - File size in bytes
2148
+ - `checksum` - File checksum
2149
+ - Download link in the `links` section
2150
+
2151
+ This resource is automatically used when serializing ActiveStorage attachments.
2152
+
2153
+ ## Contributing
2154
+
2155
+ Bug reports and pull requests are welcome on GitHub at https://github.com/klaay/json_api.
2156
+
2157
+ ## License
2158
+
2159
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).