jpie 0.4.1 → 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 +8 -3
- data/.overcommit.yml +35 -0
- data/CHANGELOG.md +14 -0
- data/README.md +58 -198
- data/examples/pagination.md +303 -0
- data/examples/relationships.md +114 -0
- data/lib/jpie/controller/crud_actions.rb +23 -2
- data/lib/jpie/controller/error_handling/handler_setup.rb +124 -0
- data/lib/jpie/controller/error_handling/handlers.rb +109 -0
- data/lib/jpie/controller/error_handling.rb +6 -175
- data/lib/jpie/controller/json_api_validation.rb +55 -33
- data/lib/jpie/controller/parameter_parsing.rb +43 -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/rendering.rb +95 -1
- 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 +4 -9
- data/lib/jpie/resource.rb +22 -8
- data/lib/jpie/routing.rb +59 -0
- data/lib/jpie/version.rb +1 -1
- data/lib/jpie.rb +1 -0
- metadata +11 -2
@@ -28,26 +28,34 @@ module JPie
|
|
28
28
|
|
29
29
|
# Validate basic JSON:API request structure
|
30
30
|
def validate_json_api_structure
|
31
|
+
request_body = read_request_body
|
32
|
+
return if request_body.blank?
|
33
|
+
|
34
|
+
parsed_body = parse_json_body(request_body)
|
35
|
+
validate_top_level_structure(parsed_body)
|
36
|
+
validate_data_structure(parsed_body['data'])
|
37
|
+
end
|
38
|
+
|
39
|
+
def read_request_body
|
31
40
|
request_body = request.body.read
|
32
41
|
request.body.rewind # Reset for later reading
|
42
|
+
request_body
|
43
|
+
end
|
33
44
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
)
|
42
|
-
end
|
45
|
+
def parse_json_body(request_body)
|
46
|
+
JSON.parse(request_body)
|
47
|
+
rescue JSON::ParserError => e
|
48
|
+
raise JPie::Errors::InvalidJsonApiRequestError.new(
|
49
|
+
detail: "Invalid JSON: #{e.message}"
|
50
|
+
)
|
51
|
+
end
|
43
52
|
|
44
|
-
|
45
|
-
|
46
|
-
detail: 'JSON:API request must have a top-level "data" member'
|
47
|
-
)
|
48
|
-
end
|
53
|
+
def validate_top_level_structure(parsed_body)
|
54
|
+
return if parsed_body.is_a?(Hash) && parsed_body.key?('data')
|
49
55
|
|
50
|
-
|
56
|
+
raise JPie::Errors::InvalidJsonApiRequestError.new(
|
57
|
+
detail: 'JSON:API request must have a top-level "data" member'
|
58
|
+
)
|
51
59
|
end
|
52
60
|
|
53
61
|
# Validate the structure of the data member
|
@@ -102,30 +110,43 @@ module JPie
|
|
102
110
|
|
103
111
|
# Validate a single include path
|
104
112
|
def validate_include_path(include_path, supported_includes)
|
105
|
-
# Handle nested includes (e.g., "posts.comments")
|
106
113
|
path_parts = include_path.split('.')
|
107
114
|
current_level = supported_includes
|
108
115
|
|
109
116
|
path_parts.each_with_index do |part, index|
|
110
|
-
|
111
|
-
|
112
|
-
available_at_level = current_level.is_a?(Hash) ? current_level.keys : current_level
|
113
|
-
|
114
|
-
raise JPie::Errors::UnsupportedIncludeError.new(
|
115
|
-
include_path: current_path,
|
116
|
-
supported_includes: available_at_level.map(&:to_s)
|
117
|
-
)
|
118
|
-
end
|
119
|
-
|
120
|
-
# Move to next level for nested validation
|
121
|
-
if current_level.is_a?(Hash) && current_level[part.to_sym].is_a?(Hash)
|
122
|
-
current_level = current_level[part.to_sym]
|
123
|
-
elsif current_level.is_a?(Hash) && current_level[part].is_a?(Hash)
|
124
|
-
current_level = current_level[part]
|
125
|
-
end
|
117
|
+
validate_include_part(part, current_level, path_parts, index)
|
118
|
+
current_level = move_to_next_include_level(part, current_level)
|
126
119
|
end
|
127
120
|
end
|
128
121
|
|
122
|
+
def validate_include_part(part, current_level, path_parts, index)
|
123
|
+
return if include_part_supported?(part, current_level)
|
124
|
+
|
125
|
+
current_path = path_parts[0..index].join('.')
|
126
|
+
available_at_level = extract_available_includes(current_level)
|
127
|
+
|
128
|
+
raise JPie::Errors::UnsupportedIncludeError.new(
|
129
|
+
include_path: current_path,
|
130
|
+
supported_includes: available_at_level.map(&:to_s)
|
131
|
+
)
|
132
|
+
end
|
133
|
+
|
134
|
+
def include_part_supported?(part, current_level)
|
135
|
+
current_level.include?(part.to_sym) || current_level.include?(part)
|
136
|
+
end
|
137
|
+
|
138
|
+
def extract_available_includes(current_level)
|
139
|
+
current_level.is_a?(Hash) ? current_level.keys : current_level
|
140
|
+
end
|
141
|
+
|
142
|
+
def move_to_next_include_level(part, current_level)
|
143
|
+
return current_level unless current_level.is_a?(Hash)
|
144
|
+
|
145
|
+
current_level[part.to_sym] if current_level[part.to_sym].is_a?(Hash)
|
146
|
+
current_level[part] if current_level[part].is_a?(Hash)
|
147
|
+
current_level
|
148
|
+
end
|
149
|
+
|
129
150
|
# Validate sort parameters against supported fields
|
130
151
|
def validate_sort_params
|
131
152
|
return if params[:sort].blank?
|
@@ -148,7 +169,8 @@ module JPie
|
|
148
169
|
# Validate field name format
|
149
170
|
unless field_name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
|
150
171
|
raise JPie::Errors::InvalidSortParameterError.new(
|
151
|
-
detail: "Invalid sort field format: '#{sort_field}'.
|
172
|
+
detail: "Invalid sort field format: '#{sort_field}'. " \
|
173
|
+
'Field names must start with a letter and contain only letters, numbers, and underscores'
|
152
174
|
)
|
153
175
|
end
|
154
176
|
|
@@ -11,6 +11,16 @@ module JPie
|
|
11
11
|
params[:sort]&.split(',')&.map(&:strip) || []
|
12
12
|
end
|
13
13
|
|
14
|
+
def parse_pagination_params
|
15
|
+
page_params = params[:page] || {}
|
16
|
+
per_page_param = params[:per_page]
|
17
|
+
|
18
|
+
{
|
19
|
+
page: extract_page_number(page_params, per_page_param),
|
20
|
+
per_page: extract_per_page_size(page_params, per_page_param)
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
14
24
|
def deserialize_params
|
15
25
|
deserializer.deserialize(request.body.read, context)
|
16
26
|
rescue JSON::ParserError => e
|
@@ -30,6 +40,39 @@ module JPie
|
|
30
40
|
action: action_name
|
31
41
|
}
|
32
42
|
end
|
43
|
+
|
44
|
+
def extract_page_number(page_params, per_page_param)
|
45
|
+
page_number = determine_page_number(page_params)
|
46
|
+
page_number = '1' if page_number.nil? && per_page_param.present?
|
47
|
+
return 1 if page_number.blank?
|
48
|
+
|
49
|
+
parsed_page = page_number.to_i
|
50
|
+
parsed_page.positive? ? parsed_page : 1
|
51
|
+
end
|
52
|
+
|
53
|
+
def extract_per_page_size(page_params, per_page_param)
|
54
|
+
per_page_size = determine_per_page_size(page_params, per_page_param)
|
55
|
+
return nil if per_page_size.blank?
|
56
|
+
|
57
|
+
parsed_size = per_page_size.to_i
|
58
|
+
parsed_size.positive? ? parsed_size : nil
|
59
|
+
end
|
60
|
+
|
61
|
+
def determine_page_number(page_params)
|
62
|
+
if page_params.is_a?(String) || page_params.is_a?(Integer)
|
63
|
+
page_params
|
64
|
+
else
|
65
|
+
page_params[:number] || page_params['number']
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def determine_per_page_size(page_params, per_page_param)
|
70
|
+
if page_params.is_a?(String) || page_params.is_a?(Integer)
|
71
|
+
per_page_param
|
72
|
+
else
|
73
|
+
page_params[:size] || page_params['size'] || per_page_param
|
74
|
+
end
|
75
|
+
end
|
33
76
|
end
|
34
77
|
end
|
35
78
|
end
|
@@ -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
|