jpie 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.aiconfig +65 -0
  3. data/.rubocop.yml +110 -35
  4. data/CHANGELOG.md +93 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +776 -1903
  7. data/Rakefile +14 -3
  8. data/jpie.gemspec +35 -18
  9. data/lib/jpie/configuration.rb +12 -0
  10. data/lib/jpie/controller/crud_actions.rb +110 -0
  11. data/lib/jpie/controller/error_handling.rb +41 -0
  12. data/lib/jpie/controller/parameter_parsing.rb +35 -0
  13. data/lib/jpie/controller/rendering.rb +60 -0
  14. data/lib/jpie/controller.rb +18 -0
  15. data/lib/jpie/deserializer.rb +110 -0
  16. data/lib/jpie/errors.rb +70 -0
  17. data/lib/jpie/generators/resource_generator.rb +39 -0
  18. data/lib/jpie/generators/templates/resource.rb.erb +12 -0
  19. data/lib/jpie/railtie.rb +36 -0
  20. data/lib/jpie/resource/attributable.rb +98 -0
  21. data/lib/jpie/resource/inferrable.rb +43 -0
  22. data/lib/jpie/resource/sortable.rb +93 -0
  23. data/lib/jpie/resource.rb +107 -0
  24. data/lib/jpie/serializer.rb +205 -0
  25. data/lib/{json_api → jpie}/version.rb +2 -2
  26. data/lib/jpie.rb +23 -3
  27. metadata +145 -50
  28. data/.gitignore +0 -21
  29. data/.rspec +0 -3
  30. data/.travis.yml +0 -7
  31. data/Gemfile +0 -21
  32. data/Gemfile.lock +0 -312
  33. data/bin/console +0 -15
  34. data/bin/setup +0 -8
  35. data/kiln/app/resources/user_message_resource.rb +0 -2
  36. data/lib/json_api/active_storage/deserialization.rb +0 -106
  37. data/lib/json_api/active_storage/detection.rb +0 -74
  38. data/lib/json_api/active_storage/serialization.rb +0 -32
  39. data/lib/json_api/configuration.rb +0 -58
  40. data/lib/json_api/controllers/base_controller.rb +0 -26
  41. data/lib/json_api/controllers/concerns/controller_helpers.rb +0 -223
  42. data/lib/json_api/controllers/concerns/resource_actions.rb +0 -657
  43. data/lib/json_api/controllers/relationships_controller.rb +0 -504
  44. data/lib/json_api/controllers/resources_controller.rb +0 -6
  45. data/lib/json_api/errors/parameter_not_allowed.rb +0 -19
  46. data/lib/json_api/railtie.rb +0 -75
  47. data/lib/json_api/resources/active_storage_blob_resource.rb +0 -11
  48. data/lib/json_api/resources/resource.rb +0 -238
  49. data/lib/json_api/resources/resource_loader.rb +0 -35
  50. data/lib/json_api/routing.rb +0 -72
  51. data/lib/json_api/serialization/deserializer.rb +0 -362
  52. data/lib/json_api/serialization/serializer.rb +0 -320
  53. data/lib/json_api/support/active_storage_support.rb +0 -85
  54. data/lib/json_api/support/collection_query.rb +0 -406
  55. data/lib/json_api/support/instrumentation.rb +0 -42
  56. data/lib/json_api/support/param_helpers.rb +0 -51
  57. data/lib/json_api/support/relationship_guard.rb +0 -16
  58. data/lib/json_api/support/relationship_helpers.rb +0 -74
  59. data/lib/json_api/support/resource_identifier.rb +0 -87
  60. data/lib/json_api/support/responders.rb +0 -100
  61. data/lib/json_api/support/response_helpers.rb +0 -10
  62. data/lib/json_api/support/sort_parsing.rb +0 -21
  63. data/lib/json_api/support/type_conversion.rb +0 -21
  64. data/lib/json_api/testing/test_helper.rb +0 -76
  65. data/lib/json_api/testing.rb +0 -3
  66. data/lib/json_api.rb +0 -50
  67. data/lib/rubocop/cop/custom/hash_value_omission.rb +0 -53
@@ -1,657 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module ResourceActions
5
- extend ActiveSupport::Concern
6
- include ActiveStorageSupport
7
-
8
- included do
9
- before_action :load_jsonapi_resource
10
- before_action :validate_fields_param, only: %i[index show]
11
- before_action :validate_filter_param, only: [:index]
12
- before_action :validate_sort_param, only: [:index]
13
- before_action :validate_include_param, only: %i[index show]
14
- before_action :validate_resource_type!, only: %i[create update]
15
- before_action :validate_resource_id!, only: [:update]
16
- before_action :preload_includes, only: %i[index show]
17
- end
18
-
19
- 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
-
32
- @total_count = query.total_count
33
- @pagination_applied = query.pagination_applied
34
-
35
- render json: serialize_collection(query.scope), status: :ok
36
- end
37
-
38
- def show
39
- resource = @preloaded_resource || @resource
40
- authorize_resource_action!(resource, action: :show)
41
- render json: serialize_resource(resource), status: :ok
42
- end
43
-
44
- 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)
67
- authorize_resource_action!(resource, action: :create)
68
-
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)
73
-
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)
85
- end
86
- rescue ActiveSupport::MessageVerifier::InvalidSignature => e
87
- render_invalid_signed_id_error(e)
88
- end
89
-
90
- def update
91
- 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
104
- rescue ArgumentError => e
105
- render_invalid_relationship_error(e)
106
- rescue JSONAPI::Exceptions::ParameterNotAllowed => e
107
- render_parameter_not_allowed_error(e)
108
- rescue ActiveSupport::MessageVerifier::InvalidSignature => e
109
- render_invalid_signed_id_error(e)
110
- end
111
-
112
- def destroy
113
- 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
176
- end
177
-
178
- private
179
-
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
- )
655
- end
656
- end
657
- end