jpie 1.0.0 → 1.0.1

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +5 -0
  4. data/.rubocop.yml +82 -38
  5. data/Gemfile +12 -10
  6. data/Gemfile.lock +10 -1
  7. data/README.md +675 -1235
  8. data/jpie.gemspec +15 -15
  9. data/kiln/app/resources/user_message_resource.rb +2 -0
  10. data/lib/jpie.rb +0 -1
  11. data/lib/json_api/active_storage/deserialization.rb +32 -22
  12. data/lib/json_api/active_storage/detection.rb +36 -41
  13. data/lib/json_api/active_storage/serialization.rb +13 -11
  14. data/lib/json_api/configuration.rb +4 -5
  15. data/lib/json_api/controllers/base_controller.rb +3 -3
  16. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  17. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  18. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  19. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
  22. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  23. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  24. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  25. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  26. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  27. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  28. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  29. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  30. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  31. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  32. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  33. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  36. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  37. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  38. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  39. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  40. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  44. data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
  45. data/lib/json_api/controllers/relationships_controller.rb +26 -422
  46. data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
  47. data/lib/json_api/railtie.rb +46 -9
  48. data/lib/json_api/resources/active_storage_blob_resource.rb +9 -1
  49. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  50. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  51. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  52. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  53. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  54. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  55. data/lib/json_api/resources/resource.rb +13 -219
  56. data/lib/json_api/routing.rb +56 -47
  57. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  58. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  59. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  60. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  61. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  62. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  63. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  64. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  65. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  66. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  67. data/lib/json_api/serialization/deserializer.rb +10 -346
  68. data/lib/json_api/serialization/serializer.rb +17 -260
  69. data/lib/json_api/support/active_storage_support.rb +10 -13
  70. data/lib/json_api/support/collection_query.rb +14 -370
  71. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  72. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  73. data/lib/json_api/support/concerns/pagination.rb +30 -0
  74. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  75. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  76. data/lib/json_api/support/concerns/sorting.rb +88 -0
  77. data/lib/json_api/support/instrumentation.rb +13 -12
  78. data/lib/json_api/support/param_helpers.rb +9 -6
  79. data/lib/json_api/support/relationship_helpers.rb +4 -2
  80. data/lib/json_api/support/resource_identifier.rb +29 -29
  81. data/lib/json_api/support/responders.rb +5 -5
  82. data/lib/json_api/version.rb +1 -1
  83. metadata +51 -1
@@ -1,9 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "resource_actions/filter_validation"
4
+ require_relative "resource_actions/field_validation"
5
+ require_relative "resource_actions/preloading"
6
+ require_relative "resource_actions/serialization"
7
+ require_relative "resource_actions/pagination"
8
+ require_relative "resource_actions/type_validation"
9
+ require_relative "resource_actions/crud_helpers"
10
+ require_relative "resource_actions/resource_loading"
11
+
3
12
  module JSONAPI
4
13
  module ResourceActions
5
14
  extend ActiveSupport::Concern
6
15
  include ActiveStorageSupport
16
+ include FilterValidation
17
+ include FieldValidation
18
+ include Preloading
19
+ include Serialization
20
+ include Pagination
21
+ include TypeValidation
22
+ include CrudHelpers
23
+ include ResourceLoading
7
24
 
8
25
  included do
9
26
  before_action :load_jsonapi_resource
@@ -17,21 +34,10 @@ module JSONAPI
17
34
  end
18
35
 
19
36
  def index
20
- initial_scope = @preloaded_resources || model_class.all
21
- scoped = apply_authorization_scope(initial_scope, action: :index)
22
-
23
- query = JSONAPI::CollectionQuery.new(
24
- scoped,
25
- definition: @resource_class,
26
- model_class: model_class,
27
- filter_params: parse_filter_param,
28
- sort_params: parse_sort_param,
29
- page_params: parse_page_param
30
- ).execute
31
-
37
+ scope = apply_authorization_scope(@preloaded_resources || model_class.all, action: :index)
38
+ query = build_query(scope)
32
39
  @total_count = query.total_count
