jpie 0.4.5 → 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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +26 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +76 -107
  6. data/.travis.yml +7 -0
  7. data/Gemfile +23 -0
  8. data/Gemfile.lock +321 -0
  9. data/README.md +1508 -136
  10. data/Rakefile +3 -14
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/jpie.gemspec +21 -38
  14. data/kiln/app/resources/user_message_resource.rb +4 -0
  15. data/lib/jpie.rb +3 -25
  16. data/lib/json_api/active_storage/deserialization.rb +116 -0
  17. data/lib/json_api/active_storage/detection.rb +69 -0
  18. data/lib/json_api/active_storage/serialization.rb +34 -0
  19. data/lib/json_api/configuration.rb +57 -0
  20. data/lib/json_api/controllers/base_controller.rb +26 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  22. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  23. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  24. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  25. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  26. data/lib/json_api/controllers/concerns/controller_helpers.rb +19 -0
  27. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  28. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  29. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  30. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  31. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  32. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  33. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  36. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  37. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  38. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  39. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  40. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  44. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  45. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  46. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  47. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  48. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  49. data/lib/json_api/controllers/concerns/resource_actions.rb +106 -0
  50. data/lib/json_api/controllers/relationships_controller.rb +108 -0
  51. data/lib/json_api/controllers/resources_controller.rb +6 -0
  52. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  53. data/lib/json_api/railtie.rb +112 -0
  54. data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
  55. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  56. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  57. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  58. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  59. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  60. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  61. data/lib/json_api/resources/resource.rb +32 -0
  62. data/lib/json_api/resources/resource_loader.rb +35 -0
  63. data/lib/json_api/routing.rb +81 -0
  64. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  65. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  66. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  67. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  68. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  69. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  70. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  71. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  72. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  73. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  74. data/lib/json_api/serialization/deserializer.rb +26 -0
  75. data/lib/json_api/serialization/serializer.rb +77 -0
  76. data/lib/json_api/support/active_storage_support.rb +82 -0
  77. data/lib/json_api/support/collection_query.rb +50 -0
  78. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  79. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  80. data/lib/json_api/support/concerns/pagination.rb +30 -0
  81. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  82. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  83. data/lib/json_api/support/concerns/sorting.rb +88 -0
  84. data/lib/json_api/support/instrumentation.rb +43 -0
  85. data/lib/json_api/support/param_helpers.rb +54 -0
  86. data/lib/json_api/support/relationship_guard.rb +16 -0
  87. data/lib/json_api/support/relationship_helpers.rb +76 -0
  88. data/lib/json_api/support/resource_identifier.rb +87 -0
  89. data/lib/json_api/support/responders.rb +100 -0
  90. data/lib/json_api/support/response_helpers.rb +10 -0
  91. data/lib/json_api/support/sort_parsing.rb +21 -0
  92. data/lib/json_api/support/type_conversion.rb +21 -0
  93. data/lib/json_api/testing/test_helper.rb +76 -0
  94. data/lib/json_api/testing.rb +3 -0
  95. data/lib/{jpie → json_api}/version.rb +2 -2
  96. data/lib/json_api.rb +50 -0
  97. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  98. metadata +100 -169
  99. data/.cursor/rules/dependencies.mdc +0 -19
  100. data/.cursor/rules/examples.mdc +0 -16
  101. data/.cursor/rules/git.mdc +0 -14
  102. data/.cursor/rules/project_structure.mdc +0 -30
  103. data/.cursor/rules/publish_gem.mdc +0 -73
  104. data/.cursor/rules/security.mdc +0 -14
  105. data/.cursor/rules/style.mdc +0 -15
  106. data/.cursor/rules/testing.mdc +0 -16
  107. data/.overcommit.yml +0 -35
  108. data/CHANGELOG.md +0 -164
  109. data/LICENSE.txt +0 -21
  110. data/PUBLISHING.md +0 -111
  111. data/examples/basic_example.md +0 -146
  112. data/examples/including_related_resources.md +0 -491
  113. data/examples/pagination.md +0 -303
  114. data/examples/relationships.md +0 -114
  115. data/examples/resource_attribute_configuration.md +0 -147
  116. data/examples/resource_meta_configuration.md +0 -244
  117. data/examples/rspec_testing.md +0 -130
  118. data/examples/single_table_inheritance.md +0 -160
  119. data/lib/jpie/configuration.rb +0 -12
  120. data/lib/jpie/controller/crud_actions.rb +0 -141
  121. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  122. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  123. data/lib/jpie/controller/error_handling.rb +0 -23
  124. data/lib/jpie/controller/json_api_validation.rb +0 -193
  125. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  126. data/lib/jpie/controller/related_actions.rb +0 -45
  127. data/lib/jpie/controller/relationship_actions.rb +0 -291
  128. data/lib/jpie/controller/relationship_validation.rb +0 -117
  129. data/lib/jpie/controller/rendering.rb +0 -154
  130. data/lib/jpie/controller.rb +0 -45
  131. data/lib/jpie/deserializer.rb +0 -110
  132. data/lib/jpie/errors.rb +0 -117
  133. data/lib/jpie/generators/resource_generator.rb +0 -116
  134. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  135. data/lib/jpie/railtie.rb +0 -42
  136. data/lib/jpie/resource/attributable.rb +0 -112
  137. data/lib/jpie/resource/inferrable.rb +0 -43
  138. data/lib/jpie/resource/sortable.rb +0 -93
  139. data/lib/jpie/resource.rb +0 -147
  140. data/lib/jpie/routing.rb +0 -59
  141. data/lib/jpie/serializer.rb +0 -205
data/README.md CHANGED
@@ -1,227 +1,1599 @@
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:
25
-
26
20
  ```bash
27
21
  bundle add jpie
28
22
  ```
29
23
 
30
- ## Development Setup
24
+ ## Requirements
31
25
 
