jpie 0.1.0 → 0.3.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/.aiconfig +65 -0
- data/.rubocop.yml +110 -35
- data/CHANGELOG.md +93 -0
- data/LICENSE.txt +21 -0
- data/README.md +776 -1903
- data/Rakefile +14 -3
- data/jpie.gemspec +35 -18
- data/lib/jpie/configuration.rb +12 -0
- data/lib/jpie/controller/crud_actions.rb +110 -0
- data/lib/jpie/controller/error_handling.rb +41 -0
- data/lib/jpie/controller/parameter_parsing.rb +35 -0
- data/lib/jpie/controller/rendering.rb +60 -0
- data/lib/jpie/controller.rb +18 -0
- data/lib/jpie/deserializer.rb +110 -0
- data/lib/jpie/errors.rb +70 -0
- data/lib/jpie/generators/resource_generator.rb +39 -0
- data/lib/jpie/generators/templates/resource.rb.erb +12 -0
- data/lib/jpie/railtie.rb +36 -0
- data/lib/jpie/resource/attributable.rb +98 -0
- data/lib/jpie/resource/inferrable.rb +43 -0
- data/lib/jpie/resource/sortable.rb +93 -0
- data/lib/jpie/resource.rb +107 -0
- data/lib/jpie/serializer.rb +205 -0
- data/lib/{json_api → jpie}/version.rb +2 -2
- data/lib/jpie.rb +23 -3
- metadata +145 -50
- data/.gitignore +0 -21
- data/.rspec +0 -3
- data/.travis.yml +0 -7
- data/Gemfile +0 -21
- data/Gemfile.lock +0 -312
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/kiln/app/resources/user_message_resource.rb +0 -2
- data/lib/json_api/active_storage/deserialization.rb +0 -106
- data/lib/json_api/active_storage/detection.rb +0 -74
- data/lib/json_api/active_storage/serialization.rb +0 -32
- data/lib/json_api/configuration.rb +0 -58
- data/lib/json_api/controllers/base_controller.rb +0 -26
- data/lib/json_api/controllers/concerns/controller_helpers.rb +0 -223
- data/lib/json_api/controllers/concerns/resource_actions.rb +0 -657
- data/lib/json_api/controllers/relationships_controller.rb +0 -504
- data/lib/json_api/controllers/resources_controller.rb +0 -6
- data/lib/json_api/errors/parameter_not_allowed.rb +0 -19
- data/lib/json_api/railtie.rb +0 -75
- data/lib/json_api/resources/active_storage_blob_resource.rb +0 -11
- data/lib/json_api/resources/resource.rb +0 -238
- data/lib/json_api/resources/resource_loader.rb +0 -35
- data/lib/json_api/routing.rb +0 -72
- data/lib/json_api/serialization/deserializer.rb +0 -362
- data/lib/json_api/serialization/serializer.rb +0 -320
- data/lib/json_api/support/active_storage_support.rb +0 -85
- data/lib/json_api/support/collection_query.rb +0 -406
- data/lib/json_api/support/instrumentation.rb +0 -42
- data/lib/json_api/support/param_helpers.rb +0 -51
- data/lib/json_api/support/relationship_guard.rb +0 -16
- data/lib/json_api/support/relationship_helpers.rb +0 -74
- data/lib/json_api/support/resource_identifier.rb +0 -87
- data/lib/json_api/support/responders.rb +0 -100
- data/lib/json_api/support/response_helpers.rb +0 -10
- data/lib/json_api/support/sort_parsing.rb +0 -21
- data/lib/json_api/support/type_conversion.rb +0 -21
- data/lib/json_api/testing/test_helper.rb +0 -76
- data/lib/json_api/testing.rb +0 -3
- data/lib/json_api.rb +0 -50
- data/lib/rubocop/cop/custom/hash_value_omission.rb +0 -53
data/README.md
CHANGED
|
@@ -1,2159 +1,1032 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JPie
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/jpie)
|
|
4
|
+
[](https://github.com/emilkampp/jpie/actions)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
JPie is a modern, lightweight Rails library for developing JSON:API compliant servers. It focuses on clean architecture with strong separation of concerns and extensibility.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
-
|
|
8
|
+
## Key Features
|
|
9
|
+
|
|
10
|
+
✨ **Modern Rails DSL** - Clean, intuitive syntax following Rails conventions
|
|
11
|
+
🔧 **Method Overrides** - Define custom attribute methods directly on resource classes
|
|
12
|
+
🎯 **Smart Inference** - Automatic model and resource class detection
|
|
13
|
+
📊 **Polymorphic Support** - Full support for complex polymorphic associations
|
|
14
|
+
🔄 **STI Ready** - Single Table Inheritance works out of the box
|
|
15
|
+
⚡ **Performance Optimized** - Efficient serialization with intelligent deduplication
|
|
16
|
+
🛡️ **Authorization Ready** - Built-in scoping support for security
|
|
17
|
+
📋 **JSON:API Compliant** - Full specification compliance with sorting, includes, and meta
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
20
|
-
Add
|
|
21
|
+
Add JPie to your Rails application:
|
|
21
22
|
|
|
22
|
-
```
|
|
23
|
-
|
|
23
|
+
```bash
|
|
24
|
+
bundle add jpie
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
$ bundle install
|
|
29
|
-
|
|
30
|
-
Or install it yourself as:
|
|
31
|
-
|
|
32
|
-
$ gem install json_api
|
|
33
|
-
|
|
34
|
-
## Requirements
|
|
35
|
-
|
|
36
|
-
- Ruby >= 3.4.0
|
|
37
|
-
- Rails >= 8.0.0
|
|
27
|
+
## Quick Start - Default Implementation
|
|
38
28
|
|
|
39
|
-
|
|
29
|
+
JPie works out of the box with minimal configuration. Here's a complete example of the default implementation:
|
|
40
30
|
|
|
41
|
-
###
|
|
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:
|
|
31
|
+
### 1. Create Your Model
|
|
48
32
|
|
|
49
33
|
```ruby
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
jsonapi_resources :posts
|
|
34
|
+
class User < ActiveRecord::Base
|
|
35
|
+
validates :name, presence: true
|
|
36
|
+
validates :email, presence: true, uniqueness: true
|
|
54
37
|
end
|
|
55
38
|
```
|
|
56
39
|
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
60
|
-
|
|
61
|
-
### Resource Definitions
|
|
62
|
-
|
|
63
|
-
Define resource classes to control which attributes and relationships are exposed via the JSON:API endpoint:
|
|
40
|
+
### 2. Create Your Resource
|
|
64
41
|
|
|
65
42
|
```ruby
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
attributes :email, :name, :phone
|
|
69
|
-
|
|
70
|
-
has_many :posts
|
|
71
|
-
has_one :profile
|
|
43
|
+
class UserResource < JPie::Resource
|
|
44
|
+
attributes :name, :email
|
|
72
45
|
end
|
|
73
46
|
```
|
|
74
47
|
|
|
75
|
-
|
|
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
|
-
### Virtual Attributes
|
|
91
|
-
|
|
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
|
-
To create a virtual attribute, declare it in `attributes` and implement a getter method:
|
|
48
|
+
### 3. Create Your Controller
|
|
97
49
|
|
|
98
50
|
```ruby
|
|
99
|
-
class
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# Virtual attribute getter
|
|
103
|
-
def full_name
|
|
104
|
-
"#{resource.name} (#{resource.email})"
|
|
105
|
-
end
|
|
51
|
+
class UsersController < ApplicationController
|
|
52
|
+
include JPie::Controller
|
|
106
53
|
end
|
|
107
54
|
```
|
|
108
55
|
|
|
109
|
-
|
|
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
|
-
}
|
|
123
|
-
```
|
|
124
|
-
|
|
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:
|
|
56
|
+
### 4. Set Up Routes
|
|
128
57
|
|
|
129
58
|
```ruby
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# Override name attribute
|
|
134
|
-
def name
|
|
135
|
-
resource.name.upcase
|
|
136
|
-
end
|
|
59
|
+
Rails.application.routes.draw do
|
|
60
|
+
resources :users
|
|
137
61
|
end
|
|
138
62
|
```
|
|
139
63
|
|
|
140
|
-
|
|
64
|
+
That's it! You now have a fully functional JSON:API compliant server.
|
|
141
65
|
|
|
142
|
-
|
|
66
|
+
## Modern DSL Examples
|
|
143
67
|
|
|
144
|
-
|
|
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
|
|
68
|
+
JPie provides a clean, modern DSL that follows Rails conventions:
|
|
154
69
|
|
|
155
|
-
|
|
156
|
-
def display_name=(value)
|
|
157
|
-
@transformed_params["name"] = value.upcase
|
|
158
|
-
end
|
|
70
|
+
### Resource Definition
|
|
159
71
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
72
|
+
```ruby
|
|
73
|
+
class UserResource < JPie::Resource
|
|
74
|
+
# Attributes (multiple syntaxes supported)
|
|
75
|
+
attributes :name, :email, :created_at
|
|
76
|
+
attribute :full_name
|
|
77
|
+
|
|
78
|
+
# Meta attributes
|
|
79
|
+
meta :account_status, :last_login
|
|
80
|
+
# or: meta_attributes :account_status, :last_login
|
|
81
|
+
|
|
82
|
+
# Relationships
|
|
83
|
+
has_many :posts
|
|
84
|
+
has_one :profile
|
|
85
|
+
|
|
86
|
+
# Custom sorting
|
|
87
|
+
sortable :popularity do |query, direction|
|
|
88
|
+
query.order(likes_count: direction)
|
|
163
89
|
end
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
90
|
+
# or: sortable_by :popularity do |query, direction|
|
|
91
|
+
|
|
92
|
+
# Custom attribute methods
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def full_name
|
|
96
|
+
"#{object.first_name} #{object.last_name}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def account_status
|
|
100
|
+
object.active? ? 'active' : 'inactive'
|
|
168
101
|
end
|
|
169
102
|
end
|
|
170
103
|
```
|
|
171
104
|
|
|
172
|
-
|
|
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:
|
|
105
|
+
### Controller Definition
|
|
179
106
|
|
|
180
107
|
```ruby
|
|
181
|
-
|
|
108
|
+
class UsersController < ApplicationController
|
|
109
|
+
include JPie::Controller
|
|
110
|
+
|
|
111
|
+
# Explicit resource (optional - auto-inferred by default)
|
|
112
|
+
resource UserResource
|
|
113
|
+
# or: jsonapi_resource UserResource
|
|
114
|
+
|
|
115
|
+
# Override methods as needed
|
|
116
|
+
def index
|
|
117
|
+
users = current_user.admin? ? User.all : User.active
|
|
118
|
+
render_jsonapi(users)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def create
|
|
122
|
+
user = User.new(deserialize_params)
|
|
123
|
+
user.created_by = current_user
|
|
124
|
+
user.save!
|
|
125
|
+
|
|
126
|
+
render_jsonapi(user, status: :created)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
182
129
|
```
|
|
183
130
|
|
|
184
|
-
|
|
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`:
|
|
131
|
+
## Suported JSON:API features
|
|
195
132
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
- `DELETE /users/:id/relationships/:relationship_name` - Remove relationship linkage
|
|
133
|
+
### Sorting
|
|
134
|
+
All defined attributes are automatically sortable:
|
|
199
135
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
```json
|
|
136
|
+
```http
|
|
137
|
+
GET /users?sort=name
|
|
138
|
+
HTTP/1.1 200 OK
|
|
139
|
+
Content-Type: application/vnd.api+json
|
|
205
140
|
{
|
|
206
|
-
"jsonapi": {
|
|
207
|
-
"version": "1.1"
|
|
208
|
-
},
|
|
209
141
|
"data": [
|
|
210
142
|
{
|
|
211
|
-
"type": "users",
|
|
212
143
|
"id": "1",
|
|
144
|
+
"type": "users",
|
|
213
145
|
"attributes": {
|
|
214
|
-
"name": "
|
|
215
|
-
"email": "
|
|
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"
|
|
146
|
+
"name": "Alice Anderson",
|
|
147
|
+
"email": "alice@example.com"
|
|
238
148
|
}
|
|
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
149
|
},
|
|
259
|
-
|
|
260
|
-
"
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
"meta": {
|
|
266
|
-
"count": 2
|
|
267
|
-
}
|
|
268
|
-
},
|
|
269
|
-
"profile": {
|
|
270
|
-
"data": {
|
|
271
|
-
"type": "admin_profiles",
|
|
272
|
-
"id": "1"
|
|
273
|
-
}
|
|
150
|
+
{
|
|
151
|
+
"id": "2",
|
|
152
|
+
"type": "users",
|
|
153
|
+
"attributes": {
|
|
154
|
+
"name": "Bob Brown",
|
|
155
|
+
"email": "bob@example.com"
|
|
274
156
|
}
|
|
275
157
|
},
|
|
276
|
-
|
|
277
|
-
"
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
158
|
+
{
|
|
159
|
+
"id": "3",
|
|
160
|
+
"type": "users",
|
|
161
|
+
"attributes": {
|
|
162
|
+
"name": "Carol Clark",
|
|
163
|
+
"email": "carol@example.com"
|
|
311
164
|
}
|
|
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
165
|
}
|
|
320
|
-
|
|
166
|
+
]
|
|
321
167
|
}
|
|
322
168
|
```
|
|
323
169
|
|
|
324
|
-
|
|
170
|
+
Or by name in reverse order by name:
|
|
325
171
|
|
|
326
|
-
```
|
|
172
|
+
```http
|
|
173
|
+
GET /users?sort=-name
|
|
174
|
+
HTTP/1.1 200 OK
|
|
175
|
+
Content-Type: application/vnd.api+json
|
|
327
176
|
{
|
|
328
|
-
"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
177
|
+
"data": [
|
|
178
|
+
{
|
|
179
|
+
"id": "3",
|
|
180
|
+
"type": "users",
|
|
181
|
+
"attributes": {
|
|
182
|
+
"name": "Carol Clark",
|
|
183
|
+
"email": "carol@example.com"
|
|
348
184
|
}
|
|
349
185
|
},
|
|
350
|
-
|
|
351
|
-
"
|
|
186
|
+
{
|
|
187
|
+
"id": "2",
|
|
188
|
+
"type": "users",
|
|
189
|
+
"attributes": {
|
|
190
|
+
"name": "Bob Brown",
|
|
191
|
+
"email": "bob@example.com"
|
|
192
|
+
}
|
|
352
193
|
},
|
|
353
|
-
|
|
354
|
-
"
|
|
355
|
-
"
|
|
194
|
+
{
|
|
195
|
+
"id": "1",
|
|
196
|
+
"type": "users",
|
|
197
|
+
"attributes": {
|
|
198
|
+
"name": "Alice Anderson",
|
|
199
|
+
"email": "alice@example.com"
|
|
200
|
+
}
|
|
356
201
|
}
|
|
357
|
-
|
|
202
|
+
]
|
|
358
203
|
}
|
|
359
204
|
```
|
|
360
205
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
Returns `204 No Content` with an empty response body.
|
|
206
|
+
## Customization and Overrides
|
|
364
207
|
|
|
365
|
-
|
|
208
|
+
Once you have the basic implementation working, you can customize JPie's behavior as needed:
|
|
366
209
|
|
|
367
|
-
|
|
210
|
+
### Resource Class Inference Override
|
|
368
211
|
|
|
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
|
|
374
|
-
|
|
375
|
-
#### Filtering
|
|
376
|
-
|
|
377
|
-
Filtering requires declaring permitted filters in your resource class:
|
|
212
|
+
JPie automatically infers the resource class from your controller name, but you can override this:
|
|
378
213
|
|
|
379
214
|
```ruby
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
215
|
+
# Automatic inference (default behavior)
|
|
216
|
+
class UsersController < ApplicationController
|
|
217
|
+
include JPie::Controller
|
|
218
|
+
# Automatically uses UserResource
|
|
383
219
|
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
220
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
}
|
|
221
|
+
# Explicit resource specification (override)
|
|
222
|
+
class UsersController < ApplicationController
|
|
223
|
+
include JPie::Controller
|
|
224
|
+
resource UserResource # Use a different resource class (modern syntax)
|
|
225
|
+
# or: jsonapi_resource UserResource # (backward compatible syntax)
|
|
418
226
|
end
|
|
419
227
|
```
|
|
420
228
|
|
|
421
|
-
|
|
229
|
+
### Model Specification Override
|
|
422
230
|
|
|
423
|
-
|
|
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:
|
|
231
|
+
JPie automatically infers the model from your resource class name, but you can override this:
|
|
437
232
|
|
|
438
233
|
```ruby
|
|
439
|
-
|
|
440
|
-
|
|
234
|
+
# Automatic inference (default behavior)
|
|
235
|
+
class UserResource < JPie::Resource
|
|
236
|
+
attributes :name, :email
|
|
237
|
+
# Automatically uses User model
|
|
441
238
|
end
|
|
442
239
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
#
|
|
446
|
-
|
|
240
|
+
# Explicit model specification (override)
|
|
241
|
+
class UserResource < JPie::Resource
|
|
242
|
+
model CustomUser # Use a different model class
|
|
243
|
+
attributes :name, :email
|
|
447
244
|
end
|
|
448
245
|
```
|
|
449
246
|
|
|
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)
|
|
247
|
+
### Controller Method Overrides
|
|
456
248
|
|
|
457
|
-
|
|
249
|
+
You can override any of the automatic CRUD methods:
|
|
458
250
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
251
|
+
```ruby
|
|
252
|
+
class UsersController < ApplicationController
|
|
253
|
+
include JPie::Controller
|
|
254
|
+
|
|
255
|
+
# Override index to add filtering
|
|
256
|
+
def index
|
|
257
|
+
users = User.where(active: true)
|
|
258
|
+
render_jsonapi(users)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Override create to add custom logic
|
|
262
|
+
def create
|
|
263
|
+
attributes = deserialize_params
|
|
264
|
+
user = User.new(attributes)
|
|
265
|
+
user.created_by = current_user
|
|
266
|
+
user.save!
|
|
267
|
+
|
|
268
|
+
render_jsonapi(user, status: :created)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# show, update, destroy still use the automatic implementations
|
|
272
|
+
end
|
|
471
273
|
```
|
|
472
274
|
|
|
473
|
-
###
|
|
275
|
+
### Custom Attributes
|
|
474
276
|
|
|
475
|
-
|
|
277
|
+
Add computed or transformed attributes to your resources using either blocks or method overrides:
|
|
278
|
+
|
|
279
|
+
#### Using Blocks (Original Approach)
|
|
476
280
|
|
|
477
281
|
```ruby
|
|
478
|
-
class UserResource <
|
|
479
|
-
|
|
282
|
+
class UserResource < JPie::Resource
|
|
283
|
+
attribute :display_name do
|
|
284
|
+
"#{object.first_name} #{object.last_name}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
attribute :admin_notes do
|
|
288
|
+
if context[:current_user]&.admin?
|
|
289
|
+
object.admin_notes
|
|
290
|
+
else
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
480
294
|
end
|
|
481
295
|
```
|
|
482
296
|
|
|
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.
|
|
486
|
-
|
|
487
|
-
#### Pagination
|
|
297
|
+
#### Using Method Overrides (New Approach)
|
|
488
298
|
|
|
489
|
-
|
|
299
|
+
You can now define custom methods directly on your resource class instead of using blocks:
|
|
490
300
|
|
|
491
|
-
|
|
301
|
+
```ruby
|
|
302
|
+
class UserResource < JPie::Resource
|
|
303
|
+
attributes :name, :email
|
|
304
|
+
attribute :full_name
|
|
305
|
+
attribute :display_name
|
|
306
|
+
meta_attribute :user_stats
|
|
492
307
|
|
|
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)
|
|
308
|
+
private
|
|
496
309
|
|
|
497
|
-
|
|
310
|
+
def full_name
|
|
311
|
+
"#{object.first_name} #{object.last_name}"
|
|
312
|
+
end
|
|
498
313
|
|
|
499
|
-
|
|
314
|
+
def display_name
|
|
315
|
+
if context[:admin]
|
|
316
|
+
"#{full_name} [ADMIN VIEW] - #{object.email}"
|
|
317
|
+
else
|
|
318
|
+
full_name
|
|
319
|
+
end
|
|
320
|
+
end
|
|
500
321
|
|
|
501
|
-
|
|
502
|
-
{
|
|
503
|
-
"jsonapi": {
|
|
504
|
-
"version": "1.1"
|
|
505
|
-
},
|
|
506
|
-
"data": [
|
|
322
|
+
def user_stats
|
|
507
323
|
{
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
"name": "User 1",
|
|
512
|
-
"email": "user1@example.com",
|
|
513
|
-
"phone": "555-0001"
|
|
514
|
-
},
|
|
515
|
-
"links": {
|
|
516
|
-
"self": "/users/1"
|
|
517
|
-
}
|
|
324
|
+
name_length: object.name.length,
|
|
325
|
+
email_domain: object.email.split('@').last,
|
|
326
|
+
account_status: object.active? ? 'active' : 'inactive'
|
|
518
327
|
}
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
}
|
|
328
|
+
end
|
|
329
|
+
end
|
|
531
330
|
```
|
|
532
331
|
|
|
533
|
-
|
|
332
|
+
**Key Benefits of Method Overrides:**
|
|
333
|
+
- **Cleaner syntax** - No need for blocks
|
|
334
|
+
- **Better IDE support** - Full method definitions with proper syntax highlighting
|
|
335
|
+
- **Easier testing** - Methods can be tested individually
|
|
336
|
+
- **Private methods supported** - Use private methods for internal logic
|
|
337
|
+
- **Access to object and context** - Full access to `object` and `context` like blocks
|
|
534
338
|
|
|
535
|
-
|
|
339
|
+
**Method Precedence:**
|
|
340
|
+
1. **Blocks** (highest priority) - `attribute :name do ... end`
|
|
341
|
+
2. **Options blocks** - `attribute :name, block: proc { ... }`
|
|
342
|
+
3. **Custom methods** - `def name; ...; end`
|
|
343
|
+
4. **Model attributes** (lowest priority) - Direct model attribute lookup
|
|
536
344
|
|
|
537
|
-
|
|
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
|
|
345
|
+
### Meta attributes
|
|
549
346
|
|
|
550
|
-
|
|
551
|
-
GET /users?include=posts.comments,posts.author
|
|
347
|
+
JPie supports adding meta data to your JSON:API resources in two ways: using the `meta_attributes` macro or by defining a custom `meta` method.
|
|
552
348
|
|
|
553
|
-
|
|
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.
|
|
349
|
+
#### Using meta_attributes Macro
|
|
558
350
|
|
|
559
|
-
|
|
351
|
+
It's easy to add meta attributes:
|
|
560
352
|
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
"title": "Invalid Include Path",
|
|
567
|
-
"detail": "Invalid include paths requested: invalid_association"
|
|
568
|
-
}
|
|
569
|
-
]
|
|
570
|
-
}
|
|
353
|
+
```ruby
|
|
354
|
+
class UserResource < JPie::Resource
|
|
355
|
+
meta_attributes :created_at, :updated_at
|
|
356
|
+
meta_attributes :last_login_at
|
|
357
|
+
end
|
|
571
358
|
```
|
|
572
359
|
|
|
573
|
-
|
|
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:
|
|
725
|
-
|
|
726
|
-
```ruby
|
|
727
|
-
# config/routes.rb
|
|
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
|
|
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
|
|
756
|
-
end
|
|
757
|
-
```
|
|
758
|
-
|
|
759
|
-
##### Serialization
|
|
760
|
-
|
|
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:
|
|
762
|
-
|
|
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
|
|
787
|
-
|
|
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:
|
|
789
|
-
|
|
790
|
-
```json
|
|
791
|
-
POST /notifications
|
|
792
|
-
Content-Type: application/vnd.api+json
|
|
793
|
-
|
|
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
|
-
```
|
|
810
|
-
|
|
811
|
-
The controller automatically instantiates the correct model class based on the `type` field.
|
|
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
|
|
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
|
|
885
|
-
end
|
|
886
|
-
```
|
|
887
|
-
|
|
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
|
-
```ruby
|
|
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
|
|
917
|
-
end
|
|
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
|
-
}
|
|
1033
|
-
end
|
|
1034
|
-
end
|
|
1035
|
-
```
|
|
1036
|
-
|
|
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.
|
|
360
|
+
#### Using Custom meta Method
|
|
1734
361
|
|
|
1735
|
-
|
|
362
|
+
For more complex meta data, you can define a `meta` method that returns a hash:
|
|
1736
363
|
|
|
1737
|
-
|
|
364
|
+
```ruby
|
|
365
|
+
class UserResource < JPie::Resource
|
|
366
|
+
attributes :name, :email
|
|
367
|
+
meta_attributes :created_at, :updated_at
|
|
1738
368
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
-
}
|
|
369
|
+
def meta
|
|
370
|
+
super.merge(
|
|
371
|
+
full_name: "#{object.first_name} #{object.last_name}",
|
|
372
|
+
user_role: context[:current_user]&.role || 'guest',
|
|
373
|
+
account_status: object.active? ? 'active' : 'inactive',
|
|
374
|
+
last_seen: object.last_login_at&.iso8601
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
end
|
|
1755
378
|
```
|
|
1756
379
|
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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
|
-
```
|
|
380
|
+
The `meta` method has access to:
|
|
381
|
+
- `super` - returns the hash from `meta_attributes`
|
|
382
|
+
- `object` - the underlying model instance
|
|
383
|
+
- `context` - any context passed during resource initialization
|
|
1773
384
|
|
|
1774
|
-
**Example
|
|
385
|
+
**Example JSON:API Response with Custom Meta:**
|
|
1775
386
|
|
|
1776
387
|
```json
|
|
1777
388
|
{
|
|
1778
|
-
"
|
|
1779
|
-
"
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
"
|
|
1784
|
-
"title": "Validation Error",
|
|
1785
|
-
"detail": "Email can't be blank",
|
|
1786
|
-
"source": {
|
|
1787
|
-
"pointer": "/data/attributes/email"
|
|
1788
|
-
}
|
|
389
|
+
"data": {
|
|
390
|
+
"id": "1",
|
|
391
|
+
"type": "users",
|
|
392
|
+
"attributes": {
|
|
393
|
+
"name": "John Doe",
|
|
394
|
+
"email": "john@example.com"
|
|
1789
395
|
},
|
|
1790
|
-
{
|
|
1791
|
-
"
|
|
1792
|
-
"
|
|
1793
|
-
"
|
|
1794
|
-
"
|
|
1795
|
-
|
|
1796
|
-
|
|
396
|
+
"meta": {
|
|
397
|
+
"created_at": "2024-01-01T12:00:00Z",
|
|
398
|
+
"updated_at": "2024-01-15T14:30:00Z",
|
|
399
|
+
"full_name": "John Doe",
|
|
400
|
+
"user_role": "admin",
|
|
401
|
+
"account_status": "active",
|
|
402
|
+
"last_seen": "2024-01-15T14:00:00Z"
|
|
1797
403
|
}
|
|
1798
|
-
|
|
404
|
+
}
|
|
1799
405
|
}
|
|
1800
406
|
```
|
|
1801
407
|
|
|
1802
|
-
|
|
408
|
+
#### Meta Method Inheritance
|
|
1803
409
|
|
|
1804
|
-
|
|
410
|
+
Meta methods work seamlessly with inheritance:
|
|
1805
411
|
|
|
1806
|
-
|
|
412
|
+
```ruby
|
|
413
|
+
class BaseResource < JPie::Resource
|
|
414
|
+
meta_attributes :created_at, :updated_at
|
|
415
|
+
|
|
416
|
+
def meta
|
|
417
|
+
super.merge(
|
|
418
|
+
resource_version: '1.0',
|
|
419
|
+
timestamp: Time.current.iso8601
|
|
420
|
+
)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
1807
423
|
|
|
1808
|
-
|
|
424
|
+
class UserResource < BaseResource
|
|
425
|
+
attributes :name, :email
|
|
426
|
+
meta_attributes :last_login_at
|
|
1809
427
|
|
|
1810
|
-
|
|
428
|
+
def meta
|
|
429
|
+
super.merge(
|
|
430
|
+
user_specific_data: calculate_user_metrics
|
|
431
|
+
)
|
|
432
|
+
end
|
|
1811
433
|
|
|
1812
|
-
|
|
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
|
|
434
|
+
private
|
|
1822
435
|
|
|
1823
|
-
|
|
436
|
+
def calculate_user_metrics
|
|
437
|
+
{
|
|
438
|
+
post_count: object.posts.count,
|
|
439
|
+
comment_count: object.comments.count
|
|
440
|
+
}
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
```
|
|
1824
444
|
|
|
1825
|
-
|
|
445
|
+
### Custom Sorting
|
|
1826
446
|
|
|
1827
|
-
|
|
447
|
+
Override the default sorting behavior with custom logic:
|
|
1828
448
|
|
|
1829
|
-
```
|
|
1830
|
-
|
|
449
|
+
```ruby
|
|
450
|
+
class PostResource < JPie::Resource
|
|
451
|
+
attributes :title, :content
|
|
452
|
+
|
|
453
|
+
sortable_by :popularity do |query, direction|
|
|
454
|
+
if direction == :asc
|
|
455
|
+
query.order(:likes_count, :comments_count)
|
|
456
|
+
else
|
|
457
|
+
query.order(likes_count: :desc, comments_count: :desc)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
1831
461
|
```
|
|
1832
462
|
|
|
1833
|
-
###
|
|
463
|
+
### Polymorphic Associations
|
|
1834
464
|
|
|
1835
|
-
|
|
465
|
+
JPie supports polymorphic associations seamlessly. Here's a complete example with comments that can belong to multiple types of commentable resources:
|
|
1836
466
|
|
|
1837
|
-
|
|
467
|
+
#### Models with Polymorphic Associations
|
|
1838
468
|
|
|
1839
469
|
```ruby
|
|
1840
|
-
#
|
|
1841
|
-
|
|
470
|
+
# Comment model with belongs_to polymorphic association
|
|
471
|
+
class Comment < ActiveRecord::Base
|
|
472
|
+
belongs_to :commentable, polymorphic: true
|
|
473
|
+
belongs_to :author, class_name: 'User'
|
|
474
|
+
|
|
475
|
+
validates :content, presence: true
|
|
476
|
+
end
|
|
1842
477
|
|
|
1843
|
-
|
|
1844
|
-
|
|
478
|
+
# Post model with has_many polymorphic association
|
|
479
|
+
class Post < ActiveRecord::Base
|
|
480
|
+
has_many :comments, as: :commentable, dependent: :destroy
|
|
481
|
+
belongs_to :author, class_name: 'User'
|
|
482
|
+
|
|
483
|
+
validates :title, :content, presence: true
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Article model with has_many polymorphic association
|
|
487
|
+
class Article < ActiveRecord::Base
|
|
488
|
+
has_many :comments, as: :commentable, dependent: :destroy
|
|
489
|
+
belongs_to :author, class_name: 'User'
|
|
490
|
+
|
|
491
|
+
validates :title, :body, presence: true
|
|
1845
492
|
end
|
|
1846
493
|
```
|
|
1847
494
|
|
|
1848
|
-
|
|
495
|
+
#### Resources for Polymorphic Associations
|
|
1849
496
|
|
|
1850
497
|
```ruby
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
498
|
+
# Comment resource with belongs_to polymorphic relationship
|
|
499
|
+
class CommentResource < JPie::Resource
|
|
500
|
+
attributes :content, :created_at
|
|
501
|
+
|
|
502
|
+
# Polymorphic belongs_to relationship
|
|
503
|
+
relationship :commentable do
|
|
504
|
+
# Dynamically determine the resource class based on the commentable type
|
|
505
|
+
case object.commentable_type
|
|
506
|
+
when 'Post'
|
|
507
|
+
PostResource.new(object.commentable, context)
|
|
508
|
+
when 'Article'
|
|
509
|
+
ArticleResource.new(object.commentable, context)
|
|
510
|
+
else
|
|
511
|
+
nil
|
|
1859
512
|
end
|
|
1860
513
|
end
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
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
|
|
514
|
+
|
|
515
|
+
relationship :author do
|
|
516
|
+
UserResource.new(object.author, context) if object.author
|
|
1875
517
|
end
|
|
1876
518
|
end
|
|
1877
|
-
```
|
|
1878
|
-
|
|
1879
|
-
**Behavior by HTTP method:**
|
|
1880
519
|
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
520
|
+
# Post resource with has_many polymorphic relationship
|
|
521
|
+
class PostResource < JPie::Resource
|
|
522
|
+
attributes :title, :content, :published_at
|
|
523
|
+
|
|
524
|
+
# Has_many polymorphic relationship
|
|
525
|
+
relationship :comments do
|
|
526
|
+
object.comments.map { |comment| CommentResource.new(comment, context) }
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
relationship :author do
|
|
530
|
+
UserResource.new(object.author, context) if object.author
|
|
531
|
+
end
|
|
532
|
+
end
|
|
1894
533
|
|
|
1895
|
-
|
|
1896
|
-
|
|
534
|
+
# Article resource with has_many polymorphic relationship
|
|
535
|
+
class ArticleResource < JPie::Resource
|
|
536
|
+
attributes :title, :body, :published_at
|
|
537
|
+
|
|
538
|
+
# Has_many polymorphic relationship
|
|
539
|
+
relationship :comments do
|
|
540
|
+
object.comments.map { |comment| CommentResource.new(comment, context) }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
relationship :author do
|
|
544
|
+
UserResource.new(object.author, context) if object.author
|
|
545
|
+
end
|
|
546
|
+
end
|
|
1897
547
|
```
|
|
1898
548
|
|
|
1899
|
-
|
|
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.
|
|
549
|
+
#### Controllers for Polymorphic Resources
|
|
1912
550
|
|
|
1913
|
-
|
|
551
|
+
```ruby
|
|
552
|
+
class CommentsController < ApplicationController
|
|
553
|
+
include JPie::Controller
|
|
554
|
+
|
|
555
|
+
# Override create to handle polymorphic assignment
|
|
556
|
+
def create
|
|
557
|
+
attributes = deserialize_params
|
|
558
|
+
commentable = find_commentable
|
|
559
|
+
|
|
560
|
+
comment = commentable.comments.build(attributes)
|
|
561
|
+
comment.author = current_user
|
|
562
|
+
comment.save!
|
|
563
|
+
|
|
564
|
+
render_jsonapi_resource(comment, status: :created)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
private
|
|
568
|
+
|
|
569
|
+
def find_commentable
|
|
570
|
+
# Extract commentable info from request path or parameters
|
|
571
|
+
if params[:post_id]
|
|
572
|
+
Post.find(params[:post_id])
|
|
573
|
+
elsif params[:article_id]
|
|
574
|
+
Article.find(params[:article_id])
|
|
575
|
+
else
|
|
576
|
+
raise ArgumentError, "Commentable not specified"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
1914
580
|
|
|
1915
|
-
|
|
581
|
+
class PostsController < ApplicationController
|
|
582
|
+
include JPie::Controller
|
|
583
|
+
# Uses default CRUD operations with polymorphic comments included
|
|
584
|
+
end
|
|
1916
585
|
|
|
1917
|
-
|
|
586
|
+
class ArticlesController < ApplicationController
|
|
587
|
+
include JPie::Controller
|
|
588
|
+
# Uses default CRUD operations with polymorphic comments included
|
|
589
|
+
end
|
|
590
|
+
```
|
|
1918
591
|
|
|
1919
|
-
|
|
592
|
+
#### Routes for Polymorphic Resources
|
|
1920
593
|
|
|
1921
594
|
```ruby
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
595
|
+
Rails.application.routes.draw do
|
|
596
|
+
resources :posts do
|
|
597
|
+
resources :comments, only: [:index, :create]
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
resources :articles do
|
|
601
|
+
resources :comments, only: [:index, :create]
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
resources :comments, only: [:show, :update, :destroy]
|
|
1926
605
|
end
|
|
1927
606
|
```
|
|
1928
607
|
|
|
1929
|
-
|
|
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)
|
|
608
|
+
#### Example JSON:API Responses
|
|
1935
609
|
|
|
1936
|
-
|
|
610
|
+
**GET /posts/1?include=comments,comments.author**
|
|
1937
611
|
|
|
1938
612
|
```json
|
|
1939
613
|
{
|
|
1940
614
|
"data": {
|
|
1941
|
-
"type": "users",
|
|
1942
615
|
"id": "1",
|
|
616
|
+
"type": "posts",
|
|
1943
617
|
"attributes": {
|
|
1944
|
-
"
|
|
1945
|
-
"
|
|
618
|
+
"title": "My First Post",
|
|
619
|
+
"content": "This is the content of my first post.",
|
|
620
|
+
"published_at": "2024-01-15T10:30:00Z"
|
|
1946
621
|
},
|
|
1947
622
|
"relationships": {
|
|
1948
|
-
"
|
|
1949
|
-
"data": {
|
|
1950
|
-
"type": "active_storage_blobs",
|
|
1951
|
-
"id": "1"
|
|
1952
|
-
}
|
|
1953
|
-
},
|
|
1954
|
-
"documents": {
|
|
623
|
+
"comments": {
|
|
1955
624
|
"data": [
|
|
1956
|
-
{ "
|
|
1957
|
-
{ "
|
|
625
|
+
{ "id": "1", "type": "comments" },
|
|
626
|
+
{ "id": "2", "type": "comments" }
|
|
1958
627
|
]
|
|
1959
628
|
}
|
|
1960
|
-
},
|
|
1961
|
-
"links": {
|
|
1962
|
-
"self": "/users/1"
|
|
1963
629
|
}
|
|
1964
630
|
},
|
|
1965
631
|
"included": [
|
|
1966
632
|
{
|
|
1967
|
-
"type": "active_storage_blobs",
|
|
1968
633
|
"id": "1",
|
|
634
|
+
"type": "comments",
|
|
1969
635
|
"attributes": {
|
|
1970
|
-
"
|
|
1971
|
-
"
|
|
1972
|
-
"byte_size": 102400,
|
|
1973
|
-
"checksum": "abc123..."
|
|
636
|
+
"content": "Great post!",
|
|
637
|
+
"created_at": "2024-01-15T11:00:00Z"
|
|
1974
638
|
},
|
|
1975
|
-
"
|
|
1976
|
-
"
|
|
1977
|
-
|
|
639
|
+
"relationships": {
|
|
640
|
+
"commentable": {
|
|
641
|
+
"data": { "id": "1", "type": "posts" }
|
|
642
|
+
},
|
|
643
|
+
"author": {
|
|
644
|
+
"data": { "id": "5", "type": "users" }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
"id": "2",
|
|
650
|
+
"type": "comments",
|
|
651
|
+
"attributes": {
|
|
652
|
+
"content": "Thanks for sharing!",
|
|
653
|
+
"created_at": "2024-01-15T12:00:00Z"
|
|
654
|
+
},
|
|
655
|
+
"relationships": {
|
|
656
|
+
"commentable": {
|
|
657
|
+
"data": { "id": "1", "type": "posts" }
|
|
658
|
+
},
|
|
659
|
+
"author": {
|
|
660
|
+
"data": { "id": "6", "type": "users" }
|
|
661
|
+
}
|
|
1978
662
|
}
|
|
1979
663
|
}
|
|
1980
664
|
]
|
|
1981
665
|
}
|
|
1982
666
|
```
|
|
1983
667
|
|
|
1984
|
-
###
|
|
668
|
+
### Single Table Inheritance (STI)
|
|
1985
669
|
|
|
1986
|
-
|
|
670
|
+
JPie provides comprehensive support for Rails Single Table Inheritance (STI) models. STI allows multiple models to share a single database table with a "type" column to differentiate between them.
|
|
1987
671
|
|
|
1988
|
-
|
|
672
|
+
#### STI Models
|
|
673
|
+
|
|
674
|
+
```ruby
|
|
675
|
+
# Base model
|
|
676
|
+
class Vehicle < ActiveRecord::Base
|
|
677
|
+
validates :name, presence: true
|
|
678
|
+
validates :brand, presence: true
|
|
679
|
+
validates :year, presence: true
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# STI subclasses
|
|
683
|
+
class Car < Vehicle
|
|
684
|
+
validates :engine_size, presence: true
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
class Truck < Vehicle
|
|
688
|
+
validates :cargo_capacity, presence: true
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
#### STI Resources
|
|
693
|
+
|
|
694
|
+
JPie automatically handles STI type inference and resource inheritance:
|
|
695
|
+
|
|
696
|
+
```ruby
|
|
697
|
+
# Base resource
|
|
698
|
+
class VehicleResource < JPie::Resource
|
|
699
|
+
attributes :name, :brand, :year
|
|
700
|
+
meta_attributes :created_at, :updated_at
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# STI resources inherit from base resource
|
|
704
|
+
class CarResource < VehicleResource
|
|
705
|
+
attributes :engine_size # Car-specific attribute
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
class TruckResource < VehicleResource
|
|
709
|
+
attributes :cargo_capacity # Truck-specific attribute
|
|
710
|
+
end
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
#### STI Type Inference
|
|
714
|
+
|
|
715
|
+
JPie automatically infers the correct JSON:API type from the STI model class:
|
|
716
|
+
|
|
717
|
+
```ruby
|
|
718
|
+
car = Car.create!(name: 'Civic', brand: 'Honda', year: 2020, engine_size: 1500)
|
|
719
|
+
car_resource = CarResource.new(car)
|
|
720
|
+
|
|
721
|
+
car_resource.type # => "cars" (automatically inferred from Car model)
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
#### STI Serialization
|
|
725
|
+
|
|
726
|
+
Each STI model serializes with its specific type and attributes:
|
|
727
|
+
|
|
728
|
+
```ruby
|
|
729
|
+
# Car serialization
|
|
730
|
+
car_serializer = JPie::Serializer.new(CarResource)
|
|
731
|
+
result = car_serializer.serialize(car)
|
|
732
|
+
|
|
733
|
+
# Result:
|
|
1989
734
|
{
|
|
1990
735
|
"data": {
|
|
1991
|
-
"
|
|
736
|
+
"id": "1",
|
|
737
|
+
"type": "cars", # STI type
|
|
1992
738
|
"attributes": {
|
|
1993
|
-
"name": "
|
|
1994
|
-
"
|
|
1995
|
-
|
|
1996
|
-
|
|
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
|
-
}
|
|
739
|
+
"name": "Civic",
|
|
740
|
+
"brand": "Honda",
|
|
741
|
+
"year": 2020,
|
|
742
|
+
"engine_size": 1500 # Car-specific attribute
|
|
2009
743
|
}
|
|
2010
744
|
}
|
|
2011
745
|
}
|
|
2012
|
-
```
|
|
2013
|
-
|
|
2014
|
-
The deserializer will:
|
|
2015
746
|
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
747
|
+
# Truck serialization
|
|
748
|
+
truck_serializer = JPie::Serializer.new(TruckResource)
|
|
749
|
+
result = truck_serializer.serialize(truck)
|
|
2019
750
|
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
To detach (remove) an attachment, send `null` for to-one relationships or an empty array `[]` for to-many relationships:
|
|
2023
|
-
|
|
2024
|
-
```json
|
|
751
|
+
# Result:
|
|
2025
752
|
{
|
|
2026
753
|
"data": {
|
|
2027
|
-
"
|
|
2028
|
-
"
|
|
2029
|
-
"
|
|
2030
|
-
"
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
"
|
|
2034
|
-
"data": []
|
|
2035
|
-
}
|
|
754
|
+
"id": "2",
|
|
755
|
+
"type": "trucks", # STI type
|
|
756
|
+
"attributes": {
|
|
757
|
+
"name": "F-150",
|
|
758
|
+
"brand": "Ford",
|
|
759
|
+
"year": 2021,
|
|
760
|
+
"cargo_capacity": 1000 # Truck-specific attribute
|
|
2036
761
|
}
|
|
2037
762
|
}
|
|
2038
763
|
}
|
|
2039
764
|
```
|
|
2040
765
|
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
#### Controlling Purge Behavior with `purge_on_nil`
|
|
766
|
+
#### STI Controllers
|
|
2044
767
|
|
|
2045
|
-
|
|
768
|
+
Controllers work seamlessly with STI models:
|
|
2046
769
|
|
|
2047
770
|
```ruby
|
|
2048
|
-
class
|
|
2049
|
-
|
|
771
|
+
class CarsController < ApplicationController
|
|
772
|
+
include JPie::Controller
|
|
773
|
+
# Automatically uses CarResource and Car model
|
|
774
|
+
end
|
|
2050
775
|
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
776
|
+
class TrucksController < ApplicationController
|
|
777
|
+
include JPie::Controller
|
|
778
|
+
# Automatically uses TruckResource and Truck model
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
class VehiclesController < ApplicationController
|
|
782
|
+
include JPie::Controller
|
|
783
|
+
# Uses VehicleResource and returns all vehicles (cars, trucks, etc.)
|
|
2054
784
|
end
|
|
2055
785
|
```
|
|
2056
786
|
|
|
2057
|
-
|
|
787
|
+
#### STI Scoping
|
|
2058
788
|
|
|
2059
|
-
|
|
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`)
|
|
789
|
+
Each STI resource automatically scopes to its specific type:
|
|
2062
790
|
|
|
2063
|
-
|
|
791
|
+
```ruby
|
|
792
|
+
CarResource.scope # Returns only Car records
|
|
793
|
+
TruckResource.scope # Returns only Truck records
|
|
794
|
+
VehicleResource.scope # Returns all Vehicle records (including STI subclasses)
|
|
795
|
+
```
|
|
2064
796
|
|
|
2065
|
-
|
|
2066
|
-
- When attachments should only be removed through explicit delete operations
|
|
2067
|
-
- When you need more control over attachment lifecycle management
|
|
797
|
+
#### STI in Polymorphic Relationships
|
|
2068
798
|
|
|
2069
|
-
|
|
799
|
+
JPie's serializer automatically determines the correct resource class for STI models in polymorphic relationships:
|
|
2070
800
|
|
|
2071
|
-
|
|
801
|
+
```ruby
|
|
802
|
+
# If a polymorphic relationship returns STI objects,
|
|
803
|
+
# JPie will automatically use the correct resource class
|
|
804
|
+
# (CarResource for Car objects, TruckResource for Truck objects, etc.)
|
|
805
|
+
```
|
|
2072
806
|
|
|
2073
|
-
|
|
807
|
+
#### Complete STI Example
|
|
2074
808
|
|
|
2075
|
-
|
|
2076
|
-
class UserResource < JSONAPI::Resource
|
|
2077
|
-
attributes :name, :email
|
|
809
|
+
Here's a complete example showing STI in action with HTTP requests and responses:
|
|
2078
810
|
|
|
2079
|
-
|
|
2080
|
-
|
|
811
|
+
**1. Database Setup**
|
|
812
|
+
|
|
813
|
+
```ruby
|
|
814
|
+
# Migration
|
|
815
|
+
class CreateVehicles < ActiveRecord::Migration[7.0]
|
|
816
|
+
def change
|
|
817
|
+
create_table :vehicles do |t|
|
|
818
|
+
t.string :type, null: false # STI discriminator column
|
|
819
|
+
t.string :name, null: false
|
|
820
|
+
t.string :brand, null: false
|
|
821
|
+
t.integer :year, null: false
|
|
822
|
+
t.integer :engine_size # Car-specific
|
|
823
|
+
t.integer :cargo_capacity # Truck-specific
|
|
824
|
+
t.timestamps
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
add_index :vehicles, :type
|
|
828
|
+
end
|
|
2081
829
|
end
|
|
2082
830
|
```
|
|
2083
831
|
|
|
2084
|
-
**
|
|
832
|
+
**2. Models**
|
|
2085
833
|
|
|
2086
|
-
|
|
834
|
+
```ruby
|
|
835
|
+
class Vehicle < ApplicationRecord
|
|
836
|
+
validates :name, :brand, :year, presence: true
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
class Car < Vehicle
|
|
840
|
+
validates :engine_size, presence: true
|
|
841
|
+
end
|
|
2087
842
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
```
|
|
843
|
+
class Truck < Vehicle
|
|
844
|
+
validates :cargo_capacity, presence: true
|
|
845
|
+
end
|
|
846
|
+
```
|
|
2093
847
|
|
|
2094
|
-
|
|
848
|
+
**3. Resources**
|
|
2095
849
|
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
850
|
+
```ruby
|
|
851
|
+
class VehicleResource < JPie::Resource
|
|
852
|
+
attributes :name, :brand, :year
|
|
853
|
+
meta_attributes :created_at, :updated_at
|
|
854
|
+
end
|
|
2101
855
|
|
|
2102
|
-
|
|
856
|
+
class CarResource < VehicleResource
|
|
857
|
+
attributes :engine_size
|
|
858
|
+
end
|
|
2103
859
|
|
|
2104
|
-
|
|
860
|
+
class TruckResource < VehicleResource
|
|
861
|
+
attributes :cargo_capacity
|
|
862
|
+
end
|
|
863
|
+
```
|
|
2105
864
|
|
|
2106
|
-
**
|
|
865
|
+
**4. Controllers**
|
|
2107
866
|
|
|
2108
867
|
```ruby
|
|
2109
|
-
|
|
2110
|
-
|
|
868
|
+
class VehiclesController < ApplicationController
|
|
869
|
+
include JPie::Controller
|
|
870
|
+
# Returns all vehicles (cars, trucks, etc.)
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
class CarsController < ApplicationController
|
|
874
|
+
include JPie::Controller
|
|
875
|
+
# Returns only cars with car-specific attributes
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
class TrucksController < ApplicationController
|
|
879
|
+
include JPie::Controller
|
|
880
|
+
# Returns only trucks with truck-specific attributes
|
|
881
|
+
end
|
|
2111
882
|
```
|
|
2112
883
|
|
|
2113
|
-
**
|
|
884
|
+
**5. Routes**
|
|
885
|
+
|
|
886
|
+
```ruby
|
|
887
|
+
Rails.application.routes.draw do
|
|
888
|
+
resources :vehicles, only: [:index, :show]
|
|
889
|
+
resources :cars
|
|
890
|
+
resources :trucks
|
|
891
|
+
end
|
|
892
|
+
```
|
|
2114
893
|
|
|
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
|
|
894
|
+
**6. Example HTTP Requests and Responses**
|
|
2119
895
|
|
|
2120
|
-
**
|
|
896
|
+
**GET /cars/1**
|
|
897
|
+
```json
|
|
898
|
+
{
|
|
899
|
+
"data": {
|
|
900
|
+
"id": "1",
|
|
901
|
+
"type": "cars",
|
|
902
|
+
"attributes": {
|
|
903
|
+
"name": "Model 3",
|
|
904
|
+
"brand": "Tesla",
|
|
905
|
+
"year": 2023,
|
|
906
|
+
"engine_size": 0
|
|
907
|
+
},
|
|
908
|
+
"meta": {
|
|
909
|
+
"created_at": "2024-01-15T10:00:00Z",
|
|
910
|
+
"updated_at": "2024-01-15T10:00:00Z"
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
```
|
|
2121
915
|
|
|
2122
|
-
|
|
916
|
+
**GET /trucks/2**
|
|
917
|
+
```json
|
|
918
|
+
{
|
|
919
|
+
"data": {
|
|
920
|
+
"id": "2",
|
|
921
|
+
"type": "trucks",
|
|
922
|
+
"attributes": {
|
|
923
|
+
"name": "F-150",
|
|
924
|
+
"brand": "Ford",
|
|
925
|
+
"year": 2023,
|
|
926
|
+
"cargo_capacity": 1200
|
|
927
|
+
},
|
|
928
|
+
"meta": {
|
|
929
|
+
"created_at": "2024-01-15T11:00:00Z",
|
|
930
|
+
"updated_at": "2024-01-15T11:00:00Z"
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
```
|
|
2123
935
|
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
936
|
+
**GET /vehicles (Mixed STI Collection)**
|
|
937
|
+
```json
|
|
938
|
+
{
|
|
939
|
+
"data": [
|
|
940
|
+
{
|
|
941
|
+
"id": "1",
|
|
942
|
+
"type": "cars",
|
|
943
|
+
"attributes": {
|
|
944
|
+
"name": "Model 3",
|
|
945
|
+
"brand": "Tesla",
|
|
946
|
+
"year": 2023,
|
|
947
|
+
"engine_size": 0
|
|
948
|
+
}
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
"id": "2",
|
|
952
|
+
"type": "trucks",
|
|
953
|
+
"attributes": {
|
|
954
|
+
"name": "F-150",
|
|
955
|
+
"brand": "Ford",
|
|
956
|
+
"year": 2023,
|
|
957
|
+
"cargo_capacity": 1200
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
]
|
|
961
|
+
}
|
|
962
|
+
```
|
|
2128
963
|
|
|
2129
|
-
|
|
2130
|
-
# { "name" => "Jane Doe", "email" => "jane@example.com", "avatar" => <ActiveStorage::Blob>, "documents" => [<ActiveStorage::Blob>, ...] }
|
|
964
|
+
**7. Creating STI Records**
|
|
2131
965
|
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
966
|
+
**POST /cars**
|
|
967
|
+
```json
|
|
968
|
+
{
|
|
969
|
+
"data": {
|
|
970
|
+
"type": "cars",
|
|
971
|
+
"attributes": {
|
|
972
|
+
"name": "Model Y",
|
|
973
|
+
"brand": "Tesla",
|
|
974
|
+
"year": 2024,
|
|
975
|
+
"engine_size": 0
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
2135
979
|
```
|
|
2136
980
|
|
|
2137
|
-
|
|
981
|
+
#### Custom STI Types
|
|
982
|
+
|
|
983
|
+
You can override the automatic type inference if needed:
|
|
2138
984
|
|
|
2139
|
-
|
|
985
|
+
```ruby
|
|
986
|
+
class CarResource < VehicleResource
|
|
987
|
+
type 'automobiles' # Custom type instead of 'cars'
|
|
988
|
+
attributes :engine_size
|
|
989
|
+
end
|
|
990
|
+
```
|
|
2140
991
|
|
|
2141
|
-
###
|
|
992
|
+
### Authorization and Scoping
|
|
2142
993
|
|
|
2143
|
-
|
|
994
|
+
Override the default scope method to add authorization:
|
|
2144
995
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
996
|
+
```ruby
|
|
997
|
+
class PostResource < JPie::Resource
|
|
998
|
+
attributes :title, :content
|
|
999
|
+
|
|
1000
|
+
def self.scope(context = {})
|
|
1001
|
+
current_user = context[:current_user]
|
|
1002
|
+
return model.none unless current_user
|
|
1003
|
+
Pundit.policy_scope(current_user, model)
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
```
|
|
2150
1007
|
|
|
2151
|
-
|
|
1008
|
+
### Custom Context
|
|
2152
1009
|
|
|
2153
|
-
|
|
1010
|
+
Override the context building to pass additional data to resources:
|
|
2154
1011
|
|
|
2155
|
-
|
|
1012
|
+
```ruby
|
|
1013
|
+
class UsersController < ApplicationController
|
|
1014
|
+
include JPie::Controller
|
|
1015
|
+
|
|
1016
|
+
private
|
|
1017
|
+
|
|
1018
|
+
def build_context
|
|
1019
|
+
{
|
|
1020
|
+
current_user: current_user,
|
|
1021
|
+
controller: self,
|
|
1022
|
+
action: action_name,
|
|
1023
|
+
request_ip: request.remote_ip,
|
|
1024
|
+
user_agent: request.user_agent
|
|
1025
|
+
}
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
```
|
|
2156
1029
|
|
|
2157
1030
|
## License
|
|
2158
1031
|
|
|
2159
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
1032
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|