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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.aiconfig +65 -0
  3. data/.rubocop.yml +110 -35
  4. data/CHANGELOG.md +93 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +776 -1903
  7. data/Rakefile +14 -3
  8. data/jpie.gemspec +35 -18
  9. data/lib/jpie/configuration.rb +12 -0
  10. data/lib/jpie/controller/crud_actions.rb +110 -0
  11. data/lib/jpie/controller/error_handling.rb +41 -0
  12. data/lib/jpie/controller/parameter_parsing.rb +35 -0
  13. data/lib/jpie/controller/rendering.rb +60 -0
  14. data/lib/jpie/controller.rb +18 -0
  15. data/lib/jpie/deserializer.rb +110 -0
  16. data/lib/jpie/errors.rb +70 -0
  17. data/lib/jpie/generators/resource_generator.rb +39 -0
  18. data/lib/jpie/generators/templates/resource.rb.erb +12 -0
  19. data/lib/jpie/railtie.rb +36 -0
  20. data/lib/jpie/resource/attributable.rb +98 -0
  21. data/lib/jpie/resource/inferrable.rb +43 -0
  22. data/lib/jpie/resource/sortable.rb +93 -0
  23. data/lib/jpie/resource.rb +107 -0
  24. data/lib/jpie/serializer.rb +205 -0
  25. data/lib/{json_api → jpie}/version.rb +2 -2
  26. data/lib/jpie.rb +23 -3
  27. metadata +145 -50
  28. data/.gitignore +0 -21
  29. data/.rspec +0 -3
  30. data/.travis.yml +0 -7
  31. data/Gemfile +0 -21
  32. data/Gemfile.lock +0 -312
  33. data/bin/console +0 -15
  34. data/bin/setup +0 -8
  35. data/kiln/app/resources/user_message_resource.rb +0 -2
  36. data/lib/json_api/active_storage/deserialization.rb +0 -106
  37. data/lib/json_api/active_storage/detection.rb +0 -74
  38. data/lib/json_api/active_storage/serialization.rb +0 -32
  39. data/lib/json_api/configuration.rb +0 -58
  40. data/lib/json_api/controllers/base_controller.rb +0 -26
  41. data/lib/json_api/controllers/concerns/controller_helpers.rb +0 -223
  42. data/lib/json_api/controllers/concerns/resource_actions.rb +0 -657
  43. data/lib/json_api/controllers/relationships_controller.rb +0 -504
  44. data/lib/json_api/controllers/resources_controller.rb +0 -6
  45. data/lib/json_api/errors/parameter_not_allowed.rb +0 -19
  46. data/lib/json_api/railtie.rb +0 -75
  47. data/lib/json_api/resources/active_storage_blob_resource.rb +0 -11
  48. data/lib/json_api/resources/resource.rb +0 -238
  49. data/lib/json_api/resources/resource_loader.rb +0 -35
  50. data/lib/json_api/routing.rb +0 -72
  51. data/lib/json_api/serialization/deserializer.rb +0 -362
  52. data/lib/json_api/serialization/serializer.rb +0 -320
  53. data/lib/json_api/support/active_storage_support.rb +0 -85
  54. data/lib/json_api/support/collection_query.rb +0 -406
  55. data/lib/json_api/support/instrumentation.rb +0 -42
  56. data/lib/json_api/support/param_helpers.rb +0 -51
  57. data/lib/json_api/support/relationship_guard.rb +0 -16
  58. data/lib/json_api/support/relationship_helpers.rb +0 -74
  59. data/lib/json_api/support/resource_identifier.rb +0 -87
  60. data/lib/json_api/support/responders.rb +0 -100
  61. data/lib/json_api/support/response_helpers.rb +0 -10
  62. data/lib/json_api/support/sort_parsing.rb +0 -21
  63. data/lib/json_api/support/type_conversion.rb +0 -21
  64. data/lib/json_api/testing/test_helper.rb +0 -76
  65. data/lib/json_api/testing.rb +0 -3
  66. data/lib/json_api.rb +0 -50
  67. data/lib/rubocop/cop/custom/hash_value_omission.rb +0 -53
data/README.md CHANGED
@@ -1,2159 +1,1032 @@
1
- # JSONAPI
1
+ # JPie
2
2
 