32
- This project uses [Overcommit](https://github.com/sds/overcommit) to enforce code quality through Git hooks. After cloning the repository:
26
+ - Ruby >= 3.4.0
27
+ - Rails >= 8.0.0
33
28
 
34
- ### Quick Setup (Recommended)
29
+ ## Routing
35
30
 
36
- ```bash
37
- # One command to set up everything
38
- ./bin/setup-hooks
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:
32
+
33
+ ```ruby
34
+ # config/routes.rb
35
+ Rails.application.routes.draw do
36
+ jsonapi_resources :users
37
+ jsonapi_resources :posts
38
+ end
39
39
  ```
40
40
 
41
- ### Manual Setup
41
+ To use a custom controller instead of the default:
42
42
 
43
- If you prefer to set up manually:
43
+ ```ruby
44
+ jsonapi_resources :users, controller: "api/users"
45
+ ```
44
46
 
45
- ```bash
46
- # 1. Install dependencies
47
- bundle install
47
+ ## Resource Definitions
48
+
49
+ Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:
50
+
51
+ ```ruby
52
+ # app/resources/user_resource.rb
53
+ class UserResource < JSONAPI::Resource
54
+ attributes :email, :name, :phone
55
+
56
+ has_many :posts
57
+ has_one :profile
58
+ end
59
+ ```
48
60
 
49
- # 2. Install overcommit globally (one-time setup)
50
- gem install overcommit
61
+ ### Virtual Attributes
51
62
 
52
- # 3. Install the Git hooks for this project
53
- overcommit --install
63
+ To create a virtual attribute, declare it in `attributes` and implement a getter method:
54
64
 
55
- # 4. Sign the configuration (required for security)
56
- overcommit --sign
65
+ ```ruby
66
+ class UserResource < JSONAPI::Resource
67
+ attributes :name, :email, :full_name
68
+
69
+ # Virtual attribute getter
70
+ def full_name
71
+ "#{resource.name} (#{resource.email})"
72
+ end
73
+ end
57
74
  ```
58
75
 
59
- ### 3. Automated Quality Checks
76
+ The getter method receives the underlying model instance via `resource`. Virtual attributes are serialized just like regular attributes and appear in the response:
60
77
 
61
- The following checks run automatically:
78
+ ```json
79
+ {
80
+ "data": {
81
+ "type": "users",
82
+ "id": "1",
83
+ "attributes": {
84
+ "name": "John Doe",
85
+ "email": "john@example.com",
86
+ "full_name": "John Doe (john@example.com)"
87
+ }
88
+ }
89
+ }
90
+ ```
62
91
 
63
- **Pre-commit hooks:**
64
- - ✅ **RuboCop** - Code style and quality analysis
65
- - ✅ **Trailing whitespace** - Prevents whitespace issues
66
- - ✅ **Merge conflicts** - Catches unresolved conflicts
92
+ You can also define setters for virtual attributes that transform incoming values into real model attributes:
67
93
 
68
- **Pre-push hooks:**
69
- - **RSpec** - Full test suite execution
94
+ ```ruby
95
+ class UserResource < JSONAPI::Resource
96
+ attributes :name, :email, :display_name
97
+ creatable_fields :name, :email, :display_name
98
+ updatable_fields :name, :display_name
70
99
 
71
- ### 4. Manual Quality Checks
100
+ def initialize(resource = nil, context = {})
101
+ super
102
+ @transformed_params = {}
103
+ end
72
104
 
73
- You can run these checks manually:
105
+ # Setter that transforms virtual attribute to model attribute
106
+ def display_name=(value)
107
+ @transformed_params["name"] = value.upcase
108
+ end
74
109
 
75
- ```bash
76
- # Run RuboCop with auto-fix
77
- bundle exec rubocop -A
110
+ # Getter for the virtual attribute (for serialization)
111
+ def display_name
112
+ resource&.name&.downcase
113
+ end
114
+
115
+ # Return transformed params accumulated by setters
116
+ def transformed_params
117
+ @transformed_params || {}
118
+ end
119
+ end
120
+ ```
121
+
122
+ 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.
123
+
124
+ **Important**: You must initialize `@transformed_params` in your `initialize` method if you use setters that modify it.
125
+
126
+ ### Creatable and Updatable Fields
127
+
128
+ By default, all attributes are available for both create and update. Restrict fields per operation:
129
+
130
+ ```ruby
131
+ class UserResource < JSONAPI::Resource
132
+ attributes :name, :email, :phone, :role
133
+
134
+ creatable_fields :name, :email, :phone # role is system-set
135
+ updatable_fields :name, :phone # email is immutable
136
+ end
137
+ ```
138
+
139
+ ## Relationship Endpoints
140
+
141
+ Manage relationship links independently of the parent resource. This will not delete the related resource itself, but only manage the relationship.
142
+
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.
144
+
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
148
+
149
+ ## Filtering
150
+
151
+ Declare permitted filters in your resource class:
152
+
153
+ ```ruby
154
+ class UserResource < JSONAPI::Resource
155
+ attributes :name, :email, :phone
156
+ filters :name_eq, :name_match, :created_at_gte
157
+ end
158
+ ```
159
+
160
+ Filters ending in `_eq`, `_match`, `_lt`, `_lte`, `_gt`, or `_gte` are applied to the corresponding column:
161
+
162
+ ```
163
+ GET /users?filter[name_eq]=John
164
+ GET /users?filter[created_at_gte]=2024-01-01
165
+ ```
78
166
 
79
- # Run tests
80
- bundle exec rspec
167
+ Filter through relationships by nesting the filter key:
81
168
 
82
- # Test hooks without committing
83
- overcommit --run pre-commit
84
- overcommit --run pre-push
85
169
  ```
170
+ GET /posts?filter[user][email]=jane@example.com
171
+ GET /comments?filter[post][user][id]=123
172
+ ```
173
+
174
+ To pass multiple values, use bracket notation:
175
+
176
+ ```
177
+ GET /users?filter[roles][]=admin&filter[roles][]=editor
178
+ ```
179
+
180
+ ## Pagination
181
+
182
+ Paginate collections with `page[number]` and `page[size]`:
183
+
184
+ ```
185
+ GET /users?page[number]=1&page[size]=10
186
+ GET /users?page[number]=2&page[size]=25
187
+ ```
188
+
189
+ Paginated responses include a `links` object with `self`, `first`, `last`, and conditional `prev`/`next` URLs, plus `meta.total` with the full count:
190
+
191
+ ```json
192
+ {
193
+ "data": [...],
194
+ "links": {
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"
200
+ },
201
+ "meta": { "total": 42 }
202
+ }
203
+ ```
204
+
205
+ Default page size is 25, maximum is 100 (configurable).
86
206
 
87
- ## Quick Start
207
+ ## Includes
88
208
 
89
- JPie works out of the box with minimal configuration:
209
+ Include related resources with the `include` parameter:
90
210
 
91
- ### 1. Create Your Model
211
+ ```
212
+ GET /users?include=posts
213
+ GET /users?include=posts,comments
214
+ GET /users?include=posts.comments.author
215
+ ```
216
+
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`:
218
+
219
+ ```json
220
+ {
221
+ "data": {
222
+ "type": "users",
223
+ "id": "1",
224
+ "relationships": {
225
+ "posts": {
226
+ "data": [{ "type": "posts", "id": "5" }]
227
+ }
228
+ }
229
+ },
230
+ "included": [
231
+ {
232
+ "type": "posts",
233
+ "id": "5",
234
+ "attributes": { "title": "Hello World" }
235
+ }
236
+ ]
237
+ }
238
+ ```
239
+
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`:
92
250
 
93
251
  ```ruby
94
252
  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
253
+ belongs_to :profile, polymorphic: true
100
254
  end
101
255
  ```
102
256
 
103
- ### 2. Create Your Resource
257
+ Define a resource class for each concrete type:
104
258
 
105
259
  ```ruby
106
- class UserResource < JPie::Resource
260
+ # app/resources/user_resource.rb
261
+ class UserResource < JSONAPI::Resource
107
262
  attributes :name, :email
108
- meta_attributes :created_at, :updated_at
109
-
110
- has_many :posts
111
- has_one :profile
263
+ has_one :profile # auto-detected as polymorphic from model
112
264
  end
265
+
266
+ # app/resources/admin_profile_resource.rb
267
+ class AdminProfileResource < JSONAPI::Resource
268
+ attributes :department, :level
269
+ end
270
+
271
+ # app/resources/customer_profile_resource.rb
272
+ class CustomerProfileResource < JSONAPI::Resource
273
+ attributes :company_name, :industry
274
+ end
275
+ ```
276
+
277
+ The gem auto-detects polymorphism from the model's association. To override, use `polymorphic: true` explicitly.
278
+
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
287
+
288
+ {
289
+ "data": {
290
+ "type": "users",
291
+ "id": "1",
292
+ "relationships": {
293
+ "profile": {
294
+ "data": { "type": "admin_profiles", "id": "5" }
295
+ }
296
+ }
297
+ },
298
+ "included": [
299
+ {
300
+ "type": "admin_profiles",
301
+ "id": "5",
302
+ "attributes": { "department": "Engineering", "level": "Senior" }
303
+ }
304
+ ]
305
+ }
113
306
  ```
114
307
 
115
- ### 3. Create Your Controller
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:
116
329
 
117
330
  ```ruby
118
- class UsersController < ApplicationController
119
- include JPie::Controller
331
+ jsonapi_resources :admin_profiles
332
+ jsonapi_resources :customer_profiles
333
+ ```
334
+
335
+ ## Single Table Inheritance (STI)
336
+
337
+ STI subclasses are treated as first-class JSON:API resources with their own types. Use the `sti` option in routes:
338
+
339
+ ```ruby
340
+ jsonapi_resources :notifications, sti: [:email_notifications, :sms_notifications]
341
+ ```
342
+
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
120
362
  end
121
363
  ```
122
364
 
123
- ### 4. Set Up Routes
365
+ Subclass resources inherit attributes from the parent:
124
366
 
125
367
  ```ruby
126
- Rails.application.routes.draw do
127
- resources :users
368
+ class NotificationResource < JSONAPI::Resource
369
+ attributes :subject, :body
370
+ end
371
+
372
+ class EmailNotificationResource < NotificationResource
373
+ attributes :recipient_email # inherits :subject, :body
374
+ end
375
+
376
+ class SmsNotificationResource < NotificationResource
377
+ attributes :phone_number # inherits :subject, :body
128
378
  end
129
379
  ```
130
380
 
131
- That's it! You now have a fully functional JSON:API compliant server with automatic CRUD operations, sorting, includes, and validation.
381
+ The base endpoint returns all subtypes with their concrete types:
382
+
383
+ ```http
384
+ GET /notifications HTTP/1.1
385
+ Accept: application/vnd.api+json
132
386
 
133
- ## 📚 Comprehensive Examples
387
+ HTTP/1.1 200 OK
388
+ Content-Type: application/vnd.api+json
134
389
 
135
- JPie includes a complete set of examples demonstrating all features:
390
+ {
391
+ "data": [
392
+ {
393
+ "type": "email_notifications",
394
+ "id": "1",
395
+ "attributes": {
396
+ "subject": "Welcome",
397
+ "body": "...",
398
+ "recipient_email": "user@example.com"
399
+ }
400
+ },
401
+ {
402
+ "type": "sms_notifications",
403
+ "id": "2",
404
+ "attributes": {
405
+ "subject": "Alert",
406
+ "body": "...",
407
+ "phone_number": "555-1234"
408
+ }
409
+ }
410
+ ]
411
+ }
412
+ ```
136
413
 
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
414
+ To create or update, use the subtype endpoint with the concrete type:
144
415
 
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/)**
416
+ ```http
417
+ POST /email_notifications HTTP/1.1
418
+ Content-Type: application/vnd.api+json
419
+ Accept: application/vnd.api+json
146
420
 
147
- ## Generators
421
+ {
422
+ "data": {
423
+ "type": "email_notifications",
424
+ "attributes": {
425
+ "subject": "Welcome",
426
+ "body": "Hello",
427
+ "recipient_email": "user@example.com"
428
+ }
429
+ }
430
+ }
431
+ ```
148
432
 
149
- JPie includes a resource generator for quickly creating new resource classes:
433
+ ## Sorting
150
434
 
151
- ### Basic Usage
435
+ Sort collections with the `sort` parameter. Prefix with `-` for descending. Seperate multiple sorts with comma `,`:
152
436
 
153
- ```bash
154
- # Generate a basic resource with semantic syntax
155
- rails generate jpie:resource User attribute:name attribute:email meta:created_at
437
+ ```http
438
+ GET /users?sort=name,-created_at HTTP/1.1
439
+ Accept: application/vnd.api+json
440
+ ```
156
441
 
157
- # Shorthand for relationships
158
- rails generate jpie:resource Post attribute:title attribute:content has_many:comments has_one:author
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:
159
443
 
160
- # Mix explicit categorization with auto-detection
161
- rails generate jpie:resource User attribute:name email created_at updated_at
444
+ ```ruby
445
+ class User < ActiveRecord::Base
446
+ has_many :posts
447
+ end
162
448
  ```
163
449
 
164
- **Generated file:**
165
450
  ```ruby
166
- class UserResource < JPie::Resource
451
+ class UserResource < JSONAPI::Resource
167
452
  attributes :name, :email
168
- meta_attributes :created_at, :updated_at
169
-
170
- has_many :comments
171
- has_one :author
453
+ sortable_fields :posts_count
454
+
455
+ def posts_count
456
+ resource.posts.size
457
+ end
458
+ end
459
+ ```
460
+
461
+ ## JSON:API Object
462
+
463
+ All responses automatically include a `jsonapi` object indicating JSON:API version compliance:
464
+
465
+ ```json
466
+ {
467
+ "jsonapi": {
468
+ "version": "1.1"
469
+ },
470
+ "data": { ... }
471
+ }
472
+ ```
473
+
474
+ The version is hardcoded to "1.1". You can add meta information to the jsonapi object via configuration:
475
+
476
+ ```ruby
477
+ # config/initializers/json_api.rb
478
+ JSONAPI.configure do |config|
479
+ config.jsonapi_meta = { ext: ["https://jsonapi.org/ext/atomic"] }
172
480
  end
173
481
  ```
174
482
 
175
- ### Semantic Field Syntax
483
+ ## Meta Information
176
484
 
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` |
485
+ The gem supports meta information at three levels: document-level, resource-level, and relationship-level.
183
486
 
184
- ### Generator Options
487
+ ### Document-Level Meta
185
488
 
186
- | Option | Description | Example |
187
- |--------|-------------|---------|
188
- | `--model=NAME` | Specify model class | `--model=Person` |
189
- | `--skip-model` | Skip explicit model declaration | `--skip-model` |
489
+ Document-level meta appears at the top level of the response. Pagination automatically includes `total` when pagination is applied.
190
490
 
191
- ### Modern DSL
491
+ To add custom document-level meta globally, configure `document_meta_resolver`:
192
492
 
193
493
  ```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)
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
+ }
208
500
  end
