jpie 1.0.0 → 1.0.1

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +5 -0
  4. data/.rubocop.yml +82 -38
  5. data/Gemfile +12 -10
  6. data/Gemfile.lock +10 -1
  7. data/README.md +675 -1235
  8. data/jpie.gemspec +15 -15
  9. data/kiln/app/resources/user_message_resource.rb +2 -0
  10. data/lib/jpie.rb +0 -1
  11. data/lib/json_api/active_storage/deserialization.rb +32 -22
  12. data/lib/json_api/active_storage/detection.rb +36 -41
  13. data/lib/json_api/active_storage/serialization.rb +13 -11
  14. data/lib/json_api/configuration.rb +4 -5
  15. data/lib/json_api/controllers/base_controller.rb +3 -3
  16. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  17. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  18. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  19. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
  22. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  23. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  24. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  25. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  26. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  27. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  28. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  29. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  30. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  31. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  32. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  33. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  36. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  37. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  38. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  39. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  40. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  44. data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
  45. data/lib/json_api/controllers/relationships_controller.rb +26 -422
  46. data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
  47. data/lib/json_api/railtie.rb +46 -9
  48. data/lib/json_api/resources/active_storage_blob_resource.rb +9 -1
  49. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  50. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  51. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  52. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  53. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  54. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  55. data/lib/json_api/resources/resource.rb +13 -219
  56. data/lib/json_api/routing.rb +56 -47
  57. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  58. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  59. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  60. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  61. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  62. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  63. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  64. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  65. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  66. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  67. data/lib/json_api/serialization/deserializer.rb +10 -346
  68. data/lib/json_api/serialization/serializer.rb +17 -260
  69. data/lib/json_api/support/active_storage_support.rb +10 -13
  70. data/lib/json_api/support/collection_query.rb +14 -370
  71. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  72. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  73. data/lib/json_api/support/concerns/pagination.rb +30 -0
  74. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  75. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  76. data/lib/json_api/support/concerns/sorting.rb +88 -0
  77. data/lib/json_api/support/instrumentation.rb +13 -12
  78. data/lib/json_api/support/param_helpers.rb +9 -6
  79. data/lib/json_api/support/relationship_helpers.rb +4 -2
  80. data/lib/json_api/support/resource_identifier.rb +29 -29
  81. data/lib/json_api/support/responders.rb +5 -5
  82. data/lib/json_api/version.rb +1 -1
  83. metadata +51 -1
data/README.md CHANGED
@@ -17,34 +17,18 @@ A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API
17
17
 
18
18
  ## Installation
19
19
 
20
- Add this line to your application's Gemfile:
21
-
22
- ```ruby
23
- gem 'json_api'
20
+ ```bash
21
+ bundle add jpie
24
22
  ```
25
23
 
26
- And then execute:
27
-
28
- $ bundle install
29
-
30
- Or install it yourself as:
31
-
32
- $ gem install json_api
33
-
34
24
  ## Requirements
35
25
 
36
26
  - Ruby >= 3.4.0
37
27
  - Rails >= 8.0.0
38
28
 
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
29
+ ## Routing
46
30
 
47
- Use the `jsonapi_resources` DSL in your routes file:
31
+ Use the `jsonapi_resources` DSL in your routes file to create standard RESTful routes (index, show, create, update, destroy) that default to the `json_api/resources` controller and `jsonapi` format:
48
32
 
49
33
  ```ruby
50
34
  # config/routes.rb
@@ -54,11 +38,13 @@ Rails.application.routes.draw do
54
38
  end
55
39
  ```
56
40
 
57
- This creates standard RESTful routes (index, show, create, update, destroy) that default to the `json_api/resources` controller and `jsonapi` format.
41
+ To use a custom controller instead of the default:
58
42
 
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.
43
+ ```ruby
44
+ jsonapi_resources :users, controller: "api/users"
45
+ ```
60
46
 
61
- ### Resource Definitions
47
+ ## Resource Definitions
62
48
 
63
49
  Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:
64
50
 
@@ -72,27 +58,8 @@ class UserResource < JSONAPI::Resource
72
58
  end
73
59
  ```
74
60
 
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
61
  ### Virtual Attributes
91
62
 
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
63
  To create a virtual attribute, declare it in `attributes` and implement a getter method:
97
64
 
98
65
  ```ruby
@@ -122,24 +89,7 @@ The getter method receives the underlying model instance via `resource`. Virtual
122
89
  }
123
90
  ```
124
91
 
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:
92
+ You can also define setters for virtual attributes that transform incoming values into real model attributes:
143
93
 
144
94
  ```ruby
145
95
  class UserResource < JSONAPI::Resource
@@ -173,208 +123,32 @@ When a client sends `display_name` in a create or update request, the setter tra
173
123
 
174
124
  **Important**: You must initialize `@transformed_params` in your `initialize` method if you use setters that modify it.
175
125
 
176
- ### Custom Controllers
126
+ ### Creatable and Updatable Fields
177
127
 
178
- If you need custom behavior, you can override the default controller:
128
+ By default, all attributes are available for both create and update. Restrict fields per operation:
179
129
 
180
130
  ```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):**
131
+ class UserResource < JSONAPI::Resource
132
+ attributes :name, :email, :phone, :role
325
133
 
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
- }
134
+ creatable_fields :name, :email, :phone # role is system-set
135
+ updatable_fields :name, :phone # email is immutable
136
+ end
359
137
  ```
360
138
 
361
- **DELETE /users/:id:**
139
+ ## Relationship Endpoints
362
140
 
363
- Returns `204 No Content` with an empty response body.
141
+ Manage relationship links independently of the parent resource. This will not delete the related resource itself, but only manage the relationship.
364
142
 
365
- ### Query Parameters
143
+ If the relationship is `User -(has_one)-> AccountUser -(belong_to)-> Account` and we DELETE `/users/1/relationships/account` this will not delete the account, but delete the account user.
366
144
 
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
145
+ - `GET /users/:id/relationships/:relationship_name` - Show relationship linkage
146
+ - `PATCH /users/:id/relationships/:relationship_name` - Replace relationship linkage
147
+ - `DELETE /users/:id/relationships/:relationship_name` - Remove relationship linkage
374
148
 
375
- #### Filtering
149
+ ## Filtering
376
150
 
377
- Filtering requires declaring permitted filters in your resource class:
151
+ Declare permitted filters in your resource class:
378
152
 
379
153
  ```ruby
380
154
  class UserResource < JSONAPI::Resource
@@ -383,399 +157,253 @@ class UserResource < JSONAPI::Resource
383
157
  end
