jpie 0.4.4 → 1.0.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +35 -110
  5. data/.travis.yml +7 -0
  6. data/Gemfile +21 -0
  7. data/Gemfile.lock +312 -0
  8. data/README.md +2072 -140
  9. data/Rakefile +3 -14
  10. data/bin/console +15 -0
  11. data/bin/setup +8 -0
  12. data/jpie.gemspec +18 -35
  13. data/kiln/app/resources/user_message_resource.rb +2 -0
  14. data/lib/jpie.rb +3 -28
  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/{jpie → json_api}/version.rb +2 -2
  46. data/lib/json_api.rb +50 -0
  47. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  48. metadata +50 -167
  49. data/.cursor/rules/dependencies.mdc +0 -19
  50. data/.cursor/rules/examples.mdc +0 -16
  51. data/.cursor/rules/git.mdc +0 -14
  52. data/.cursor/rules/project_structure.mdc +0 -30
  53. data/.cursor/rules/security.mdc +0 -14
  54. data/.cursor/rules/style.mdc +0 -15
  55. data/.cursor/rules/testing.mdc +0 -16
  56. data/.overcommit.yml +0 -35
  57. data/CHANGELOG.md +0 -164
  58. data/LICENSE.txt +0 -21
  59. data/examples/basic_example.md +0 -146
  60. data/examples/including_related_resources.md +0 -491
  61. data/examples/pagination.md +0 -303
  62. data/examples/relationships.md +0 -114
  63. data/examples/resource_attribute_configuration.md +0 -147
  64. data/examples/resource_meta_configuration.md +0 -244
  65. data/examples/single_table_inheritance.md +0 -160
  66. data/lib/jpie/configuration.rb +0 -12
  67. data/lib/jpie/controller/crud_actions.rb +0 -141
  68. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  69. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  70. data/lib/jpie/controller/error_handling.rb +0 -23
  71. data/lib/jpie/controller/json_api_validation.rb +0 -193
  72. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  73. data/lib/jpie/controller/related_actions.rb +0 -45
  74. data/lib/jpie/controller/relationship_actions.rb +0 -291
  75. data/lib/jpie/controller/relationship_validation.rb +0 -117
  76. data/lib/jpie/controller/rendering.rb +0 -154
  77. data/lib/jpie/controller.rb +0 -45
  78. data/lib/jpie/deserializer.rb +0 -110
  79. data/lib/jpie/errors.rb +0 -117
  80. data/lib/jpie/generators/resource_generator.rb +0 -116
  81. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  82. data/lib/jpie/railtie.rb +0 -42
  83. data/lib/jpie/resource/attributable.rb +0 -112
  84. data/lib/jpie/resource/inferrable.rb +0 -43
  85. data/lib/jpie/resource/sortable.rb +0 -93
  86. data/lib/jpie/resource.rb +0 -147
  87. data/lib/jpie/routing.rb +0 -59
  88. data/lib/jpie/rspec.rb +0 -71
  89. data/lib/jpie/serializer.rb +0 -205