209
-
210
- # Custom attribute methods (modern approach)
501
+ end
502
+ ```
503
+
504
+ The resolver receives the controller instance and returns a hash that is merged with pagination meta:
505
+
506
+ ```json
507
+ {
508
+ "jsonapi": { "version": "1.1" },
509
+ "data": [...],
510
+ "meta": {
511
+ "request_id": "abc-123",
512
+ "api_version": "v1",
513
+ "total": 100
514
+ }
515
+ }
516
+ ```
517
+
518
+ For per-controller customization, override `jsonapi_document_meta` in a custom controller:
519
+
520
+ ```ruby
521
+ class Api::UsersController < JSONAPI::ResourcesController
211
522
  private
212
-
213
- def account_status
214
- object.active? ? 'active' : 'inactive'
523
+
524
+ def jsonapi_document_meta(extra_meta = {})
525
+ super(extra_meta.merge(custom_field: "value"))
526
+ end
527
+ end
528
+ ```
529
+
530
+ ### Resource-Level Meta
531
+
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.
533
+
534
+ You can also define custom meta in two ways:
535
+
536
+ **Class-level static meta:**
537
+
538
+ ```ruby
539
+ class UserResource < JSONAPI::Resource
540
+ attributes :email, :name
541
+
542
+ meta({ version: "v1", custom: "value" })
543
+ end
544
+ ```
545
+
546
+ **Instance-level dynamic meta:**
547
+
548
+ ```ruby
549
+ class UserResource < JSONAPI::Resource
550
+ attributes :email, :name
551
+
552
+ def meta
553
+ {
554
+ name_length: resource.name.length,
555
+ custom_field: "value"
556
+ }
557
+ end
558
+ end
559
+ ```
560
+
561
+ 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.
562
+
563
+ **Example Resource-Level Meta:**
564
+
565
+ ```json
566
+ {
567
+ "jsonapi": {
568
+ "version": "1.1"
569
+ },
570
+ "data": {
571
+ "type": "users",
572
+ "id": "1",
573
+ "attributes": {
574
+ "name": "John Doe",
575
+ "email": "john@example.com"
576
+ },
577
+ "meta": {
578
+ "created_at": "2024-01-15T10:30:00Z",
579
+ "updated_at": "2024-01-15T10:30:00Z",
580
+ "name_length": 8,
581
+ "custom_field": "value"
582
+ },
583
+ "links": {
584
+ "self": "/users/1"
585
+ }
586
+ }
587
+ }
588
+ ```
589
+
590
+ ### Relationship-Level Meta
591
+
592
+ Relationship-level meta appears within relationship objects:
593
+
594
+ ```ruby
595
+ class UserResource < JSONAPI::Resource
596
+ attributes :email, :name
597
+
598
+ has_many :posts, meta: { count: 5, custom: "relationship_meta" }
599
+ has_one :profile, meta: { type: "polymorphic" }
600
+ end
601
+ ```
602
+
603
+ The response will include:
604
+
605
+ ```json
606
+ {
607
+ "jsonapi": {
608
+ "version": "1.1"
609
+ },
610
+ "data": {
611
+ "type": "users",
612
+ "id": "1",
613
+ "attributes": {
614
+ "name": "John Doe",
615
+ "email": "john@example.com"
616
+ },
617
+ "relationships": {
618
+ "posts": {
619
+ "data": [
620
+ { "type": "posts", "id": "1" },
621
+ { "type": "posts", "id": "2" }
622
+ ],
623
+ "meta": {
624
+ "count": 5,
625
+ "custom": "relationship_meta"
626
+ }
627
+ },
628
+ "profile": {
629
+ "data": {
630
+ "type": "admin_profiles",
631
+ "id": "1"
632
+ },
633
+ "meta": {
634
+ "type": "polymorphic"
635
+ }
636
+ }
637
+ },
638
+ "links": {
639
+ "self": "/users/1"
640
+ }
641
+ }
642
+ }
643
+ ```
644
+
645
+ ## Configuration
646
+
647
+ Configure the gem in an initializer:
648
+
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
656
+ end
657
+ ```
658
+
659
+ ### Base Controller Class
660
+
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):
662
+
663
+ ```ruby
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
671
+ ```
672
+
673
+ This is useful when you need access to features available in `ActionController::Base` that aren't in `ActionController::API`, such as:
674
+
675
+ - View rendering helpers
676
+ - Layout support
677
+ - Cookie-based sessions
678
+ - Flash messages
679
+
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.
681
+
682
+ ## Authorization
683
+
684
+ The gem provides two optional authorization hooks:
685
+
686
+ - `authorization_scope` — filters collections (index) to authorized records
687
+ - `authorization_handler` — authorizes individual actions (show, create, update, destroy)
688
+
689
+ If not configured, authorization is bypassed.
690
+
691
+ ### Pundit Integration
692
+
693
+ ```ruby
694
+ # config/initializers/json_api.rb
695
+ JSONAPI.configure do |config|
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
701
+
702
+ config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
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"
707
+ end
708
+ end
709
+ end
710
+ ```
711
+
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+)
717
+
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.
719
+
720
+ ### Resource Events
721
+
722
+ The gem emits events for resource lifecycle operations:
723
+
724
+ - **`jsonapi.{resource_type}.created`** - Emitted after successful resource creation
725
+ - **`jsonapi.{resource_type}.updated`** - Emitted after successful resource updates (includes changed fields)
726
+ - **`jsonapi.{resource_type}.deleted`** - Emitted after successful resource deletion
727
+
728
+ **Event Payload Structure:**
729
+
730
+ ```ruby
731
+ {
732
+ resource_type: "users",
733
+ resource_id: 123,
734
+ changes: { "name" => ["old", "new"], "phone" => ["old", "new"] } # Only for updates
735
+ }
736
+ ```
737
+
738
+ **Example Usage:**
739
+
740
+ ```ruby
741
+ # Subscribe to events
742
+ class JsonApiEventSubscriber
743
+ def emit(event)
744
+ encoded = ActiveSupport::EventReporter.encoder(:json).encode(event)
745
+ # Forward to your monitoring service
746
+ MonitoringService.send_event(encoded)
747
+ end
748
+ end
749
+
750
+ Rails.event.subscribe(JsonApiEventSubscriber.new) if Rails.respond_to?(:event)
751
+ ```
752
+
753
+ ### Relationship Events
754
+
755
+ The gem also emits events for relationship operations:
756
+
757
+ - **`jsonapi.{resource_type}.relationship.updated`** - Emitted after successful relationship updates
758
+ - **`jsonapi.{resource_type}.relationship.removed`** - Emitted after successful relationship removals
759
+
760
+ **Event Payload Structure:**
761
+
762
+ ```ruby
763
+ {
764
+ resource_type: "users",
765
+ resource_id: 123,
766
+ relationship_name: "posts",
767
+ related_type: "posts", # Optional
768
+ related_ids: [456, 789] # Optional
769
+ }
770
+ ```
771
+
772
+ ### Testing Instrumentation
773
+
774
+ Use Rails 8.1's `assert_events_reported` test helper to verify events are emitted:
775
+
776
+ ```ruby
777
+ assert_events_reported([
778
+ { name: "jsonapi.users.created", payload: { resource_type: "users", resource_id: 123 } },
779
+ { name: "jsonapi.users.relationship.updated", payload: { relationship_name: "posts" } }
780
+ ]) do
781
+ post "/users", params: payload.to_json, headers: jsonapi_headers
782
+ end
783
+ ```
784
+
785
+ ## Content Negotiation
786
+
787
+ The gem enforces JSON:API content negotiation:
788
+
789
+ - **Content-Type**: POST, PATCH, and PUT requests must include `Content-Type: application/vnd.api+json` header (returns `415 Unsupported Media Type` if missing)
790
+ - **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)
791
+
792
+ Blank or `*/*` Accept headers are allowed to support browser defaults.
793
+
794
+ ## Custom Controllers
795
+
796
+ You can inherit from `JSONAPI::BaseController` to create custom controllers:
797
+
798
+ ```ruby
799
+ class UsersController < JsonApi::BaseController
800
+ def index
801
+ # Custom implementation
215
802
  end
216
803
  end
217
804
  ```