33
40
  @pagination_applied = query.pagination_applied
34
-
35
41
  render json: serialize_collection(query.scope), status: :ok
36
42
  end
37
43
 
@@ -42,616 +48,59 @@ module JSONAPI
42
48
  end
43
49
 
44
50
  def create
45
- # Determine STI class from type in payload
46
- sti_class = determine_sti_class_for_create
47
-
48
- # Use subtype model class for deserialization so it finds the correct resource class
49
- params_hash = deserialize_params(:create, model_class: sti_class)
50
- attachment_params = extract_active_storage_params_from_hash(params_hash, sti_class)
51
- # Remove attachment params from regular params
52
- attachment_params.each_key { |key| params_hash.delete(key.to_s) }
53
-
54
- # Remove type from params_hash if present - STI handles type automatically
55
- params_hash.delete("type")
56
- params_hash.delete(:type)
57
-
58
- # For STI base class, ensure type is set explicitly (Rails STI doesn't always set it for base class)
59
- # Only set type if this is actually an STI base class (has type column and base_class == class)
60
- if sti_class.respond_to?(:base_class) &&
61
- sti_class.base_class == sti_class &&
62
- sti_class.column_names.include?("type")
63
- params_hash["type"] = sti_class.name
64
- end
65
-
66
- resource = sti_class.new(params_hash)
51
+ resource = build_resource_for_create
67
52
  authorize_resource_action!(resource, action: :create)
53
+ attach_active_storage_files(resource, @create_attachments, resource_class: determine_sti_resource_class)
54
+ save_created(resource)
55
+ rescue ArgumentError => e
56
+ render_create_error(e)
57
+ rescue JSONAPI::Exceptions::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
58
+ handle_create_exception(e)
59
+ end
68
60
 
69
- # Attach files before saving so validation can check files.attached?
70
- # ActiveStorage allows attaching to unsaved records and will persist attachments when the record is saved
71
- sti_resource_class = determine_sti_resource_class_for_create
72
- attach_active_storage_files(resource, attachment_params, resource_class: sti_resource_class)
61
+ def build_resource_for_create
62
+ sti_class = determine_sti_class
63
+ params_hash, @create_attachments = prepare_create_params(sti_class)
64
+ sti_class.new(params_hash)
65
+ end
73
66
 
74
- if resource.save
75
- emit_resource_event(:created, resource)
76
- render json: serialize_resource(resource), status: :created, location: resource_url(resource)
77
- else
78
- render_validation_errors(resource)
79
- end
80
- rescue ArgumentError => e
81
- if e.message.match?(/invalid.*subtype/i)
82
- render_invalid_subtype_error(e)
83
- else
84
- render_invalid_relationship_error(e)
67
+ def handle_create_exception(error)
68
+ case error
69
+ when JSONAPI::Exceptions::ParameterNotAllowed then render_parameter_not_allowed_error(error)
70
+ when ActiveSupport::MessageVerifier::InvalidSignature then render_signed_id_error(error)
85
71
  end
86
- rescue ActiveSupport::MessageVerifier::InvalidSignature => e
87
- render_invalid_signed_id_error(e)
88
72
  end
89
73
 
90
74
  def update
91
75
  authorize_resource_action!(@resource, action: :update)
92
- params_hash = deserialize_params(:update)
93
- attachment_params = extract_active_storage_params_from_hash(params_hash, model_class)
94
- # Remove attachment params from regular params
95
- attachment_params.each_key { |key| params_hash.delete(key.to_s) }
96
-
97
- if @resource.update(params_hash)
98
- attach_active_storage_files(@resource, attachment_params, resource_class: @resource_class)
99
- emit_resource_event(:updated, @resource)
100
- render json: serialize_resource(@resource), status: :ok
101
- else
102
- render_validation_errors(@resource)
103
- end
76
+ params_hash, attachments = prepare_update_params
77
+ save_updated(params_hash, attachments)
104
78
  rescue ArgumentError => e
105
79
  render_invalid_relationship_error(e)