384
158
  ```
385
159
 
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:
160
+ Filters ending in `_eq`, `_match`, `_lt`, `_lte`, `_gt`, or `_gte` are applied to the corresponding column:
397
161
 
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
162
  ```
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
163
+ GET /users?filter[name_eq]=John
164
+ GET /users?filter[created_at_gte]=2024-01-01
448
165
  ```
449
166
 
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)
167
+ Filter through relationships by nesting the filter key:
456
168
 
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
169
  ```
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
170
+ GET /posts?filter[user][email]=jane@example.com
171
+ GET /comments?filter[post][user][id]=123
481
172
  ```
482
173
 
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.
174
+ To pass multiple values, use bracket notation:
486
175
 
487
- #### Pagination
488
-
489
- Resources can be paginated using the `page[number]` and `page[size]` parameters. Pagination is only available on collection (index) endpoints.
176
+ ```
177
+ GET /users?filter[roles][]=admin&filter[roles][]=editor
178
+ ```
490
179
 
491
- Examples:
180
+ ## Pagination
492
181
 
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)
182
+ Paginate collections with `page[number]` and `page[size]`:
496
183
 
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.
184
+ ```
185
+ GET /users?page[number]=1&page[size]=10
186
+ GET /users?page[number]=2&page[size]=25
187
+ ```
498
188
 
499
- **Example Paginated Response:**
189
+ Paginated responses include a `links` object with `self`, `first`, `last`, and conditional `prev`/`next` URLs, plus `meta.total` with the full count:
500
190
 
501
191
  ```json
502
192
  {
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
- ],
193
+ "data": [...],
520
194
  "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"
195
+ "self": "/users?page[number]=2&page[size]=10",
196
+ "first": "/users?page[number]=1&page[size]=10",
197
+ "last": "/users?page[number]=5&page[size]=10",
198
+ "prev": "/users?page[number]=1&page[size]=10",
199
+ "next": "/users?page[number]=3&page[size]=10"
526
200
  },
527
- "meta": {
528
- "total": 15
529
- }
201
+ "meta": { "total": 42 }
530
202
  }
531
203
  ```
532
204
 
533
- #### Including Related Resources
205
+ Default page size is 25, maximum is 100 (configurable).
534
206
 
535
- Use the `include` parameter to include related resources in the response. This is available on both index and show endpoints:
207
+ ## Includes
536
208
 
537
- ```ruby
538
- # Include single relationship
539
- GET /users?include=posts
209
+ Include related resources with the `include` parameter:
540
210
 
541
- # Include multiple relationships
211
+ ```
212
+ GET /users?include=posts
542
213
  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
214
  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
215
  ```
556
216
 
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:
217
+ Nested includes use dot notation and support arbitrary depth. Related resources appear in the `included` array, and relationships reference them by `type` and `id`:
560
218
 
561
219
  ```json
562
220
  {
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
221
  "data": {
583
222
  "type": "users",
584
223
  "id": "1",
585
- "attributes": {
586
- "name": "John Doe",
587
- "email": "john@example.com",
588
- "phone": "555-0100"
589
- },
590
224
  "relationships": {
591
225
  "posts": {
592
- "data": [
593
- { "type": "posts", "id": "1" },
594
- { "type": "posts", "id": "2" }
595
- ],
596
- "meta": {
597
- "count": 2
598
- }
226
+ "data": [{ "type": "posts", "id": "5" }]
599
227
  }
600
- },
601
- "links": {
602
- "self": "/users/1"
603
228
  }
604
229
  },
605
230
  "included": [
606
231
  {
607
232
  "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
- }
233
+ "id": "5",
234
+ "attributes": { "title": "Hello World" }
655
235
  }
656
236
  ]
657
237
  }
658
238
  ```
659
239
 
660
- #### Polymorphic Relationships
240
+ ## Polymorphic Relationships
241
+
242
+ Polymorphic relationships are accessed through the parent resource's relationship endpoints and includes. Route only the parent resource; the gem automatically handles polymorphic types through the relationship endpoints.
243
+
244
+ ```ruby
245
+ # config/routes.rb
246
+ jsonapi_resources :users
247
+ ```
248
+
249
+ The user model declares a polymorphic `belongs_to`:
250
+
251
+ ```ruby
252
+ class User < ActiveRecord::Base
253
+ belongs_to :profile, polymorphic: true
254
+ end
255
+ ```
661
256
 
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:
257
+ Define a resource class for each concrete type:
663
258
 
664
259
  ```ruby
665
- # User belongs_to :profile, polymorphic: true
666
- # User has_many :activities, as: :actor
260
+ # app/resources/user_resource.rb
261
+ class UserResource < JSONAPI::Resource
262
+ attributes :name, :email
263
+ has_one :profile # auto-detected as polymorphic from model
264
+ end
265
+
266
+ # app/resources/admin_profile_resource.rb
267
+ class AdminProfileResource < JSONAPI::Resource
268
+ attributes :department, :level
269
+ end
667
270
 
668
- GET /users?include=profile
669
- GET /users?include=activities
670
- GET /users/:id?include=profile,activities
271
+ # app/resources/customer_profile_resource.rb
272
+ class CustomerProfileResource < JSONAPI::Resource
273
+ attributes :company_name, :industry
274
+ end
671
275
  ```
672
276
 
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).
277
+ The gem auto-detects polymorphism from the model's association. To override, use `polymorphic: true` explicitly.
674
278
 
675
- **Example Response with Polymorphic Relationship:**
279
+ Responses use the concrete type. For example, a user with an admin profile returns the concrete type in the relationship data and `included` array:
280
+
281
+ ```http
282
+ GET /users/1?include=profile HTTP/1.1
283
+ Accept: application/vnd.api+json
284
+
285
+ HTTP/1.1 200 OK
286
+ Content-Type: application/vnd.api+json
676
287
 
677
- ```json
678
288
  {
679
- "jsonapi": {
680
- "version": "1.1"
681
- },
682
289
  "data": {
683
290
  "type": "users",
684
291
  "id": "1",
685
- "attributes": {
686
- "name": "John Doe",
687
- "email": "john@example.com",
688
- "phone": "555-0100"
689
- },
690
292
  "relationships": {
691
293
  "profile": {
692
- "data": {
693
- "type": "admin_profiles",
694
- "id": "1"
695
- }
294
+ "data": { "type": "admin_profiles", "id": "5" }
696
295
  }
697
- },
698
- "links": {
699
- "self": "/users/1"
700
296
  }
701
297
  },
702
298
  "included": [
703
299
  {
704
300
  "type": "admin_profiles",
705
- "id": "1",
706
- "attributes": {
707
- "department": "Engineering",
708
- "level": "Senior"
709
- },
710
- "links": {
711
- "self": "/admin_profiles/1"
712
- }
301
+ "id": "5",
302
+ "attributes": { "department": "Engineering", "level": "Senior" }
713
303
  }
714
304
  ]
715
305
  }
