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.
@@ -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
- return if request_body.blank?
35
-
36
- begin
37
- parsed_body = JSON.parse(request_body)
38
- rescue JSON::ParserError => e
39
- raise JPie::Errors::InvalidJsonApiRequestError.new(
40
- detail: "Invalid JSON: #{e.message}"
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
- unless parsed_body.is_a?(Hash) && parsed_body.key?('data')
45
- raise JPie::Errors::InvalidJsonApiRequestError.new(
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
- validate_data_structure(parsed_body['data'])
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
- unless current_level.include?(part.to_sym) || current_level.include?(part)
111
- current_path = path_parts[0..index].join('.')
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}'. Field names must start with a letter and contain only letters, numbers, and underscores"
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