106
80
  rescue JSONAPI::Exceptions::ParameterNotAllowed => e
107
81
  render_parameter_not_allowed_error(e)
108
82
  rescue ActiveSupport::MessageVerifier::InvalidSignature => e
109
- render_invalid_signed_id_error(e)
83
+ render_signed_id_error(e)
110
84
  end
111
85
 
112
86
  def destroy
113
87
  authorize_resource_action!(@resource, action: :destroy)
114
- resource_id = @resource.id
115
- resource_type_name = resource_type
116
- if @resource.destroy
117
- emit_resource_event(:deleted, @resource, resource_id:, resource_type: resource_type_name)
118
- head :no_content
119
- else
120
- render_validation_errors(@resource)
121
- end
122
- end
123
-
124
- def build_resource_from_params
125
- params_hash = deserialize_params(:create)
126
- attachment_params = extract_active_storage_params_from_hash(params_hash, model_class)
127
- # Remove attachment params from regular params
128
- attachment_params.each_key { |key| params_hash.delete(key.to_s) }
129
- model_class.new(params_hash)
130
- end
131
-
132
- def deserialize_params(action = :update, model_class: nil)
133
- params_hash = raw_jsonapi_data
134
- target_model_class = model_class || self.model_class
135
- deserializer = JSONAPI::Deserializer.new(params_hash, model_class: target_model_class, action:)
136
- deserializer.to_model_attributes
137
- end
138
-
139
- def render_invalid_signed_id_error(error)
140
- render_jsonapi_error(
141
- status: 400,
142
- title: "Invalid Signed ID",
143
- detail: "Invalid signed blob ID provided: #{error.message}"
144
- )
145
- end
146
-
147
- def determine_sti_class_for_create(_subtype = nil)
148
- # Check the type directly from the payload
149
- type_from_payload = raw_jsonapi_data&.dig(:type)
150
-
151
- return model_class unless type_from_payload
152
-
153
- # If the payload type differs from the controller's type,
154
- # resolve the class for the specific type.
155
- if type_from_payload == params[:resource_type]
156
- model_class
157
- else
158
- resource_class = JSONAPI::ResourceLoader.find(type_from_payload)
159
- resource_class.model_class
160
- end
161
- rescue JSONAPI::ResourceLoader::MissingResourceClass
162
- model_class
163
- end
164
-
165
- def determine_sti_resource_class_for_create(_subtype = nil)
166
- type_from_payload = raw_jsonapi_data&.dig(:type)
167
- return @resource_class unless type_from_payload
168
-
169
- if type_from_payload == params[:resource_type]
170
- @resource_class
171
- else
172
- JSONAPI::ResourceLoader.find(type_from_payload)
173
- end
174
- rescue JSONAPI::ResourceLoader::MissingResourceClass
175
- @resource_class
88
+ @resource.destroy
89
+ emit_resource_event(:deleted, @resource)
90
+ head :no_content
176
91
  end
177
92
 
178
93
  private
179
94
 