716
306
  ```
717
307
 
718
- #### Single Table Inheritance (STI) Support
308
+ When creating or updating, provide the concrete type in the relationship payload:
309
+
310
+ ```http
311
+ PATCH /users/1 HTTP/1.1
312
+ Content-Type: application/vnd.api+json
313
+ Accept: application/vnd.api+json
314
+
315
+ {
316
+ "data": {
317
+ "type": "users",
318
+ "id": "1",
319
+ "relationships": {
320
+ "profile": {
321
+ "data": { "type": "customer_profiles", "id": "10" }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ ```
327
+
328
+ If you need direct access to polymorphic resources (e.g., `GET /admin_profiles/1`), add explicit routes:
719
329
 
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.
330
+ ```ruby
331
+ jsonapi_resources :admin_profiles
332
+ jsonapi_resources :customer_profiles
333
+ ```
721
334
 
722
- ##### Routing
335
+ ## Single Table Inheritance (STI)
723
336
 
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:
337
+ STI subclasses are treated as first-class JSON:API resources with their own types. Use the `sti` option in routes:
725
338
 
726
339
  ```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
340
+ jsonapi_resources :notifications, sti: [:email_notifications, :sms_notifications]
735
341
  ```
736
342
 
737
- ##### Resource Definitions
343
+ This generates:
344
+
345
+ - `GET /notifications` — lists all notifications (index only)
346
+ - `GET /email_notifications/:id`, `POST /email_notifications`, etc. — full CRUD for each subtype
347
+ - `GET /sms_notifications/:id`, `POST /sms_notifications`, etc.
348
+
349
+ The models use standard Rails STI inheritance:
350
+
351
+ ```ruby
352
+ class Notification < ActiveRecord::Base
353
+ validates :subject, presence: true
354
+ end
355
+
356
+ class EmailNotification < Notification
357
+ validates :recipient_email, presence: true
358
+ end
359
+
360
+ class SmsNotification < Notification
361
+ validates :phone_number, presence: true
362
+ end
363
+ ```
738
364
 
739
- Define a resource class for the base model and each subclass. Subclasses should inherit from the base resource class to share configuration:
365
+ Subclass resources inherit attributes from the parent:
740
366
 
741
367
  ```ruby
742
- # app/resources/notification_resource.rb
743
368
  class NotificationResource < JSONAPI::Resource
744
- attributes :body, :created_at
745
- has_one :user
369
+ attributes :subject, :body
746
370
  end
747
371
 
748
- # app/resources/email_notification_resource.rb
749
372
  class EmailNotificationResource < NotificationResource
750
- attributes :subject, :recipient_email
373
+ attributes :recipient_email # inherits :subject, :body
751
374
  end
752
375
 
753
- # app/resources/sms_notification_resource.rb
754
376
  class SmsNotificationResource < NotificationResource
755
- attributes :phone_number
377
+ attributes :phone_number # inherits :subject, :body
756
378
  end
757
379
  ```
758
380
 
759
- ##### Serialization
381
+ The base endpoint returns all subtypes with their concrete types:
760
382
 
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:
383
+ ```http
384
+ GET /notifications HTTP/1.1
385
+ Accept: application/vnd.api+json
386
+
387
+ HTTP/1.1 200 OK
388
+ Content-Type: application/vnd.api+json
762
389
 
763
- ```json
764
390
  {
765
391
  "data": [
766
392
  {
767
393
  "type": "email_notifications",
768
394
  "id": "1",
769
395
  "attributes": {
770
- "body": "Welcome!",
771
- "subject": "Hello"
396
+ "subject": "Welcome",
397
+ "body": "...",
398
+ "recipient_email": "user@example.com"
772
399
  }
773
400
  },
774
401
  {
775
402
  "type": "sms_notifications",
776
403
  "id": "2",
777
404
  "attributes": {
778
- "body": "Code: 1234",
405
+ "subject": "Alert",
406
+ "body": "...",
779
407
  "phone_number": "555-1234"
780
408
  }
781
409
  }
@@ -783,161 +411,54 @@ Resources are serialized with their specific type. When querying the base endpoi
783
411
  }
784
412
  ```
785
413
 
786
- ##### Creating STI Resources
414
+ To create or update, use the subtype endpoint with the concrete type:
787
415
 
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
416
+ ```http
417
+ POST /email_notifications HTTP/1.1
792
418
  Content-Type: application/vnd.api+json
419
+ Accept: application/vnd.api+json
793
420
 
794
421
  {
795
422
  "data": {
796
423
  "type": "email_notifications",
797
424
  "attributes": {
798
- "subject": "Important",
799
- "body": "Please read this.",
425
+ "subject": "Welcome",
426
+ "body": "Hello",
800
427
  "recipient_email": "user@example.com"
801
- },
802
- "relationships": {
803
- "user": {
804
- "data": { "type": "users", "id": "1" }
805
- }
806
428
  }
807
429
  }
808
430
  }
809
431
  ```
810
432
 
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
433
+ ## Sorting
862
434
 
863
- The gem supports sorting by virtual attributes (attributes that don't correspond to database columns). When sorting by a virtual attribute, the gem:
435
+ Sort collections with the `sort` parameter. Prefix with `-` for descending. Seperate multiple sorts with comma `,`:
864
436
 
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
437
+ ```http
438
+ GET /users?sort=name,-created_at HTTP/1.1
439
+ Accept: application/vnd.api+json
874
440
  ```
875
441
 
876
- **Example:**
442
+ Virtual attributes can also be sorted (loaded into memory first). Declare sort-only fields with `sortable_fields` to allow sorting without exposing the value:
877
443
 
878
444
  ```ruby
879
- class UserResource < JSONAPI::Resource
880
- attributes :name, :email, :full_name
881
-
882
- def full_name
883
- "#{resource.name} (#{resource.email})"
884
- end
445
+ class User < ActiveRecord::Base
446
+ has_many :posts
885
447
  end
886
448
  ```
887
449
 
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
450
  ```ruby
908
451
  class UserResource < JSONAPI::Resource
909
452
  attributes :name, :email
910
-
911
- # Declare sort-only field
912
453
  sortable_fields :posts_count
913
454
 
914
- # Implement getter method (same as virtual attribute)
915
455
  def posts_count
916
456
  resource.posts.size
917
457
  end
918
458
  end
919
459
  ```
920
460
 
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
461
+ ## JSON:API Object
941
462
 
942
463
  All responses automatically include a `jsonapi` object indicating JSON:API version compliance:
943
464
 
@@ -950,60 +471,63 @@ All responses automatically include a `jsonapi` object indicating JSON:API versi
950
471
  }
951
472
  ```
952
473
 
953
- You can customize the jsonapi object via configuration:
474
+ The version is hardcoded to "1.1". You can add meta information to the jsonapi object via configuration:
954
475
 
955
476
  ```ruby
956
477
  # config/initializers/json_api.rb
957
478
  JSONAPI.configure do |config|
958
- config.jsonapi_version = "1.1"
959
479
  config.jsonapi_meta = { ext: ["https://jsonapi.org/ext/atomic"] }
960
480
  end
961
481
  ```
962
482
 
963
- ### Meta Information
483
+ ## Meta Information
964
484
 
965
485
  The gem supports meta information at three levels: document-level, resource-level, and relationship-level.
966
486
 
967
- #### Document-Level Meta
487
+ ### Document-Level Meta
488
+
489
+ Document-level meta appears at the top level of the response. Pagination automatically includes `total` when pagination is applied.
490
+
491
+ To add custom document-level meta globally, configure `document_meta_resolver`:
492
+
493
+ ```ruby
494
+ JSONAPI.configure do |config|
495
+ config.document_meta_resolver = lambda do |controller:|
496
+ {
497
+ request_id: controller.request.request_id,
498
+ api_version: "v1"
499
+ }
500
+ end
501
+ end
502
+ ```
968
503
 
969
- Document-level meta appears at the top level of the response and is typically used for pagination:
504
+ The resolver receives the controller instance and returns a hash that is merged with pagination meta:
970
505
 
971
506
  ```json
972
507
  {
973
508
  "jsonapi": { "version": "1.1" },
974
- "data": [ ... ],
509
+ "data": [...],
975
510
  "meta": {
511
+ "request_id": "abc-123",
512
+ "api_version": "v1",
976
513
  "total": 100
977
514
  }
978
515
  }
979
516
  ```
980
517
 
981
- Pagination automatically includes meta with the total count when pagination is applied.
518
+ For per-controller customization, override `jsonapi_document_meta` in a custom controller:
982
519
 
983
- **Example Document-Level Meta:**
520
+ ```ruby
521
+ class Api::UsersController < JSONAPI::ResourcesController
522
+ private
984
523
 
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
- }
524
+ def jsonapi_document_meta(extra_meta = {})
525
+ super(extra_meta.merge(custom_field: "value"))
526
+ end
527
+ end
1004
528
  ```
1005
529
 
1006
- #### Resource-Level Meta
530
+ ### Resource-Level Meta
1007
531
 
1008
532
  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
533
 
@@ -1063,7 +587,7 @@ The instance method has access to the model instance via `resource`. Custom meta
1063
587
  }
1064
588
  ```
1065
589
 
1066
- #### Relationship-Level Meta
590
+ ### Relationship-Level Meta
1067
591
 
1068
592
  Relationship-level meta appears within relationship objects:
1069
593
 
@@ -1111,272 +635,89 @@ The response will include:
1111
635
  }
1112
636
  }
1113
637
  },
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
638
+ "links": {
639
+ "self": "/users/1"
640
+ }
641
+ }
642
+ }
643
+ ```
1291
644
 
1292
- def update?
1293
- user.admin? || user == record
1294
- end
645
+ ## Configuration
1295
646
 
1296
- def destroy?
1297
- user.admin?
1298
- end
647
+ Configure the gem in an initializer:
1299
648
 
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
649
+ ```ruby
650
+ # config/initializers/json_api.rb
651
+ JSONAPI.configure do |config|
652
+ config.default_page_size = 25
653
+ config.max_page_size = 100
654
+ config.jsonapi_meta = nil
655
+ config.base_controller_class = ActionController::API # Default: ActionController::API
1309
656
  end