218
805
 
219
- See the examples folder for more examples of how to use the DSL to solve various serialization/deserialization scenarios.
806
+ The base controller provides helper methods:
807
+
808
+ - `jsonapi_params` - Parsed JSON:API parameters
809
+ - `jsonapi_attributes` - Extracted attributes
810
+ - `jsonapi_relationships` - Extracted relationships
811
+ - `parse_include_param` - Parsed include parameter
812
+ - `parse_fields_param` - Parsed fields parameter
813
+ - `parse_filter_param` - Parsed filter parameter
814
+ - `parse_sort_param` - Parsed sort parameter
815
+ - `parse_page_param` - Parsed page parameter
816
+
817
+ ## Serialization
818
+
819
+ Use `JSONAPI::Serializer` to serialize resources:
820
+
821
+ ```ruby
822
+ serializer = JSONAPI::Serializer.new(user)
823
+ serializer.to_hash(include: ["posts"], fields: { users: ["name", "email"] })
824
+ ```
825
+
826
+ ## Deserialization
827
+
828
+ Use `JSONAPI::Deserializer` to deserialize JSON:API payloads:
829
+
830
+ ```ruby
831
+ deserializer = JSONAPI::Deserializer.new(params, resource_class: User)
832
+ deserializer.attributes # => { "name" => "John", "email" => "john@example.com" }
833
+ deserializer.relationship_ids(:posts) # => ["1", "2"]
834
+ deserializer.to_params # => { "name" => "John", "post_ids" => ["1", "2"], "profile_id" => "1", "profile_type" => "CustomerProfile" }
835
+ ```
836
+
837
+ The deserializer automatically converts JSON:API relationship format to Rails-friendly params:
838
+
839
+ - To-many relationships: `posts` → `post_ids` (array)
840
+ - To-one relationships: `account` → `account_id`
841
+ - Polymorphic relationships: `profile` → `profile_id` and `profile_type`
842
+
843
+ ### Creating Resources with Relationships
844
+
845
+ You can include relationships when creating resources:
846
+
847
+ ```http
848
+ POST /users HTTP/1.1
849
+ Content-Type: application/vnd.api+json
850
+ Accept: application/vnd.api+json
851
+
852
+ {
853
+ "data": {
854
+ "type": "users",
855
+ "attributes": {
856
+ "name": "John Doe",
857
+ "email": "john@example.com"
858
+ },
859
+ "relationships": {
860
+ "profile": {
861
+ "data": {
862
+ "type": "customer_profiles",
863
+ "id": "1"
864
+ }
865
+ },
866
+ "posts": {
867
+ "data": [
868
+ { "type": "posts", "id": "1" },
869
+ { "type": "posts", "id": "2" }
870
+ ]
871
+ }
872
+ }
873
+ }
874
+ }
875
+ ```
876
+
877
+ ### Updating Resources with Relationships
878
+
879
+ You can update relationships when updating resources:
880
+
881
+ ```http
882
+ PATCH /users/1 HTTP/1.1
883
+ Content-Type: application/vnd.api+json
884
+ Accept: application/vnd.api+json
885
+
886
+ {
887
+ "data": {
888
+ "type": "users",
889
+ "id": "1",
890
+ "attributes": {
891
+ "name": "John Doe Updated"
892
+ },
893
+ "relationships": {
894
+ "profile": {
895
+ "data": {
896
+ "type": "admin_profiles",
897
+ "id": "2"
898
+ }
899
+ },
900
+ "posts": {
901
+ "data": [
902
+ { "type": "posts", "id": "3" }
903
+ ]
904
+ }
905
+ }
906
+ }
907
+ }
908
+ ```
909
+
910
+ ### Clearing Relationships
911
+
912
+ To clear a relationship, send `null` for to-one relationships or an empty array for to-many relationships:
913
+
914
+ ```http
915
+ PATCH /users/1 HTTP/1.1
916
+ Content-Type: application/vnd.api+json
917
+ Accept: application/vnd.api+json
918
+
919
+ {
920
+ "data": {
921
+ "type": "users",
922
+ "id": "1",
923
+ "relationships": {
924
+ "profile": {
925
+ "data": null
926
+ }
927
+ }
928
+ }
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
936
+
937
+ {
938
+ "data": {
939
+ "type": "users",
940
+ "id": "1",
941
+ "relationships": {
942
+ "posts": {
943
+ "data": []
944
+ }
945
+ }
946
+ }
947
+ }
948
+ ```
949
+
950
+ ### Relationship Validation
951
+
952
+ The gem validates relationship data:
953
+
954
+ - Missing `type` or `id` in relationship data returns `400 Bad Request`
955
+ - Invalid relationship type (for non-polymorphic associations) returns `400 Bad Request`
956
+ - Invalid polymorphic type (class doesn't exist) returns `400 Bad Request`
957
+ - Attempting to unset linkage that cannot be nullified (e.g., foreign key has NOT NULL constraint) returns `400 Bad Request`
958
+
959
+ ## Relationship Endpoint Details
960
+
961
+ The gem provides dedicated endpoints for managing relationships independently of the main resource:
962
+
963
+ ### Show Relationship
964
+
965
+ ```http
966
+ GET /users/1/relationships/posts HTTP/1.1
967
+ Accept: application/vnd.api+json
968
+ ```
969
+
970
+ Returns the relationship data (resource identifiers) with links and meta:
971
+
972
+ **To-Many Relationship Response:**
973
+
974
+ ```json
975
+ {
976
+ "jsonapi": {
977
+ "version": "1.1"
978
+ },
979
+ "data": [
980
+ { "type": "posts", "id": "1" },
981
+ { "type": "posts", "id": "2" }
982
+ ],
983
+ "links": {
984
+ "self": "/users/1/relationships/posts",
985
+ "related": "/users/1/posts"
986
+ },
987
+ "meta": {
988
+ "count": 2
989
+ }
990
+ }
991
+ ```
992
+
993
+ **To-One Relationship Response:**
994
+
995
+ ```json
996
+ {
997
+ "jsonapi": {
998
+ "version": "1.1"
999
+ },
1000
+ "data": {
1001
+ "type": "admin_profiles",
1002
+ "id": "1"
1003
+ },
1004
+ "links": {
1005
+ "self": "/users/1/relationships/profile",
1006
+ "related": "/users/1/profile"
1007
+ }
1008
+ }
1009
+ ```
1010
+
1011
+ **Empty To-One Relationship Response:**
1012
+
1013
+ ```json
1014
+ {
1015
+ "jsonapi": {
1016
+ "version": "1.1"
1017
+ },
1018
+ "data": null,
1019
+ "links": {
1020
+ "self": "/users/1/relationships/profile",
1021
+ "related": "/users/1/profile"
1022
+ }
1023
+ }
1024
+ ```
1025
+
1026
+ For collection relationships, you can sort using the `sort` parameter:
1027
+
1028
+ ```http
1029
+ GET /users/1/relationships/posts?sort=title,-created_at HTTP/1.1
1030
+ Accept: application/vnd.api+json
1031
+ ```
1032
+
1033
+ ### Update Relationship
1034
+
1035
+ ```http
1036
+ PATCH /users/1/relationships/posts HTTP/1.1
1037
+ Content-Type: application/vnd.api+json
1038
+ Accept: application/vnd.api+json
1039
+
1040
+ {
1041
+ "data": [
1042
+ { "type": "posts", "id": "3" },
1043
+ { "type": "posts", "id": "4" }
1044
+ ]
1045
+ }
1046
+ ```
1047
+
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).
1049
+
1050
+ ### Delete Relationship Linkage
1051
+
1052
+ ```http
1053
+ DELETE /users/1/relationships/posts HTTP/1.1
1054
+ Content-Type: application/vnd.api+json
1055
+ Accept: application/vnd.api+json
1056
+
1057
+ {
1058
+ "data": [
1059
+ { "type": "posts", "id": "1" }
1060
+ ]
1061
+ }
1062
+ ```
1063
+
1064
+ 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.
1065
+
1066
+ **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.
1067
+
1068
+ Error responses follow JSON:API error format:
1069
+
1070
+ **Example Error Response:**
1071
+
1072
+ ```json
1073
+ {
1074
+ "jsonapi": {
1075
+ "version": "1.1"
1076
+ },
1077
+ "errors": [
1078
+ {
1079
+ "status": "400",
1080
+ "title": "Invalid Relationship",
1081
+ "detail": "Invalid relationship type for profile: 'invalid_type' does not correspond to a valid model class",
1082
+ "source": {
1083
+ "pointer": "/data/relationships/profile/data/type"
1084
+ }
1085
+ }
1086
+ ]
1087
+ }
1088
+ ```
1089
+
1090
+ **Example 404 Not Found Error:**
1091
+
1092
+ ```json
1093
+ {
1094
+ "jsonapi": {
1095
+ "version": "1.1"
1096
+ },
1097
+ "errors": [
1098
+ {
1099
+ "status": "404",
1100
+ "title": "Record Not Found",
1101
+ "detail": "Couldn't find User with 'id'=999"
1102
+ }
1103
+ ]
1104
+ }
1105
+ ```
1106
+
1107
+ **Example Validation Error:**
1108
+
1109
+ ```json
1110
+ {
1111
+ "jsonapi": {
1112
+ "version": "1.1"
1113
+ },
1114
+ "errors": [
1115
+ {
1116
+ "status": "422",
1117
+ "title": "Validation Error",
1118
+ "detail": "Email can't be blank",
1119
+ "source": {
1120
+ "pointer": "/data/attributes/email"
1121
+ }
1122
+ },
1123
+ {
1124
+ "status": "422",
1125
+ "title": "Validation Error",
1126
+ "detail": "Name is too short (minimum is 2 characters)",
1127
+ "source": {
1128
+ "pointer": "/data/attributes/name"
1129
+ }
1130
+ }
1131
+ ]
1132
+ }
1133
+ ```
1134
+
1135
+ ## ActiveStorage Support
1136
+
1137
+ The gem automatically detects and serializes ActiveStorage attachments when exposed as relationships.
1138
+
1139
+ ### Exposing Attachments
1140
+
1141
+ Declare ActiveStorage attachments as relationships in your resource:
1142
+
1143
+ ```ruby
1144
+ class User < ApplicationRecord
1145
+ has_one_attached :avatar
1146
+ has_many_attached :documents
1147
+ end
1148
+
1149
+ class UserResource < JSONAPI::Resource
1150
+ attributes :name, :email
1151
+ has_one :avatar
1152
+ has_many :documents
1153
+ end
1154
+ ```
1155
+
1156
+ The gem auto-detects these are ActiveStorage attachments and serializes them as `active_storage_blobs` relationships:
1157
+
1158
+ ```http
1159
+ GET /users/1?include=avatar HTTP/1.1
1160
+ Accept: application/vnd.api+json
1161
+
1162
+ HTTP/1.1 200 OK
1163
+ Content-Type: application/vnd.api+json
1164
+
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
+ }
1194
+ ```
1195
+
1196
+ The built-in `JSONAPI::ActiveStorageBlobResource` provides `filename`, `content_type`, `byte_size`, `checksum`, and `url` attributes, plus a download link.
1197
+
1198
+ ### Attaching Files
1199
+
1200
+ Clients attach files by providing signed blob IDs from ActiveStorage direct uploads:
1201
+
1202
+ ```http
1203
+ POST /users HTTP/1.1
1204
+ Content-Type: application/vnd.api+json
1205
+
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
+ }
1219
+ ```
1220
+
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`.
1222
+
1223
+ ### Detaching Files
1224
+
1225
+ Send `null` or `[]` to detach attachments:
1226
+
1227
+ ```http
1228
+ PATCH /users/1 HTTP/1.1
1229
+ Content-Type: application/vnd.api+json
1230
+
1231
+ {
1232
+ "data": {
1233
+ "type": "users",
1234
+ "id": "1",
1235
+ "relationships": {
1236
+ "avatar": { "data": null },
1237
+ "documents": { "data": [] }
1238
+ }
1239
+ }
1240
+ }
1241
+ ```
1242
+
1243
+ By default, this purges the attachments. For has_one, sending `null` detaches. For has_many, sending `[]` removes all.
1244
+
1245
+ ### Relationship Options
1246
+
1247
+ **`purge_on_nil`** (default: `true`) — Controls whether attachments are purged when set to `null`/`[]`:
1248
+
1249
+ ```ruby
1250
+ has_one :avatar, purge_on_nil: false # Keep existing when null
1251
+ has_many :documents, purge_on_nil: false
1252
+ ```
1253
+
1254
+ **`append_only`** (has_many only, default: `false`) — Append new blobs instead of replacing:
1255
+
1256
+ ```ruby
1257
+ has_many :documents, append_only: true
1258
+ ```
1259
+
1260
+ When enabled:
1261
+
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
1266
+
1267
+ These options are mutually exclusive — `append_only: true` with `purge_on_nil: true` raises `ArgumentError`.
1268
+
1269
+ ## Client Integration: devour-client-ts
1270
+
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.
1272
+
1273
+ ### Installation
1274
+
1275
+ ```bash
1276
+ npm install devour-client-ts
1277
+ ```
1278
+
1279
+ ### Basic Client Setup
1280
+
1281
+ ```typescript
1282
+ import { JsonApi } from "devour-client-ts";
1283
+
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
+ ```
1294
+
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
1322
+ },
1323
+ comments: {
1324
+ jsonApi: "hasMany",
1325
+ type: "comments", // Must be plural
1326
+ },
1327
+ },
1328
+ {
1329
+ collectionPath: "posts",
1330
+ }
1331
+ );
1332
+ ```
1333
+
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.
1335
+
1336
+ ### CRUD Operations
1337
+
1338
+ **Find all resources:**
1339
+
1340
+ ```typescript
1341
+ const { data: users } = await api.findAll("user").toPromise();
1342
+ ```
1343
+
1344
+ **Find a single resource:**
1345
+
1346
+ ```typescript
1347
+ const { data: user } = await api.find("user", "123").toPromise();
1348
+ ```
1349
+
1350
+ **Find with relationships:**
1351
+
1352
+ ```typescript
1353
+ const { data: user } = await api
1354
+ .find("user", "123", {
1355
+ include: ["posts", "profile"],
1356
+ })
1357
+ .toPromise();
1358
+
1359
+ // Relationships are deserialized onto the resource
1360
+ console.log(user.posts); // Array of post objects
1361
+ console.log(user.profile); // Profile object
1362
+ ```
1363
+
1364
+ **Create a resource:**
1365
+
1366
+ ```typescript
1367
+ const { data: newUser } = await api
1368
+ .create("user", {
1369
+ name: "John Doe",
1370
+ email: "john@example.com",
1371
+ })
1372
+ .toPromise();
1373
+ ```
1374
+
1375
+ **Create with relationships:**
1376
+
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
+ ```
1386
+
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();
1396
+ ```
1397
+
1398
+ **Delete a resource:**
1399
+
1400
+ ```typescript
1401
+ await api.destroy("user", "123").toPromise();
1402
+ ```
1403
+
1404
+ ### Query Parameters
1405
+
1406
+ **Filtering:**
1407
+
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
+ ```
1418
+
1419
+ **Sorting:**
1420
+
1421
+ ```typescript
1422
+ const { data: users } = await api
1423
+ .findAll("user", {
1424
+ sort: "name", // Single field ascending
1425
+ })
1426
+ .toPromise();
1427
+
1428
+ const { data: users } = await api
1429
+ .findAll("user", {
1430
+ sort: "-created_at", // Descending (prefix with -)
1431
+ })
1432
+ .toPromise();
1433
+
1434
+ const { data: users } = await api
1435
+ .findAll("user", {
1436
+ sort: ["name", "-created_at"], // Multiple fields
1437
+ })
1438
+ .toPromise();
1439
+ ```
1440
+
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();
1452
+
1453
+ console.log(meta.total); // Total count
1454
+ ```
1455
+
1456
+ **Sparse fieldsets:**
1457
+
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
+ ```
1468
+
1469
+ **Including related resources:**
1470
+
1471
+ ```typescript
1472
+ const { data: user } = await api
1473
+ .find("user", "123", {
1474
+ include: ["posts", "posts.comments"],
1475
+ })
1476
+ .toPromise();
1477
+ ```
1478
+
1479
+ ### Relationship Operations
1480
+
1481
+ **Update a relationship:**
1482
+
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();
1492
+ ```
1493
+
1494
+ ### Authentication Middleware
1495
+
1496
+ Add authentication middleware to automatically inject tokens:
1497
+
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
+ };
1519
+
1520
+ api.replaceMiddleware("add-bearer-token", authMiddleware);
1521
+ ```
1522
+
1523
+ ### Polymorphic Relationships
1524
+
1525
+ For polymorphic relationships, omit the `type` in the model definition and provide it when creating/updating:
1526
+
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();
1550
+ ```
1551
+
1552
+ ### Error Handling
1553
+
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
+ ```
1572
+
1573
+ ### Key Integration Points
1574
+
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'] }` |
1585
+
1586
+ ### Common Gotchas
1587
+
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
220
1592
 
221
1593
  ## Contributing
222
1594
 
223
- Bug reports and pull requests are welcome on GitHub at https://github.com/emilkampp/jpie.
1595
+ Bug reports and pull requests are welcome on GitHub at https://github.com/klaay/json_api.
224
1596
 
225
1597
  ## License
226
1598
 
227
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1599
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).