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.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +26 -0
- data/.rspec +3 -0
- data/.rubocop.yml +76 -107
- data/.travis.yml +7 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +321 -0
- data/README.md +1508 -136
- data/Rakefile +3 -14
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/jpie.gemspec +21 -38
- data/kiln/app/resources/user_message_resource.rb +4 -0
- data/lib/jpie.rb +3 -25
- data/lib/json_api/active_storage/deserialization.rb +116 -0
- data/lib/json_api/active_storage/detection.rb +69 -0
- data/lib/json_api/active_storage/serialization.rb +34 -0
- data/lib/json_api/configuration.rb +57 -0
- data/lib/json_api/controllers/base_controller.rb +26 -0
- data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
- data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
- data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +19 -0
- data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
- data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
- data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
- data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
- data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +106 -0
- data/lib/json_api/controllers/relationships_controller.rb +108 -0
- data/lib/json_api/controllers/resources_controller.rb +6 -0
- data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
- data/lib/json_api/railtie.rb +112 -0
- data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
- data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
- data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
- data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
- data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
- data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
- data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
- data/lib/json_api/resources/resource.rb +32 -0
- data/lib/json_api/resources/resource_loader.rb +35 -0
- data/lib/json_api/routing.rb +81 -0
- data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
- data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
- data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
- data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
- data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
- data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
- data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
- data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
- data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
- data/lib/json_api/serialization/deserializer.rb +26 -0
- data/lib/json_api/serialization/serializer.rb +77 -0
- data/lib/json_api/support/active_storage_support.rb +82 -0
- data/lib/json_api/support/collection_query.rb +50 -0
- data/lib/json_api/support/concerns/condition_building.rb +57 -0
- data/lib/json_api/support/concerns/nested_filters.rb +130 -0
- data/lib/json_api/support/concerns/pagination.rb +30 -0
- data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
- data/lib/json_api/support/concerns/regular_filters.rb +81 -0
- data/lib/json_api/support/concerns/sorting.rb +88 -0
- data/lib/json_api/support/instrumentation.rb +43 -0
- data/lib/json_api/support/param_helpers.rb +54 -0
- data/lib/json_api/support/relationship_guard.rb +16 -0
- data/lib/json_api/support/relationship_helpers.rb +76 -0
- data/lib/json_api/support/resource_identifier.rb +87 -0
- data/lib/json_api/support/responders.rb +100 -0
- data/lib/json_api/support/response_helpers.rb +10 -0
- data/lib/json_api/support/sort_parsing.rb +21 -0
- data/lib/json_api/support/type_conversion.rb +21 -0
- data/lib/json_api/testing/test_helper.rb +76 -0
- data/lib/json_api/testing.rb +3 -0
- data/lib/{jpie → json_api}/version.rb +2 -2
- data/lib/json_api.rb +50 -0
- data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
- metadata +100 -169
- data/.cursor/rules/dependencies.mdc +0 -19
- data/.cursor/rules/examples.mdc +0 -16
- data/.cursor/rules/git.mdc +0 -14
- data/.cursor/rules/project_structure.mdc +0 -30
- data/.cursor/rules/publish_gem.mdc +0 -73
- data/.cursor/rules/security.mdc +0 -14
- data/.cursor/rules/style.mdc +0 -15
- data/.cursor/rules/testing.mdc +0 -16
- data/.overcommit.yml +0 -35
- data/CHANGELOG.md +0 -164
- data/LICENSE.txt +0 -21
- data/PUBLISHING.md +0 -111
- data/examples/basic_example.md +0 -146
- data/examples/including_related_resources.md +0 -491
- data/examples/pagination.md +0 -303
- data/examples/relationships.md +0 -114
- data/examples/resource_attribute_configuration.md +0 -147
- data/examples/resource_meta_configuration.md +0 -244
- data/examples/rspec_testing.md +0 -130
- data/examples/single_table_inheritance.md +0 -160
- data/lib/jpie/configuration.rb +0 -12
- data/lib/jpie/controller/crud_actions.rb +0 -141
- data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
- data/lib/jpie/controller/error_handling/handlers.rb +0 -109
- data/lib/jpie/controller/error_handling.rb +0 -23
- data/lib/jpie/controller/json_api_validation.rb +0 -193
- data/lib/jpie/controller/parameter_parsing.rb +0 -78
- data/lib/jpie/controller/related_actions.rb +0 -45
- data/lib/jpie/controller/relationship_actions.rb +0 -291
- data/lib/jpie/controller/relationship_validation.rb +0 -117
- data/lib/jpie/controller/rendering.rb +0 -154
- data/lib/jpie/controller.rb +0 -45
- data/lib/jpie/deserializer.rb +0 -110
- data/lib/jpie/errors.rb +0 -117
- data/lib/jpie/generators/resource_generator.rb +0 -116
- data/lib/jpie/generators/templates/resource.rb.erb +0 -31
- data/lib/jpie/railtie.rb +0 -42
- data/lib/jpie/resource/attributable.rb +0 -112
- data/lib/jpie/resource/inferrable.rb +0 -43
- data/lib/jpie/resource/sortable.rb +0 -93
- data/lib/jpie/resource.rb +0 -147
- data/lib/jpie/routing.rb +0 -59
- data/lib/jpie/serializer.rb +0 -205
data/README.md
CHANGED
|
@@ -1,227 +1,1599 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JSONAPI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](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
|
-
|
|
5
|
+
## Features
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
##
|
|
24
|
+
## Requirements
|
|
31
25
|
|
|
32
|
-
|
|
26
|
+
- Ruby >= 3.4.0
|
|
27
|
+
- Rails >= 8.0.0
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
## Routing
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
+
To use a custom controller instead of the default:
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
```ruby
|
|
44
|
+
jsonapi_resources :users, controller: "api/users"
|
|
45
|
+
```
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
gem install overcommit
|
|
61
|
+
### Virtual Attributes
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
overcommit --install
|
|
63
|
+
To create a virtual attribute, declare it in `attributes` and implement a getter method:
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
100
|
+
def initialize(resource = nil, context = {})
|
|
101
|
+
super
|
|
102
|
+
@transformed_params = {}
|
|
103
|
+
end
|
|
72
104
|
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
207
|
+
## Includes
|
|
88
208
|
|
|
89
|
-
|
|
209
|
+
Include related resources with the `include` parameter:
|
|
90
210
|
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
+
Define a resource class for each concrete type:
|
|
104
258
|
|
|
105
259
|
```ruby
|
|
106
|
-
|
|
260
|
+
# app/resources/user_resource.rb
|
|
261
|
+
class UserResource < JSONAPI::Resource
|
|
107
262
|
attributes :name, :email
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
365
|
+
Subclass resources inherit attributes from the parent:
|
|
124
366
|
|
|
125
367
|
```ruby
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
+
HTTP/1.1 200 OK
|
|
388
|
+
Content-Type: application/vnd.api+json
|
|
134
389
|
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
+
## Sorting
|
|
150
434
|
|
|
151
|
-
|
|
435
|
+
Sort collections with the `sort` parameter. Prefix with `-` for descending. Seperate multiple sorts with comma `,`:
|
|
152
436
|
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
|
|
437
|
+
```http
|
|
438
|
+
GET /users?sort=name,-created_at HTTP/1.1
|
|
439
|
+
Accept: application/vnd.api+json
|
|
440
|
+
```
|
|
156
441
|
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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 <
|
|
451
|
+
class UserResource < JSONAPI::Resource
|
|
167
452
|
attributes :name, :email
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
483
|
+
## Meta Information
|
|
176
484
|
|
|
177
|
-
|
|
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
|
-
###
|
|
487
|
+
### Document-Level Meta
|
|
185
488
|
|
|
186
|
-
|
|
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
|
-
|
|
491
|
+
To add custom document-level meta globally, configure `document_meta_resolver`:
|
|
192
492
|
|
|
193
493
|
```ruby
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
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/
|
|
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).
|