jpie 0.4.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/.rubocop.yml +35 -110
- data/.travis.yml +7 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +312 -0
- data/README.md +2072 -140
- data/Rakefile +3 -14
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/jpie.gemspec +18 -35
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +3 -24
- data/lib/json_api/active_storage/deserialization.rb +106 -0
- data/lib/json_api/active_storage/detection.rb +74 -0
- data/lib/json_api/active_storage/serialization.rb +32 -0
- data/lib/json_api/configuration.rb +58 -0
- data/lib/json_api/controllers/base_controller.rb +26 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
- data/lib/json_api/controllers/relationships_controller.rb +504 -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 +75 -0
- data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
- data/lib/json_api/resources/resource.rb +238 -0
- data/lib/json_api/resources/resource_loader.rb +35 -0
- data/lib/json_api/routing.rb +72 -0
- data/lib/json_api/serialization/deserializer.rb +362 -0
- data/lib/json_api/serialization/serializer.rb +320 -0
- data/lib/json_api/support/active_storage_support.rb +85 -0
- data/lib/json_api/support/collection_query.rb +406 -0
- data/lib/json_api/support/instrumentation.rb +42 -0
- data/lib/json_api/support/param_helpers.rb +51 -0
- data/lib/json_api/support/relationship_guard.rb +16 -0
- data/lib/json_api/support/relationship_helpers.rb +74 -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 +50 -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,2159 @@
|
|
|
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
|
|
20
|
+
Add this line to your application's Gemfile:
|
|
25
21
|
|
|
26
|
-
```
|
|
27
|
-
|
|
22
|
+
```ruby
|
|
23
|
+
gem 'json_api'
|
|
28
24
|
```
|
|
29
25
|
|
|
30
|
-
|
|
26
|
+
And then execute:
|
|
31
27
|
|
|
32
|
-
|
|
28
|
+
$ bundle install
|
|
33
29
|
|
|
34
|
-
|
|
30
|
+
Or install it yourself as:
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
$ gem install json_api
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Ruby >= 3.4.0
|
|
37
|
+
- Rails >= 8.0.0
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Basic Setup
|
|
42
|
+
|
|
43
|
+
Add the gem to your Gemfile and run `bundle install`. The gem will automatically register the JSON:API MIME type and extend Rails routing.
|
|
44
|
+
|
|
45
|
+
### Routing
|
|
46
|
+
|
|
47
|
+
Use the `jsonapi_resources` DSL in your routes file:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# config/routes.rb
|
|
51
|
+
Rails.application.routes.draw do
|
|
52
|
+
jsonapi_resources :users
|
|
53
|
+
jsonapi_resources :posts
|
|
54
|
+
end
|
|
39
55
|
```
|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
This creates standard RESTful routes (index, show, create, update, destroy) that default to the `json_api/resources` controller and `jsonapi` format.
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
**Important**: Each resource must have a corresponding resource class defined (e.g., `UserResource` for `jsonapi_resources :users`). The routing will fail at boot time if the resource class is missing.
|
|
44
60
|
|
|
45
|
-
|
|
46
|
-
# 1. Install dependencies
|
|
47
|
-
bundle install
|
|
61
|
+
### Resource Definitions
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
gem install overcommit
|
|
63
|
+
Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:
|
|
51
64
|
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
```ruby
|
|
66
|
+
# app/resources/user_resource.rb
|
|
67
|
+
class UserResource < JSONAPI::Resource
|
|
68
|
+
attributes :email, :name, :phone
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
has_many :posts
|
|
71
|
+
has_one :profile
|
|
72
|
+
end
|
|
57
73
|
```
|
|
58
74
|
|
|
59
|
-
|
|
75
|
+
Resource classes must:
|
|
60
76
|
|
|
61
|
-
|
|
77
|
+
- Inherit from `JSONAPI::Resource`
|
|
78
|
+
- Be named `<ResourceName>Resource` (e.g., `UserResource` for `users` resource type)
|
|
79
|
+
- Define permitted attributes using `attributes`
|
|
80
|
+
- Define relationships using `has_many`, `has_one`, or `belongs_to`
|
|
62
81
|
|
|
63
|
-
|
|
64
|
-
- ✅ **RuboCop** - Code style and quality analysis
|
|
65
|
-
- ✅ **Trailing whitespace** - Prevents whitespace issues
|
|
66
|
-
- ✅ **Merge conflicts** - Catches unresolved conflicts
|
|
82
|
+
The generic controller uses these resource definitions to:
|
|
67
83
|
|
|
68
|
-
|
|
69
|
-
-
|
|
84
|
+
- Validate requested fields (sparse fieldsets)
|
|
85
|
+
- Validate requested includes
|
|
86
|
+
- Control which attributes are exposed in responses
|
|
87
|
+
- Validate filters
|
|
88
|
+
- Control which fields can be created vs updated
|
|
70
89
|
|
|
71
|
-
###
|
|
90
|
+
### Virtual Attributes
|
|
72
91
|
|
|
73
|
-
|
|
92
|
+
Resources can define virtual attributes that don't correspond to database columns. Virtual attributes are useful for computed values, formatted data, or transforming model attributes.
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
# Run RuboCop with auto-fix
|
|
77
|
-
bundle exec rubocop -A
|
|
94
|
+
#### Defining Virtual Attributes
|
|
78
95
|
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
To create a virtual attribute, declare it in `attributes` and implement a getter method:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class UserResource < JSONAPI::Resource
|
|
100
|
+
attributes :name, :email, :full_name
|
|
81
101
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
# Virtual attribute getter
|
|
103
|
+
def full_name
|
|
104
|
+
"#{resource.name} (#{resource.email})"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The getter method receives the underlying model instance via `resource`. Virtual attributes are serialized just like regular attributes and appear in the response:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"data": {
|
|
114
|
+
"type": "users",
|
|
115
|
+
"id": "1",
|
|
116
|
+
"attributes": {
|
|
117
|
+
"name": "John Doe",
|
|
118
|
+
"email": "john@example.com",
|
|
119
|
+
"full_name": "John Doe (john@example.com)"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
85
123
|
```
|
|
86
124
|
|
|
87
|
-
|
|
125
|
+
#### Overriding Model Attributes
|
|
126
|
+
|
|
127
|
+
Resource getters take precedence over model attributes. If you define a getter for a real database column, it will override the model's attribute value:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class UserResource < JSONAPI::Resource
|
|
131
|
+
attributes :name, :email
|
|
132
|
+
|
|
133
|
+
# Override name attribute
|
|
134
|
+
def name
|
|
135
|
+
resource.name.upcase
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
88
139
|
|
|
89
|
-
|
|
140
|
+
#### Virtual Attribute Setters
|
|
90
141
|
|
|
91
|
-
|
|
142
|
+
You can define setters for virtual attributes that transform incoming values into real model attributes. This is useful for accepting formatted input that needs to be stored differently:
|
|
92
143
|
|
|
93
144
|
```ruby
|
|
94
|
-
class
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
145
|
+
class UserResource < JSONAPI::Resource
|
|
146
|
+
attributes :name, :email, :display_name
|
|
147
|
+
creatable_fields :name, :email, :display_name
|
|
148
|
+
updatable_fields :name, :display_name
|
|
149
|
+
|
|
150
|
+
def initialize(resource = nil, context = {})
|
|
151
|
+
super
|
|
152
|
+
@transformed_params = {}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Setter that transforms virtual attribute to model attribute
|
|
156
|
+
def display_name=(value)
|
|
157
|
+
@transformed_params["name"] = value.upcase
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Getter for the virtual attribute (for serialization)
|
|
161
|
+
def display_name
|
|
162
|
+
resource&.name&.downcase
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Return transformed params accumulated by setters
|
|
166
|
+
def transformed_params
|
|
167
|
+
@transformed_params || {}
|
|
168
|
+
end
|
|
100
169
|
end
|
|
101
170
|
```
|
|
102
171
|
|
|
103
|
-
|
|
172
|
+
When a client sends `display_name` in a create or update request, the setter transforms it to `name` (uppercase). The virtual attribute `display_name` is automatically excluded from the final params returned by the deserializer, so it won't be passed to the model.
|
|
173
|
+
|
|
174
|
+
**Important**: You must initialize `@transformed_params` in your `initialize` method if you use setters that modify it.
|
|
175
|
+
|
|
176
|
+
### Custom Controllers
|
|
177
|
+
|
|
178
|
+
If you need custom behavior, you can override the default controller:
|
|
104
179
|
|
|
105
180
|
```ruby
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
181
|
+
jsonapi_resources :users, controller: "api/users"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Controller Actions
|
|
185
|
+
|
|
186
|
+
The default `JSONAPI::ResourcesController` provides:
|
|
187
|
+
|
|
188
|
+
- `GET /users` - List all users (with filtering, sorting, pagination)
|
|
189
|
+
- `GET /users/:id` - Show a user (with includes, sparse fieldsets)
|
|
190
|
+
- `POST /users` - Create a user (with relationships)
|
|
191
|
+
- `PATCH /users/:id` - Update a user (with relationships)
|
|
192
|
+
- `DELETE /users/:id` - Delete a user
|
|
193
|
+
|
|
194
|
+
Additionally, relationship endpoints are available via `JSONAPI::RelationshipsController`:
|
|
195
|
+
|
|
196
|
+
- `GET /users/:id/relationships/:relationship_name` - Show relationship data (with sorting for collections)
|
|
197
|
+
- `PATCH /users/:id/relationships/:relationship_name` - Update relationship linkage
|
|
198
|
+
- `DELETE /users/:id/relationships/:relationship_name` - Remove relationship linkage
|
|
199
|
+
|
|
200
|
+
#### Example Responses
|
|
201
|
+
|
|
202
|
+
**GET /users (Collection Response):**
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"jsonapi": {
|
|
207
|
+
"version": "1.1"
|
|
208
|
+
},
|
|
209
|
+
"data": [
|
|
210
|
+
{
|
|
211
|
+
"type": "users",
|
|
212
|
+
"id": "1",
|
|
213
|
+
"attributes": {
|
|
214
|
+
"name": "John Doe",
|
|
215
|
+
"email": "john@example.com",
|
|
216
|
+
"phone": "555-0100"
|
|
217
|
+
},
|
|
218
|
+
"relationships": {
|
|
219
|
+
"posts": {
|
|
220
|
+
"data": [
|
|
221
|
+
{ "type": "posts", "id": "1" },
|
|
222
|
+
{ "type": "posts", "id": "2" }
|
|
223
|
+
],
|
|
224
|
+
"meta": {
|
|
225
|
+
"count": 2
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
"profile": {
|
|
229
|
+
"data": null
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
"links": {
|
|
233
|
+
"self": "/users/1"
|
|
234
|
+
},
|
|
235
|
+
"meta": {
|
|
236
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
237
|
+
"updated_at": "2024-01-15T10:30:00Z"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**GET /users/:id (Single Resource Response):**
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"jsonapi": {
|
|
249
|
+
"version": "1.1"
|
|
250
|
+
},
|
|
251
|
+
"data": {
|
|
252
|
+
"type": "users",
|
|
253
|
+
"id": "1",
|
|
254
|
+
"attributes": {
|
|
255
|
+
"name": "John Doe",
|
|
256
|
+
"email": "john@example.com",
|
|
257
|
+
"phone": "555-0100"
|
|
258
|
+
},
|
|
259
|
+
"relationships": {
|
|
260
|
+
"posts": {
|
|
261
|
+
"data": [
|
|
262
|
+
{ "type": "posts", "id": "1" },
|
|
263
|
+
{ "type": "posts", "id": "2" }
|
|
264
|
+
],
|
|
265
|
+
"meta": {
|
|
266
|
+
"count": 2
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
"profile": {
|
|
270
|
+
"data": {
|
|
271
|
+
"type": "admin_profiles",
|
|
272
|
+
"id": "1"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"links": {
|
|
277
|
+
"self": "/users/1"
|
|
278
|
+
},
|
|
279
|
+
"meta": {
|
|
280
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
281
|
+
"updated_at": "2024-01-15T10:30:00Z"
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**POST /users (Create Response):**
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"jsonapi": {
|
|
292
|
+
"version": "1.1"
|
|
293
|
+
},
|
|
294
|
+
"data": {
|
|
295
|
+
"type": "users",
|
|
296
|
+
"id": "2",
|
|
297
|
+
"attributes": {
|
|
298
|
+
"name": "New User",
|
|
299
|
+
"email": "newuser@example.com",
|
|
300
|
+
"phone": "555-0101"
|
|
301
|
+
},
|
|
302
|
+
"relationships": {
|
|
303
|
+
"posts": {
|
|
304
|
+
"data": [],
|
|
305
|
+
"meta": {
|
|
306
|
+
"count": 0
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
"profile": {
|
|
310
|
+
"data": null
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
"links": {
|
|
314
|
+
"self": "/users/2"
|
|
315
|
+
},
|
|
316
|
+
"meta": {
|
|
317
|
+
"created_at": "2024-01-15T11:00:00Z",
|
|
318
|
+
"updated_at": "2024-01-15T11:00:00Z"
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**PATCH /users/:id (Update Response):**
|
|
325
|
+
|
|
326
|
+
```json
|
|
327
|
+
{
|
|
328
|
+
"jsonapi": {
|
|
329
|
+
"version": "1.1"
|
|
330
|
+
},
|
|
331
|
+
"data": {
|
|
332
|
+
"type": "users",
|
|
333
|
+
"id": "1",
|
|
334
|
+
"attributes": {
|
|
335
|
+
"name": "Updated Name",
|
|
336
|
+
"email": "john@example.com",
|
|
337
|
+
"phone": "555-9999"
|
|
338
|
+
},
|
|
339
|
+
"relationships": {
|
|
340
|
+
"posts": {
|
|
341
|
+
"data": [{ "type": "posts", "id": "1" }],
|
|
342
|
+
"meta": {
|
|
343
|
+
"count": 1
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
"profile": {
|
|
347
|
+
"data": null
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
"links": {
|
|
351
|
+
"self": "/users/1"
|
|
352
|
+
},
|
|
353
|
+
"meta": {
|
|
354
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
355
|
+
"updated_at": "2024-01-15T11:15:00Z"
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**DELETE /users/:id:**
|
|
362
|
+
|
|
363
|
+
Returns `204 No Content` with an empty response body.
|
|
364
|
+
|
|
365
|
+
### Query Parameters
|
|
366
|
+
|
|
367
|
+
The controller supports standard JSON:API query parameters:
|
|
368
|
+
|
|
369
|
+
- `filter[name]=John` - Filter resources (must be declared in resource class)
|
|
370
|
+
- `sort=name,-created_at` - Sort resources (ascending by default, prefix with `-` for descending)
|
|
371
|
+
- `page[number]=1&page[size]=25` - Pagination (number and size only)
|
|
372
|
+
- `include=posts,comments` - Include related resources
|
|
373
|
+
- `fields[users]=name,email` - Sparse fieldsets
|
|
374
|
+
|
|
375
|
+
#### Filtering
|
|
376
|
+
|
|
377
|
+
Filtering requires declaring permitted filters in your resource class:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
class UserResource < JSONAPI::Resource
|
|
381
|
+
attributes :name, :email, :phone
|
|
382
|
+
filters :name_eq, :name_match, :created_at_gte
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
For regular filters, the gem applies column-aware operators when you use suffixes:
|
|
387
|
+
|
|
388
|
+
- `_eq`
|
|
389
|
+
- `_match` (string/text; uses ILIKE with sanitized patterns)
|
|
390
|
+
- `_lt`, `_lte`, `_gt`, `_gte` (numeric, date, datetime)
|
|
391
|
+
|
|
392
|
+
If a filter name doesn't match a supported operator but a model scope with that name exists, the scope is called instead. Filters are only applied when declared via `filters`. Invalid filter names return a `400 Bad Request` error.
|
|
393
|
+
|
|
394
|
+
#### Filter Value Formats
|
|
395
|
+
|
|
396
|
+
Filter values can be provided as either strings or arrays, depending on the filter's requirements:
|
|
397
|
+
|
|
398
|
+
**String Format:**
|
|
399
|
+
|
|
400
|
+
- Single value: `filter[name_eq]=John`
|
|
401
|
+
- Comma-separated values: `filter[name_eq]=John,Jane` (parsed as a single string `"John,Jane"`)
|
|
402
|
+
|
|
403
|
+
**Array Format:**
|
|
404
|
+
|
|
405
|
+
- Multiple values: `filter[categories_include][]=security&filter[categories_include][]=governance`
|
|
406
|
+
- Rails parses this as an array: `["security", "governance"]`
|
|
407
|
+
|
|
408
|
+
**When Arrays Are Required:**
|
|
409
|
+
|
|
410
|
+
Some filters require arrays, particularly when using PostgreSQL array operators:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
class SelectedControl < ApplicationRecord
|
|
414
|
+
# This scope uses PostgreSQL's ?| operator which requires an array
|
|
415
|
+
scope :categories_include, ->(categories) {
|
|
416
|
+
where("#{table_name}.categories ?| array[:categories]", categories:)
|
|
417
|
+
}
|
|
418
|
+
end
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
For such scopes, filters must be provided in array format:
|
|
422
|
+
|
|
423
|
+
- ✅ Correct: `filter[categories_include][]=security&filter[categories_include][]=governance`
|
|
424
|
+
- ❌ Incorrect: `filter[categories_include]=security,governance` (parsed as string `"security,governance"`)
|
|
425
|
+
|
|
426
|
+
**How Rails Parses Query Parameters:**
|
|
427
|
+
|
|
428
|
+
- `filter[key]=value` → Rails parses as `{ "filter" => { "key" => "value" } }`
|
|
429
|
+
- `filter[key][]=value1&filter[key][]=value2` → Rails parses as `{ "filter" => { "key" => ["value1", "value2"] } }`
|
|
430
|
+
- `filter[key]=value1,value2` → Rails parses as `{ "filter" => { "key" => "value1,value2" } }` (single string)
|
|
431
|
+
|
|
432
|
+
The `json_api` gem passes filter values directly to model scopes as parsed by Rails. If a scope expects an array (e.g., for PostgreSQL array operators), ensure the filter is sent in array format.
|
|
433
|
+
|
|
434
|
+
#### Filtering through relationships (nested filter hashes)
|
|
435
|
+
|
|
436
|
+
Expose filters on related resources using nested hashes. Relationships declared with `has_one`/`has_many` automatically allow nested filters on the related resource's filters plus primary key filters. Example:
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
class PostResource < JSONAPI::Resource
|
|
440
|
+
filters :title
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
class User < ApplicationRecord
|
|
444
|
+
# Column filters: filter[user][email]=foo@example.com
|
|
445
|
+
# Scope filters: filter[user][name_search]=Jane
|
|
446
|
+
scope :name_search, ->(query) { where("users.name LIKE ?", "%#{query}%") }
|
|
112
447
|
end
|
|
113
448
|
```
|
|
114
449
|
|
|
115
|
-
|
|
450
|
+
Examples:
|
|
451
|
+
|
|
452
|
+
- `GET /posts?filter[user][id]=123` (joins users and filters on users.id)
|
|
453
|
+
- `GET /posts?filter[user][email]=jane@example.com` (filters on related column)
|
|
454
|
+
- `GET /posts?filter[user][name_search]=Jane` (calls the related scope and merges it)
|
|
455
|
+
- `GET /comments?filter[post][user][email_eq]=john@example.com` (multi-level chain)
|
|
456
|
+
|
|
457
|
+
Nested filter paths must point to either a column on the related model, a class method/scope on that model, or a filter declared on the related resource.
|
|
458
|
+
|
|
459
|
+
Invalid filter fields will return a `400 Bad Request` error:
|
|
460
|
+
|
|
461
|
+
```json
|
|
462
|
+
{
|
|
463
|
+
"errors": [
|
|
464
|
+
{
|
|
465
|
+
"status": "400",
|
|
466
|
+
"title": "Invalid Filter",
|
|
467
|
+
"detail": "Invalid filters requested: invalid_field"
|
|
468
|
+
}
|
|
469
|
+
]
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Writable through relationships
|
|
474
|
+
|
|
475
|
+
By default, has-many-through and has-one-through relationships are writable via JSON:API payloads and relationship endpoints. Set `readonly: true` on the relationship to block writes and return `ParameterNotAllowed`.
|
|
116
476
|
|
|
117
477
|
```ruby
|
|
118
|
-
class
|
|
119
|
-
|
|
478
|
+
class UserResource < JSONAPI::Resource
|
|
479
|
+
has_many :post_comments, readonly: true
|
|
120
480
|
end
|
|
121
481
|
```
|
|
122
482
|
|
|
123
|
-
|
|
483
|
+
- The opt-out applies to both main resource deserialization and relationship controller updates.
|
|
484
|
+
- Use `readonly: true` when the underlying model should not allow assignment (for example, when it lacks `*_ids` setters or manages joins differently).
|
|
485
|
+
- Without the flag, through relationships are writable.
|
|
486
|
+
|
|
487
|
+
#### Pagination
|
|
488
|
+
|
|
489
|
+
Resources can be paginated using the `page[number]` and `page[size]` parameters. Pagination is only available on collection (index) endpoints.
|
|
490
|
+
|
|
491
|
+
Examples:
|
|
492
|
+
|
|
493
|
+
- `GET /users?page[number]=1&page[size]=10` (first page, 10 items per page)
|
|
494
|
+
- `GET /users?page[number]=2&page[size]=10` (second page, 10 items per page)
|
|
495
|
+
- `GET /users?page[number]=1` (first page with default page size)
|
|
496
|
+
|
|
497
|
+
The default page size is 25, and the maximum page size is 100 (configurable via `JSONAPI.configuration`). If a size larger than the maximum is requested, it will be capped at the maximum.
|
|
498
|
+
|
|
499
|
+
**Example Paginated Response:**
|
|
500
|
+
|
|
501
|
+
```json
|
|
502
|
+
{
|
|
503
|
+
"jsonapi": {
|
|
504
|
+
"version": "1.1"
|
|
505
|
+
},
|
|
506
|
+
"data": [
|
|
507
|
+
{
|
|
508
|
+
"type": "users",
|
|
509
|
+
"id": "1",
|
|
510
|
+
"attributes": {
|
|
511
|
+
"name": "User 1",
|
|
512
|
+
"email": "user1@example.com",
|
|
513
|
+
"phone": "555-0001"
|
|
514
|
+
},
|
|
515
|
+
"links": {
|
|
516
|
+
"self": "/users/1"
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
],
|
|
520
|
+
"links": {
|
|
521
|
+
"self": "/users?page[number]=2&page[size]=5",
|
|
522
|
+
"first": "/users?page[number]=1&page[size]=5",
|
|
523
|
+
"last": "/users?page[number]=3&page[size]=5",
|
|
524
|
+
"prev": "/users?page[number]=1&page[size]=5",
|
|
525
|
+
"next": "/users?page[number]=3&page[size]=5"
|
|
526
|
+
},
|
|
527
|
+
"meta": {
|
|
528
|
+
"total": 15
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
#### Including Related Resources
|
|
534
|
+
|
|
535
|
+
Use the `include` parameter to include related resources in the response. This is available on both index and show endpoints:
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
# Include single relationship
|
|
539
|
+
GET /users?include=posts
|
|
540
|
+
|
|
541
|
+
# Include multiple relationships
|
|
542
|
+
GET /users?include=posts,comments
|
|
543
|
+
|
|
544
|
+
# Include nested relationships (two levels)
|
|
545
|
+
GET /users?include=posts.comments
|
|
546
|
+
|
|
547
|
+
# Include deeply nested relationships (arbitrary depth)
|
|
548
|
+
GET /users?include=posts.comments.author
|
|
549
|
+
|
|
550
|
+
# Include multiple nested paths
|
|
551
|
+
GET /users?include=posts.comments,posts.author
|
|
552
|
+
|
|
553
|
+
# Mix single and nested includes
|
|
554
|
+
GET /users?include=posts.comments,notifications
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The gem supports arbitrary depth for nested includes. You can chain as many associations as needed (e.g., `posts.comments.author.profile`). Overlapping paths are automatically merged, so `posts.comments` and `posts.comments.author` will correctly include posts, comments, and authors.
|
|
558
|
+
|
|
559
|
+
Invalid include paths will return a `400 Bad Request` error with a JSON:API error response:
|
|
560
|
+
|
|
561
|
+
```json
|
|
562
|
+
{
|
|
563
|
+
"errors": [
|
|
564
|
+
{
|
|
565
|
+
"status": "400",
|
|
566
|
+
"title": "Invalid Include Path",
|
|
567
|
+
"detail": "Invalid include paths requested: invalid_association"
|
|
568
|
+
}
|
|
569
|
+
]
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Included resources appear in the `included` array at the top level of the response, and relationships reference them using resource identifiers (`type` and `id`).
|
|
574
|
+
|
|
575
|
+
**Example Response with Includes:**
|
|
576
|
+
|
|
577
|
+
```json
|
|
578
|
+
{
|
|
579
|
+
"jsonapi": {
|
|
580
|
+
"version": "1.1"
|
|
581
|
+
},
|
|
582
|
+
"data": {
|
|
583
|
+
"type": "users",
|
|
584
|
+
"id": "1",
|
|
585
|
+
"attributes": {
|
|
586
|
+
"name": "John Doe",
|
|
587
|
+
"email": "john@example.com",
|
|
588
|
+
"phone": "555-0100"
|
|
589
|
+
},
|
|
590
|
+
"relationships": {
|
|
591
|
+
"posts": {
|
|
592
|
+
"data": [
|
|
593
|
+
{ "type": "posts", "id": "1" },
|
|
594
|
+
{ "type": "posts", "id": "2" }
|
|
595
|
+
],
|
|
596
|
+
"meta": {
|
|
597
|
+
"count": 2
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
"links": {
|
|
602
|
+
"self": "/users/1"
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
"included": [
|
|
606
|
+
{
|
|
607
|
+
"type": "posts",
|
|
608
|
+
"id": "1",
|
|
609
|
+
"attributes": {
|
|
610
|
+
"title": "First Post",
|
|
611
|
+
"body": "Content 1"
|
|
612
|
+
},
|
|
613
|
+
"relationships": {
|
|
614
|
+
"user": {
|
|
615
|
+
"data": {
|
|
616
|
+
"type": "users",
|
|
617
|
+
"id": "1"
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
"comments": {
|
|
621
|
+
"data": [],
|
|
622
|
+
"meta": {
|
|
623
|
+
"count": 0
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
"links": {
|
|
628
|
+
"self": "/posts/1"
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
"type": "posts",
|
|
633
|
+
"id": "2",
|
|
634
|
+
"attributes": {
|
|
635
|
+
"title": "Second Post",
|
|
636
|
+
"body": "Content 2"
|
|
637
|
+
},
|
|
638
|
+
"relationships": {
|
|
639
|
+
"user": {
|
|
640
|
+
"data": {
|
|
641
|
+
"type": "users",
|
|
642
|
+
"id": "1"
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
"comments": {
|
|
646
|
+
"data": [],
|
|
647
|
+
"meta": {
|
|
648
|
+
"count": 0
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
"links": {
|
|
653
|
+
"self": "/posts/2"
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
]
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
#### Polymorphic Relationships
|
|
661
|
+
|
|
662
|
+
The gem supports polymorphic associations (both `belongs_to :profile, polymorphic: true` and `has_many :activities, as: :actor`). When including polymorphic relationships, the serializer automatically determines the correct resource type based on the actual class of the related object:
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
# User belongs_to :profile, polymorphic: true
|
|
666
|
+
# User has_many :activities, as: :actor
|
|
667
|
+
|
|
668
|
+
GET /users?include=profile
|
|
669
|
+
GET /users?include=activities
|
|
670
|
+
GET /users/:id?include=profile,activities
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
The response will include the correct resource type for each polymorphic association (e.g., `customer_profiles` or `admin_profiles` for a polymorphic `profile` association).
|
|
674
|
+
|
|
675
|
+
**Example Response with Polymorphic Relationship:**
|
|
676
|
+
|
|
677
|
+
```json
|
|
678
|
+
{
|
|
679
|
+
"jsonapi": {
|
|
680
|
+
"version": "1.1"
|
|
681
|
+
},
|
|
682
|
+
"data": {
|
|
683
|
+
"type": "users",
|
|
684
|
+
"id": "1",
|
|
685
|
+
"attributes": {
|
|
686
|
+
"name": "John Doe",
|
|
687
|
+
"email": "john@example.com",
|
|
688
|
+
"phone": "555-0100"
|
|
689
|
+
},
|
|
690
|
+
"relationships": {
|
|
691
|
+
"profile": {
|
|
692
|
+
"data": {
|
|
693
|
+
"type": "admin_profiles",
|
|
694
|
+
"id": "1"
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
"links": {
|
|
699
|
+
"self": "/users/1"
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
"included": [
|
|
703
|
+
{
|
|
704
|
+
"type": "admin_profiles",
|
|
705
|
+
"id": "1",
|
|
706
|
+
"attributes": {
|
|
707
|
+
"department": "Engineering",
|
|
708
|
+
"level": "Senior"
|
|
709
|
+
},
|
|
710
|
+
"links": {
|
|
711
|
+
"self": "/admin_profiles/1"
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
]
|
|
715
|
+
}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
#### Single Table Inheritance (STI) Support
|
|
719
|
+
|
|
720
|
+
The gem supports Single Table Inheritance (STI) resources and relationships. Subclasses are treated as first-class JSON:API resources with their own types, while sharing the underlying table.
|
|
721
|
+
|
|
722
|
+
##### Routing
|
|
723
|
+
|
|
724
|
+
To enable STI support, use the `sti` option in your routes configuration. Pass an array of subtype names to generate routes for both the base resource and its subclasses:
|
|
124
725
|
|
|
125
726
|
```ruby
|
|
727
|
+
# config/routes.rb
|
|
126
728
|
Rails.application.routes.draw do
|
|
127
|
-
|
|
729
|
+
# Generates routes for:
|
|
730
|
+
# - /notifications (Base resource)
|
|
731
|
+
# - /email_notifications
|
|
732
|
+
# - /sms_notifications
|
|
733
|
+
jsonapi_resources :notifications, sti: [:email_notifications, :sms_notifications]
|
|
734
|
+
end
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
##### Resource Definitions
|
|
738
|
+
|
|
739
|
+
Define a resource class for the base model and each subclass. Subclasses should inherit from the base resource class to share configuration:
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
# app/resources/notification_resource.rb
|
|
743
|
+
class NotificationResource < JSONAPI::Resource
|
|
744
|
+
attributes :body, :created_at
|
|
745
|
+
has_one :user
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# app/resources/email_notification_resource.rb
|
|
749
|
+
class EmailNotificationResource < NotificationResource
|
|
750
|
+
attributes :subject, :recipient_email
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# app/resources/sms_notification_resource.rb
|
|
754
|
+
class SmsNotificationResource < NotificationResource
|
|
755
|
+
attributes :phone_number
|
|
128
756
|
end
|
|
129
757
|
```
|
|
130
758
|
|
|
131
|
-
|
|
759
|
+
##### Serialization
|
|
132
760
|
|
|
133
|
-
|
|
761
|
+
Resources are serialized with their specific type. When querying the base endpoint (e.g., `GET /notifications`), the response will contain a mix of types:
|
|
134
762
|
|
|
135
|
-
|
|
763
|
+
```json
|
|
764
|
+
{
|
|
765
|
+
"data": [
|
|
766
|
+
{
|
|
767
|
+
"type": "email_notifications",
|
|
768
|
+
"id": "1",
|
|
769
|
+
"attributes": {
|
|
770
|
+
"body": "Welcome!",
|
|
771
|
+
"subject": "Hello"
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
"type": "sms_notifications",
|
|
776
|
+
"id": "2",
|
|
777
|
+
"attributes": {
|
|
778
|
+
"body": "Code: 1234",
|
|
779
|
+
"phone_number": "555-1234"
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
]
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
##### Creating STI Resources
|
|
136
787
|
|
|
137
|
-
|
|
138
|
-
- **[🔗 Through Associations](https://github.com/emilkampp/jpie/blob/main/examples/through_associations.rb)** - Many-to-many relationships with `:through`
|
|
139
|
-
- **[🎨 Custom Attributes & Meta](https://github.com/emilkampp/jpie/blob/main/examples/custom_attributes_and_meta.rb)** - Custom computed attributes and meta data
|
|
140
|
-
- **[🔄 Polymorphic Associations](https://github.com/emilkampp/jpie/blob/main/examples/polymorphic_associations.rb)** - Complex polymorphic relationships
|
|
141
|
-
- **[🏗️ Single Table Inheritance](https://github.com/emilkampp/jpie/blob/main/examples/single_table_inheritance.rb)** - STI models and resources
|
|
142
|
-
- **[📊 Custom Sorting](https://github.com/emilkampp/jpie/blob/main/examples/custom_sorting.rb)** - Advanced sorting with complex algorithms
|
|
143
|
-
- **[⚠️ Error Handling](https://github.com/emilkampp/jpie/blob/main/examples/error_handling.rb)** - Comprehensive error handling strategies
|
|
788
|
+
To create a specific subclass, send a POST request to either the base endpoint or the specific subclass endpoint, specifying the correct `type` in the payload:
|
|
144
789
|
|
|
145
|
-
|
|
790
|
+
```json
|
|
791
|
+
POST /notifications
|
|
792
|
+
Content-Type: application/vnd.api+json
|
|
146
793
|
|
|
147
|
-
|
|
794
|
+
{
|
|
795
|
+
"data": {
|
|
796
|
+
"type": "email_notifications",
|
|
797
|
+
"attributes": {
|
|
798
|
+
"subject": "Important",
|
|
799
|
+
"body": "Please read this.",
|
|
800
|
+
"recipient_email": "user@example.com"
|
|
801
|
+
},
|
|
802
|
+
"relationships": {
|
|
803
|
+
"user": {
|
|
804
|
+
"data": { "type": "users", "id": "1" }
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
```
|
|
148
810
|
|
|
149
|
-
|
|
811
|
+
The controller automatically instantiates the correct model class based on the `type` field.
|
|
150
812
|
|
|
151
|
-
|
|
813
|
+
#### STI resource DSL inheritance
|
|
152
814
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
815
|
+
- Subclasses inherit parent DSL (attributes, filters, sortable_fields, relationships, creatable_fields, updatable_fields, class-level `meta`) only when they do **not** declare that DSL themselves.
|
|
816
|
+
- Once a subclass calls a DSL method, it uses only its own declarations; it must opt-in to parent definitions explicitly (e.g., `attributes(*superclass.permitted_attributes, :child_attr)`).
|
|
817
|
+
- Instance-level `meta` methods still inherit via Ruby method lookup; class-level `meta` follows the “silent inherits, declare resets” rule.
|
|
818
|
+
- For sparse fieldsets, expose needed attributes on the subtype by including the parent set when you declare subtype DSL.
|
|
819
|
+
|
|
820
|
+
#### Sorting
|
|
821
|
+
|
|
822
|
+
Sorting is only available on index endpoints (collection endpoints). Use the `sort` parameter to specify one or more fields to sort by:
|
|
823
|
+
|
|
824
|
+
```ruby
|
|
825
|
+
# Sort by name ascending
|
|
826
|
+
GET /users?sort=name
|
|
827
|
+
|
|
828
|
+
# Sort by name descending (prefix with -)
|
|
829
|
+
GET /users?sort=-name
|
|
830
|
+
|
|
831
|
+
# Sort by multiple fields
|
|
832
|
+
GET /users?sort=name,created_at
|
|
833
|
+
|
|
834
|
+
# Sort by multiple fields with mixed directions
|
|
835
|
+
GET /users?sort=name,-created_at
|
|
836
|
+
```
|
|
156
837
|
|
|
157
|
-
|
|
158
|
-
rails generate jpie:resource Post attribute:title attribute:content has_many:comments has_one:author
|
|
838
|
+
Invalid sort fields will return a `400 Bad Request` error with a JSON:API error response:
|
|
159
839
|
|
|
160
|
-
|
|
161
|
-
|
|
840
|
+
```json
|
|
841
|
+
{
|
|
842
|
+
"errors": [
|
|
843
|
+
{
|
|
844
|
+
"status": "400",
|
|
845
|
+
"title": "Invalid Sort Field",
|
|
846
|
+
"detail": "Invalid sort fields requested: invalid_field"
|
|
847
|
+
}
|
|
848
|
+
]
|
|
849
|
+
}
|
|
162
850
|
```
|
|
163
851
|
|
|
164
|
-
|
|
852
|
+
The sort parameter is ignored on show endpoints (single resource endpoints). However, relationship endpoints support sorting for collection relationships:
|
|
853
|
+
|
|
165
854
|
```ruby
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
855
|
+
# Sort posts relationship
|
|
856
|
+
GET /users/:id/relationships/posts?sort=title,-created_at
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
Sorting on relationship endpoints validates against the related model's columns, not the parent resource.
|
|
860
|
+
|
|
861
|
+
#### Virtual Attribute Sorting
|
|
862
|
+
|
|
863
|
+
The gem supports sorting by virtual attributes (attributes that don't correspond to database columns). When sorting by a virtual attribute, the gem:
|
|
864
|
+
|
|
865
|
+
1. Loads all records into memory
|
|
866
|
+
2. Sorts them in Ruby using the resource's getter method for the virtual attribute
|
|
867
|
+
3. Recalculates the total count after sorting
|
|
868
|
+
|
|
869
|
+
You can mix database columns and virtual attributes in the same sort:
|
|
870
|
+
|
|
871
|
+
```ruby
|
|
872
|
+
# Sort by database column first, then by virtual attribute
|
|
873
|
+
GET /users?sort=name,full_name
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
**Example:**
|
|
877
|
+
|
|
878
|
+
```ruby
|
|
879
|
+
class UserResource < JSONAPI::Resource
|
|
880
|
+
attributes :name, :email, :full_name
|
|
881
|
+
|
|
882
|
+
def full_name
|
|
883
|
+
"#{resource.name} (#{resource.email})"
|
|
884
|
+
end
|
|
172
885
|
end
|
|
173
886
|
```
|
|
174
887
|
|
|
175
|
-
|
|
888
|
+
```ruby
|
|
889
|
+
# Sort by virtual attribute ascending
|
|
890
|
+
GET /users?sort=full_name
|
|
891
|
+
|
|
892
|
+
# Sort by virtual attribute descending
|
|
893
|
+
GET /users?sort=-full_name
|
|
894
|
+
|
|
895
|
+
# Mix database column and virtual attribute
|
|
896
|
+
GET /users?sort=name,full_name
|
|
897
|
+
```
|
|
176
898
|
|
|
177
|
-
|
|
178
|
-
|--------|---------|---------|
|
|
179
|
-
| `attribute:field` | Regular JSON:API attribute | `attribute:name` |
|
|
180
|
-
| `meta:field` | JSON:API meta attribute | `meta:created_at` |
|
|
181
|
-
| `has_many:resource` | JSON:API relationship | `has_many:posts` |
|
|
182
|
-
| `has_one:resource` | JSON:API relationship | `has_one:profile` |
|
|
899
|
+
**Performance Note**: Sorting by virtual attributes requires loading all matching records into memory. For large collections, consider using database columns or computed database columns instead.
|
|
183
900
|
|
|
184
|
-
|
|
901
|
+
#### Sort-Only Fields
|
|
185
902
|
|
|
186
|
-
|
|
187
|
-
|--------|-------------|---------|
|
|
188
|
-
| `--model=NAME` | Specify model class | `--model=Person` |
|
|
189
|
-
| `--skip-model` | Skip explicit model declaration | `--skip-model` |
|
|
903
|
+
You can declare virtual fields that are sortable but not exposed as attributes. This is useful when you want to allow sorting by a computed value without making it readable or writable in the API response.
|
|
190
904
|
|
|
191
|
-
|
|
905
|
+
To declare a sort-only field, use `sortable_fields` in your resource class and implement a getter method:
|
|
192
906
|
|
|
193
907
|
```ruby
|
|
194
|
-
class UserResource <
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
has_one :profile
|
|
204
|
-
|
|
205
|
-
# Custom sorting
|
|
206
|
-
sortable :popularity do |query, direction|
|
|
207
|
-
query.order(likes_count: direction)
|
|
908
|
+
class UserResource < JSONAPI::Resource
|
|
909
|
+
attributes :name, :email
|
|
910
|
+
|
|
911
|
+
# Declare sort-only field
|
|
912
|
+
sortable_fields :posts_count
|
|
913
|
+
|
|
914
|
+
# Implement getter method (same as virtual attribute)
|
|
915
|
+
def posts_count
|
|
916
|
+
resource.posts.size
|
|
208
917
|
end
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
918
|
+
end
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
Sort-only fields:
|
|
922
|
+
|
|
923
|
+
- Can be used in sort parameters: `GET /users?sort=posts_count`
|
|
924
|
+
- Are validated as valid sort fields
|
|
925
|
+
- Are NOT included in serialized attributes
|
|
926
|
+
- Can be mixed with database columns and regular attributes in sort parameters
|
|
927
|
+
|
|
928
|
+
**Example:**
|
|
929
|
+
|
|
930
|
+
```ruby
|
|
931
|
+
# Sort by sort-only field
|
|
932
|
+
GET /users?sort=posts_count
|
|
933
|
+
|
|
934
|
+
# Mix sort-only field with database column
|
|
935
|
+
GET /users?sort=posts_count,name
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
**Note**: Sort-only fields still require loading records into memory for sorting, just like virtual attributes. The difference is that sort-only fields won't appear in the response attributes.
|
|
939
|
+
|
|
940
|
+
### JSON:API Object
|
|
941
|
+
|
|
942
|
+
All responses automatically include a `jsonapi` object indicating JSON:API version compliance:
|
|
943
|
+
|
|
944
|
+
```json
|
|
945
|
+
{
|
|
946
|
+
"jsonapi": {
|
|
947
|
+
"version": "1.1"
|
|
948
|
+
},
|
|
949
|
+
"data": { ... }
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
You can customize the jsonapi object via configuration:
|
|
954
|
+
|
|
955
|
+
```ruby
|
|
956
|
+
# config/initializers/json_api.rb
|
|
957
|
+
JSONAPI.configure do |config|
|
|
958
|
+
config.jsonapi_version = "1.1"
|
|
959
|
+
config.jsonapi_meta = { ext: ["https://jsonapi.org/ext/atomic"] }
|
|
960
|
+
end
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
### Meta Information
|
|
964
|
+
|
|
965
|
+
The gem supports meta information at three levels: document-level, resource-level, and relationship-level.
|
|
966
|
+
|
|
967
|
+
#### Document-Level Meta
|
|
968
|
+
|
|
969
|
+
Document-level meta appears at the top level of the response and is typically used for pagination:
|
|
970
|
+
|
|
971
|
+
```json
|
|
972
|
+
{
|
|
973
|
+
"jsonapi": { "version": "1.1" },
|
|
974
|
+
"data": [ ... ],
|
|
975
|
+
"meta": {
|
|
976
|
+
"total": 100
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
Pagination automatically includes meta with the total count when pagination is applied.
|
|
982
|
+
|
|
983
|
+
**Example Document-Level Meta:**
|
|
984
|
+
|
|
985
|
+
```json
|
|
986
|
+
{
|
|
987
|
+
"jsonapi": {
|
|
988
|
+
"version": "1.1"
|
|
989
|
+
},
|
|
990
|
+
"data": [
|
|
991
|
+
{
|
|
992
|
+
"type": "users",
|
|
993
|
+
"id": "1",
|
|
994
|
+
"attributes": {
|
|
995
|
+
"name": "John Doe",
|
|
996
|
+
"email": "john@example.com"
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
],
|
|
1000
|
+
"meta": {
|
|
1001
|
+
"total": 100
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
#### Resource-Level Meta
|
|
1007
|
+
|
|
1008
|
+
Resource-level meta appears within each resource object. By default, the gem automatically includes `created_at` and `updated_at` timestamps in ISO8601 format if the model responds to these methods.
|
|
1009
|
+
|
|
1010
|
+
You can also define custom meta in two ways:
|
|
1011
|
+
|
|
1012
|
+
**Class-level static meta:**
|
|
1013
|
+
|
|
1014
|
+
```ruby
|
|
1015
|
+
class UserResource < JSONAPI::Resource
|
|
1016
|
+
attributes :email, :name
|
|
1017
|
+
|
|
1018
|
+
meta({ version: "v1", custom: "value" })
|
|
1019
|
+
end
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
**Instance-level dynamic meta:**
|
|
1023
|
+
|
|
1024
|
+
```ruby
|
|
1025
|
+
class UserResource < JSONAPI::Resource
|
|
1026
|
+
attributes :email, :name
|
|
1027
|
+
|
|
1028
|
+
def meta
|
|
1029
|
+
{
|
|
1030
|
+
name_length: resource.name.length,
|
|
1031
|
+
custom_field: "value"
|
|
1032
|
+
}
|
|
215
1033
|
end
|
|
216
1034
|
end
|
|
217
1035
|
```
|
|
218
1036
|
|
|
219
|
-
|
|
1037
|
+
The instance method has access to the model instance via `resource`. Custom meta is merged with the default timestamp meta, with custom values taking precedence.
|
|
1038
|
+
|
|
1039
|
+
**Example Resource-Level Meta:**
|
|
1040
|
+
|
|
1041
|
+
```json
|
|
1042
|
+
{
|
|
1043
|
+
"jsonapi": {
|
|
1044
|
+
"version": "1.1"
|
|
1045
|
+
},
|
|
1046
|
+
"data": {
|
|
1047
|
+
"type": "users",
|
|
1048
|
+
"id": "1",
|
|
1049
|
+
"attributes": {
|
|
1050
|
+
"name": "John Doe",
|
|
1051
|
+
"email": "john@example.com"
|
|
1052
|
+
},
|
|
1053
|
+
"meta": {
|
|
1054
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
1055
|
+
"updated_at": "2024-01-15T10:30:00Z",
|
|
1056
|
+
"name_length": 8,
|
|
1057
|
+
"custom_field": "value"
|
|
1058
|
+
},
|
|
1059
|
+
"links": {
|
|
1060
|
+
"self": "/users/1"
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
#### Relationship-Level Meta
|
|
1067
|
+
|
|
1068
|
+
Relationship-level meta appears within relationship objects:
|
|
1069
|
+
|
|
1070
|
+
```ruby
|
|
1071
|
+
class UserResource < JSONAPI::Resource
|
|
1072
|
+
attributes :email, :name
|
|
1073
|
+
|
|
1074
|
+
has_many :posts, meta: { count: 5, custom: "relationship_meta" }
|
|
1075
|
+
has_one :profile, meta: { type: "polymorphic" }
|
|
1076
|
+
end
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
The response will include:
|
|
1080
|
+
|
|
1081
|
+
```json
|
|
1082
|
+
{
|
|
1083
|
+
"jsonapi": {
|
|
1084
|
+
"version": "1.1"
|
|
1085
|
+
},
|
|
1086
|
+
"data": {
|
|
1087
|
+
"type": "users",
|
|
1088
|
+
"id": "1",
|
|
1089
|
+
"attributes": {
|
|
1090
|
+
"name": "John Doe",
|
|
1091
|
+
"email": "john@example.com"
|
|
1092
|
+
},
|
|
1093
|
+
"relationships": {
|
|
1094
|
+
"posts": {
|
|
1095
|
+
"data": [
|
|
1096
|
+
{ "type": "posts", "id": "1" },
|
|
1097
|
+
{ "type": "posts", "id": "2" }
|
|
1098
|
+
],
|
|
1099
|
+
"meta": {
|
|
1100
|
+
"count": 5,
|
|
1101
|
+
"custom": "relationship_meta"
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
"profile": {
|
|
1105
|
+
"data": {
|
|
1106
|
+
"type": "admin_profiles",
|
|
1107
|
+
"id": "1"
|
|
1108
|
+
},
|
|
1109
|
+
"meta": {
|
|
1110
|
+
"type": "polymorphic"
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
"links": {
|
|
1115
|
+
"self": "/users/1"
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
### Configuration
|
|
1122
|
+
|
|
1123
|
+
Configure the gem in an initializer:
|
|
1124
|
+
|
|
1125
|
+
```ruby
|
|
1126
|
+
# config/initializers/json_api.rb
|
|
1127
|
+
JSONAPI.configure do |config|
|
|
1128
|
+
config.default_page_size = 25
|
|
1129
|
+
config.max_page_size = 100
|
|
1130
|
+
config.jsonapi_version = "1.1"
|
|
1131
|
+
config.jsonapi_meta = nil
|
|
1132
|
+
config.base_controller_class = ActionController::API # Default: ActionController::API
|
|
1133
|
+
end
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
#### Base Controller Class
|
|
1137
|
+
|
|
1138
|
+
By default, `JSONAPI::BaseController` inherits from `ActionController::API`. You can configure it to inherit from a different base class (e.g., `ActionController::Base` or a custom base controller):
|
|
1139
|
+
|
|
1140
|
+
```ruby
|
|
1141
|
+
JSONAPI.configure do |config|
|
|
1142
|
+
# Use ActionController::Base instead of ActionController::API
|
|
1143
|
+
config.base_controller_class = ActionController::Base
|
|
1144
|
+
|
|
1145
|
+
# Or use a custom base controller
|
|
1146
|
+
config.base_controller_class = ApplicationController
|
|
1147
|
+
end
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
This is useful when you need access to features available in `ActionController::Base` that aren't in `ActionController::API`, such as:
|
|
1151
|
+
|
|
1152
|
+
- View rendering helpers
|
|
1153
|
+
- Layout support
|
|
1154
|
+
- Cookie-based sessions
|
|
1155
|
+
- Flash messages
|
|
1156
|
+
|
|
1157
|
+
**Note:** The configuration must be set before the gem's controllers are loaded. Set it in a Rails initializer that loads before `json_api` is required.
|
|
1158
|
+
|
|
1159
|
+
### Authorization
|
|
1160
|
+
|
|
1161
|
+
The gem provides configurable authorization hooks that allow you to integrate with any authorization library (e.g., Pundit, CanCanCan). Authorization is handled through two hooks:
|
|
1162
|
+
|
|
1163
|
+
- **`authorization_scope`**: Filters collection queries (index actions) to only return records the user is authorized to see
|
|
1164
|
+
- **`authorization_handler`**: Authorizes individual actions (show, create, update, destroy) on specific records
|
|
1165
|
+
|
|
1166
|
+
Both hooks are optional - if not configured, all records are accessible (authorization is bypassed).
|
|
1167
|
+
|
|
1168
|
+
#### Authorization Scope Hook
|
|
1169
|
+
|
|
1170
|
+
The `authorization_scope` hook receives the initial ActiveRecord scope and should return a filtered scope containing only records the user is authorized to access:
|
|
1171
|
+
|
|
1172
|
+
```ruby
|
|
1173
|
+
JSONAPI.configure do |config|
|
|
1174
|
+
config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
|
|
1175
|
+
# Filter the scope based on authorization logic
|
|
1176
|
+
# For example, only return records belonging to the current user
|
|
1177
|
+
scope.where(user_id: controller.current_user.id)
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
**Parameters:**
|
|
1183
|
+
|
|
1184
|
+
- `controller`: The controller instance (provides access to `current_user`, `params`, etc.)
|
|
1185
|
+
- `scope`: The initial ActiveRecord scope (e.g., `User.all` or preloaded resources)
|
|
1186
|
+
- `action`: The action being performed (`:index`)
|
|
1187
|
+
- `model_class`: The ActiveRecord model class (e.g., `User`)
|
|
1188
|
+
|
|
1189
|
+
**Return value:** An ActiveRecord scope containing only authorized records
|
|
1190
|
+
|
|
1191
|
+
#### Authorization Handler Hook
|
|
1192
|
+
|
|
1193
|
+
The `authorization_handler` hook is called for individual resource actions (show, create, update, destroy) and should raise an exception if the user is not authorized:
|
|
1194
|
+
|
|
1195
|
+
```ruby
|
|
1196
|
+
JSONAPI.configure do |config|
|
|
1197
|
+
config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
|
|
1198
|
+
# Raise an exception if the user is not authorized
|
|
1199
|
+
unless authorized?(controller.current_user, record, action)
|
|
1200
|
+
raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
|
|
1201
|
+
end
|
|
1202
|
+
end
|
|
1203
|
+
end
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
**Parameters:**
|
|
1207
|
+
|
|
1208
|
+
- `controller`: The controller instance
|
|
1209
|
+
- `record`: The ActiveRecord record being accessed (for create, this is a new unsaved record)
|
|
1210
|
+
- `action`: The action being performed (`:show`, `:create`, `:update`, or `:destroy`)
|
|
1211
|
+
- `context`: Optional context hash (for relationship actions, includes `relationship:` key)
|
|
1212
|
+
|
|
1213
|
+
**Exceptions:** Raise `JSONAPI::AuthorizationError` to deny access. Your application is responsible for rescuing this error and rendering an appropriate response (e.g., a `403 Forbidden` JSON:API error object).
|
|
1214
|
+
|
|
1215
|
+
#### Pundit Integration Example
|
|
1216
|
+
|
|
1217
|
+
Here's a complete example using Pundit:
|
|
1218
|
+
|
|
1219
|
+
```ruby
|
|
1220
|
+
# config/initializers/json_api_authorization.rb
|
|
1221
|
+
|
|
1222
|
+
# Include Pundit in JSONAPI::BaseController
|
|
1223
|
+
JSONAPI::BaseController.class_eval do
|
|
1224
|
+
include Pundit::Authorization
|
|
1225
|
+
|
|
1226
|
+
rescue_from JSONAPI::AuthorizationError, with: :render_jsonapi_authorization_error
|
|
1227
|
+
|
|
1228
|
+
# Provide current_user method (override in your application)
|
|
1229
|
+
def current_user
|
|
1230
|
+
# Return the current authenticated user
|
|
1231
|
+
# This is application-specific
|
|
1232
|
+
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
private
|
|
1236
|
+
|
|
1237
|
+
def render_jsonapi_authorization_error(error)
|
|
1238
|
+
detail = error&.message.presence || "You are not authorized to perform this action"
|
|
1239
|
+
render json: {
|
|
1240
|
+
errors: [
|
|
1241
|
+
{
|
|
1242
|
+
status: "403",
|
|
1243
|
+
title: "Forbidden",
|
|
1244
|
+
detail:
|
|
1245
|
+
}
|
|
1246
|
+
]
|
|
1247
|
+
}, status: :forbidden
|
|
1248
|
+
end
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# Configure JSON:API authorization hooks using Pundit
|
|
1252
|
+
JSONAPI.configure do |config|
|
|
1253
|
+
# Authorization scope hook - filters collections based on Pundit scopes
|
|
1254
|
+
config.authorization_scope = lambda do |controller:, scope:, action:, model_class:|
|
|
1255
|
+
policy_class = Pundit::PolicyFinder.new(model_class).policy
|
|
1256
|
+
policy_scope = policy_class.const_get(:Scope).new(controller.current_user, scope)
|
|
1257
|
+
policy_scope.resolve
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
# Authorization handler hook - authorizes individual actions using Pundit policies
|
|
1261
|
+
# Note: We convert Pundit authorization failures to JSONAPI::AuthorizationError
|
|
1262
|
+
# so the gem can handle them consistently
|
|
1263
|
+
config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
|
|
1264
|
+
policy_class = Pundit::PolicyFinder.new(record).policy
|
|
1265
|
+
policy = policy_class.new(controller.current_user, record)
|
|
1266
|
+
|
|
1267
|
+
action_method = "#{action}?"
|
|
1268
|
+
unless policy.public_send(action_method)
|
|
1269
|
+
raise JSONAPI::AuthorizationError, "Not authorized to #{action} this resource"
|
|
1270
|
+
end
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
**Example Policy:**
|
|
1276
|
+
|
|
1277
|
+
```ruby
|
|
1278
|
+
# app/policies/user_policy.rb
|
|
1279
|
+
class UserPolicy < ApplicationPolicy
|
|
1280
|
+
def index?
|
|
1281
|
+
true
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
def show?
|
|
1285
|
+
record.public? || user == record
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def create?
|
|
1289
|
+
user.admin?
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
def update?
|
|
1293
|
+
user.admin? || user == record
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def destroy?
|
|
1297
|
+
user.admin?
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
class Scope < ApplicationPolicy::Scope
|
|
1301
|
+
def resolve
|
|
1302
|
+
if user.admin?
|
|
1303
|
+
scope.all
|
|
1304
|
+
else
|
|
1305
|
+
scope.where(public: true).or(scope.where(id: user.id))
|
|
1306
|
+
end
|
|
1307
|
+
end
|
|
1308
|
+
end
|
|
1309
|
+
end
|
|
1310
|
+
```
|
|
1311
|
+
|
|
1312
|
+
#### Relationship Authorization
|
|
1313
|
+
|
|
1314
|
+
Relationship endpoints (show, update, destroy) authorize the parent resource using the `:update` action with a context hash containing the relationship name:
|
|
1315
|
+
|
|
1316
|
+
```ruby
|
|
1317
|
+
# The authorization_handler receives:
|
|
1318
|
+
{
|
|
1319
|
+
controller: controller_instance,
|
|
1320
|
+
record: parent_resource,
|
|
1321
|
+
action: :update,
|
|
1322
|
+
context: { relationship: :posts }
|
|
1323
|
+
}
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
This allows you to implement relationship-specific authorization logic in your policies if needed.
|
|
1327
|
+
|
|
1328
|
+
#### Custom Authorization Error Handling
|
|
1329
|
+
|
|
1330
|
+
The gem automatically handles `JSONAPI::AuthorizationError` and `Pundit::NotAuthorizedError` exceptions, rendering a JSON:API compliant `403 Forbidden` response:
|
|
1331
|
+
|
|
1332
|
+
```json
|
|
1333
|
+
{
|
|
1334
|
+
"jsonapi": {
|
|
1335
|
+
"version": "1.1"
|
|
1336
|
+
},
|
|
1337
|
+
"errors": [
|
|
1338
|
+
{
|
|
1339
|
+
"status": "403",
|
|
1340
|
+
"title": "Forbidden",
|
|
1341
|
+
"detail": "You are not authorized to perform this action"
|
|
1342
|
+
}
|
|
1343
|
+
]
|
|
1344
|
+
}
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
You can customize the error message by raising an exception with a specific message:
|
|
1348
|
+
|
|
1349
|
+
```ruby
|
|
1350
|
+
raise JSONAPI::AuthorizationError, "Not authorized to view this resource"
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
#### Overriding Authorization
|
|
1354
|
+
|
|
1355
|
+
Authorization hooks can be easily overridden or disabled:
|
|
1356
|
+
|
|
1357
|
+
```ruby
|
|
1358
|
+
# Disable authorization (allow all access)
|
|
1359
|
+
JSONAPI.configure do |config|
|
|
1360
|
+
config.authorization_scope = nil
|
|
1361
|
+
config.authorization_handler = nil
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
# Use custom authorization logic
|
|
1365
|
+
JSONAPI.configure do |config|
|
|
1366
|
+
config.authorization_handler = lambda do |controller:, record:, action:, context: nil|
|
|
1367
|
+
# Your custom authorization logic here
|
|
1368
|
+
unless MyAuthService.authorized?(controller.current_user, record, action)
|
|
1369
|
+
raise JSONAPI::AuthorizationError, "Access denied"
|
|
1370
|
+
end
|
|
1371
|
+
end
|
|
1372
|
+
end
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
### Instrumentation (Rails 8.1+)
|
|
1376
|
+
|
|
1377
|
+
When running on Rails 8.1 or later, the gem automatically emits structured events via `Rails.event` for all CRUD and relationship operations. This enables seamless integration with monitoring and APM platforms like Datadog, AppSignal, New Relic, or Honeycomb.
|
|
1378
|
+
|
|
1379
|
+
#### Resource Events
|
|
1380
|
+
|
|
1381
|
+
The gem emits events for resource lifecycle operations:
|
|
1382
|
+
|
|
1383
|
+
- **`jsonapi.{resource_type}.created`** - Emitted after successful resource creation
|
|
1384
|
+
- **`jsonapi.{resource_type}.updated`** - Emitted after successful resource updates (includes changed fields)
|
|
1385
|
+
- **`jsonapi.{resource_type}.deleted`** - Emitted after successful resource deletion
|
|
1386
|
+
|
|
1387
|
+
**Event Payload Structure:**
|
|
1388
|
+
|
|
1389
|
+
```ruby
|
|
1390
|
+
{
|
|
1391
|
+
resource_type: "users",
|
|
1392
|
+
resource_id: 123,
|
|
1393
|
+
changes: { "name" => ["old", "new"], "phone" => ["old", "new"] } # Only for updates
|
|
1394
|
+
}
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
**Example Usage:**
|
|
1398
|
+
|
|
1399
|
+
```ruby
|
|
1400
|
+
# Subscribe to events
|
|
1401
|
+
class JsonApiEventSubscriber
|
|
1402
|
+
def emit(event)
|
|
1403
|
+
encoded = ActiveSupport::EventReporter.encoder(:json).encode(event)
|
|
1404
|
+
# Forward to your monitoring service
|
|
1405
|
+
MonitoringService.send_event(encoded)
|
|
1406
|
+
end
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
Rails.event.subscribe(JsonApiEventSubscriber.new) if Rails.respond_to?(:event)
|
|
1410
|
+
```
|
|
1411
|
+
|
|
1412
|
+
#### Relationship Events
|
|
1413
|
+
|
|
1414
|
+
The gem also emits events for relationship operations:
|
|
1415
|
+
|
|
1416
|
+
- **`jsonapi.{resource_type}.relationship.updated`** - Emitted after successful relationship updates
|
|
1417
|
+
- **`jsonapi.{resource_type}.relationship.removed`** - Emitted after successful relationship removals
|
|
1418
|
+
|
|
1419
|
+
**Event Payload Structure:**
|
|
1420
|
+
|
|
1421
|
+
```ruby
|
|
1422
|
+
{
|
|
1423
|
+
resource_type: "users",
|
|
1424
|
+
resource_id: 123,
|
|
1425
|
+
relationship_name: "posts",
|
|
1426
|
+
related_type: "posts", # Optional
|
|
1427
|
+
related_ids: [456, 789] # Optional
|
|
1428
|
+
}
|
|
1429
|
+
```
|
|
1430
|
+
|
|
1431
|
+
#### Testing Instrumentation
|
|
1432
|
+
|
|
1433
|
+
Use Rails 8.1's `assert_events_reported` test helper to verify events are emitted:
|
|
1434
|
+
|
|
1435
|
+
```ruby
|
|
1436
|
+
assert_events_reported([
|
|
1437
|
+
{ name: "jsonapi.users.created", payload: { resource_type: "users", resource_id: 123 } },
|
|
1438
|
+
{ name: "jsonapi.users.relationship.updated", payload: { relationship_name: "posts" } }
|
|
1439
|
+
]) do
|
|
1440
|
+
post "/users", params: payload.to_json, headers: jsonapi_headers
|
|
1441
|
+
end
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
#### Compatibility
|
|
1445
|
+
|
|
1446
|
+
The instrumentation feature is automatically enabled when `Rails.event` is available (Rails 8.1+). On older Rails versions, the gem continues to work normally without emitting events. No configuration is required.
|
|
1447
|
+
|
|
1448
|
+
### Creatable and Updatable Fields
|
|
1449
|
+
|
|
1450
|
+
By default, all attributes defined with `attributes` are available for both create and update operations. You can restrict which fields can be created or updated separately:
|
|
1451
|
+
|
|
1452
|
+
```ruby
|
|
1453
|
+
class UserResource < JSONAPI::Resource
|
|
1454
|
+
attributes :name, :email, :phone, :role
|
|
1455
|
+
|
|
1456
|
+
# Only these fields can be set during creation
|
|
1457
|
+
creatable_fields :name, :email, :phone
|
|
1458
|
+
|
|
1459
|
+
# Only these fields can be updated
|
|
1460
|
+
updatable_fields :name, :phone
|
|
1461
|
+
end
|
|
1462
|
+
```
|
|
1463
|
+
|
|
1464
|
+
If `creatable_fields` or `updatable_fields` are not explicitly defined, the gem defaults to using all `permitted_attributes`. This allows you to:
|
|
1465
|
+
|
|
1466
|
+
- Prevent certain fields from being set during creation (e.g., `role` might be set by the system)
|
|
1467
|
+
- Prevent certain fields from being updated (e.g., `email` might be immutable after creation)
|
|
1468
|
+
- Have different field sets for create vs update operations
|
|
1469
|
+
|
|
1470
|
+
### Content Negotiation
|
|
1471
|
+
|
|
1472
|
+
The gem enforces JSON:API content negotiation:
|
|
1473
|
+
|
|
1474
|
+
- **Content-Type**: POST, PATCH, and PUT requests must include `Content-Type: application/vnd.api+json` header (returns `415 Unsupported Media Type` if missing)
|
|
1475
|
+
- **Accept**: If an `Accept` header is provided, it must include `application/vnd.api+json` or be `*/*` (returns `406 Not Acceptable` if explicitly set to non-JSON:API types)
|
|
1476
|
+
|
|
1477
|
+
Blank or `*/*` Accept headers are allowed to support browser defaults.
|
|
1478
|
+
|
|
1479
|
+
### Custom Controllers
|
|
1480
|
+
|
|
1481
|
+
You can inherit from `JSONAPI::BaseController` to create custom controllers:
|
|
1482
|
+
|
|
1483
|
+
```ruby
|
|
1484
|
+
class UsersController < JsonApi::BaseController
|
|
1485
|
+
def index
|
|
1486
|
+
# Custom implementation
|
|
1487
|
+
end
|
|
1488
|
+
end
|
|
1489
|
+
```
|
|
1490
|
+
|
|
1491
|
+
The base controller provides helper methods:
|
|
1492
|
+
|
|
1493
|
+
- `jsonapi_params` - Parsed JSON:API parameters
|
|
1494
|
+
- `jsonapi_attributes` - Extracted attributes
|
|
1495
|
+
- `jsonapi_relationships` - Extracted relationships
|
|
1496
|
+
- `parse_include_param` - Parsed include parameter
|
|
1497
|
+
- `parse_fields_param` - Parsed fields parameter
|
|
1498
|
+
- `parse_filter_param` - Parsed filter parameter
|
|
1499
|
+
- `parse_sort_param` - Parsed sort parameter
|
|
1500
|
+
- `parse_page_param` - Parsed page parameter
|
|
1501
|
+
|
|
1502
|
+
### Serialization
|
|
1503
|
+
|
|
1504
|
+
Use `JSONAPI::Serializer` to serialize resources:
|
|
1505
|
+
|
|
1506
|
+
```ruby
|
|
1507
|
+
serializer = JSONAPI::Serializer.new(user)
|
|
1508
|
+
serializer.to_hash(include: ["posts"], fields: { users: ["name", "email"] })
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
### Deserialization
|
|
1512
|
+
|
|
1513
|
+
Use `JSONAPI::Deserializer` to deserialize JSON:API payloads:
|
|
1514
|
+
|
|
1515
|
+
```ruby
|
|
1516
|
+
deserializer = JSONAPI::Deserializer.new(params, resource_class: User)
|
|
1517
|
+
deserializer.attributes # => { "name" => "John", "email" => "john@example.com" }
|
|
1518
|
+
deserializer.relationship_ids(:posts) # => ["1", "2"]
|
|
1519
|
+
deserializer.to_params # => { "name" => "John", "post_ids" => ["1", "2"], "profile_id" => "1", "profile_type" => "CustomerProfile" }
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1522
|
+
The deserializer automatically converts JSON:API relationship format to Rails-friendly params:
|
|
1523
|
+
|
|
1524
|
+
- To-many relationships: `posts` → `post_ids` (array)
|
|
1525
|
+
- To-one relationships: `account` → `account_id`
|
|
1526
|
+
- Polymorphic relationships: `profile` → `profile_id` and `profile_type`
|
|
1527
|
+
|
|
1528
|
+
#### Creating Resources with Relationships
|
|
1529
|
+
|
|
1530
|
+
You can include relationships when creating resources:
|
|
1531
|
+
|
|
1532
|
+
```ruby
|
|
1533
|
+
# POST /users
|
|
1534
|
+
{
|
|
1535
|
+
"data": {
|
|
1536
|
+
"type": "users",
|
|
1537
|
+
"attributes": {
|
|
1538
|
+
"name": "John Doe",
|
|
1539
|
+
"email": "john@example.com"
|
|
1540
|
+
},
|
|
1541
|
+
"relationships": {
|
|
1542
|
+
"profile": {
|
|
1543
|
+
"data": {
|
|
1544
|
+
"type": "customer_profiles",
|
|
1545
|
+
"id": "1"
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
"posts": {
|
|
1549
|
+
"data": [
|
|
1550
|
+
{ "type": "posts", "id": "1" },
|
|
1551
|
+
{ "type": "posts", "id": "2" }
|
|
1552
|
+
]
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
#### Updating Resources with Relationships
|
|
1560
|
+
|
|
1561
|
+
You can update relationships when updating resources:
|
|
1562
|
+
|
|
1563
|
+
```ruby
|
|
1564
|
+
# PATCH /users/:id
|
|
1565
|
+
{
|
|
1566
|
+
"data": {
|
|
1567
|
+
"type": "users",
|
|
1568
|
+
"id": "1",
|
|
1569
|
+
"attributes": {
|
|
1570
|
+
"name": "John Doe Updated"
|
|
1571
|
+
},
|
|
1572
|
+
"relationships": {
|
|
1573
|
+
"profile": {
|
|
1574
|
+
"data": {
|
|
1575
|
+
"type": "admin_profiles",
|
|
1576
|
+
"id": "2"
|
|
1577
|
+
}
|
|
1578
|
+
},
|
|
1579
|
+
"posts": {
|
|
1580
|
+
"data": [
|
|
1581
|
+
{ "type": "posts", "id": "3" }
|
|
1582
|
+
]
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
```
|
|
1588
|
+
|
|
1589
|
+
#### Clearing Relationships
|
|
1590
|
+
|
|
1591
|
+
To clear a relationship, send `null` for to-one relationships or an empty array for to-many relationships:
|
|
1592
|
+
|
|
1593
|
+
```ruby
|
|
1594
|
+
# Clear to-one relationship
|
|
1595
|
+
{
|
|
1596
|
+
"data": {
|
|
1597
|
+
"type": "users",
|
|
1598
|
+
"id": "1",
|
|
1599
|
+
"relationships": {
|
|
1600
|
+
"profile": {
|
|
1601
|
+
"data": null
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
# Clear to-many relationship
|
|
1608
|
+
{
|
|
1609
|
+
"data": {
|
|
1610
|
+
"type": "users",
|
|
1611
|
+
"id": "1",
|
|
1612
|
+
"relationships": {
|
|
1613
|
+
"posts": {
|
|
1614
|
+
"data": []
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
```
|
|
1620
|
+
|
|
1621
|
+
#### Relationship Validation
|
|
1622
|
+
|
|
1623
|
+
The gem validates relationship data:
|
|
1624
|
+
|
|
1625
|
+
- Missing `type` or `id` in relationship data returns `400 Bad Request`
|
|
1626
|
+
- Invalid relationship type (for non-polymorphic associations) returns `400 Bad Request`
|
|
1627
|
+
- Invalid polymorphic type (class doesn't exist) returns `400 Bad Request`
|
|
1628
|
+
- Attempting to unset linkage that cannot be nullified (e.g., foreign key has NOT NULL constraint) returns `400 Bad Request`
|
|
1629
|
+
|
|
1630
|
+
### Relationship Endpoints
|
|
1631
|
+
|
|
1632
|
+
The gem provides dedicated endpoints for managing relationships independently of the main resource:
|
|
1633
|
+
|
|
1634
|
+
#### Show Relationship
|
|
1635
|
+
|
|
1636
|
+
```ruby
|
|
1637
|
+
GET /users/:id/relationships/posts
|
|
1638
|
+
```
|
|
1639
|
+
|
|
1640
|
+
Returns the relationship data (resource identifiers) with links and meta:
|
|
1641
|
+
|
|
1642
|
+
**To-Many Relationship Response:**
|
|
1643
|
+
|
|
1644
|
+
```json
|
|
1645
|
+
{
|
|
1646
|
+
"jsonapi": {
|
|
1647
|
+
"version": "1.1"
|
|
1648
|
+
},
|
|
1649
|
+
"data": [
|
|
1650
|
+
{ "type": "posts", "id": "1" },
|
|
1651
|
+
{ "type": "posts", "id": "2" }
|
|
1652
|
+
],
|
|
1653
|
+
"links": {
|
|
1654
|
+
"self": "/users/1/relationships/posts",
|
|
1655
|
+
"related": "/users/1/posts"
|
|
1656
|
+
},
|
|
1657
|
+
"meta": {
|
|
1658
|
+
"count": 2
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
```
|
|
1662
|
+
|
|
1663
|
+
**To-One Relationship Response:**
|
|
1664
|
+
|
|
1665
|
+
```json
|
|
1666
|
+
{
|
|
1667
|
+
"jsonapi": {
|
|
1668
|
+
"version": "1.1"
|
|
1669
|
+
},
|
|
1670
|
+
"data": {
|
|
1671
|
+
"type": "admin_profiles",
|
|
1672
|
+
"id": "1"
|
|
1673
|
+
},
|
|
1674
|
+
"links": {
|
|
1675
|
+
"self": "/users/1/relationships/profile",
|
|
1676
|
+
"related": "/users/1/profile"
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
**Empty To-One Relationship Response:**
|
|
1682
|
+
|
|
1683
|
+
```json
|
|
1684
|
+
{
|
|
1685
|
+
"jsonapi": {
|
|
1686
|
+
"version": "1.1"
|
|
1687
|
+
},
|
|
1688
|
+
"data": null,
|
|
1689
|
+
"links": {
|
|
1690
|
+
"self": "/users/1/relationships/profile",
|
|
1691
|
+
"related": "/users/1/profile"
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
For collection relationships, you can sort using the `sort` parameter:
|
|
1697
|
+
|
|
1698
|
+
```ruby
|
|
1699
|
+
GET /users/:id/relationships/posts?sort=title,-created_at
|
|
1700
|
+
```
|
|
1701
|
+
|
|
1702
|
+
#### Update Relationship
|
|
1703
|
+
|
|
1704
|
+
```ruby
|
|
1705
|
+
PATCH /users/:id/relationships/posts
|
|
1706
|
+
Content-Type: application/vnd.api+json
|
|
1707
|
+
|
|
1708
|
+
{
|
|
1709
|
+
"data": [
|
|
1710
|
+
{ "type": "posts", "id": "3" },
|
|
1711
|
+
{ "type": "posts", "id": "4" }
|
|
1712
|
+
]
|
|
1713
|
+
}
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
Replaces the entire relationship linkage. For to-one relationships, send a single resource identifier object (or `null` to clear). For to-many relationships, send an array of resource identifiers (or empty array `[]` to clear).
|
|
1717
|
+
|
|
1718
|
+
#### Delete Relationship Linkage
|
|
1719
|
+
|
|
1720
|
+
```ruby
|
|
1721
|
+
DELETE /users/:id/relationships/posts
|
|
1722
|
+
Content-Type: application/vnd.api+json
|
|
1723
|
+
|
|
1724
|
+
{
|
|
1725
|
+
"data": [
|
|
1726
|
+
{ "type": "posts", "id": "1" }
|
|
1727
|
+
]
|
|
1728
|
+
}
|
|
1729
|
+
```
|
|
1730
|
+
|
|
1731
|
+
Removes specific resources from a to-many relationship by setting their foreign key to `NULL`. For to-one relationships, send a single resource identifier object to remove the linkage.
|
|
1732
|
+
|
|
1733
|
+
**Important**: Per JSON:API specification, relationship endpoints (`DELETE /users/:id/relationships/posts`) only modify linkage and never destroy resources. The gem attempts to unset the linkage by setting the foreign key to `NULL`. If this operation fails (due to NOT NULL constraints, validations, or other database constraints), a `400 Bad Request` error is returned. To allow relationship removal, ensure the foreign key column allows `NULL` values and any validations permit nullification.
|
|
1734
|
+
|
|
1735
|
+
Error responses follow JSON:API error format:
|
|
1736
|
+
|
|
1737
|
+
**Example Error Response:**
|
|
1738
|
+
|
|
1739
|
+
```json
|
|
1740
|
+
{
|
|
1741
|
+
"jsonapi": {
|
|
1742
|
+
"version": "1.1"
|
|
1743
|
+
},
|
|
1744
|
+
"errors": [
|
|
1745
|
+
{
|
|
1746
|
+
"status": "400",
|
|
1747
|
+
"title": "Invalid Relationship",
|
|
1748
|
+
"detail": "Invalid relationship type for profile: 'invalid_type' does not correspond to a valid model class",
|
|
1749
|
+
"source": {
|
|
1750
|
+
"pointer": "/data/relationships/profile/data/type"
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
]
|
|
1754
|
+
}
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
**Example 404 Not Found Error:**
|
|
1758
|
+
|
|
1759
|
+
```json
|
|
1760
|
+
{
|
|
1761
|
+
"jsonapi": {
|
|
1762
|
+
"version": "1.1"
|
|
1763
|
+
},
|
|
1764
|
+
"errors": [
|
|
1765
|
+
{
|
|
1766
|
+
"status": "404",
|
|
1767
|
+
"title": "Record Not Found",
|
|
1768
|
+
"detail": "Couldn't find User with 'id'=999"
|
|
1769
|
+
}
|
|
1770
|
+
]
|
|
1771
|
+
}
|
|
1772
|
+
```
|
|
1773
|
+
|
|
1774
|
+
**Example Validation Error:**
|
|
1775
|
+
|
|
1776
|
+
```json
|
|
1777
|
+
{
|
|
1778
|
+
"jsonapi": {
|
|
1779
|
+
"version": "1.1"
|
|
1780
|
+
},
|
|
1781
|
+
"errors": [
|
|
1782
|
+
{
|
|
1783
|
+
"status": "422",
|
|
1784
|
+
"title": "Validation Error",
|
|
1785
|
+
"detail": "Email can't be blank",
|
|
1786
|
+
"source": {
|
|
1787
|
+
"pointer": "/data/attributes/email"
|
|
1788
|
+
}
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
"status": "422",
|
|
1792
|
+
"title": "Validation Error",
|
|
1793
|
+
"detail": "Name is too short (minimum is 2 characters)",
|
|
1794
|
+
"source": {
|
|
1795
|
+
"pointer": "/data/attributes/name"
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
]
|
|
1799
|
+
}
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
## Development
|
|
1803
|
+
|
|
1804
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
|
|
1805
|
+
|
|
1806
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
1807
|
+
|
|
1808
|
+
### Code Organization
|
|
1809
|
+
|
|
1810
|
+
The gem is organized into several directories within `lib/json_api/`:
|
|
1811
|
+
|
|
1812
|
+
- **`controllers/`** - Controller classes (`BaseController`, `ResourcesController`, `RelationshipsController`)
|
|
1813
|
+
- **`resources/`** - Resource DSL and resource loading (`Resource`, `ResourceLoader`, `ActiveStorageBlobResource`)
|
|
1814
|
+
- **`serialization/`** - Serialization and deserialization (`Serializer`, `Deserializer`)
|
|
1815
|
+
- **`support/`** - Shared concerns and utilities:
|
|
1816
|
+
- `ActiveStorageSupport` - Concern for handling ActiveStorage attachments
|
|
1817
|
+
- `CollectionQuery` - Service class for building filtered, sorted, and paginated queries
|
|
1818
|
+
- `RelationshipHelpers` - Utilities for relationship handling
|
|
1819
|
+
- `ParamHelpers` - Parameter parsing utilities
|
|
1820
|
+
- `Responders` - Content negotiation and error rendering
|
|
1821
|
+
- `Instrumentation` - Rails event emission
|
|
1822
|
+
|
|
1823
|
+
This organization makes it easier to understand the gem's structure and locate specific functionality.
|
|
1824
|
+
|
|
1825
|
+
## Testing
|
|
1826
|
+
|
|
1827
|
+
Run the test suite:
|
|
1828
|
+
|
|
1829
|
+
```bash
|
|
1830
|
+
bundle exec rspec
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
### Test Helper for Request Specs
|
|
1834
|
+
|
|
1835
|
+
The gem provides a test helper module that makes it easy to write request specs with proper JSON:API content negotiation. The helper ensures `as: :jsonapi` works consistently across all HTTP methods.
|
|
1836
|
+
|
|
1837
|
+
**Setup (RSpec):**
|
|
1838
|
+
|
|
1839
|
+
```ruby
|
|
1840
|
+
# spec/rails_helper.rb or spec/support/json_api.rb
|
|
1841
|
+
require "json_api/testing"
|
|
1842
|
+
|
|
1843
|
+
RSpec.configure do |config|
|
|
1844
|
+
config.include JSONAPI::Testing::TestHelper, type: :request
|
|
1845
|
+
end
|
|
1846
|
+
```
|
|
1847
|
+
|
|
1848
|
+
**Usage:**
|
|
1849
|
+
|
|
1850
|
+
```ruby
|
|
1851
|
+
RSpec.describe "Users API", type: :request do
|
|
1852
|
+
let(:headers) { { "Authorization" => "Bearer #{token}" } }
|
|
1853
|
+
|
|
1854
|
+
describe "GET /users" do
|
|
1855
|
+
it "returns users" do
|
|
1856
|
+
# GET requests: Accept header is set, params go to query string
|
|
1857
|
+
get users_path, params: { filter: { active: true } }, headers:, as: :jsonapi
|
|
1858
|
+
expect(response).to have_http_status(:ok)
|
|
1859
|
+
end
|
|
1860
|
+
end
|
|
1861
|
+
|
|
1862
|
+
describe "POST /users" do
|
|
1863
|
+
it "creates a user" do
|
|
1864
|
+
# POST/PATCH/PUT/DELETE: Content-Type and Accept headers are set,
|
|
1865
|
+
# params are JSON-encoded in the request body
|
|
1866
|
+
payload = {
|
|
1867
|
+
data: {
|
|
1868
|
+
type: "users",
|
|
1869
|
+
attributes: { name: "John", email: "john@example.com" }
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
post users_path, params: payload, headers:, as: :jsonapi
|
|
1873
|
+
expect(response).to have_http_status(:created)
|
|
1874
|
+
end
|
|
1875
|
+
end
|
|
1876
|
+
end
|
|
1877
|
+
```
|
|
1878
|
+
|
|
1879
|
+
**Behavior by HTTP method:**
|
|
1880
|
+
|
|
1881
|
+
| Method | Accept Header | Content-Type Header | Params Encoding |
|
|
1882
|
+
| ------ | ------------- | ------------------- | --------------- |
|
|
1883
|
+
| GET | ✅ Set | ❌ Not set | Query string |
|
|
1884
|
+
| POST | ✅ Set | ✅ Set | JSON body |
|
|
1885
|
+
| PATCH | ✅ Set | ✅ Set | JSON body |
|
|
1886
|
+
| PUT | ✅ Set | ✅ Set | JSON body |
|
|
1887
|
+
| DELETE | ✅ Set | ✅ Set | JSON body |
|
|
1888
|
+
|
|
1889
|
+
### Integration Tests
|
|
1890
|
+
|
|
1891
|
+
The gem includes a dummy Rails app in `spec/dummy` for integration testing. The integration tests verify that the gem works correctly with a real Rails application.
|
|
1892
|
+
|
|
1893
|
+
To run only integration tests:
|
|
1894
|
+
|
|
1895
|
+
```bash
|
|
1896
|
+
bundle exec rspec spec/integration
|
|
1897
|
+
```
|
|
1898
|
+
|
|
1899
|
+
The integration tests cover:
|
|
1900
|
+
|
|
1901
|
+
- Full CRUD operations (create, read, update, delete)
|
|
1902
|
+
- JSON:API response format validation using JSON Schema
|
|
1903
|
+
- Sparse fieldsets (fields parameter) for single and multiple fields
|
|
1904
|
+
- Sorting (ascending, descending, multiple fields, invalid fields)
|
|
1905
|
+
- Including related resources (include parameter) with validation
|
|
1906
|
+
- Creating and updating resources with relationships (to-one, to-many, polymorphic)
|
|
1907
|
+
- Relationship validation and error handling
|
|
1908
|
+
- Error handling and validation responses
|
|
1909
|
+
- HTTP status codes
|
|
1910
|
+
|
|
1911
|
+
The dummy app includes a simple `User` model with basic validations and relationships (posts, profile, activities, notifications) to test the full request/response cycle including relationship writes.
|
|
1912
|
+
|
|
1913
|
+
## ActiveStorage Support
|
|
1914
|
+
|
|
1915
|
+
The gem includes built-in support for serializing and deserializing ActiveStorage attachments through JSON:API.
|
|
1916
|
+
|
|
1917
|
+
### Serializing Attachments
|
|
1918
|
+
|
|
1919
|
+
When a model has ActiveStorage attachments (`has_one_attached` or `has_many_attached`), you can expose them as relationships in your resource:
|
|
1920
|
+
|
|
1921
|
+
```ruby
|
|
1922
|
+
class UserResource < JSONAPI::Resource
|
|
1923
|
+
attributes :name, :email
|
|
1924
|
+
has_one :avatar # For has_one_attached :avatar
|
|
1925
|
+
has_many :documents # For has_many_attached :documents
|
|
1926
|
+
end
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
The serializer will automatically:
|
|
1930
|
+
|
|
1931
|
+
- Include attachment relationships pointing to `active_storage_blobs` resources
|
|
1932
|
+
- Add download links for each blob
|
|
1933
|
+
- Include blob details in the `included` section when requested via `include` parameter
|
|
1934
|
+
- Filter out ActiveStorage attachments from include paths (attachments are loaded on-demand by the serializer, not via ActiveRecord includes)
|
|
1935
|
+
|
|
1936
|
+
Example response:
|
|
1937
|
+
|
|
1938
|
+
```json
|
|
1939
|
+
{
|
|
1940
|
+
"data": {
|
|
1941
|
+
"type": "users",
|
|
1942
|
+
"id": "1",
|
|
1943
|
+
"attributes": {
|
|
1944
|
+
"name": "John Doe",
|
|
1945
|
+
"email": "john@example.com"
|
|
1946
|
+
},
|
|
1947
|
+
"relationships": {
|
|
1948
|
+
"avatar": {
|
|
1949
|
+
"data": {
|
|
1950
|
+
"type": "active_storage_blobs",
|
|
1951
|
+
"id": "1"
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
"documents": {
|
|
1955
|
+
"data": [
|
|
1956
|
+
{ "type": "active_storage_blobs", "id": "2" },
|
|
1957
|
+
{ "type": "active_storage_blobs", "id": "3" }
|
|
1958
|
+
]
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
"links": {
|
|
1962
|
+
"self": "/users/1"
|
|
1963
|
+
}
|
|
1964
|
+
},
|
|
1965
|
+
"included": [
|
|
1966
|
+
{
|
|
1967
|
+
"type": "active_storage_blobs",
|
|
1968
|
+
"id": "1",
|
|
1969
|
+
"attributes": {
|
|
1970
|
+
"filename": "avatar.jpg",
|
|
1971
|
+
"content_type": "image/jpeg",
|
|
1972
|
+
"byte_size": 102400,
|
|
1973
|
+
"checksum": "abc123..."
|
|
1974
|
+
},
|
|
1975
|
+
"links": {
|
|
1976
|
+
"self": "/active_storage_blobs/1",
|
|
1977
|
+
"download": "/rails/active_storage/blobs/.../avatar.jpg"
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
]
|
|
1981
|
+
}
|
|
1982
|
+
```
|
|
1983
|
+
|
|
1984
|
+
### Deserializing Attachments
|
|
1985
|
+
|
|
1986
|
+
When creating or updating resources, clients can attach files by providing signed blob IDs obtained from ActiveStorage direct uploads:
|
|
1987
|
+
|
|
1988
|
+
```json
|
|
1989
|
+
{
|
|
1990
|
+
"data": {
|
|
1991
|
+
"type": "users",
|
|
1992
|
+
"attributes": {
|
|
1993
|
+
"name": "Jane Doe",
|
|
1994
|
+
"email": "jane@example.com"
|
|
1995
|
+
},
|
|
1996
|
+
"relationships": {
|
|
1997
|
+
"avatar": {
|
|
1998
|
+
"data": {
|
|
1999
|
+
"type": "active_storage_blobs",
|
|
2000
|
+
"id": "eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--..."
|
|
2001
|
+
}
|
|
2002
|
+
},
|
|
2003
|
+
"documents": {
|
|
2004
|
+
"data": [
|
|
2005
|
+
{ "type": "active_storage_blobs", "id": "signed-id-1" },
|
|
2006
|
+
{ "type": "active_storage_blobs", "id": "signed-id-2" }
|
|
2007
|
+
]
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
```
|
|
2013
|
+
|
|
2014
|
+
The deserializer will:
|
|
2015
|
+
|
|
2016
|
+
- Validate the signed blob IDs
|
|
2017
|
+
- Convert them to blob objects
|
|
2018
|
+
- Set them as parameters that ActiveStorage can attach (e.g., `avatar: blob` or `documents: [blob1, blob2]`)
|
|
2019
|
+
|
|
2020
|
+
#### Detaching Attachments
|
|
2021
|
+
|
|
2022
|
+
To detach (remove) an attachment, send `null` for to-one relationships or an empty array `[]` for to-many relationships:
|
|
2023
|
+
|
|
2024
|
+
```json
|
|
2025
|
+
{
|
|
2026
|
+
"data": {
|
|
2027
|
+
"type": "users",
|
|
2028
|
+
"id": "1",
|
|
2029
|
+
"relationships": {
|
|
2030
|
+
"avatar": {
|
|
2031
|
+
"data": null
|
|
2032
|
+
},
|
|
2033
|
+
"documents": {
|
|
2034
|
+
"data": []
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
```
|
|
2040
|
+
|
|
2041
|
+
By default, this will purge the attachments from the model. For to-one attachments, sending `null` detaches the current attachment. For to-many attachments, sending an empty array `[]` removes all attachments.
|
|
2042
|
+
|
|
2043
|
+
#### Controlling Purge Behavior with `purge_on_nil`
|
|
2044
|
+
|
|
2045
|
+
By default, when you set an attachment relationship to `null` (for `has_one_attached`) or an empty array `[]` (for `has_many_attached`), the gem will purge the existing attachments. You can opt out of this behavior by setting `purge_on_nil: false` in the relationship declaration:
|
|
2046
|
+
|
|
2047
|
+
```ruby
|
|
2048
|
+
class UserResource < JSONAPI::Resource
|
|
2049
|
+
attributes :name, :email
|
|
2050
|
+
|
|
2051
|
+
# Opt out of purging when set to nil
|
|
2052
|
+
has_one :avatar, purge_on_nil: false
|
|
2053
|
+
has_many :documents, purge_on_nil: false
|
|
2054
|
+
end
|
|
2055
|
+
```
|
|
2056
|
+
|
|
2057
|
+
When `purge_on_nil: false` is set:
|
|
2058
|
+
|
|
2059
|
+
- Setting a `has_one_attached` relationship to `null` will keep the existing attachment
|
|
2060
|
+
- Setting a `has_many_attached` relationship to an empty array `[]` will keep all existing attachments
|
|
2061
|
+
- Attaching new blobs will still replace existing attachments (this behavior is not affected by `purge_on_nil`)
|
|
2062
|
+
|
|
2063
|
+
**Use cases for `purge_on_nil: false`:**
|
|
2064
|
+
|
|
2065
|
+
- When you want to prevent accidental deletion of attachments
|
|
2066
|
+
- When attachments should only be removed through explicit delete operations
|
|
2067
|
+
- When you need more control over attachment lifecycle management
|
|
2068
|
+
|
|
2069
|
+
**Note:** The default behavior (`purge_on_nil: true`) ensures that setting a relationship to `null` or `[]` actually removes the attachments, which is typically the expected behavior for most use cases.
|
|
2070
|
+
|
|
2071
|
+
#### Append-Only Mode with `append_only`
|
|
2072
|
+
|
|
2073
|
+
For `has_many_attached` relationships, you can enable append-only mode by setting `append_only: true`. In this mode, new blobs are appended to existing attachments rather than replacing them:
|
|
2074
|
+
|
|
2075
|
+
```ruby
|
|
2076
|
+
class UserResource < JSONAPI::Resource
|
|
2077
|
+
attributes :name, :email
|
|
2078
|
+
|
|
2079
|
+
# Enable append-only mode for documents
|
|
2080
|
+
has_many :documents, append_only: true
|
|
2081
|
+
end
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
**Behavior when `append_only: true` is set:**
|
|
2085
|
+
|
|
2086
|
+
- **Appending new blobs:** When updating a resource with new blobs in the relationship, they are added to the existing attachments instead of replacing them
|
|
2087
|
+
|
|
2088
|
+
```json
|
|
2089
|
+
// Existing attachments: [blob1, blob2]
|
|
2090
|
+
// Payload includes: [blob3, blob4]
|
|
2091
|
+
// Result: [blob1, blob2, blob3, blob4]
|
|
2092
|
+
```
|
|
2093
|
+
|
|
2094
|
+
- **Empty array is a no-op:** Sending an empty array `[]` preserves all existing attachments
|
|
2095
|
+
|
|
2096
|
+
```json
|
|
2097
|
+
// Existing attachments: [blob1, blob2]
|
|
2098
|
+
// Payload includes: []
|
|
2099
|
+
// Result: [blob1, blob2] (unchanged)
|
|
2100
|
+
```
|
|
2101
|
+
|
|
2102
|
+
- **Implicit `purge_on_nil: false`:** When `append_only: true` is set, `purge_on_nil` is automatically set to `false` and cannot be overridden
|
|
2103
|
+
|
|
2104
|
+
- **Deletions:** Deletions via PATCH/PUT with empty arrays are not possible. Use the DELETE `/relationships/:name` endpoint if you need to remove specific attachments
|
|
2105
|
+
|
|
2106
|
+
**Important:** `append_only: true` and `purge_on_nil: true` are mutually exclusive. If both are explicitly set, an `ArgumentError` will be raised at resource class definition time:
|
|
2107
|
+
|
|
2108
|
+
```ruby
|
|
2109
|
+
# This will raise ArgumentError
|
|
2110
|
+
has_many :documents, append_only: true, purge_on_nil: true
|
|
2111
|
+
```
|
|
2112
|
+
|
|
2113
|
+
**Use cases for `append_only: true`:**
|
|
2114
|
+
|
|
2115
|
+
- When you want to accumulate attachments over time without replacing existing ones
|
|
2116
|
+
- When attachments represent a log or history that should only grow
|
|
2117
|
+
- When you need to prevent accidental replacement of existing attachments
|
|
2118
|
+
- When attachments should only be removed through explicit DELETE operations
|
|
2119
|
+
|
|
2120
|
+
**Note:** `append_only` only applies to `has_many_attached` relationships. For `has_one_attached`, attachments are always replaced regardless of this setting.
|
|
2121
|
+
|
|
2122
|
+
Example usage in a controller:
|
|
2123
|
+
|
|
2124
|
+
```ruby
|
|
2125
|
+
def create
|
|
2126
|
+
deserializer = JSONAPI::Deserializer.new(params, resource_class: User, action: :create)
|
|
2127
|
+
attrs = deserializer.to_params
|
|
2128
|
+
|
|
2129
|
+
# attrs will include:
|
|
2130
|
+
# { "name" => "Jane Doe", "email" => "jane@example.com", "avatar" => <ActiveStorage::Blob>, "documents" => [<ActiveStorage::Blob>, ...] }
|
|
2131
|
+
|
|
2132
|
+
user = User.create!(attrs)
|
|
2133
|
+
# Attachments are automatically attached via ActiveStorage
|
|
2134
|
+
end
|
|
2135
|
+
```
|
|
2136
|
+
|
|
2137
|
+
### Error Handling
|
|
2138
|
+
|
|
2139
|
+
Invalid signed IDs will raise `ActiveSupport::MessageVerifier::InvalidSignature`, which should be handled appropriately in your controllers.
|
|
2140
|
+
|
|
2141
|
+
### Built-in ActiveStorage Resource
|
|
2142
|
+
|
|
2143
|
+
The gem provides a built-in `JSONAPI::ActiveStorageBlobResource` that automatically serializes ActiveStorage blobs with:
|
|
2144
|
+
|
|
2145
|
+
- `filename` - The original filename
|
|
2146
|
+
- `content_type` - MIME type
|
|
2147
|
+
- `byte_size` - File size in bytes
|
|
2148
|
+
- `checksum` - File checksum
|
|
2149
|
+
- Download link in the `links` section
|
|
2150
|
+
|
|
2151
|
+
This resource is automatically used when serializing ActiveStorage attachments.
|
|
220
2152
|
|
|
221
2153
|
## Contributing
|
|
222
2154
|
|
|
223
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
2155
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/klaay/json_api.
|
|
224
2156
|
|
|
225
2157
|
## License
|
|
226
2158
|
|
|
227
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
2159
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|