1310
657
  ```
1311
658
 
1312
- #### Relationship Authorization
659
+ ### Base Controller Class
1313
660
 
1314
- Relationship endpoints (show, update, destroy) authorize the parent resource using the `:update` action with a context hash containing the relationship name:
661
+ 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):
1315
662
 
1316
663
  ```ruby
1317
- # The authorization_handler receives:
1318
- {
1319
- controller: controller_instance,
1320
- record: parent_resource,
1321
- action: :update,
1322
- context: { relationship: :posts }
1323
- }
664
+ JSONAPI.configure do |config|
665
+ # Use ActionController::Base instead of ActionController::API
666
+ config.base_controller_class = ActionController::Base
667
+
668
+ # Or use a custom base controller
669
+ config.base_controller_class = ApplicationController
670
+ end
1324
671
  ```
1325
672
 
1326
- This allows you to implement relationship-specific authorization logic in your policies if needed.
673
+ This is useful when you need access to features available in `ActionController::Base` that aren't in `ActionController::API`, such as:
1327
674
 
1328
- #### Custom Authorization Error Handling
675
+ - View rendering helpers
676
+ - Layout support
677
+ - Cookie-based sessions
678
+ - Flash messages
1329
679
 
1330
- The gem automatically handles `JSONAPI::AuthorizationError` and `Pundit::NotAuthorizedError` exceptions, rendering a JSON:API compliant `403 Forbidden` response:
680
+ **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.
1331
681
 
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
- ```
682
+ ## Authorization
1346
683
 
1347
- You can customize the error message by raising an exception with a specific message:
684
+ The gem provides two optional authorization hooks:
1348
685
 
1349
- ```ruby
1350
- raise JSONAPI::AuthorizationError, "Not authorized to view this resource"
1351
- ```
686
+ - `authorization_scope` — filters collections (index) to authorized records
687
+ - `authorization_handler` authorizes individual actions (show, create, update, destroy)
1352
688
 
1353
- #### Overriding Authorization
689
+ If not configured, authorization is bypassed.
1354
690
 
1355
- Authorization hooks can be easily overridden or disabled:
691
+ ### Pundit Integration
1356
692
 
1357
693
  ```ruby
1358
- # Disable authorization (allow all access)
694
+ # config/initializers/json_api.rb
1359
695
  JSONAPI.configure do |config|
1360
- config.authorization_scope = nil
1361
- config.authorization_handler = nil
1362
- end
696
+ config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
697
+ policy_class = Pundit::PolicyFinder.new(model_class).policy
698
+ policy_scope = policy_class.const_get(:Scope).new(controller.current_user, scope)
699
+ policy_scope.resolve
700
+ end
1363
701
 
1364
- # Use custom authorization logic
1365
- JSONAPI.configure do |config|
1366
702
  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"
703
+ policy_class = Pundit::PolicyFinder.new(record).policy
704
+ policy = policy_class.new(controller.current_user, record)
705
+ unless policy.public_send("#{action}?")
706
+ raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
1370
707
  end
1371
708
  end
1372
709
  end
1373
710
  ```
1374
711
 
1375
- ### Instrumentation (Rails 8.1+)
712
+ The gem automatically renders `403 Forbidden` for `JSONAPI::AuthorizationError` and `Pundit::NotAuthorizedError`.
713
+
714
+ Relationship endpoints authorize the parent resource with `action: :update` and `context: { relationship: :relationship_name }`.
715
+
716
+ ## Instrumentation (Rails 8.1+)
1376
717
 
1377
718
  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
719
 
1379
- #### Resource Events
720
+ ### Resource Events
1380
721
 
1381
722
  The gem emits events for resource lifecycle operations:
1382
723
 
@@ -1409,7 +750,7 @@ end
1409
750
  Rails.event.subscribe(JsonApiEventSubscriber.new) if Rails.respond_to?(:event)
1410
751
  ```
1411
752
 
1412
- #### Relationship Events
753
+ ### Relationship Events
1413
754
 
1414
755
  The gem also emits events for relationship operations:
1415
756
 
@@ -1428,7 +769,7 @@ The gem also emits events for relationship operations:
1428
769
  }
1429
770
  ```
1430
771
 
1431
- #### Testing Instrumentation
772
+ ### Testing Instrumentation
1432
773
 
1433
774
  Use Rails 8.1's `assert_events_reported` test helper to verify events are emitted:
1434
775
 