3
- A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API controllers for producing and consuming JSON:API resources.
3
+ [![Gem Version](https://badge.fury.io/rb/jpie.svg)](https://badge.fury.io/rb/jpie)
4
+ [![Build Status](https://github.com/emilkampp/jpie/workflows/CI/badge.svg)](https://github.com/emilkampp/jpie/actions)
4
5
 
5
- ## Features
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
- - JSON:API v1.1 compliant routing and controllers
8
- - Automatic MIME type registration (`application/vnd.api+json`)
9
- - Generic resource controller with CRUD operations
10
- - Built-in serialization and deserialization
11
- - Support for filtering (explicit filters with column-aware operators), sorting, pagination, sparse fieldsets, and includes
12
- - Relationship endpoints for managing resource relationships independently
13
- - Separate creatable and updatable field definitions
14
- - Configurable pagination options
15
- - Content negotiation with Accept and Content-Type headers
16
- - Support for polymorphic and STI relationships
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 this line to your application's Gemfile:
21
+ Add JPie to your Rails application:
21
22
 
22
- ```ruby
23
- gem 'json_api'
23
+ ```bash
24
+ bundle add jpie
24
25
  ```
25
26
 
26
- And then execute:
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
- ## Usage
29
+ JPie works out of the box with minimal configuration. Here's a complete example of the default implementation:
40
30
 
41
- ### Basic Setup
42
-
43
- Add the gem to your Gemfile and run `bundle install`. The gem will automatically register the JSON:API MIME type and extend Rails routing.
44
-
45
- ### Routing
46
-
47
- Use the `jsonapi_resources` DSL in your routes file:
31
+ ### 1. Create Your Model
48
32
 
49
33
  ```ruby
50
- # config/routes.rb
51
- Rails.application.routes.draw do
52
- jsonapi_resources :users
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
- This creates standard RESTful routes (index, show, create, update, destroy) that default to the `json_api/resources` controller and `jsonapi` format.
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
- # app/resources/user_resource.rb
67
- class UserResource < JSONAPI::Resource
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
- Resource classes must:
76
-
77
- - Inherit from `JSONAPI::Resource`
78
- - Be named `<ResourceName>Resource` (e.g., `UserResource` for `users` resource type)
79
- - Define permitted attributes using `attributes`
80
- - Define relationships using `has_many`, `has_one`, or `belongs_to`
81
-
82
- The generic controller uses these resource definitions to:
83
-
84
- - Validate requested fields (sparse fieldsets)
85
- - Validate requested includes
86
- - Control which attributes are exposed in responses
87
- - Validate filters
88
- - Control which fields can be created vs updated
89
-
90
- ### 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 UserResource < JSONAPI::Resource
100
- attributes :name, :email, :full_name
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
- The getter method receives the underlying model instance via `resource`. Virtual attributes are serialized just like regular attributes and appear in the response:
110
-
111
- ```json
112
- {
113
- "data": {
114
- "type": "users",
115
- "id": "1",
116
- "attributes": {
117
- "name": "John Doe",
118
- "email": "john@example.com",
119
- "full_name": "John Doe (john@example.com)"
120
- }
121
- }
122
- }
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
- class UserResource < JSONAPI::Resource
131
- attributes :name, :email
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
- #### Virtual Attribute Setters
64
+ That's it! You now have a fully functional JSON:API compliant server.
141
65
 
142
- You can define setters for virtual attributes that transform incoming values into real model attributes. This is useful for accepting formatted input that needs to be stored differently:
66
+ ## Modern DSL Examples
143
67
 
144
- ```ruby
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
- # Setter that transforms virtual attribute to model attribute
156
- def display_name=(value)
157
- @transformed_params["name"] = value.upcase
158
- end
70
+ ### Resource Definition
159
71
 
160
- # Getter for the virtual attribute (for serialization)
161
- def display_name
162
- resource&.name&.downcase
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
- # Return transformed params accumulated by setters
166
- def transformed_params
167
- @transformed_params || {}
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
- When a client sends `display_name` in a create or update request, the setter transforms it to `name` (uppercase). The virtual attribute `display_name` is automatically excluded from the final params returned by the deserializer, so it won't be passed to the model.
173
-
174
- **Important**: You must initialize `@transformed_params` in your `initialize` method if you use setters that modify it.
175
-
176
- ### Custom Controllers
177
-
178
- If you need custom behavior, you can override the default controller:
105
+ ### Controller Definition
179
106
 
180
107
  ```ruby
181
- jsonapi_resources :users, controller: "api/users"
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
- ### Controller Actions
185
-
186
- The default `JSONAPI::ResourcesController` provides:
187
-
188
- - `GET /users` - List all users (with filtering, sorting, pagination)
189
- - `GET /users/:id` - Show a user (with includes, sparse fieldsets)
190
- - `POST /users` - Create a user (with relationships)
191
- - `PATCH /users/:id` - Update a user (with relationships)
192
- - `DELETE /users/:id` - Delete a user
193
-
194
- Additionally, relationship endpoints are available via `JSONAPI::RelationshipsController`:
131
+ ## Suported JSON:API features
195
132
 
196
- - `GET /users/:id/relationships/:relationship_name` - Show relationship data (with sorting for collections)
197
- - `PATCH /users/:id/relationships/:relationship_name` - Update relationship linkage
198
- - `DELETE /users/:id/relationships/:relationship_name` - Remove relationship linkage
133
+ ### Sorting
134
+ All defined attributes are automatically sortable:
199
135
 
200
- #### Example Responses
201
-
202
- **GET /users (Collection Response):**
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": "John Doe",
215
- "email": "john@example.com",
216
- "phone": "555-0100"
217
- },
218
- "relationships": {
219
- "posts": {
220
- "data": [
221
- { "type": "posts", "id": "1" },
222
- { "type": "posts", "id": "2" }
223
- ],
224
- "meta": {
225
- "count": 2
226
- }
227
- },
228
- "profile": {
229
- "data": null
230
- }
231
- },
232
- "links": {
233
- "self": "/users/1"
234
- },
235
- "meta": {
236
- "created_at": "2024-01-15T10:30:00Z",
237
- "updated_at": "2024-01-15T10:30:00Z"
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
- "relationships": {
260
- "posts": {
261
- "data": [
262
- { "type": "posts", "id": "1" },
263
- { "type": "posts", "id": "2" }
264
- ],
265
- "meta": {
266
- "count": 2
267
- }
268
- },
269
- "profile": {
270
- "data": {
271
- "type": "admin_profiles",
272
- "id": "1"
273
- }
150
+ {
151
+ "id": "2",
152
+ "type": "users",
153
+ "attributes": {
154
+ "name": "Bob Brown",
155
+ "email": "bob@example.com"
274
156
  }
275
157
  },
276
- "links": {
277
- "self": "/users/1"
278
- },
279
- "meta": {
280
- "created_at": "2024-01-15T10:30:00Z",
281
- "updated_at": "2024-01-15T10:30:00Z"
282
- }
283
- }
284
- }
285
- ```
286
-
287
- **POST /users (Create Response):**
288
-
289
- ```json
290
- {
291
- "jsonapi": {
292
- "version": "1.1"
293
- },
294
- "data": {
295
- "type": "users",
296
- "id": "2",
297
- "attributes": {
298
- "name": "New User",
299
- "email": "newuser@example.com",
300
- "phone": "555-0101"
301
- },
302
- "relationships": {
303
- "posts": {
304
- "data": [],
305
- "meta": {
306
- "count": 0
307
- }
308
- },
309
- "profile": {
310
- "data": null
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
- **PATCH /users/:id (Update Response):**
170
+ Or by name in reverse order by name:
325
171
 
326
- ```json
172
+ ```http
173
+ GET /users?sort=-name
174
+ HTTP/1.1 200 OK
175
+ Content-Type: application/vnd.api+json
327
176
  {
328
- "jsonapi": {
329
- "version": "1.1"
330
- },
331
- "data": {
332
- "type": "users",
333
- "id": "1",
334
- "attributes": {
335
- "name": "Updated Name",
336
- "email": "john@example.com",
337
- "phone": "555-9999"
338
- },
339
- "relationships": {
340
- "posts": {
341
- "data": [{ "type": "posts", "id": "1" }],
342
- "meta": {
343
- "count": 1
344
- }
345
- },
346
- "profile": {
347
- "data": null
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
- "links": {
351
- "self": "/users/1"
186
+ {
187
+ "id": "2",
188
+ "type": "users",
189
+ "attributes": {
190
+ "name": "Bob Brown",
191
+ "email": "bob@example.com"
192
+ }
352
193
  },
353
- "meta": {
354
- "created_at": "2024-01-15T10:30:00Z",
355
- "updated_at": "2024-01-15T11:15:00Z"
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
- **DELETE /users/:id:**
362
-
363
- Returns `204 No Content` with an empty response body.
206
+ ## Customization and Overrides
364
207
 
365
- ### Query Parameters
208
+ Once you have the basic implementation working, you can customize JPie's behavior as needed:
366
209
 
367
- The controller supports standard JSON:API query parameters:
210
+ ### Resource Class Inference Override
368
211
 
369
- - `filter[name]=John` - Filter resources (must be declared in resource class)
370
- - `sort=name,-created_at` - Sort resources (ascending by default, prefix with `-` for descending)
371
- - `page[number]=1&page[size]=25` - Pagination (number and size only)
372
- - `include=posts,comments` - Include related resources
373
- - `fields[users]=name,email` - Sparse fieldsets
374
-
375
- #### Filtering
376
-
377
- Filtering requires declaring permitted filters in your resource class:
212
+ JPie automatically infers the resource class from your controller name, but you can override this:
378
213
 
379
214
  ```ruby
380
- class UserResource < JSONAPI::Resource
381
- attributes :name, :email, :phone
382
- filters :name_eq, :name_match, :created_at_gte
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
- **String Format:**
399
-
400
- - Single value: `filter[name_eq]=John`
401
- - Comma-separated values: `filter[name_eq]=John,Jane` (parsed as a single string `"John,Jane"`)
402
-
403
- **Array Format:**
404
-
405
- - Multiple values: `filter[categories_include][]=security&filter[categories_include][]=governance`
406
- - Rails parses this as an array: `["security", "governance"]`
407
-
408
- **When Arrays Are Required:**
409
-
410
- Some filters require arrays, particularly when using PostgreSQL array operators:
411
-
412
- ```ruby
413
- class SelectedControl < ApplicationRecord
414
- # This scope uses PostgreSQL's ?| operator which requires an array
415
- scope :categories_include, ->(categories) {
416
- where("#{table_name}.categories ?| array[:categories]", categories:)
417
- }
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
- For such scopes, filters must be provided in array format:
229
+ ### Model Specification Override
422
230
 
423
- - Correct: `filter[categories_include][]=security&filter[categories_include][]=governance`
424
- - ❌ Incorrect: `filter[categories_include]=security,governance` (parsed as string `"security,governance"`)
425
-
426
- **How Rails Parses Query Parameters:**
427
-
428
- - `filter[key]=value` → Rails parses as `{ "filter" => { "key" => "value" } }`
429
- - `filter[key][]=value1&filter[key][]=value2` → Rails parses as `{ "filter" => { "key" => ["value1", "value2"] } }`
430
- - `filter[key]=value1,value2` → Rails parses as `{ "filter" => { "key" => "value1,value2" } }` (single string)
431
-
432
- The `json_api` gem passes filter values directly to model scopes as parsed by Rails. If a scope expects an array (e.g., for PostgreSQL array operators), ensure the filter is sent in array format.
433
-
434
- #### Filtering through relationships (nested filter hashes)
435
-
436
- Expose filters on related resources using nested hashes. Relationships declared with `has_one`/`has_many` automatically allow nested filters on the related resource's filters plus primary key filters. Example:
231
+ JPie automatically infers the model from your resource class name, but you can override this:
437
232
 
438
233
  ```ruby
439
- class PostResource < JSONAPI::Resource
440
- filters :title
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
- class User < ApplicationRecord
444
- # Column filters: filter[user][email]=foo@example.com
445
- # Scope filters: filter[user][name_search]=Jane
446
- scope :name_search, ->(query) { where("users.name LIKE ?", "%#{query}%") }
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
- Examples:
451
-
452
- - `GET /posts?filter[user][id]=123` (joins users and filters on users.id)
453
- - `GET /posts?filter[user][email]=jane@example.com` (filters on related column)
454
- - `GET /posts?filter[user][name_search]=Jane` (calls the related scope and merges it)
455
- - `GET /comments?filter[post][user][email_eq]=john@example.com` (multi-level chain)
247
+ ### Controller Method Overrides
456
248
 
457
- Nested filter paths must point to either a column on the related model, a class method/scope on that model, or a filter declared on the related resource.
249
+ You can override any of the automatic CRUD methods:
458
250
 
459
- Invalid filter fields will return a `400 Bad Request` error:
460
-
461
- ```json
462
- {
463
- "errors": [
464
- {
465
- "status": "400",
466
- "title": "Invalid Filter",
467
- "detail": "Invalid filters requested: invalid_field"
468
- }
469
- ]
470
- }
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
- ### Writable through relationships
275
+ ### Custom Attributes
474
276
 
475
- By default, has-many-through and has-one-through relationships are writable via JSON:API payloads and relationship endpoints. Set `readonly: true` on the relationship to block writes and return `ParameterNotAllowed`.
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 < JSONAPI::Resource
479
- has_many :post_comments, readonly: true
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
- - The opt-out applies to both main resource deserialization and relationship controller updates.
484
- - Use `readonly: true` when the underlying model should not allow assignment (for example, when it lacks `*_ids` setters or manages joins differently).
485
- - Without the flag, through relationships are writable.
486
-
487
- #### Pagination
297
+ #### Using Method Overrides (New Approach)
488
298
 
489
- Resources can be paginated using the `page[number]` and `page[size]` parameters. Pagination is only available on collection (index) endpoints.
299
+ You can now define custom methods directly on your resource class instead of using blocks:
490
300
 
491
- Examples:
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
- - `GET /users?page[number]=1&page[size]=10` (first page, 10 items per page)
494
- - `GET /users?page[number]=2&page[size]=10` (second page, 10 items per page)
495
- - `GET /users?page[number]=1` (first page with default page size)
308
+ private
496
309
 
497
- The default page size is 25, and the maximum page size is 100 (configurable via `JSONAPI.configuration`). If a size larger than the maximum is requested, it will be capped at the maximum.
310
+ def full_name
311
+ "#{object.first_name} #{object.last_name}"
312
+ end
498
313
 
499
- **Example Paginated Response:**
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
- ```json
502
- {
503
- "jsonapi": {
504
- "version": "1.1"
505
- },
506
- "data": [
322
+ def user_stats
507
323
  {
508
- "type": "users",
509
- "id": "1",
510
- "attributes": {
511
- "name": "User 1",
512
- "email": "user1@example.com",
513
- "phone": "555-0001"
514
- },
515
- "links": {
516
- "self": "/users/1"
517
- }
324
+ name_length: object.name.length,
325
+ email_domain: object.email.split('@').last,
326
+ account_status: object.active? ? 'active' : 'inactive'
518
327
  }
519
- ],
520
- "links": {
521
- "self": "/users?page[number]=2&page[size]=5",
522
- "first": "/users?page[number]=1&page[size]=5",
523
- "last": "/users?page[number]=3&page[size]=5",
524
- "prev": "/users?page[number]=1&page[size]=5",
525
- "next": "/users?page[number]=3&page[size]=5"
526
- },
527
- "meta": {
528
- "total": 15
529
- }
530
- }
328
+ end
329
+ end
531
330
  ```
532
331
 
533
- #### Including Related Resources
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
- Use the `include` parameter to include related resources in the response. This is available on both index and show endpoints:
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
- ```ruby
538
- # Include single relationship
539
- GET /users?include=posts
540
-
541
- # Include multiple relationships
542
- GET /users?include=posts,comments
543
-
544
- # Include nested relationships (two levels)
545
- GET /users?include=posts.comments
546
-
547
- # Include deeply nested relationships (arbitrary depth)
548
- GET /users?include=posts.comments.author
345
+ ### Meta attributes
549
346
 
550
- # Include multiple nested paths
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
- # Mix single and nested includes
554
- GET /users?include=posts.comments,notifications
555
- ```
556
-
557
- The gem supports arbitrary depth for nested includes. You can chain as many associations as needed (e.g., `posts.comments.author.profile`). Overlapping paths are automatically merged, so `posts.comments` and `posts.comments.author` will correctly include posts, comments, and authors.
349
+ #### Using meta_attributes Macro
558
350
 
559
- Invalid include paths will return a `400 Bad Request` error with a JSON:API error response:
351
+ It's easy to add meta attributes:
560
352
 
561
- ```json
562
- {
563
- "errors": [
564
- {
565
- "status": "400",
566
- "title": "Invalid Include Path",
567
- "detail": "Invalid include paths requested: invalid_association"
568
- }
569
- ]
570
- }
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
- Included resources appear in the `included` array at the top level of the response, and relationships reference them using resource identifiers (`type` and `id`).
574
-
575
- **Example Response with Includes:**
576
-
577
- ```json
578
- {
579
- "jsonapi": {
580
- "version": "1.1"
581
- },
582
- "data": {
583
- "type": "users",
584
- "id": "1",
585
- "attributes": {
586
- "name": "John Doe",
587
- "email": "john@example.com",
588
- "phone": "555-0100"
589
- },
590
- "relationships": {
591
- "posts": {
592
- "data": [
593
- { "type": "posts", "id": "1" },
594
- { "type": "posts", "id": "2" }
595
- ],
596
- "meta": {
597
- "count": 2
598
- }
599
- }
600
- },
601
- "links": {
602
- "self": "/users/1"
603
- }
604
- },
605
- "included": [
606
- {
607
- "type": "posts",
608
- "id": "1",
609
- "attributes": {
610
- "title": "First Post",
611
- "body": "Content 1"
612
- },
613
- "relationships": {
614
- "user": {
615
- "data": {
616
- "type": "users",
617
- "id": "1"
618
- }
619
- },
620
- "comments": {
621
- "data": [],
622
- "meta": {
623
- "count": 0
624
- }
625
- }
626
- },
627
- "links": {
628
- "self": "/posts/1"
629
- }
630
- },
631
- {
632
- "type": "posts",
633
- "id": "2",
634
- "attributes": {
635
- "title": "Second Post",
636
- "body": "Content 2"
637
- },
638
- "relationships": {
639
- "user": {
640
- "data": {
641
- "type": "users",
642
- "id": "1"
643
- }
644
- },
645
- "comments": {
646
- "data": [],
647
- "meta": {
648
- "count": 0
649
- }
650
- }
651
- },
652
- "links": {
653
- "self": "/posts/2"
654
- }
655
- }
656
- ]
657
- }
658
- ```
659
-
660
- #### Polymorphic Relationships
661
-
662
- The gem supports polymorphic associations (both `belongs_to :profile, polymorphic: true` and `has_many :activities, as: :actor`). When including polymorphic relationships, the serializer automatically determines the correct resource type based on the actual class of the related object:
663
-
664
- ```ruby
665
- # User belongs_to :profile, polymorphic: true
666
- # User has_many :activities, as: :actor
667
-
668
- GET /users?include=profile
669
- GET /users?include=activities
670
- GET /users/:id?include=profile,activities
671
- ```
672
-
673
- The response will include the correct resource type for each polymorphic association (e.g., `customer_profiles` or `admin_profiles` for a polymorphic `profile` association).
674
-
675
- **Example Response with Polymorphic Relationship:**
676
-
677
- ```json
678
- {
679
- "jsonapi": {
680
- "version": "1.1"
681
- },
682
- "data": {
683
- "type": "users",
684
- "id": "1",
685
- "attributes": {
686
- "name": "John Doe",
687
- "email": "john@example.com",
688
- "phone": "555-0100"
689
- },
690
- "relationships": {
691
- "profile": {
692
- "data": {
693
- "type": "admin_profiles",
694
- "id": "1"
695
- }
696
- }
697
- },
698
- "links": {
699
- "self": "/users/1"
700
- }
701
- },
702
- "included": [
703
- {
704
- "type": "admin_profiles",
705
- "id": "1",
706
- "attributes": {
707
- "department": "Engineering",
708
- "level": "Senior"
709
- },
710
- "links": {
711
- "self": "/admin_profiles/1"
712
- }
713
- }
714
- ]
715
- }
716
- ```
717
-
718
- #### Single Table Inheritance (STI) Support
719
-
720
- The gem supports Single Table Inheritance (STI) resources and relationships. Subclasses are treated as first-class JSON:API resources with their own types, while sharing the underlying table.
721
-
722
- ##### Routing
723
-
724
- To enable STI support, use the `sti` option in your routes configuration. Pass an array of subtype names to generate routes for both the base resource and its subclasses:
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
- Error responses follow JSON:API error format:
362
+ For more complex meta data, you can define a `meta` method that returns a hash:
1736
363
 
1737
- **Example Error Response:**
364
+ ```ruby
365
+ class UserResource < JPie::Resource
366
+ attributes :name, :email
367
+ meta_attributes :created_at, :updated_at
1738
368
 
1739
- ```json
1740
- {
1741
- "jsonapi": {
1742
- "version": "1.1"
1743
- },
1744
- "errors": [
1745
- {
1746
- "status": "400",
1747
- "title": "Invalid Relationship",
1748
- "detail": "Invalid relationship type for profile: 'invalid_type' does not correspond to a valid model class",
1749
- "source": {
1750
- "pointer": "/data/relationships/profile/data/type"
1751
- }
1752
- }
1753
- ]
1754
- }
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
- **Example 404 Not Found Error:**
1758
-
1759
- ```json
1760
- {
1761
- "jsonapi": {
1762
- "version": "1.1"
1763
- },
1764
- "errors": [
1765
- {
1766
- "status": "404",
1767
- "title": "Record Not Found",
1768
- "detail": "Couldn't find User with 'id'=999"
1769
- }
1770
- ]
1771
- }
1772
- ```
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 Validation Error:**
385
+ **Example JSON:API Response with Custom Meta:**
1775
386
 
1776
387
  ```json
1777
388
  {
1778
- "jsonapi": {
1779
- "version": "1.1"
1780
- },
1781
- "errors": [
1782
- {
1783
- "status": "422",
1784
- "title": "Validation Error",
1785
- "detail": "Email can't be blank",
1786
- "source": {
1787
- "pointer": "/data/attributes/email"
1788
- }
389
+ "data": {
390
+ "id": "1",
391
+ "type": "users",
392
+ "attributes": {
393
+ "name": "John Doe",
394
+ "email": "john@example.com"
1789
395
  },
1790
- {
1791
- "status": "422",
1792
- "title": "Validation Error",
1793
- "detail": "Name is too short (minimum is 2 characters)",
1794
- "source": {
1795
- "pointer": "/data/attributes/name"
1796
- }
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
- ## Development
408
+ #### Meta Method Inheritance
1803
409
 
1804
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
410
+ Meta methods work seamlessly with inheritance:
1805
411
 
1806
- You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
- ### Code Organization
424
+ class UserResource < BaseResource
425
+ attributes :name, :email
426
+ meta_attributes :last_login_at
1809
427
 
1810
- The gem is organized into several directories within `lib/json_api/`:
428
+ def meta
429
+ super.merge(
430
+ user_specific_data: calculate_user_metrics
431
+ )
432
+ end
1811
433
 
1812
- - **`controllers/`** - Controller classes (`BaseController`, `ResourcesController`, `RelationshipsController`)
1813
- - **`resources/`** - Resource DSL and resource loading (`Resource`, `ResourceLoader`, `ActiveStorageBlobResource`)
1814
- - **`serialization/`** - Serialization and deserialization (`Serializer`, `Deserializer`)
1815
- - **`support/`** - Shared concerns and utilities:
1816
- - `ActiveStorageSupport` - Concern for handling ActiveStorage attachments
1817
- - `CollectionQuery` - Service class for building filtered, sorted, and paginated queries
1818
- - `RelationshipHelpers` - Utilities for relationship handling
1819
- - `ParamHelpers` - Parameter parsing utilities
1820
- - `Responders` - Content negotiation and error rendering
1821
- - `Instrumentation` - Rails event emission
434
+ private
1822
435
 
1823
- This organization makes it easier to understand the gem's structure and locate specific functionality.
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
- ## Testing
445
+ ### Custom Sorting
1826
446
 
1827
- Run the test suite:
447
+ Override the default sorting behavior with custom logic:
1828
448
 
1829
- ```bash
1830
- bundle exec rspec
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
- ### Test Helper for Request Specs
463
+ ### Polymorphic Associations
1834
464
 
1835
- The gem provides a test helper module that makes it easy to write request specs with proper JSON:API content negotiation. The helper ensures `as: :jsonapi` works consistently across all HTTP methods.
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
- **Setup (RSpec):**
467
+ #### Models with Polymorphic Associations
1838
468
 
1839
469
  ```ruby
1840
- # spec/rails_helper.rb or spec/support/json_api.rb
1841
- require "json_api/testing"
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
- RSpec.configure do |config|
1844
- config.include JSONAPI::Testing::TestHelper, type: :request
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
- **Usage:**
495
+ #### Resources for Polymorphic Associations
1849
496
 
1850
497
  ```ruby
1851
- RSpec.describe "Users API", type: :request do
1852
- let(:headers) { { "Authorization" => "Bearer #{token}" } }
1853
-
1854
- describe "GET /users" do
1855
- it "returns users" do
1856
- # GET requests: Accept header is set, params go to query string
1857
- get users_path, params: { filter: { active: true } }, headers:, as: :jsonapi
1858
- expect(response).to have_http_status(:ok)
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
- describe "POST /users" do
1863
- it "creates a user" do
1864
- # POST/PATCH/PUT/DELETE: Content-Type and Accept headers are set,
1865
- # params are JSON-encoded in the request body
1866
- payload = {
1867
- data: {
1868
- type: "users",
1869
- attributes: { name: "John", email: "john@example.com" }
1870
- }
1871
- }
1872
- post users_path, params: payload, headers:, as: :jsonapi
1873
- expect(response).to have_http_status(:created)
1874
- end
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
- | Method | Accept Header | Content-Type Header | Params Encoding |
1882
- | ------ | ------------- | ------------------- | --------------- |
1883
- | GET | Set | ❌ Not set | Query string |
1884
- | POST | ✅ Set | ✅ Set | JSON body |
1885
- | PATCH | Set | ✅ Set | JSON body |
1886
- | PUT | ✅ Set | ✅ Set | JSON body |
1887
- | DELETE | ✅ Set | Set | JSON body |
1888
-
1889
- ### Integration Tests
1890
-
1891
- The gem includes a dummy Rails app in `spec/dummy` for integration testing. The integration tests verify that the gem works correctly with a real Rails application.
1892
-
1893
- To run only integration tests:
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
- ```bash
1896
- bundle exec rspec spec/integration
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
- The integration tests cover:
1900
-
1901
- - Full CRUD operations (create, read, update, delete)
1902
- - JSON:API response format validation using JSON Schema
1903
- - Sparse fieldsets (fields parameter) for single and multiple fields
1904
- - Sorting (ascending, descending, multiple fields, invalid fields)
1905
- - Including related resources (include parameter) with validation
1906
- - Creating and updating resources with relationships (to-one, to-many, polymorphic)
1907
- - Relationship validation and error handling
1908
- - Error handling and validation responses
1909
- - HTTP status codes
1910
-
1911
- The dummy app includes a simple `User` model with basic validations and relationships (posts, profile, activities, notifications) to test the full request/response cycle including relationship writes.
549
+ #### Controllers for Polymorphic Resources
1912
550
 
1913
- ## ActiveStorage Support
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
- The gem includes built-in support for serializing and deserializing ActiveStorage attachments through JSON:API.
581
+ class PostsController < ApplicationController
582
+ include JPie::Controller
583
+ # Uses default CRUD operations with polymorphic comments included
584
+ end
1916
585
 
1917
- ### Serializing Attachments
586
+ class ArticlesController < ApplicationController
587
+ include JPie::Controller
588
+ # Uses default CRUD operations with polymorphic comments included
589
+ end
590
+ ```
1918
591
 
1919
- When a model has ActiveStorage attachments (`has_one_attached` or `has_many_attached`), you can expose them as relationships in your resource:
592
+ #### Routes for Polymorphic Resources
1920
593
 
1921
594
  ```ruby
1922
- class UserResource < JSONAPI::Resource
1923
- attributes :name, :email
1924
- has_one :avatar # For has_one_attached :avatar
1925
- has_many :documents # For has_many_attached :documents
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
- The serializer will automatically:
1930
-
1931
- - Include attachment relationships pointing to `active_storage_blobs` resources
1932
- - Add download links for each blob
1933
- - Include blob details in the `included` section when requested via `include` parameter
1934
- - Filter out ActiveStorage attachments from include paths (attachments are loaded on-demand by the serializer, not via ActiveRecord includes)
608
+ #### Example JSON:API Responses
1935
609
 
1936
- Example response:
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
- "name": "John Doe",
1945
- "email": "john@example.com"
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
- "avatar": {
1949
- "data": {
1950
- "type": "active_storage_blobs",
1951
- "id": "1"
1952
- }
1953
- },
1954
- "documents": {
623
+ "comments": {
1955
624
  "data": [
1956
- { "type": "active_storage_blobs", "id": "2" },
1957
- { "type": "active_storage_blobs", "id": "3" }
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
- "filename": "avatar.jpg",
1971
- "content_type": "image/jpeg",
1972
- "byte_size": 102400,
1973
- "checksum": "abc123..."
636
+ "content": "Great post!",
637
+ "created_at": "2024-01-15T11:00:00Z"
1974
638
  },
1975
- "links": {
1976
- "self": "/active_storage_blobs/1",
1977
- "download": "/rails/active_storage/blobs/.../avatar.jpg"
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
- ### Deserializing Attachments
668
+ ### Single Table Inheritance (STI)
1985
669
 
1986
- When creating or updating resources, clients can attach files by providing signed blob IDs obtained from ActiveStorage direct uploads:
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
- ```json
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
- "type": "users",
736
+ "id": "1",
737
+ "type": "cars", # STI type
1992
738
  "attributes": {
1993
- "name": "Jane Doe",
1994
- "email": "jane@example.com"
1995
- },
1996
- "relationships": {
1997
- "avatar": {
1998
- "data": {
1999
- "type": "active_storage_blobs",
2000
- "id": "eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--..."
2001
- }
2002
- },
2003
- "documents": {
2004
- "data": [
2005
- { "type": "active_storage_blobs", "id": "signed-id-1" },
2006
- { "type": "active_storage_blobs", "id": "signed-id-2" }
2007
- ]
2008
- }
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
- - Validate the signed blob IDs
2017
- - Convert them to blob objects
2018
- - Set them as parameters that ActiveStorage can attach (e.g., `avatar: blob` or `documents: [blob1, blob2]`)
747
+ # Truck serialization
748
+ truck_serializer = JPie::Serializer.new(TruckResource)
749
+ result = truck_serializer.serialize(truck)
2019
750
 
2020
- #### Detaching Attachments
2021
-
2022
- To detach (remove) an attachment, send `null` for to-one relationships or an empty array `[]` for to-many relationships:
2023
-
2024
- ```json
751
+ # Result:
2025
752
  {
2026
753
  "data": {
2027
- "type": "users",
2028
- "id": "1",
2029
- "relationships": {
2030
- "avatar": {
2031
- "data": null
2032
- },
2033
- "documents": {
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
- By default, this will purge the attachments from the model. For to-one attachments, sending `null` detaches the current attachment. For to-many attachments, sending an empty array `[]` removes all attachments.
2042
-
2043
- #### Controlling Purge Behavior with `purge_on_nil`
766
+ #### STI Controllers
2044
767
 
2045
- By default, when you set an attachment relationship to `null` (for `has_one_attached`) or an empty array `[]` (for `has_many_attached`), the gem will purge the existing attachments. You can opt out of this behavior by setting `purge_on_nil: false` in the relationship declaration:
768
+ Controllers work seamlessly with STI models:
2046
769
 
2047
770
  ```ruby
2048
- class UserResource < JSONAPI::Resource
2049
- attributes :name, :email
771
+ class CarsController < ApplicationController
772
+ include JPie::Controller
773
+ # Automatically uses CarResource and Car model
774
+ end
2050
775
 
2051
- # Opt out of purging when set to nil
2052
- has_one :avatar, purge_on_nil: false
2053
- has_many :documents, purge_on_nil: false
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
- When `purge_on_nil: false` is set:
787
+ #### STI Scoping
2058
788
 
2059
- - Setting a `has_one_attached` relationship to `null` will keep the existing attachment
2060
- - Setting a `has_many_attached` relationship to an empty array `[]` will keep all existing attachments
2061
- - Attaching new blobs will still replace existing attachments (this behavior is not affected by `purge_on_nil`)
789
+ Each STI resource automatically scopes to its specific type:
2062
790
 
2063
- **Use cases for `purge_on_nil: false`:**
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
- - When you want to prevent accidental deletion of attachments
2066
- - When attachments should only be removed through explicit delete operations
2067
- - When you need more control over attachment lifecycle management
797
+ #### STI in Polymorphic Relationships
2068
798
 
2069
- **Note:** The default behavior (`purge_on_nil: true`) ensures that setting a relationship to `null` or `[]` actually removes the attachments, which is typically the expected behavior for most use cases.
799
+ JPie's serializer automatically determines the correct resource class for STI models in polymorphic relationships:
2070
800
 
2071
- #### Append-Only Mode with `append_only`
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
- For `has_many_attached` relationships, you can enable append-only mode by setting `append_only: true`. In this mode, new blobs are appended to existing attachments rather than replacing them:
807
+ #### Complete STI Example
2074
808
 
2075
- ```ruby
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
- # Enable append-only mode for documents
2080
- has_many :documents, append_only: true
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
- **Behavior when `append_only: true` is set:**
832
+ **2. Models**
2085
833
 
2086
- - **Appending new blobs:** When updating a resource with new blobs in the relationship, they are added to the existing attachments instead of replacing them
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
- ```json
2089
- // Existing attachments: [blob1, blob2]
2090
- // Payload includes: [blob3, blob4]
2091
- // Result: [blob1, blob2, blob3, blob4]
2092
- ```
843
+ class Truck < Vehicle
844
+ validates :cargo_capacity, presence: true
845
+ end
846
+ ```
2093
847
 
2094
- - **Empty array is a no-op:** Sending an empty array `[]` preserves all existing attachments
848
+ **3. Resources**
2095
849
 
2096
- ```json
2097
- // Existing attachments: [blob1, blob2]
2098
- // Payload includes: []
2099
- // Result: [blob1, blob2] (unchanged)
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
- - **Implicit `purge_on_nil: false`:** When `append_only: true` is set, `purge_on_nil` is automatically set to `false` and cannot be overridden
856
+ class CarResource < VehicleResource
857
+ attributes :engine_size
858
+ end
2103
859
 
2104
- - **Deletions:** Deletions via PATCH/PUT with empty arrays are not possible. Use the DELETE `/relationships/:name` endpoint if you need to remove specific attachments
860
+ class TruckResource < VehicleResource
861
+ attributes :cargo_capacity
862
+ end
863
+ ```
2105
864
 
2106
- **Important:** `append_only: true` and `purge_on_nil: true` are mutually exclusive. If both are explicitly set, an `ArgumentError` will be raised at resource class definition time:
865
+ **4. Controllers**
2107
866
 
2108
867
  ```ruby
2109
- # This will raise ArgumentError
2110
- has_many :documents, append_only: true, purge_on_nil: true
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
- **Use cases for `append_only: true`:**
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
- - When you want to accumulate attachments over time without replacing existing ones
2116
- - When attachments represent a log or history that should only grow
2117
- - When you need to prevent accidental replacement of existing attachments
2118
- - When attachments should only be removed through explicit DELETE operations
894
+ **6. Example HTTP Requests and Responses**
2119
895
 
2120
- **Note:** `append_only` only applies to `has_many_attached` relationships. For `has_one_attached`, attachments are always replaced regardless of this setting.
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
- Example usage in a controller:
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
- ```ruby
2125
- def create
2126
- deserializer = JSONAPI::Deserializer.new(params, resource_class: User, action: :create)
2127
- attrs = deserializer.to_params
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
- # attrs will include:
2130
- # { "name" => "Jane Doe", "email" => "jane@example.com", "avatar" => <ActiveStorage::Blob>, "documents" => [<ActiveStorage::Blob>, ...] }
964
+ **7. Creating STI Records**
2131
965
 
2132
- user = User.create!(attrs)
2133
- # Attachments are automatically attached via ActiveStorage
2134
- end
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
- ### Error Handling
981
+ #### Custom STI Types
982
+
983
+ You can override the automatic type inference if needed:
2138
984
 
2139
- Invalid signed IDs will raise `ActiveSupport::MessageVerifier::InvalidSignature`, which should be handled appropriately in your controllers.
985
+ ```ruby
986
+ class CarResource < VehicleResource
987
+ type 'automobiles' # Custom type instead of 'cars'
988
+ attributes :engine_size
989
+ end
990
+ ```
2140
991
 
2141
- ### Built-in ActiveStorage Resource
992
+ ### Authorization and Scoping
2142
993
 
2143
- The gem provides a built-in `JSONAPI::ActiveStorageBlobResource` that automatically serializes ActiveStorage blobs with:
994
+ Override the default scope method to add authorization:
2144
995
 
2145
- - `filename` - The original filename
2146
- - `content_type` - MIME type
2147
- - `byte_size` - File size in bytes
2148
- - `checksum` - File checksum
2149
- - Download link in the `links` section
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
- This resource is automatically used when serializing ActiveStorage attachments.
1008
+ ### Custom Context
2152
1009
 
2153
- ## Contributing
1010
+ Override the context building to pass additional data to resources:
2154
1011
 
2155
- Bug reports and pull requests are welcome on GitHub at https://github.com/klaay/json_api.
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).