jpie 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursorrules +1 -0
- data/examples/relationships.md +114 -0
- data/lib/jpie/controller/related_actions.rb +45 -0
- data/lib/jpie/controller/relationship_actions.rb +291 -0
- data/lib/jpie/controller/relationship_validation.rb +117 -0
- data/lib/jpie/controller.rb +25 -0
- data/lib/jpie/errors.rb +6 -0
- data/lib/jpie/railtie.rb +6 -0
- data/lib/jpie/resource/attributable.rb +2 -2
- data/lib/jpie/routing.rb +59 -0
- data/lib/jpie/version.rb +1 -1
- data/lib/jpie.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 17c72f6974e3f671d772c5acec6ccd40541e06c36797603b4e0625b2235834f2
|
4
|
+
data.tar.gz: 6e13c925f6c6605278a0f3544165c8c3f7c9c6feaf3f97c54265c033f0a498e5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8a9d2f4a6505c5bd7e94798fd63c382150b4170f8cd610548439161bec82dc9bfc07807468e32cc50d980018e7bc5625724db509742a97281e32dfb069acbe47
|
7
|
+
data.tar.gz: 8e1eb70f4967a8d83c25583b234172a6fd9e9767ef1ea816a2fe5b6dc447f7bd11a68e67cab7e27851eee342395e616249d32033680cdc83050d896bd5726ec9
|
data/.cursorrules
CHANGED
@@ -22,6 +22,7 @@
|
|
22
22
|
- Test both success and error cases
|
23
23
|
- Use modern RSpec features and syntax
|
24
24
|
- Don't reduce test coverage (line, file, branch, or other)
|
25
|
+
- Only care about coverge reduction when running all specs
|
25
26
|
|
26
27
|
# Git commit guidelines
|
27
28
|
- Write clear, descriptive commit messages
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# JSON:API Relationship Management
|
2
|
+
|
3
|
+
This example demonstrates how to manage relationships using JPie's JSON:API compliant relationship endpoints.
|
4
|
+
|
5
|
+
## Setup
|
6
|
+
|
7
|
+
Define your resources with relationships:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
# app/resources/user_resource.rb
|
11
|
+
class UserResource < JPie::Resource
|
12
|
+
model User
|
13
|
+
attributes :name, :email
|
14
|
+
has_many :posts
|
15
|
+
end
|
16
|
+
|
17
|
+
# app/resources/post_resource.rb
|
18
|
+
class PostResource < JPie::Resource
|
19
|
+
model Post
|
20
|
+
attributes :title, :content
|
21
|
+
has_one :author, resource: 'UserResource'
|
22
|
+
end
|
23
|
+
|
24
|
+
# app/controllers/users_controller.rb
|
25
|
+
class UsersController < ApplicationController
|
26
|
+
include JPie::Controller
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
Configure routes:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
# config/routes.rb
|
34
|
+
Rails.application.routes.draw do
|
35
|
+
jpie_resources :users
|
36
|
+
jpie_resources :posts
|
37
|
+
end
|
38
|
+
```
|
39
|
+
|
40
|
+
## Relationship Operations
|
41
|
+
|
42
|
+
### Get Relationship Linkage
|
43
|
+
|
44
|
+
```http
|
45
|
+
GET /users/1/relationships/posts
|
46
|
+
|
47
|
+
Response:
|
48
|
+
{
|
49
|
+
"data": [
|
50
|
+
{ "type": "posts", "id": "1" },
|
51
|
+
{ "type": "posts", "id": "3" }
|
52
|
+
]
|
53
|
+
}
|
54
|
+
```
|
55
|
+
|
56
|
+
### Replace Relationship
|
57
|
+
|
58
|
+
```http
|
59
|
+
PATCH /users/1/relationships/posts
|
60
|
+
Content-Type: application/vnd.api+json
|
61
|
+
|
62
|
+
{
|
63
|
+
"data": [
|
64
|
+
{ "type": "posts", "id": "2" },
|
65
|
+
{ "type": "posts", "id": "4" }
|
66
|
+
]
|
67
|
+
}
|
68
|
+
```
|
69
|
+
|
70
|
+
### Add to Relationship
|
71
|
+
|
72
|
+
```http
|
73
|
+
POST /users/1/relationships/posts
|
74
|
+
Content-Type: application/vnd.api+json
|
75
|
+
|
76
|
+
{
|
77
|
+
"data": [
|
78
|
+
{ "type": "posts", "id": "5" }
|
79
|
+
]
|
80
|
+
}
|
81
|
+
```
|
82
|
+
|
83
|
+
### Remove from Relationship
|
84
|
+
|
85
|
+
```http
|
86
|
+
DELETE /users/1/relationships/posts
|
87
|
+
Content-Type: application/vnd.api+json
|
88
|
+
|
89
|
+
{
|
90
|
+
"data": [
|
91
|
+
{ "type": "posts", "id": "2" }
|
92
|
+
]
|
93
|
+
}
|
94
|
+
```
|
95
|
+
|
96
|
+
### Get Related Resources
|
97
|
+
|
98
|
+
```http
|
99
|
+
GET /users/1/posts
|
100
|
+
|
101
|
+
Response:
|
102
|
+
{
|
103
|
+
"data": [
|
104
|
+
{
|
105
|
+
"type": "posts",
|
106
|
+
"id": "1",
|
107
|
+
"attributes": {
|
108
|
+
"title": "First Post",
|
109
|
+
"content": "Hello world!"
|
110
|
+
}
|
111
|
+
}
|
112
|
+
]
|
113
|
+
}
|
114
|
+
```
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JPie
|
4
|
+
module Controller
|
5
|
+
module RelatedActions
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
# GET /resources/:id/:relationship_name
|
9
|
+
# Returns the related resources themselves (not just linkage)
|
10
|
+
def related_show
|
11
|
+
validate_relationship_exists
|
12
|
+
validate_include_params
|
13
|
+
resource = find_resource
|
14
|
+
related_resources = get_related_resources(resource)
|
15
|
+
render_jsonapi(related_resources)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def validate_relationship_exists
|
21
|
+
relationship_name = params[:relationship_name]
|
22
|
+
return unless relationship_name # Skip validation if no relationship_name param
|
23
|
+
|
24
|
+
return if resource_class._relationships.key?(relationship_name.to_sym)
|
25
|
+
|
26
|
+
raise JPie::Errors::NotFoundError.new(
|
27
|
+
detail: "Relationship '#{relationship_name}' does not exist for #{resource_class.name}"
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def find_resource
|
32
|
+
resource_class.scope(context).find(params[:id])
|
33
|
+
end
|
34
|
+
|
35
|
+
def relationship_name
|
36
|
+
@relationship_name ||= params[:relationship_name].to_sym
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_related_resources(resource)
|
40
|
+
relationship_method = relationship_name
|
41
|
+
resource.send(relationship_method)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,291 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'relationship_validation'
|
4
|
+
|
5
|
+
module JPie
|
6
|
+
module Controller
|
7
|
+
module RelationshipActions
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
include RelationshipValidation
|
10
|
+
|
11
|
+
# GET /resources/:id/relationships/:relationship_name
|
12
|
+
# Returns relationship linkage data
|
13
|
+
def relationship_show
|
14
|
+
validate_relationship_exists
|
15
|
+
resource = find_resource
|
16
|
+
relationship_data = get_relationship_data(resource)
|
17
|
+
render_relationship_data(relationship_data)
|
18
|
+
end
|
19
|
+
|
20
|
+
# PATCH /resources/:id/relationships/:relationship_name
|
21
|
+
# Updates relationship linkage (replaces all relationships)
|
22
|
+
def relationship_update
|
23
|
+
validate_relationship_exists
|
24
|
+
validate_relationship_update_request
|
25
|
+
resource = find_resource
|
26
|
+
relationship_data = parse_relationship_data
|
27
|
+
update_relationship_data(resource, relationship_data)
|
28
|
+
render_relationship_data(get_relationship_data(resource))
|
29
|
+
end
|
30
|
+
|
31
|
+
# POST /resources/:id/relationships/:relationship_name
|
32
|
+
# Adds to relationship linkage (for to-many relationships)
|
33
|
+
def relationship_create
|
34
|
+
validate_relationship_exists
|
35
|
+
validate_relationship_update_request
|
36
|
+
resource = find_resource
|
37
|
+
|
38
|
+
unless relationship_is_to_many?
|
39
|
+
raise JPie::Errors::BadRequestError.new(
|
40
|
+
detail: 'POST is only supported for to-many relationships'
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
relationship_data = parse_relationship_data
|
45
|
+
|
46
|
+
unless relationship_data.is_a?(Array)
|
47
|
+
raise JPie::Errors::BadRequestError.new(
|
48
|
+
detail: 'Adding to relationships requires an array of resource identifier objects'
|
49
|
+
)
|
50
|
+
end
|
51
|
+
|
52
|
+
add_to_relationship(resource, relationship_data)
|
53
|
+
render_relationship_data(get_relationship_data(resource))
|
54
|
+
end
|
55
|
+
|
56
|
+
# DELETE /resources/:id/relationships/:relationship_name
|
57
|
+
# Removes from relationship linkage (for to-many relationships)
|
58
|
+
def relationship_destroy
|
59
|
+
validate_relationship_exists
|
60
|
+
validate_relationship_update_request
|
61
|
+
resource = find_resource
|
62
|
+
|
63
|
+
unless relationship_is_to_many?
|
64
|
+
raise JPie::Errors::BadRequestError.new(
|
65
|
+
detail: 'DELETE is only supported for to-many relationships'
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
relationship_data = parse_relationship_data
|
70
|
+
|
71
|
+
unless relationship_data.is_a?(Array)
|
72
|
+
raise JPie::Errors::BadRequestError.new(
|
73
|
+
detail: 'Removing from relationships requires an array of resource identifier objects'
|
74
|
+
)
|
75
|
+
end
|
76
|
+
|
77
|
+
remove_from_relationship(resource, relationship_data)
|
78
|
+
render_relationship_data(get_relationship_data(resource))
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def find_resource
|
84
|
+
resource_class.scope(context).find(params[:id])
|
85
|
+
end
|
86
|
+
|
87
|
+
def relationship_name
|
88
|
+
@relationship_name ||= params[:relationship_name].to_sym
|
89
|
+
end
|
90
|
+
|
91
|
+
def relationship_config
|
92
|
+
@relationship_config ||= resource_class._relationships[relationship_name]
|
93
|
+
end
|
94
|
+
|
95
|
+
def get_relationship_data(resource)
|
96
|
+
relationship_method = relationship_name
|
97
|
+
related_objects = resource.send(relationship_method)
|
98
|
+
|
99
|
+
if related_objects.respond_to?(:each)
|
100
|
+
# to-many relationship
|
101
|
+
related_objects.map { |obj| { type: infer_type(obj), id: obj.id.to_s } }
|
102
|
+
elsif related_objects
|
103
|
+
# to-one relationship
|
104
|
+
{ type: infer_type(related_objects), id: related_objects.id.to_s }
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def parse_relationship_data
|
109
|
+
body = request.body.read
|
110
|
+
request.body.rewind
|
111
|
+
parsed_body = JSON.parse(body)
|
112
|
+
|
113
|
+
unless parsed_body.key?('data')
|
114
|
+
raise JPie::Errors::BadRequestError.new(
|
115
|
+
detail: 'Request must include a "data" member'
|
116
|
+
)
|
117
|
+
end
|
118
|
+
|
119
|
+
parsed_body['data']
|
120
|
+
end
|
121
|
+
|
122
|
+
def update_relationship_data(resource, relationship_data)
|
123
|
+
if relationship_data.nil?
|
124
|
+
# Set relationship to null (only valid for to-one relationships)
|
125
|
+
unless relationship_is_to_many?
|
126
|
+
clear_relationship(resource)
|
127
|
+
else
|
128
|
+
raise JPie::Errors::BadRequestError.new(
|
129
|
+
detail: 'Cannot set a to-many relationship to null'
|
130
|
+
)
|
131
|
+
end
|
132
|
+
elsif relationship_data.is_a?(Array)
|
133
|
+
# to-many relationship - replace all
|
134
|
+
if relationship_is_to_many?
|
135
|
+
replace_to_many_relationship(resource, relationship_data)
|
136
|
+
else
|
137
|
+
raise JPie::Errors::BadRequestError.new(
|
138
|
+
detail: 'Invalid data type for to-one relationship'
|
139
|
+
)
|
140
|
+
end
|
141
|
+
elsif relationship_data.is_a?(Hash)
|
142
|
+
# to-one relationship - replace
|
143
|
+
unless relationship_is_to_many?
|
144
|
+
replace_to_one_relationship(resource, relationship_data)
|
145
|
+
else
|
146
|
+
raise JPie::Errors::BadRequestError.new(
|
147
|
+
detail: 'Invalid data type for to-many relationship'
|
148
|
+
)
|
149
|
+
end
|
150
|
+
else
|
151
|
+
raise JPie::Errors::BadRequestError.new(
|
152
|
+
detail: 'Relationship data must be null, an object, or an array of objects'
|
153
|
+
)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def add_to_relationship(resource, relationship_data)
|
158
|
+
begin
|
159
|
+
related_objects = find_related_objects(relationship_data)
|
160
|
+
association = association_for_resource(resource)
|
161
|
+
|
162
|
+
related_objects.each do |related_object|
|
163
|
+
association << related_object unless association.include?(related_object)
|
164
|
+
end
|
165
|
+
|
166
|
+
resource.save!
|
167
|
+
rescue ActiveRecord::AssociationTypeMismatch => e
|
168
|
+
raise JPie::Errors::NotFoundError.new(
|
169
|
+
detail: "Related resource not found: Invalid resource type for relationship"
|
170
|
+
)
|
171
|
+
rescue ActiveRecord::RecordInvalid => e
|
172
|
+
raise JPie::Errors::ValidationError.new(
|
173
|
+
detail: "Failed to add relationships: #{e.message}"
|
174
|
+
)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def remove_from_relationship(resource, relationship_data)
|
179
|
+
begin
|
180
|
+
related_objects = find_related_objects(relationship_data)
|
181
|
+
association = association_for_resource(resource)
|
182
|
+
|
183
|
+
related_objects.each do |related_object|
|
184
|
+
association.delete(related_object)
|
185
|
+
end
|
186
|
+
|
187
|
+
resource.save!
|
188
|
+
rescue ActiveRecord::AssociationTypeMismatch => e
|
189
|
+
raise JPie::Errors::NotFoundError.new(
|
190
|
+
detail: "Related resource not found: Invalid resource type for relationship"
|
191
|
+
)
|
192
|
+
rescue ActiveRecord::RecordInvalid => e
|
193
|
+
raise JPie::Errors::ValidationError.new(
|
194
|
+
detail: "Failed to remove relationships: #{e.message}"
|
195
|
+
)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def clear_relationship(resource)
|
200
|
+
association_name = association_name_for_relationship
|
201
|
+
resource.send("#{association_name}=", nil)
|
202
|
+
resource.save!
|
203
|
+
rescue ActiveRecord::RecordInvalid => e
|
204
|
+
raise JPie::Errors::ValidationError.new(
|
205
|
+
detail: "Failed to clear relationship: #{e.message}"
|
206
|
+
)
|
207
|
+
end
|
208
|
+
|
209
|
+
def replace_to_many_relationship(resource, relationship_data)
|
210
|
+
begin
|
211
|
+
related_objects = find_related_objects(relationship_data)
|
212
|
+
association_name = association_name_for_relationship
|
213
|
+
resource.send("#{association_name}=", related_objects)
|
214
|
+
resource.save!
|
215
|
+
rescue ActiveRecord::AssociationTypeMismatch => e
|
216
|
+
raise JPie::Errors::NotFoundError.new(
|
217
|
+
detail: "Related resource not found: Invalid resource type for relationship"
|
218
|
+
)
|
219
|
+
rescue ActiveRecord::RecordInvalid => e
|
220
|
+
raise JPie::Errors::ValidationError.new(
|
221
|
+
detail: "Failed to replace relationships: #{e.message}"
|
222
|
+
)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def replace_to_one_relationship(resource, relationship_data)
|
227
|
+
begin
|
228
|
+
related_object = find_related_object(relationship_data)
|
229
|
+
association_name = association_name_for_relationship
|
230
|
+
resource.send("#{association_name}=", related_object)
|
231
|
+
resource.save!
|
232
|
+
rescue ActiveRecord::AssociationTypeMismatch => e
|
233
|
+
raise JPie::Errors::NotFoundError.new(
|
234
|
+
detail: "Related resource not found: Invalid resource type for relationship"
|
235
|
+
)
|
236
|
+
rescue ActiveRecord::RecordInvalid => e
|
237
|
+
raise JPie::Errors::ValidationError.new(
|
238
|
+
detail: "Failed to replace relationship: #{e.message}"
|
239
|
+
)
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def find_related_objects(relationship_data)
|
244
|
+
relationship_data.map { |data| find_related_object(data) }
|
245
|
+
end
|
246
|
+
|
247
|
+
def find_related_object(resource_identifier)
|
248
|
+
validate_resource_identifier(resource_identifier)
|
249
|
+
|
250
|
+
type = resource_identifier['type']
|
251
|
+
id = resource_identifier['id']
|
252
|
+
|
253
|
+
related_model_class = infer_model_class_from_type(type)
|
254
|
+
related_model_class.find(id)
|
255
|
+
rescue ActiveRecord::RecordNotFound
|
256
|
+
raise JPie::Errors::NotFoundError.new(
|
257
|
+
detail: "Related resource not found: #{type}##{id}"
|
258
|
+
)
|
259
|
+
end
|
260
|
+
|
261
|
+
def association_for_resource(resource)
|
262
|
+
association_name = association_name_for_relationship
|
263
|
+
resource.send(association_name)
|
264
|
+
end
|
265
|
+
|
266
|
+
def association_name_for_relationship
|
267
|
+
# For now, assume the relationship name matches the association name
|
268
|
+
# This could be made more sophisticated to handle custom association names
|
269
|
+
relationship_name
|
270
|
+
end
|
271
|
+
|
272
|
+
def infer_type(object)
|
273
|
+
# Convert model class name to JSON:API type
|
274
|
+
# e.g., "User" -> "users", "BlogPost" -> "blog-posts"
|
275
|
+
object.class.name.underscore.dasherize.pluralize
|
276
|
+
end
|
277
|
+
|
278
|
+
def infer_model_class_from_type(type)
|
279
|
+
# Convert JSON:API type back to model class
|
280
|
+
# e.g., "users" -> User, "blog-posts" -> BlogPost
|
281
|
+
class_name = type.singularize.underscore.camelize
|
282
|
+
class_name.constantize
|
283
|
+
end
|
284
|
+
|
285
|
+
def render_relationship_data(relationship_data)
|
286
|
+
response_data = { data: relationship_data }
|
287
|
+
render json: response_data, content_type: 'application/vnd.api+json'
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JPie
|
4
|
+
module Controller
|
5
|
+
module RelationshipValidation
|
6
|
+
private
|
7
|
+
|
8
|
+
def validate_relationship_exists
|
9
|
+
relationship_name = params[:relationship_name]
|
10
|
+
return unless relationship_name # Skip validation if no relationship_name param
|
11
|
+
|
12
|
+
return if resource_class._relationships.key?(relationship_name.to_sym)
|
13
|
+
|
14
|
+
raise JPie::Errors::NotFoundError.new(
|
15
|
+
detail: "Relationship '#{relationship_name}' does not exist for #{resource_class.name}"
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
def validate_relationship_update_request
|
20
|
+
validate_content_type
|
21
|
+
validate_request_body
|
22
|
+
validate_relationship_type
|
23
|
+
end
|
24
|
+
|
25
|
+
def validate_content_type
|
26
|
+
# Only validate content type for write operations
|
27
|
+
return unless request.post? || request.patch? || request.put?
|
28
|
+
|
29
|
+
content_type = request.content_type
|
30
|
+
return if content_type&.include?('application/vnd.api+json')
|
31
|
+
|
32
|
+
raise JPie::Errors::InvalidJsonApiRequestError.new(
|
33
|
+
detail: 'Content-Type must be application/vnd.api+json for JSON:API requests'
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
def validate_request_body
|
38
|
+
body = request.body.read
|
39
|
+
request.body.rewind
|
40
|
+
|
41
|
+
raise JPie::Errors::BadRequestError.new(detail: 'Request body cannot be empty') if body.blank?
|
42
|
+
|
43
|
+
JSON.parse(body)
|
44
|
+
rescue JSON::ParserError => e
|
45
|
+
raise JPie::Errors::BadRequestError.new(detail: "Invalid JSON: #{e.message}")
|
46
|
+
end
|
47
|
+
|
48
|
+
def validate_resource_identifier(resource_identifier)
|
49
|
+
unless resource_identifier.is_a?(Hash) &&
|
50
|
+
resource_identifier.key?('type') &&
|
51
|
+
resource_identifier.key?('id')
|
52
|
+
raise JPie::Errors::BadRequestError.new(
|
53
|
+
detail: 'Resource identifier objects must have "type" and "id" members'
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
type = resource_identifier['type']
|
58
|
+
id = resource_identifier['id']
|
59
|
+
|
60
|
+
unless type.is_a?(String) && id.is_a?(String)
|
61
|
+
raise JPie::Errors::BadRequestError.new(
|
62
|
+
detail: 'Resource identifier object members must be strings'
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
if type.empty? || id.empty?
|
67
|
+
raise JPie::Errors::BadRequestError.new(
|
68
|
+
detail: 'Resource identifier object members cannot be empty strings'
|
69
|
+
)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def validate_relationship_type
|
74
|
+
validate_relationship_exists
|
75
|
+
data = parse_relationship_data
|
76
|
+
|
77
|
+
if relationship_is_to_many?
|
78
|
+
validate_to_many_relationship_data(data)
|
79
|
+
else
|
80
|
+
validate_to_one_relationship_data(data)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def validate_to_many_relationship_data(data)
|
85
|
+
if data.nil?
|
86
|
+
raise JPie::Errors::BadRequestError.new(
|
87
|
+
detail: 'Cannot set a to-many relationship to null'
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
unless data.is_a?(Array)
|
92
|
+
raise JPie::Errors::BadRequestError.new(
|
93
|
+
detail: 'The value of data must be an array for to-many relationships'
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
data.each { |identifier| validate_resource_identifier(identifier) }
|
98
|
+
end
|
99
|
+
|
100
|
+
def validate_to_one_relationship_data(data)
|
101
|
+
unless data.nil? || data.is_a?(Hash)
|
102
|
+
raise JPie::Errors::BadRequestError.new(
|
103
|
+
detail: 'The value of data must be a single resource identifier object or null for to-one relationships'
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
validate_resource_identifier(data) if data
|
108
|
+
end
|
109
|
+
|
110
|
+
def relationship_is_to_many?
|
111
|
+
return false unless relationship_config
|
112
|
+
|
113
|
+
relationship_config[:type] == :has_many
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
data/lib/jpie/controller.rb
CHANGED
@@ -6,6 +6,8 @@ require_relative 'controller/parameter_parsing'
|
|
6
6
|
require_relative 'controller/rendering'
|
7
7
|
require_relative 'controller/crud_actions'
|
8
8
|
require_relative 'controller/json_api_validation'
|
9
|
+
require_relative 'controller/relationship_actions'
|
10
|
+
require_relative 'controller/related_actions'
|
9
11
|
|
10
12
|
module JPie
|
11
13
|
module Controller
|
@@ -16,5 +18,28 @@ module JPie
|
|
16
18
|
include Rendering
|
17
19
|
include CrudActions
|
18
20
|
include JsonApiValidation
|
21
|
+
include RelationshipActions
|
22
|
+
include RelatedActions
|
23
|
+
|
24
|
+
# Relationship route actions
|
25
|
+
def show_relationship
|
26
|
+
relationship_show
|
27
|
+
end
|
28
|
+
|
29
|
+
def update_relationship
|
30
|
+
relationship_update
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_relationship
|
34
|
+
relationship_create
|
35
|
+
end
|
36
|
+
|
37
|
+
def destroy_relationship
|
38
|
+
relationship_destroy
|
39
|
+
end
|
40
|
+
|
41
|
+
def show_related
|
42
|
+
related_show
|
43
|
+
end
|
19
44
|
end
|
20
45
|
end
|
data/lib/jpie/errors.rb
CHANGED
@@ -55,6 +55,12 @@ module JPie
|
|
55
55
|
end
|
56
56
|
end
|
57
57
|
|
58
|
+
class UnsupportedMediaTypeError < Error
|
59
|
+
def initialize(detail: 'Unsupported Media Type')
|
60
|
+
super(status: 415, title: 'Unsupported Media Type', detail:)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
58
64
|
class InternalServerError < Error
|
59
65
|
def initialize(detail: 'Internal Server Error')
|
60
66
|
super(status: 500, title: 'Internal Server Error', detail:)
|
data/lib/jpie/railtie.rb
CHANGED
@@ -29,6 +29,12 @@ module JPie
|
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
32
|
+
initializer 'jpie.routing' do
|
33
|
+
ActiveSupport.on_load(:action_dispatch) do
|
34
|
+
ActionDispatch::Routing::Mapper.include JPie::Routing
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
32
38
|
generators do
|
33
39
|
require 'jpie/generators/resource_generator'
|
34
40
|
end
|
@@ -59,13 +59,13 @@ module JPie
|
|
59
59
|
def has_many(name, options = {})
|
60
60
|
name = name.to_sym
|
61
61
|
resource_class_name = options[:resource] || infer_resource_class_name(name)
|
62
|
-
relationship(name, { resource: resource_class_name }.merge(options))
|
62
|
+
relationship(name, { type: :has_many, resource: resource_class_name }.merge(options))
|
63
63
|
end
|
64
64
|
|
65
65
|
def has_one(name, options = {})
|
66
66
|
name = name.to_sym
|
67
67
|
resource_class_name = options[:resource] || infer_resource_class_name(name)
|
68
|
-
relationship(name, { resource: resource_class_name }.merge(options))
|
68
|
+
relationship(name, { type: :has_one, resource: resource_class_name }.merge(options))
|
69
69
|
end
|
70
70
|
|
71
71
|
private
|
data/lib/jpie/routing.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JPie
|
4
|
+
module Routing
|
5
|
+
# Add jpie_resources method to Rails routing DSL that creates JSON:API compliant routes
|
6
|
+
def jpie_resources(*resources)
|
7
|
+
options = resources.extract_options!
|
8
|
+
merged_options = build_merged_options(options)
|
9
|
+
|
10
|
+
# Create standard RESTful routes for the resource
|
11
|
+
resources(*resources, merged_options) do
|
12
|
+
yield if block_given?
|
13
|
+
add_jsonapi_relationship_routes(merged_options) if relationship_routes_allowed?(merged_options)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def build_merged_options(options)
|
20
|
+
default_options = {
|
21
|
+
defaults: { format: :json },
|
22
|
+
constraints: { format: :json }
|
23
|
+
}
|
24
|
+
default_options.merge(options)
|
25
|
+
end
|
26
|
+
|
27
|
+
def relationship_routes_allowed?(merged_options)
|
28
|
+
only_actions = merged_options[:only]
|
29
|
+
except_actions = merged_options[:except]
|
30
|
+
|
31
|
+
if only_actions
|
32
|
+
# If only specific actions are allowed, don't add relationship routes
|
33
|
+
# unless multiple member actions (show, update, destroy) are included
|
34
|
+
(only_actions & %i[show update destroy]).size >= 2
|
35
|
+
elsif except_actions
|
36
|
+
# If actions are excluded, only add if member actions aren't excluded
|
37
|
+
!except_actions.intersect?(%i[show update destroy])
|
38
|
+
else
|
39
|
+
true
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def add_jsonapi_relationship_routes(_merged_options)
|
44
|
+
# These routes handle relationship management as per JSON:API spec
|
45
|
+
member do
|
46
|
+
# Routes for fetching and updating relationships
|
47
|
+
# Pattern: /resources/:id/relationships/:relationship_name
|
48
|
+
get 'relationships/*relationship_name', action: :show_relationship, as: :relationship
|
49
|
+
patch 'relationships/*relationship_name', action: :update_relationship
|
50
|
+
post 'relationships/*relationship_name', action: :create_relationship
|
51
|
+
delete 'relationships/*relationship_name', action: :destroy_relationship
|
52
|
+
|
53
|
+
# Routes for fetching related resources
|
54
|
+
# Pattern: /resources/:id/:relationship_name
|
55
|
+
get '*relationship_name', action: :show_related, as: :related
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/jpie/version.rb
CHANGED
data/lib/jpie.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jpie
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.4.
|
4
|
+
version: 0.4.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Emil Kampp
|
@@ -179,6 +179,7 @@ files:
|
|
179
179
|
- examples/basic_example.md
|
180
180
|
- examples/including_related_resources.md
|
181
181
|
- examples/pagination.md
|
182
|
+
- examples/relationships.md
|
182
183
|
- examples/resource_attribute_configuration.md
|
183
184
|
- examples/resource_meta_configuration.md
|
184
185
|
- examples/single_table_inheritance.md
|
@@ -192,6 +193,9 @@ files:
|
|
192
193
|
- lib/jpie/controller/error_handling/handlers.rb
|
193
194
|
- lib/jpie/controller/json_api_validation.rb
|
194
195
|
- lib/jpie/controller/parameter_parsing.rb
|
196
|
+
- lib/jpie/controller/related_actions.rb
|
197
|
+
- lib/jpie/controller/relationship_actions.rb
|
198
|
+
- lib/jpie/controller/relationship_validation.rb
|
195
199
|
- lib/jpie/controller/rendering.rb
|
196
200
|
- lib/jpie/deserializer.rb
|
197
201
|
- lib/jpie/errors.rb
|
@@ -202,6 +206,7 @@ files:
|
|
202
206
|
- lib/jpie/resource/attributable.rb
|
203
207
|
- lib/jpie/resource/inferrable.rb
|
204
208
|
- lib/jpie/resource/sortable.rb
|
209
|
+
- lib/jpie/routing.rb
|
205
210
|
- lib/jpie/serializer.rb
|
206
211
|
- lib/jpie/version.rb
|
207
212
|
homepage: https://github.com/emk-klaay/jpie
|