data/README.md CHANGED
@@ -1,227 +1,2159 @@
1
- # JPie
1
+ # JSONAPI
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/jpie.svg)](https://badge.fury.io/rb/jpie)
4
- [![Build Status](https://github.com/emilkampp/jpie/workflows/CI/badge.svg)](https://github.com/emilkampp/jpie/actions)
3
+ A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API controllers for producing and consuming JSON:API resources.
5
4
 
6
- JPie is a modern, lightweight Rails library for developing JSON:API compliant servers. It focuses on clean architecture with strong separation of concerns and extensibility.
5
+ ## Features
7
6
 
8
- ## Key Features
9
-
10
- ✨ **Modern Rails DSL** - Clean, intuitive syntax following Rails conventions
11
- 🔧 **Method Overrides** - Define custom attribute methods directly on resource classes
12
- 🎯 **Smart Inference** - Automatic model and resource class detection
13
- **Powerful Generators** - Scaffold resources with relationships, meta attributes, and automatic inference
14
- 📊 **Polymorphic Support** - Full support for complex polymorphic associations
15
- 🔄 **STI Ready** - Single Table Inheritance works out of the box
16
- 🔗 **Through Associations** - Full support for Rails `:through` associations
17
- **Performance Optimized** - Efficient serialization with intelligent deduplication
18
- 🛡️ **Authorization Ready** - Built-in scoping support for security
19
- 📋 **JSON:API Compliant** - Full specification compliance with sorting, includes, and meta
20
- 🚨 **Robust Error Handling** - Smart inheritance-aware error handling with full customization options
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
21
17
 
22
18
  ## Installation
23
19
 
24
- Add JPie to your Rails application:
20
+ Add this line to your application's Gemfile:
25
21
 
26
- ```bash
27
- bundle add jpie
22
+ ```ruby
23
+ gem 'json_api'
28
24
  ```
29
25
 
30
- ## Development Setup
26
+ And then execute:
31
27
 
32
- This project uses [Overcommit](https://github.com/sds/overcommit) to enforce code quality through Git hooks. After cloning the repository:
28
+ $ bundle install
33
29
 
34
- ### Quick Setup (Recommended)
30
+ Or install it yourself as:
35
31
 
36
- ```bash
37
- # One command to set up everything
38
- ./bin/setup-hooks
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
39
55
  ```
40
56
 
41
- ### Manual Setup
57
+ This creates standard RESTful routes (index, show, create, update, destroy) that default to the `json_api/resources` controller and `jsonapi` format.
42
58
 
43
- If you prefer to set up manually:
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.
44
60
 
45
- ```bash
46
- # 1. Install dependencies
47
- bundle install
61
+ ### Resource Definitions
48
62
 
49
- # 2. Install overcommit globally (one-time setup)
50
- gem install overcommit
63
+ Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:
51
64
 
52
- # 3. Install the Git hooks for this project
53
- overcommit --install
65
+ ```ruby
66
+ # app/resources/user_resource.rb
67
+ class UserResource < JSONAPI::Resource
68
+ attributes :email, :name, :phone
54
69
 
55
- # 4. Sign the configuration (required for security)
56
- overcommit --sign
70
+ has_many :posts
71
+ has_one :profile
72
+ end
57
73
  ```
58
74
 
59
- ### 3. Automated Quality Checks
75
+ Resource classes must:
60
76
 
61
- The following checks run automatically:
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`
62
81
 
63
- **Pre-commit hooks:**
64
- - ✅ **RuboCop** - Code style and quality analysis
65
- - ✅ **Trailing whitespace** - Prevents whitespace issues
66
- - ✅ **Merge conflicts** - Catches unresolved conflicts
82
+ The generic controller uses these resource definitions to:
67
83
 
68
- **Pre-push hooks:**
69
- - **RSpec** - Full test suite execution
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
70
89
 
71
- ### 4. Manual Quality Checks
90
+ ### Virtual Attributes
72
91
 
73
- You can run these checks manually:
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.
74
93
 
75
- ```bash
76
- # Run RuboCop with auto-fix
77
- bundle exec rubocop -A
94
+ #### Defining Virtual Attributes
78
95
 
79
- # Run tests
80
- bundle exec rspec
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
81
101
 
82
- # Test hooks without committing
83
- overcommit --run pre-commit
84
- overcommit --run pre-push
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
+ }
85
123
  ```
86
124
 
87
- ## Quick Start
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
+ ```
88
139
 
89
- JPie works out of the box with minimal configuration:
140
+ #### Virtual Attribute Setters
90
141
 
91
- ### 1. Create Your Model
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
143
 
93
144
  ```ruby
94
- class User < ActiveRecord::Base
95
- validates :name, presence: true
96
- validates :email, presence: true, uniqueness: true
97
-
98
- has_many :posts, dependent: :destroy
99
- has_one :profile, dependent: :destroy
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
100
169
  end
101
170
  ```
102
171
 
103
- ### 2. Create Your Resource
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:
104
179
 
105
180
  ```ruby
106
- class UserResource < JPie::Resource
107
- attributes :name, :email
108
- meta_attributes :created_at, :updated_at
109
-
110
- has_many :posts
111
- has_one :profile
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}%") }
112
447
  end
113
448
  ```
114
449
 
115
- ### 3. Create Your Controller
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`.
116
476
 
117
477
  ```ruby
118
- class UsersController < ApplicationController
119
- include JPie::Controller
478
+ class UserResource < JSONAPI::Resource
479
+ has_many :post_comments, readonly: true
120
480
  end
121
481
  ```
122
482
 
123
- ### 4. Set Up Routes
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:
124
725
 
125
726
  ```ruby
727
+ # config/routes.rb
126
728
  Rails.application.routes.draw do
127
- resources :users
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
128
756
  end
129
757
  ```
130
758
 
131
- That's it! You now have a fully functional JSON:API compliant server with automatic CRUD operations, sorting, includes, and validation.
759
+ ##### Serialization
132
760
 
133
- ## 📚 Comprehensive Examples
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:
134
762
 
135
- JPie includes a complete set of examples demonstrating all features:
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
136
787
 
137
- - **[🚀 Basic Usage](https://github.com/emilkampp/jpie/blob/main/examples/basic_usage.rb)** - Fundamental setup and configuration
138
- - **[🔗 Through Associations](https://github.com/emilkampp/jpie/blob/main/examples/through_associations.rb)** - Many-to-many relationships with `:through`
139
- - **[🎨 Custom Attributes & Meta](https://github.com/emilkampp/jpie/blob/main/examples/custom_attributes_and_meta.rb)** - Custom computed attributes and meta data
140
- - **[🔄 Polymorphic Associations](https://github.com/emilkampp/jpie/blob/main/examples/polymorphic_associations.rb)** - Complex polymorphic relationships
141
- - **[🏗️ Single Table Inheritance](https://github.com/emilkampp/jpie/blob/main/examples/single_table_inheritance.rb)** - STI models and resources
142
- - **[📊 Custom Sorting](https://github.com/emilkampp/jpie/blob/main/examples/custom_sorting.rb)** - Advanced sorting with complex algorithms
143
- - **[⚠️ Error Handling](https://github.com/emilkampp/jpie/blob/main/examples/error_handling.rb)** - Comprehensive error handling strategies
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:
144
789
 
145
- Each example is self-contained with models, resources, controllers, and sample API requests/responses. **[📋 View all examples →](https://github.com/emilkampp/jpie/blob/main/examples/)**
790
+ ```json
791
+ POST /notifications
792
+ Content-Type: application/vnd.api+json
146
793
 
147
- ## Generators
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
+ ```
148
810
 
149
- JPie includes a resource generator for quickly creating new resource classes:
811
+ The controller automatically instantiates the correct model class based on the `type` field.
150
812
 
151
- ### Basic Usage
813
+ #### STI resource DSL inheritance
152
814
 
153
- ```bash
154
- # Generate a basic resource with semantic syntax
155
- rails generate jpie:resource User attribute:name attribute:email meta:created_at
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
+ ```
156
837
 
157
- # Shorthand for relationships
158
- rails generate jpie:resource Post attribute:title attribute:content has_many:comments has_one:author
838
+ Invalid sort fields will return a `400 Bad Request` error with a JSON:API error response:
159
839
 
160
- # Mix explicit categorization with auto-detection
161
- rails generate jpie:resource User attribute:name email created_at updated_at
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
+ }
162
850
  ```
163
851
 
164
- **Generated file:**
852
+ The sort parameter is ignored on show endpoints (single resource endpoints). However, relationship endpoints support sorting for collection relationships:
853
+
165
854
  ```ruby
166
- class UserResource < JPie::Resource
167
- attributes :name, :email
168
- meta_attributes :created_at, :updated_at
169
-
170
- has_many :comments
171
- has_one :author
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
172
885
  end
173
886
  ```
174
887
 
175
- ### Semantic Field Syntax
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
+ ```
176
898
 
177
- | Syntax | Purpose | Example |
178
- |--------|---------|---------|
179
- | `attribute:field` | Regular JSON:API attribute | `attribute:name` |
180
- | `meta:field` | JSON:API meta attribute | `meta:created_at` |
181
- | `has_many:resource` | JSON:API relationship | `has_many:posts` |
182
- | `has_one:resource` | JSON:API relationship | `has_one:profile` |
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.
183
900
 
184
- ### Generator Options
901
+ #### Sort-Only Fields
185
902
 
186
- | Option | Description | Example |
187
- |--------|-------------|---------|
188
- | `--model=NAME` | Specify model class | `--model=Person` |
189
- | `--skip-model` | Skip explicit model declaration | `--skip-model` |
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.
190
904
 
191
- ### Modern DSL
905
+ To declare a sort-only field, use `sortable_fields` in your resource class and implement a getter method:
192
906
 
193
907
  ```ruby
194
- class UserResource < JPie::Resource
195
- # Multiple attributes at once
196
- attributes :name, :email, :created_at
197
-
198
- # Meta attributes (for additional data)
199
- meta :account_status, :last_login
200
-
201
- # Relationships for includes
202
- has_many :posts
203
- has_one :profile
204
-
205
- # Custom sorting
206
- sortable :popularity do |query, direction|
207
- query.order(likes_count: direction)
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
208
917
  end
209
-
210
- # Custom attribute methods (modern approach)
211
- private
212
-
213
- def account_status
214
- object.active? ? 'active' : 'inactive'
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
+ }
215
1033
  end
216
1034
  end
217
1035
  ```
218
1036
 
219
- See the examples folder for more examples of how to use the DSL to solve various serialization/deserialization scenarios.
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.
220
2152
 
221
2153
  ## Contributing
222
2154
 
223
- Bug reports and pull requests are welcome on GitHub at https://github.com/emilkampp/jpie.
2155
+ Bug reports and pull requests are welcome on GitHub at https://github.com/klaay/json_api.
224
2156
 
225
2157
  ## License
226
2158
 
227
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
2159
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).