180
- def load_jsonapi_resource
181
- return unless params[:resource_type].present?
182
-
183
- set_resource_name
184
- set_resource_class
185
- set_resource if %i[show update destroy].include?(action_name.to_sym)
186
- end
187
-
188
- def raw_jsonapi_data
189
- # Access raw params to get relationships and meta before permit filters them
190
- raw_data = params[:data] || params["data"]
191
- return {} unless raw_data
192
-
193
- # Convert to hash with symbolized keys, preserving nested structure including meta
194
- # Use to_unsafe_h to get all params including unpermitted ones like meta
195
- if raw_data.is_a?(ActionController::Parameters)
196
- JSONAPI::ParamHelpers.deep_symbolize_params(raw_data.to_unsafe_h)
197
- else
198
- JSONAPI::ParamHelpers.deep_symbolize_params(raw_data)
199
- end
200
- end
201
-
202
- def validate_filter_param
203
- filters = parse_filter_param
204
- return if filters.empty?
205
-
206
- invalid_filters = find_invalid_filter_fields(filters)
207
- return if invalid_filters.empty?
208
-
209
- render_parameter_errors(
210
- invalid_filters,
211
- title: "Invalid Filter",
212
- detail_proc: ->(filter_name) { "Invalid filter requested: #{filter_name}" },
213
- source_proc: ->(filter_name) { { parameter: "filter[#{filter_name}]" } }
214
- )
215
- end
216
-
217
- def find_invalid_filter_fields(filters)
218
- filters.keys.reject do |filter_name|
219
- valid_filter_path?(filter_name.to_s, @resource_class, model_class)
220
- end
221
- end
222
-
223
- def valid_filter_path?(filter_name, resource_cls, model_cls)
224
- path_parts = filter_name.split(".")
225
- validate_filter_parts(path_parts, resource_cls, model_cls)
226
- end
227
-
228
- def validate_filter_parts(path_parts, resource_cls, model_cls, allow_related_columns: false)
229
- return false if path_parts.empty?
230
-
231
- if path_parts.length == 1
232
- filter_name = path_parts.first
233
- return true if resource_cls.permitted_filters.map(&:to_s).include?(filter_name)
234
-
235
- if allow_related_columns
236
- column_filter = parse_column_filter_name(filter_name)
237
- return true if column_filter && model_cls.column_for_attribute(column_filter[:column])
238
-
239
- return true if model_cls.column_names.include?(filter_name)
240
- return true if model_cls.respond_to?(filter_name.to_sym)
241
- end
242
-
243
- return false
244
- end
245
-
246
- relationship_name, *remaining_parts = path_parts
247
- return false unless relationship_allowed_for_filtering?(resource_cls, relationship_name)
248
-
249
- association = model_cls.reflect_on_association(relationship_name.to_sym)
250
- return false unless association
251
-
252
- return valid_polymorphic_filter_parts?(remaining_parts) if association.polymorphic?
253
-
254
- related_model = association.klass
255
- related_resource_cls = JSONAPI::Resource.resource_for_model(related_model)
256
- return false unless related_resource_cls
257
-
258
- validate_filter_parts(
259
- remaining_parts,
260
- related_resource_cls,
261
- related_model,
262
- allow_related_columns: true
263
- )
264
- end
265
-
266
- def relationship_allowed_for_filtering?(resource_cls, relationship_name)
267
- permitted_through = resource_cls.permitted_filters_through
268
- permitted_through.include?(relationship_name.to_sym) ||
269
- permitted_through.map(&:to_s).include?(relationship_name.to_s)
270
- end
271
-
272
- def valid_polymorphic_filter_parts?(parts)
273
- return false if parts.empty? || parts.length > 1
274
-
275
- attribute_name = parts.first
276
- match = attribute_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
277
- base_name = match ? match[1] : attribute_name
278
-
279
- %w[id type].include?(base_name)
280
- end
281
-
282
- def parse_column_filter_name(filter_name)
283
- match = filter_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
284
- return nil unless match
285
-
286
- { column: match[1], operator: match[2].to_sym }
287
- end
288
-
289
- def validate_sort_param
290
- sorts = parse_sort_param
291
- return if sorts.empty?
292
-
293
- valid_fields = valid_sort_fields_for_resource(@resource_class, model_class)
294
- invalid_fields = invalid_sort_fields_for_columns(sorts, valid_fields)
295
- return if invalid_fields.empty?
296
-
297
- render_parameter_errors(
298
- invalid_fields,
299
- title: "Invalid Sort Field",
300
- detail_proc: ->(field) { "Invalid sort field requested: #{field}" },
301
- source_proc: ->(_field) { { parameter: "sort" } }
302
- )
303
- end
304
-
305
- def validate_include_param
306
- includes = parse_include_param
307
- return if includes.empty?
308
-
309
- invalid_paths = find_invalid_include_paths(includes)
310
- return if invalid_paths.empty?
311
-
312
- render_parameter_errors(
313
- invalid_paths,
314
- title: "Invalid Include Path",
315
- detail_proc: ->(path) { "Invalid include path requested: #{path}" },
316
- source_proc: ->(_path) { { parameter: "include" } }
317
- )
318
- end
319
-
320
- def find_invalid_include_paths(includes)
321
- permitted_relationships = @resource_class.relationship_names.map(&:to_s)
322
- invalid_paths = []
323
-
324
- includes.each do |include_path|
325
- invalid_paths << include_path unless valid_include_path?(include_path, permitted_relationships)
326
- end
327
-
328
- invalid_paths
329
- end
330
-
331
- def valid_include_path?(include_path, permitted_relationships)
332
- path_parts = include_path.split(".")
333
- first_association = path_parts.first
334
-
335
- return false unless permitted_relationships.include?(first_association)
336
-
337
- valid_nested_path?(path_parts)
338
- end
339
-
340
- def valid_nested_path?(path_parts)
341
- current_class = model_class
342
-
343
- path_parts.each do |association_name|
344
- # Check for ActiveStorage attachments
345
- if self.class.active_storage_attachment?(association_name, current_class)
346
- # ActiveStorage attachments point to ActiveStorage::Blob, which is a terminal relationship
347
- return true
348
- end
349
-
350
- association = current_class.reflect_on_association(association_name.to_sym)
351
- return false unless association
352
-
353
- break if association.polymorphic?
354
-
355
- current_class = association.klass
356
- end
357
-
358
- true
359
- end
360
-
361
- def preload_includes
362
- includes = parse_include_param
363
- return if includes.empty?
364
-
365
- includes_hash = build_includes_hash(includes)
366
- preload_resources(includes_hash)
367
- rescue ActiveRecord::RecordNotFound
368
- # Will be handled by set_resource
369
- end
370
-
371
- def build_includes_hash(includes)
372
- includes_hash = {}
373
- includes.each { |include_path| merge_include_path(includes_hash, include_path) }
374
- includes_hash
375
- end
376
-
377
- def merge_include_path(includes_hash, include_path)
378
- path_parts = include_path.split(".")
379
- current_hash = includes_hash
380
-
381
- path_parts.each_with_index do |part, index|
382
- part_sym = part.to_sym
383
- current_hash[part_sym] ||= {}
384
- current_hash = current_hash[part_sym] if index < path_parts.length - 1
385
- end
386
- end
387
-
388
- def preload_resources(includes_hash)
389
- # Filter out ActiveStorage attachments and polymorphic associations from includes_hash
390
- filtered_includes = filter_active_storage_from_includes(includes_hash, model_class)
391
- filtered_includes = filter_polymorphic_from_includes(filtered_includes, model_class)
392
-
393
- case action_name
394
- when "index"
395
- @preloaded_resources = filtered_includes.empty? ? model_class.all : model_class.all.includes(filtered_includes)
396
- when "show"
397
- @preloaded_resource = filtered_includes.empty? ? model_class.find(params[:id]) : model_class.includes(filtered_includes).find(params[:id])
398
- end
399
- end
400
-
401
- def serialize_resource(resource)
402
- serializer = JSONAPI::Serializer.new(resource)
403
- serializer.to_hash(
404
- include: parse_include_param,
405
- fields: parse_fields_param,
406
- document_meta: jsonapi_document_meta
407
- )
408
- end
409
-
410
- def serialize_collection(resources)
411
- includes = parse_include_param
412
- fields = parse_fields_param
413
- all_included = []
414
- processed = Set.new
415
-
416
- data = serialize_resources(resources, includes, fields, all_included, processed)
417
-
418
- build_collection_response(data, all_included)
419
- end
420
-
421
- def serialize_resources(resources, includes, fields, all_included, processed)
422
- resources.map do |resource|
423
- result = serialize_single_resource(resource, includes, fields)
424
- collect_included_resources(result, all_included, processed)
425
- result[:data]
426
- end
427
- end
428
-
429
- def serialize_single_resource(resource, includes, fields)
430
- serializer = JSONAPI::Serializer.new(resource)
431
- serializer.to_hash(include: includes, fields:, document_meta: nil)
432
- end
433
-
434
- def collect_included_resources(result, all_included, processed)
435
- included_resources = result[:included] || []
436
- included_resources.each do |included_resource|
437
- add_unique_included_resource(included_resource, all_included, processed)
438
- end
439
- end
440
-
441
- def add_unique_included_resource(included_resource, all_included, processed)
442
- resource_key = "#{included_resource[:type]}-#{included_resource[:id]}"
443
- return if processed.include?(resource_key)
444
-
445
- all_included << included_resource
446
- processed.add(resource_key)
447
- end
448
-
449
- def build_collection_response(data, all_included)
450
- result = {
451
- jsonapi: JSONAPI::Serializer.jsonapi_object,
452
- data:
453
- }
454
- result[:included] = all_included if all_included.any?
455
-
456
- pagination_meta = {}
457
-
458
- if @pagination_applied
459
- result[:links] = build_pagination_links
460
- pagination_meta = build_pagination_meta
461
- end
462
-
463
- result[:meta] = jsonapi_document_meta(pagination_meta)
464
-
465
- result
466
- end
467
-
468
- def build_pagination_links
469
- page_params = parse_page_param
470
- current_page = page_params["number"]&.to_i || 1
471
- page_size = calculate_page_size(page_params)
472
- total_pages = calculate_total_pages(page_size)
473
-
474
- links = {
475
- self: build_pagination_url(current_page, page_size),
476
- first: build_pagination_url(1, page_size),
477
- last: build_pagination_url(total_pages, page_size)
478
- }
479
- links[:prev] = build_pagination_url(current_page - 1, page_size) if current_page > 1
480
- links[:next] = build_pagination_url(current_page + 1, page_size) if current_page < total_pages
481
-
482
- links
483
- end
484
-
485
- def calculate_page_size(page_params)
486
- size = page_params["size"]&.to_i || JSONAPI.configuration.default_page_size
487
- [size, JSONAPI.configuration.max_page_size].min
488
- end
489
-
490
- def calculate_total_pages(page_size)
491
- total_pages = (@total_count.to_f / page_size).ceil
492
- total_pages.zero? ? 1 : total_pages
493
- end
494
-
495
- def build_pagination_url(page_number, page_size)
496
- base_url = request.path
497
- query_params = request.query_parameters.dup
498
- query_params["page"] = { "number" => page_number, "size" => page_size }
499
-
500
- query_string = build_query_string(query_params)
501
- query_string.present? ? "#{base_url}?#{query_string}" : base_url
502
- end
503
-
504
- def build_query_string(query_params)
505
- JSONAPI::ParamHelpers.build_query_string(query_params)
506
- end
507
-
508
- def build_pagination_meta
509
- { total: @total_count }
510
- end
511
-
512
- def validate_resource_type!
513
- # Skip validation if resource loading was skipped
514
- return unless @resource_class
515
- # Relationship endpoints carry related resource identifiers, so skip type
516
- # validation for relationship requests.
517
- return if params[:relationship_name].present?
518
-
519
- requested_type = jsonapi_type
520
- expected_type = resource_type
521
-
522
- return if requested_type == expected_type
523
-
524
- # Allow STI subtypes if posting to base endpoint
525
- if model_class.respond_to?(:base_class)
526
- # Check if requested type corresponds to a valid subclass
527
- begin
528
- requested_resource_class = JSONAPI::ResourceLoader.find(requested_type)
529
- requested_model_class = requested_resource_class.model_class
530
-
531
- # Allow if requested class is a subclass of the endpoint's class
532
- return if requested_model_class < model_class
533
- rescue JSONAPI::ResourceLoader::MissingResourceClass, NameError
534
- # Fall through to error rendering
535
- end
536
- end
537
-
538
- render_type_mismatch_error(expected_type, requested_type) and return
539
- end
540
-
541
- def validate_resource_id!
542
- # Skip validation if resource loading was skipped
543
- return unless @resource_class
544
-
545
- requested_id = jsonapi_id
546
- expected_id = params[:id].to_s
547
-
548
- return if requested_id == expected_id
549
-
550
- render_id_mismatch_error(expected_id, requested_id) and return
551
- end
552
-
553
- def render_type_mismatch_error(expected_type, requested_type)
554
- detail = requested_type.nil? ? "Missing type member. Expected '#{expected_type}'" : "Type mismatch: expected '#{expected_type}', got '#{requested_type}'"
555
-
556
- render json: {
557
- errors: [
558
- {
559
- status: "409",
560
- title: "Type Mismatch",
561
- detail:,
562
- source: { pointer: "/data/type" }
563
- }
564
- ]
565
- }, status: :conflict
566
- end
567
-
568
- def render_id_mismatch_error(expected_id, requested_id)
569
- render json: {
570
- errors: [
571
- {
572
- status: "409",
573
- title: "ID Mismatch",
574
- detail: "ID mismatch: expected '#{expected_id}', got '#{requested_id}'",
575
- source: { pointer: "/data/id" }
576
- }
577
- ]
578
- }, status: :conflict
579
- end
580
-
581
- def resource_url(resource)
582
- "/#{resource_type}/#{resource.id}"
583
- end
584
-
585
- def resource_type
586
- params[:resource_type] || @resource_name.pluralize
587
- end
588
-
589
- def validate_fields_param
590
- fields = parse_fields_param
591
- return if fields.empty?
592
-
593
- fields.each do |type, type_fields|
594
- next if type_fields.nil? || type_fields.empty?
595
-
596
- # Handle active_storage_blobs specially
597
- if type.to_s == "active_storage_blobs"
598
- invalid_fields = find_invalid_blob_fields(type_fields)
599
- if invalid_fields.any?
600
- render_parameter_errors(
601
- invalid_fields,
602
- title: "Invalid Field",
603
- detail_proc: ->(field) { "Invalid field requested for active_storage_blobs: #{field}" },
604
- source_proc: ->(_field) { { parameter: "fields[active_storage_blobs]" } }
605
- )
606
- return
607
- end
608
- else
609
- # Only validate fields for the current resource type
610
- next unless type.to_s == resource_type.to_s
611
-
612
- invalid_fields = find_invalid_fields(type_fields)
613
- if invalid_fields.any?
614
- current_type = resource_type.to_s
615
- render_parameter_errors(
616
- invalid_fields,
617
- title: "Invalid Field",
618
- detail_proc: ->(field) { "Invalid field requested for #{current_type}: #{field}" },
619
- source_proc: ->(_field) { { parameter: "fields[#{current_type}]" } }
620
- )
621
- return
622
- end
623
- end
624
- end
625
- end
626
-
627
- def find_invalid_fields(resource_fields)
628
- permitted_attributes = @resource_class.permitted_attributes.map(&:to_s)
629
- resource_fields.reject { |field| permitted_attributes.include?(field.to_s) }
630
- end
631
-
632
- def find_invalid_blob_fields(blob_fields)
633
- return [] unless defined?(::ActiveStorage)
634
-
635
- permitted_attributes = JSONAPI::ActiveStorageBlobResource.permitted_attributes.map(&:to_s)
636
- blob_fields.reject { |field| permitted_attributes.include?(field.to_s) }
637
- end
638
-
639
- def emit_resource_event(action, resource, resource_id: nil, resource_type: nil)
640
- resource_id ||= resource.id
641
- resource_type_name = resource_type || self.resource_type
642
-
643
- changes = if action == :updated && resource.respond_to?(:previous_changes)
644
- resource.previous_changes.except("updated_at", "created_at")
645
- else
646
- {}
647
- end
648
-
649
- JSONAPI::Instrumentation.resource_event(
650
- action:,
651
- resource_type: resource_type_name,
652
- resource_id:,
653
- changes:
654
- )
95
+ def build_query(scope)
96
+ JSONAPI::CollectionQuery.new(
97
+ scope,
98
+ definition: @resource_class,
99
+ model_class: model_class,
100
+ filter_params: parse_filter_param,
101
+ sort_params: parse_sort_param,
102
+ page_params: parse_page_param,
103
+ ).execute
655
104
  end
656
105
  end
657
106
  end