@@ -1441,33 +782,7 @@ assert_events_reported([
1441
782
  end
1442
783
  ```
1443
784
 
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
785
+ ## Content Negotiation
1471
786
 
1472
787
  The gem enforces JSON:API content negotiation:
1473
788
 
@@ -1476,7 +791,7 @@ The gem enforces JSON:API content negotiation:
1476
791
 
1477
792
  Blank or `*/*` Accept headers are allowed to support browser defaults.
1478
793
 
1479
- ### Custom Controllers
794
+ ## Custom Controllers
1480
795
 
1481
796
  You can inherit from `JSONAPI::BaseController` to create custom controllers:
1482
797
 
@@ -1499,7 +814,7 @@ The base controller provides helper methods:
1499
814
  - `parse_sort_param` - Parsed sort parameter
1500
815
  - `parse_page_param` - Parsed page parameter
1501
816
 
1502
- ### Serialization
817
+ ## Serialization
1503
818
 
1504
819
  Use `JSONAPI::Serializer` to serialize resources:
1505
820
 
@@ -1508,7 +823,7 @@ serializer = JSONAPI::Serializer.new(user)
1508
823
  serializer.to_hash(include: ["posts"], fields: { users: ["name", "email"] })
1509
824
  ```
1510
825
 
1511
- ### Deserialization
826
+ ## Deserialization
1512
827
 
1513
828
  Use `JSONAPI::Deserializer` to deserialize JSON:API payloads:
1514
829
 
@@ -1525,12 +840,15 @@ The deserializer automatically converts JSON:API relationship format to Rails-fr
1525
840
  - To-one relationships: `account` → `account_id`
1526
841
  - Polymorphic relationships: `profile` → `profile_id` and `profile_type`
1527
842
 
1528
- #### Creating Resources with Relationships
843
+ ### Creating Resources with Relationships
1529
844
 
1530
845
  You can include relationships when creating resources:
1531
846
 
1532
- ```ruby
1533
- # POST /users
847
+ ```http
848
+ POST /users HTTP/1.1
849
+ Content-Type: application/vnd.api+json
850
+ Accept: application/vnd.api+json
851
+
1534
852
  {
1535
853
  "data": {
1536
854
  "type": "users",
@@ -1556,12 +874,15 @@ You can include relationships when creating resources:
1556
874
  }
1557
875
  ```
1558
876
 
1559
- #### Updating Resources with Relationships
877
+ ### Updating Resources with Relationships
1560
878
 
1561
879
  You can update relationships when updating resources:
1562
880
 
1563
- ```ruby
1564
- # PATCH /users/:id
881
+ ```http
882
+ PATCH /users/1 HTTP/1.1
883
+ Content-Type: application/vnd.api+json
884
+ Accept: application/vnd.api+json
885
+
1565
886
  {
1566
887
  "data": {
1567
888
  "type": "users",
@@ -1586,12 +907,15 @@ You can update relationships when updating resources:
1586
907
  }
1587
908
  ```
1588
909
 
1589
- #### Clearing Relationships
910
+ ### Clearing Relationships
1590
911
 
1591
912
  To clear a relationship, send `null` for to-one relationships or an empty array for to-many relationships:
1592
913
 
1593
- ```ruby
1594
- # Clear to-one relationship
914
+ ```http
915
+ PATCH /users/1 HTTP/1.1
916
+ Content-Type: application/vnd.api+json
917
+ Accept: application/vnd.api+json
918
+
1595
919
  {
1596
920
  "data": {
1597
921
  "type": "users",
@@ -1603,8 +927,13 @@ To clear a relationship, send `null` for to-one relationships or an empty array
1603
927
  }
1604
928
  }
1605
929
  }
930
+ ```
931
+
932
+ ```http
933
+ PATCH /users/1 HTTP/1.1
934
+ Content-Type: application/vnd.api+json
935
+ Accept: application/vnd.api+json
1606
936
 
1607
- # Clear to-many relationship
1608
937
  {
1609
938
  "data": {
1610
939
  "type": "users",
@@ -1618,7 +947,7 @@ To clear a relationship, send `null` for to-one relationships or an empty array
1618
947
  }
1619
948
  ```
1620
949
 
1621
- #### Relationship Validation
950
+ ### Relationship Validation
1622
951
 
1623
952
  The gem validates relationship data:
1624
953
 
@@ -1627,14 +956,15 @@ The gem validates relationship data:
1627
956
  - Invalid polymorphic type (class doesn't exist) returns `400 Bad Request`
1628
957
  - Attempting to unset linkage that cannot be nullified (e.g., foreign key has NOT NULL constraint) returns `400 Bad Request`
1629
958
 
1630
- ### Relationship Endpoints
959
+ ## Relationship Endpoint Details
1631
960
 
1632
961
  The gem provides dedicated endpoints for managing relationships independently of the main resource:
1633
962
 
1634
- #### Show Relationship
963
+ ### Show Relationship
1635
964
 
1636
- ```ruby
1637
- GET /users/:id/relationships/posts
965
+ ```http
966
+ GET /users/1/relationships/posts HTTP/1.1
967
+ Accept: application/vnd.api+json
1638
968
  ```
1639
969
 
1640
970
  Returns the relationship data (resource identifiers) with links and meta:
@@ -1695,15 +1025,17 @@ Returns the relationship data (resource identifiers) with links and meta:
1695
1025
 
1696
1026
  For collection relationships, you can sort using the `sort` parameter:
1697
1027
 
1698
- ```ruby
1699
- GET /users/:id/relationships/posts?sort=title,-created_at
1028
+ ```http
1029
+ GET /users/1/relationships/posts?sort=title,-created_at HTTP/1.1
1030
+ Accept: application/vnd.api+json
1700
1031
  ```
1701
1032
 
1702
- #### Update Relationship
1033
+ ### Update Relationship
1703
1034
 
1704
- ```ruby
1705
- PATCH /users/:id/relationships/posts
1035
+ ```http
1036
+ PATCH /users/1/relationships/posts HTTP/1.1
1706
1037
  Content-Type: application/vnd.api+json
1038
+ Accept: application/vnd.api+json
1707
1039
 
1708
1040
  {
1709
1041
  "data": [
@@ -1715,11 +1047,12 @@ Content-Type: application/vnd.api+json
1715
1047
 
1716
1048
  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
1049
 
1718
- #### Delete Relationship Linkage
1050
+ ### Delete Relationship Linkage
1719
1051
 
1720
- ```ruby
1721
- DELETE /users/:id/relationships/posts
1052
+ ```http
1053
+ DELETE /users/1/relationships/posts HTTP/1.1
1722
1054
  Content-Type: application/vnd.api+json
1055
+ Accept: application/vnd.api+json
1723
1056
 
1724
1057
  {
1725
1058
  "data": [
@@ -1799,356 +1132,463 @@ Error responses follow JSON:API error format:
1799
1132
  }
1800
1133
  ```
1801
1134
 
1802
- ## Development
1135
+ ## ActiveStorage Support
1803
1136
 
1804
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
1137
+ The gem automatically detects and serializes ActiveStorage attachments when exposed as relationships.
1805
1138
 
1806
- You can also run `bin/console` for an interactive prompt that will allow you to experiment.
1139
+ ### Exposing Attachments
1807
1140
 
1808
- ### Code Organization
1141
+ Declare ActiveStorage attachments as relationships in your resource:
1809
1142
 
1810
- The gem is organized into several directories within `lib/json_api/`:
1143
+ ```ruby
1144
+ class User < ApplicationRecord
1145
+ has_one_attached :avatar
1146
+ has_many_attached :documents
1147
+ end
1811
1148
 
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
1149
+ class UserResource < JSONAPI::Resource
1150
+ attributes :name, :email
1151
+ has_one :avatar
1152
+ has_many :documents
1153
+ end
1154
+ ```
1822
1155
 
1823
- This organization makes it easier to understand the gem's structure and locate specific functionality.
1156
+ The gem auto-detects these are ActiveStorage attachments and serializes them as `active_storage_blobs` relationships:
1824
1157
 
1825
- ## Testing
1158
+ ```http
1159
+ GET /users/1?include=avatar HTTP/1.1
1160
+ Accept: application/vnd.api+json
1826
1161
 
1827
- Run the test suite:
1162
+ HTTP/1.1 200 OK
1163
+ Content-Type: application/vnd.api+json
1828
1164
 
1829
- ```bash
1830
- bundle exec rspec
1165
+ {
1166
+ "data": {
1167
+ "type": "users",
1168
+ "id": "1",
1169
+ "attributes": { "name": "John Doe", "email": "john@example.com" },
1170
+ "relationships": {
1171
+ "avatar": { "data": { "type": "active_storage_blobs", "id": "1" } },
1172
+ "documents": { "data": [
1173
+ { "type": "active_storage_blobs", "id": "2" },
1174
+ { "type": "active_storage_blobs", "id": "3" }
1175
+ ]}
1176
+ }
1177
+ },
1178
+ "included": [{
1179
+ "type": "active_storage_blobs",
1180
+ "id": "1",
1181
+ "attributes": {
1182
+ "filename": "avatar.jpg",
1183
+ "content_type": "image/jpeg",
1184
+ "byte_size": 102400,
1185
+ "checksum": "abc123...",
1186
+ "url": "/rails/active_storage/blobs/.../avatar.jpg"
1187
+ },
1188
+ "links": {
1189
+ "self": "/active_storage_blobs/1",
1190
+ "download": "/rails/active_storage/blobs/.../avatar.jpg"
1191
+ }
1192
+ }]
1193
+ }
1831
1194
  ```
1832
1195
 
1833
- ### Test Helper for Request Specs
1196
+ The built-in `JSONAPI::ActiveStorageBlobResource` provides `filename`, `content_type`, `byte_size`, `checksum`, and `url` attributes, plus a download link.
1834
1197
 
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.
1198
+ ### Attaching Files
1836
1199
 
1837
- **Setup (RSpec):**
1200
+ Clients attach files by providing signed blob IDs from ActiveStorage direct uploads:
1838
1201
 
1839
- ```ruby
1840
- # spec/rails_helper.rb or spec/support/json_api.rb
1841
- require "json_api/testing"
1202
+ ```http
1203
+ POST /users HTTP/1.1
1204
+ Content-Type: application/vnd.api+json
1842
1205
 
1843
- RSpec.configure do |config|
1844
- config.include JSONAPI::Testing::TestHelper, type: :request
1845
- end
1206
+ {
1207
+ "data": {
1208
+ "type": "users",
1209
+ "attributes": { "name": "Jane Doe", "email": "jane@example.com" },
1210
+ "relationships": {
1211
+ "avatar": { "data": { "type": "active_storage_blobs", "id": "eyJfcmFpbHMi..." } },
1212
+ "documents": { "data": [
1213
+ { "type": "active_storage_blobs", "id": "signed-id-1" },
1214
+ { "type": "active_storage_blobs", "id": "signed-id-2" }
1215
+ ]}
1216
+ }
1217
+ }
1218
+ }
1846
1219
  ```
1847
1220
 
1848
- **Usage:**
1221
+ The deserializer validates signed IDs via `ActiveStorage::Blob.find_signed!` and converts them to blob objects for attachment. Invalid signed IDs raise `ActiveSupport::MessageVerifier::InvalidSignature`.
1849
1222
 
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
1223
+ ### Detaching Files
1861
1224
 
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
- ```
1225
+ Send `null` or `[]` to detach attachments:
1878
1226
 
1879
- **Behavior by HTTP method:**
1227
+ ```http
1228
+ PATCH /users/1 HTTP/1.1
1229
+ Content-Type: application/vnd.api+json
1880
1230
 
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 |
1231
+ {
1232
+ "data": {
1233
+ "type": "users",
1234
+ "id": "1",
1235
+ "relationships": {
1236
+ "avatar": { "data": null },
1237
+ "documents": { "data": [] }
1238
+ }
1239
+ }
1240
+ }
1241
+ ```
1888
1242
 
1889
- ### Integration Tests
1243
+ By default, this purges the attachments. For has_one, sending `null` detaches. For has_many, sending `[]` removes all.
1890
1244
 
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.
1245
+ ### Relationship Options
1892
1246
 
1893
- To run only integration tests:
1247
+ **`purge_on_nil`** (default: `true`) Controls whether attachments are purged when set to `null`/`[]`:
1894
1248
 
1895
- ```bash
1896
- bundle exec rspec spec/integration
1249
+ ```ruby
1250
+ has_one :avatar, purge_on_nil: false # Keep existing when null
1251
+ has_many :documents, purge_on_nil: false
1897
1252
  ```
1898
1253
 
1899
- The integration tests cover:
1254
+ **`append_only`** (has_many only, default: `false`) — Append new blobs instead of replacing:
1900
1255
 
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
1256
+ ```ruby
1257
+ has_many :documents, append_only: true
1258
+ ```
1910
1259
 
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.
1260
+ When enabled:
1912
1261
 
1913
- ## ActiveStorage Support
1262
+ - New blobs append to existing: `[blob1, blob2] + [blob3] → [blob1, blob2, blob3]`
1263
+ - Empty array `[]` is a no-op (preserves existing)
1264
+ - Implicitly sets `purge_on_nil: false`
1265
+ - Remove attachments via the DELETE relationship endpoint
1914
1266
 
1915
- The gem includes built-in support for serializing and deserializing ActiveStorage attachments through JSON:API.
1267
+ These options are mutually exclusive `append_only: true` with `purge_on_nil: true` raises `ArgumentError`.
1916
1268
 
1917
- ### Serializing Attachments
1269
+ ## Client Integration: devour-client-ts
1918
1270
 
1919
- When a model has ActiveStorage attachments (`has_one_attached` or `has_many_attached`), you can expose them as relationships in your resource:
1271
+ The `devour-client-ts` npm package is a TypeScript JSON:API client that works seamlessly with this gem. This section covers how to configure and use devour-client-ts as a frontend client.
1920
1272
 
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
1273
+ ### Installation
1274
+
1275
+ ```bash
1276
+ npm install devour-client-ts
1927
1277
  ```
1928
1278
 
1929
- The serializer will automatically:
1279
+ ### Basic Client Setup
1930
1280
 
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)
1281
+ ```typescript
1282
+ import { JsonApi } from "devour-client-ts";
1935
1283
 
1936
- Example response:
1284
+ const api = new JsonApi({
1285
+ apiUrl: "http://localhost:3000",
1286
+ headers: {
1287
+ "Content-Type": "application/vnd.api+json",
1288
+ Accept: "application/vnd.api+json",
1289
+ },
1290
+ trailingSlash: false, // Rails doesn't use trailing slashes
1291
+ resetBuilderOnCall: true, // Prevent state pollution between calls
1292
+ });
1293
+ ```
1937
1294
 
1938
- ```json
1939
- {
1940
- "data": {
1941
- "type": "users",
1942
- "id": "1",
1943
- "attributes": {
1944
- "name": "John Doe",
1945
- "email": "john@example.com"
1295
+ ### Defining Models
1296
+
1297
+ Define models that match your Rails resources. Model names are singular, but `collectionPath` should be plural to match JSON:API conventions:
1298
+
1299
+ ```typescript
1300
+ // Simple model
1301
+ api.define(
1302
+ "user",
1303
+ {
1304
+ email: {},
1305
+ name: {},
1306
+ phone: {},
1307
+ },
1308
+ {
1309
+ collectionPath: "users",
1310
+ }
1311
+ );
1312
+
1313
+ // Model with relationships
1314
+ api.define(
1315
+ "post",
1316
+ {
1317
+ title: {},
1318
+ body: {},
1319
+ user: {
1320
+ jsonApi: "hasOne",
1321
+ type: "users", // Must be plural
1946
1322
  },
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
- }
1323
+ comments: {
1324
+ jsonApi: "hasMany",
1325
+ type: "comments", // Must be plural
1960
1326
  },
1961
- "links": {
1962
- "self": "/users/1"
1963
- }
1964
1327
  },
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
- }
1328
+ {
1329
+ collectionPath: "posts",
1330
+ }
1331
+ );
1982
1332
  ```
1983
1333
 
1984
- ### Deserializing Attachments
1334
+ **Important**: All relationship `type` values must be **plural** to match JSON:API specification. Using singular types (e.g., `type: 'user'`) will cause "Type Mismatch" errors.
1985
1335
 
1986
- When creating or updating resources, clients can attach files by providing signed blob IDs obtained from ActiveStorage direct uploads:
1336
+ ### CRUD Operations
1987
1337
 
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
- }
1338
+ **Find all resources:**
1339
+
1340
+ ```typescript
1341
+ const { data: users } = await api.findAll("user").toPromise();
2012
1342
  ```
2013
1343
 
2014
- The deserializer will:
1344
+ **Find a single resource:**
2015
1345
 
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]`)
1346
+ ```typescript
1347
+ const { data: user } = await api.find("user", "123").toPromise();
1348
+ ```
2019
1349
 
2020
- #### Detaching Attachments
1350
+ **Find with relationships:**
2021
1351
 
2022
- To detach (remove) an attachment, send `null` for to-one relationships or an empty array `[]` for to-many relationships:
1352
+ ```typescript
1353
+ const { data: user } = await api
1354
+ .find("user", "123", {
1355
+ include: ["posts", "profile"],
1356
+ })
1357
+ .toPromise();
2023
1358
 
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
- }
1359
+ // Relationships are deserialized onto the resource
1360
+ console.log(user.posts); // Array of post objects
1361
+ console.log(user.profile); // Profile object
2039
1362
  ```
2040
1363
 
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.
1364
+ **Create a resource:**
2042
1365
 
2043
- #### Controlling Purge Behavior with `purge_on_nil`
1366
+ ```typescript
1367
+ const { data: newUser } = await api
1368
+ .create("user", {
1369
+ name: "John Doe",
1370
+ email: "john@example.com",
1371
+ })
1372
+ .toPromise();
1373
+ ```
2044
1374
 
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:
1375
+ **Create with relationships:**
2046
1376
 
2047
- ```ruby
2048
- class UserResource < JSONAPI::Resource
2049
- attributes :name, :email
1377
+ ```typescript
1378
+ const { data: newPost } = await api
1379
+ .create("post", {
1380
+ title: "My Post",
1381
+ body: "Content here",
1382
+ user: { id: "123", type: "users" }, // hasOne - single object
1383
+ })
1384
+ .toPromise();
1385
+ ```
2050
1386
 
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
1387
+ **Update a resource:**
1388
+
1389
+ ```typescript
1390
+ const { data: updated } = await api
1391
+ .update("user", {
1392
+ id: "123",
1393
+ name: "Updated Name",
1394
+ })
1395
+ .toPromise();
2055
1396
  ```
2056
1397
 
2057
- When `purge_on_nil: false` is set:
1398
+ **Delete a resource:**
2058
1399
 
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`)
1400
+ ```typescript
1401
+ await api.destroy("user", "123").toPromise();
1402
+ ```
2062
1403
 
2063
- **Use cases for `purge_on_nil: false`:**
1404
+ ### Query Parameters
2064
1405
 
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
1406
+ **Filtering:**
2068
1407
 
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.
1408
+ ```typescript
1409
+ const { data: users } = await api
1410
+ .findAll("user", {
1411
+ filter: {
1412
+ name_eq: "John",
1413
+ created_at_gte: "2024-01-01",
1414
+ },
1415
+ })
1416
+ .toPromise();
1417
+ ```
2070
1418
 
2071
- #### Append-Only Mode with `append_only`
1419
+ **Sorting:**
2072
1420
 
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:
1421
+ ```typescript
1422
+ const { data: users } = await api
1423
+ .findAll("user", {
1424
+ sort: "name", // Single field ascending
1425
+ })
1426
+ .toPromise();
2074
1427
 
2075
- ```ruby
2076
- class UserResource < JSONAPI::Resource
2077
- attributes :name, :email
1428
+ const { data: users } = await api
1429
+ .findAll("user", {
1430
+ sort: "-created_at", // Descending (prefix with -)
1431
+ })
1432
+ .toPromise();
2078
1433
 
2079
- # Enable append-only mode for documents
2080
- has_many :documents, append_only: true
2081
- end
1434
+ const { data: users } = await api
1435
+ .findAll("user", {
1436
+ sort: ["name", "-created_at"], // Multiple fields
1437
+ })
1438
+ .toPromise();
2082
1439
  ```
2083
1440
 
2084
- **Behavior when `append_only: true` is set:**
1441
+ **Pagination:**
1442
+
1443
+ ```typescript
1444
+ const { data: users, meta } = await api
1445
+ .findAll("user", {
1446
+ page: {
1447
+ number: 1,
1448
+ size: 10,
1449
+ },
1450
+ })
1451
+ .toPromise();
2085
1452
 
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
1453
+ console.log(meta.total); // Total count
1454
+ ```
2087
1455
 
2088
- ```json
2089
- // Existing attachments: [blob1, blob2]
2090
- // Payload includes: [blob3, blob4]
2091
- // Result: [blob1, blob2, blob3, blob4]
2092
- ```
1456
+ **Sparse fieldsets:**
2093
1457
 
2094
- - **Empty array is a no-op:** Sending an empty array `[]` preserves all existing attachments
1458
+ ```typescript
1459
+ const { data: users } = await api
1460
+ .findAll("user", {
1461
+ fields: {
1462
+ users: ["name", "email"],
1463
+ posts: ["title"],
1464
+ },
1465
+ })
1466
+ .toPromise();
1467
+ ```
2095
1468
 
2096
- ```json
2097
- // Existing attachments: [blob1, blob2]
2098
- // Payload includes: []
2099
- // Result: [blob1, blob2] (unchanged)
2100
- ```
1469
+ **Including related resources:**
2101
1470
 
2102
- - **Implicit `purge_on_nil: false`:** When `append_only: true` is set, `purge_on_nil` is automatically set to `false` and cannot be overridden
1471
+ ```typescript
1472
+ const { data: user } = await api
1473
+ .find("user", "123", {
1474
+ include: ["posts", "posts.comments"],
1475
+ })
1476
+ .toPromise();
1477
+ ```
2103
1478
 
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
1479
+ ### Relationship Operations
2105
1480
 
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:
1481
+ **Update a relationship:**
2107
1482
 
2108
- ```ruby
2109
- # This will raise ArgumentError
2110
- has_many :documents, append_only: true, purge_on_nil: true
1483
+ ```typescript
1484
+ await api
1485
+ .one("user", "123")
1486
+ .relationships("posts")
1487
+ .patch([
1488
+ { id: "1", type: "posts" },
1489
+ { id: "2", type: "posts" },
1490
+ ])
1491
+ .toPromise();
2111
1492
  ```
2112
1493
 
2113
- **Use cases for `append_only: true`:**
1494
+ ### Authentication Middleware
2114
1495
 
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
1496
+ Add authentication middleware to automatically inject tokens:
2119
1497
 
2120
- **Note:** `append_only` only applies to `has_many_attached` relationships. For `has_one_attached`, attachments are always replaced regardless of this setting.
1498
+ ```typescript
1499
+ const authMiddleware = {
1500
+ name: "auth",
1501
+ req: (payload) => {
1502
+ const token = localStorage.getItem("auth_token");
1503
+ if (token) {
1504
+ payload.req.headers = {
1505
+ ...payload.req.headers,
1506
+ Authorization: `Bearer ${token}`,
1507
+ };
1508
+ }
1509
+ return payload;
1510
+ },
1511
+ error: (payload) => {
1512
+ if (payload.res?.status === 401) {
1513
+ localStorage.removeItem("auth_token");
1514
+ // Redirect to login
1515
+ }
1516
+ throw payload;
1517
+ },
1518
+ };
2121
1519
 
2122
- Example usage in a controller:
1520
+ api.replaceMiddleware("add-bearer-token", authMiddleware);
1521
+ ```
2123
1522
 
2124
- ```ruby
2125
- def create
2126
- deserializer = JSONAPI::Deserializer.new(params, resource_class: User, action: :create)
2127
- attrs = deserializer.to_params
1523
+ ### Polymorphic Relationships
2128
1524
 
2129
- # attrs will include:
2130
- # { "name" => "Jane Doe", "email" => "jane@example.com", "avatar" => <ActiveStorage::Blob>, "documents" => [<ActiveStorage::Blob>, ...] }
1525
+ For polymorphic relationships, omit the `type` in the model definition and provide it when creating/updating:
2131
1526
 
2132
- user = User.create!(attrs)
2133
- # Attachments are automatically attached via ActiveStorage
2134
- end
1527
+ ```typescript
1528
+ // Model definition - no type constraint
1529
+ api.define(
1530
+ "workstream",
1531
+ {
1532
+ topic: {},
1533
+ subject: {
1534
+ jsonApi: "hasOne",
1535
+ // No type - polymorphic
1536
+ },
1537
+ },
1538
+ {
1539
+ collectionPath: "workstreams",
1540
+ }
1541
+ );
1542
+
1543
+ // Create with polymorphic relationship
1544
+ const { data: workstream } = await api
1545
+ .create("workstream", {
1546
+ topic: "edit",
1547
+ subject: { id: "456", type: "vendors" }, // Provide type at runtime
1548
+ })
1549
+ .toPromise();
2135
1550
  ```
2136
1551
 
2137
1552
  ### Error Handling
2138
1553
 
2139
- Invalid signed IDs will raise `ActiveSupport::MessageVerifier::InvalidSignature`, which should be handled appropriately in your controllers.
1554
+ ```typescript
1555
+ try {
1556
+ const { data: user } = await api.find("user", "123").toPromise();
1557
+ } catch (error) {
1558
+ if (error.response?.status === 401) {
1559
+ // Authentication error
1560
+ } else if (error.response?.status === 422) {
1561
+ // Validation errors
1562
+ const errors = error.response.data.errors;
1563
+ errors.forEach((err) => {
1564
+ console.error(`${err.source?.pointer}: ${err.detail}`);
1565
+ });
1566
+ } else if (error.response?.status === 400) {
1567
+ // Bad request (invalid filters, sort fields, etc.)
1568
+ console.error(error.response.data.errors);
1569
+ }
1570
+ }
1571
+ ```
2140
1572
 
2141
- ### Built-in ActiveStorage Resource
1573
+ ### Key Integration Points
2142
1574
 
2143
- The gem provides a built-in `JSONAPI::ActiveStorageBlobResource` that automatically serializes ActiveStorage blobs with:
1575
+ | Rails (json_api gem) | devour-client-ts |
1576
+ | ------------------------------ | -------------------------------------------------- |
1577
+ | `attributes :name, :email` | `{ name: {}, email: {} }` |
1578
+ | `has_many :posts` | `posts: { jsonApi: 'hasMany', type: 'posts' }` |
1579
+ | `has_one :profile` | `profile: { jsonApi: 'hasOne', type: 'profiles' }` |
1580
+ | `filters :name_eq` | `filter: { name_eq: 'value' }` |
1581
+ | `sort=name,-date` | `sort: ['name', '-date']` |
1582
+ | `include=posts.comments` | `include: ['posts', 'posts.comments']` |
1583
+ | `page[number]=1&page[size]=10` | `page: { number: 1, size: 10 }` |
1584
+ | `fields[users]=name,email` | `fields: { users: ['name', 'email'] }` |
2144
1585
 
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
1586
+ ### Common Gotchas
2150
1587
 
2151
- This resource is automatically used when serializing ActiveStorage attachments.
1588
+ 1. **Plural types required**: All `type` values in relationships must be plural (`users`, not `user`)
1589
+ 2. **Relationship assignment**: Assign relationships directly on the resource (`user: { id, type }`), not in a `relationships` wrapper
1590
+ 3. **Content-Type header**: Must be `application/vnd.api+json` for POST/PATCH/PUT requests
1591
+ 4. **Filter values**: Comma-separated filter values are parsed as a single string; use array notation for multiple values
2152
1592
 
2153
1593
  ## Contributing
2154
1594