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