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,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json_api/support/relationship_guard"
4
+ require_relative "concerns/relationships/serialization"
5
+ require_relative "concerns/relationships/updating"
6
+ require_relative "concerns/relationships/removal"
7
+ require_relative "concerns/relationships/sorting"
8
+ require_relative "concerns/relationships/events"
9
+ require_relative "concerns/relationships/response_helpers"
4
10
 
5
11
  module JSONAPI
6
12
  class RelationshipsController < BaseController
13
+ include Relationships::Serialization
14
+ include Relationships::Updating
15
+ include Relationships::Removal
16
+ include Relationships::Sorting
17
+ include Relationships::Events
18
+ include Relationships::ResponseHelpers
19
+
7
20
  skip_before_action :validate_resource_type!, only: %i[update destroy]
8
21
  skip_before_action :validate_resource_id!, only: %i[update destroy]
9
22
 
@@ -17,31 +30,14 @@ module JSONAPI
17
30
 
18
31
  def show
19
32
  authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
20
- relationship_data = serialize_relationship_data
21
- links = serialize_relationship_links
22
- meta = serialize_relationship_meta
23
-
24
- response_data = { data: relationship_data, links: }
25
- response_data[:meta] = meta if meta.present?
26
-
27
- render json: response_data, status: :ok
33
+ render json: build_show_response, status: :ok
28
34
  end
29
35
 
30
36
  def update
31
37
  authorize_resource_action!(@resource, action: :update, context: { relationship: @relationship_name })
32
38
  relationship_data = parse_relationship_data
33
39
  update_relationship(relationship_data)
34
-
35
- if @resource.save
36
- emit_relationship_event(:updated, relationship_data)
37
- render json: {
38
- data: serialize_relationship_data,
39
- links: serialize_relationship_links,
40
- meta: serialize_relationship_meta
41
- }.compact, status: :ok
42
- else
43
- render_validation_errors(@resource)
44
- end
40
+ save_and_render_relationship(relationship_data)
45
41
  rescue ArgumentError => e
46
42
  render_invalid_relationship_error(e)
47
43
  rescue JSONAPI::Exceptions::ParameterNotAllowed => e
@@ -54,21 +50,7 @@ module JSONAPI
54
50
  return head :no_content if relationship_data.nil?
55
51
 
56
52
  remove_relationship(relationship_data)
57
-
58
- # For to-many relationships, resources are updated directly in remove_from_many_relationship
59
- # For to-one relationships, the parent resource needs to be saved
60
- association = @resource.class.reflect_on_association(@relationship_name)
61
- if association && !association.collection?
62
- if @resource.save
63
- emit_relationship_event(:removed, relationship_data)
64
- head :no_content
65
- else
66
- render_validation_errors(@resource)
67
- end
68
- else
69
- emit_relationship_event(:removed, relationship_data)
70
- head :no_content
71
- end
53
+ finalize_relationship_removal(relationship_data)
72
54
  rescue ArgumentError => e
73
55
  render_invalid_relationship_error(e)
74
56
  rescue JSONAPI::Exceptions::ParameterNotAllowed => e
@@ -82,423 +64,45 @@ module JSONAPI
82
64
  end
83
65
 
84
66
  def validate_relationship_exists
85
- relationship_def = find_relationship_definition
86
- return if relationship_def
67
+ return if find_relationship_definition
87
68
 
88
69
  render_jsonapi_error(
89
70
  status: 404,
90
71
  title: "Relationship Not Found",
91
- detail: "Relationship '#{@relationship_name}' not found on #{@resource_name}"
92
- ) and return
72
+ detail: "Relationship '#{@relationship_name}' not found on #{@resource_name}",
73
+ )
93
74
  end
94
75
 
95
76
  def find_relationship_definition
96
77
  RelationshipHelpers.find_relationship_definition(@resource_class, @relationship_name)
97
78
  end
98
79
 
99
- def serialize_relationship_data
100
- association = @resource.class.reflect_on_association(@relationship_name)
101
- return nil unless association
102
-
103
- related = @resource.public_send(@relationship_name)
104
-
105
- # Apply sorting for collection relationships
106
- if association.collection? && related.respond_to?(:order)
107
- related = apply_sorting_to_relationship(related, association)
108
- end
109
-
110
- if association.collection?
111
- serialize_collection_relationship(related, association)
112
- elsif related
113
- serialize_single_relationship(related, association)
114
- end
115
- end
116
-
117
- def serialize_collection_relationship(related, association)
118
- return [] if related.nil?
119
-
120
- related.map { |r| serialize_resource_identifier(r, association) }
121
- end
122
-
123
- def serialize_single_relationship(related, association)
124
- serialize_resource_identifier(related, association)
125
- end
126
-
127
- def serialize_resource_identifier(resource_instance, association)
128
- RelationshipHelpers.serialize_resource_identifier(
129
- resource_instance,
130
- association:,
131
- resource_class: @resource_class
132
- )
133
- end
134
-
135
- def serialize_relationship_links
136
- {
137
- self: relationship_self_url,
138
- related: relationship_related_url
139
- }
140
- end
141
-
142
- def relationship_self_url
143
- "/#{params[:resource_type]}/#{params[:id]}/relationships/#{params[:relationship_name]}"
144
- end
145
-
146
- def relationship_related_url
147
- "/#{params[:resource_type]}/#{params[:id]}/#{params[:relationship_name]}"
148
- end
149
-
150
- def serialize_relationship_meta
151
- relationship_def = find_relationship_definition
152
- return nil unless relationship_def
153
-
154
- relationship_def[:meta]
155
- end
156
-
157
80
  def parse_relationship_data
158
81
  raw_data = params[:data]
159
82
  return nil if raw_data.nil?
160
-
161
- # Handle arrays directly (for to-many relationships)
162
83
  return [] if raw_data.is_a?(Array) && raw_data.empty?
163
-
164
84
  return raw_data.map { |item| ParamHelpers.deep_symbolize_params(item) } if raw_data.is_a?(Array)
165
85
 
166
- # Handle hash/object (for to-one relationships or single resource identifiers)
167
86
  ParamHelpers.deep_symbolize_params(raw_data)
168
87
  end
169
88
 
170
- def update_relationship(relationship_data)
171
- association = @resource.class.reflect_on_association(@relationship_name)
172
- raise ArgumentError, "Association not found: #{@relationship_name}" unless association
173
-
174
- ensure_relationship_writable!(association)
175
-
176
- if association.collection?
177
- update_to_many_relationship(association, relationship_data)
178
- else
179
- update_to_one_relationship(association, relationship_data)
180
- end
181
- end
182
-
183
- def update_to_many_relationship(association, relationship_data)
184
- if relationship_data.nil? || (relationship_data.is_a?(Array) && relationship_data.empty?)
185
- @resource.public_send("#{@relationship_name}=", [])
186
- return
187
- end
188
-
189
- raise ArgumentError, "Expected array for to-many relationship" unless relationship_data.is_a?(Array)
190
-
191
- related_resources = relationship_data.map do |identifier|
192
- find_related_resource(identifier, association)
193
- end
194
-
195
- @resource.public_send("#{@relationship_name}=", related_resources)
196
- end
197
-
198
- def update_to_one_relationship(association, relationship_data)
199
- if relationship_data.nil?
200
- @resource.public_send("#{@relationship_name}=", nil)
201
- return
202
- end
203
-
204
- if relationship_data.is_a?(Array)
205
- raise ArgumentError, "Expected single resource identifier for to-one relationship"
206
- end
207
-
208
- related_resource = find_related_resource(relationship_data, association)
209
- @resource.public_send("#{@relationship_name}=", related_resource)
210
- end
211
-
212
- def find_related_resource(identifier, association)
213
- RelationshipHelpers.resolve_and_find_related_resource(
214
- identifier,
215
- association:,
216
- resource_class: @resource_class,
217
- relationship_name: @relationship_name
218
- )
219
- end
220
-
221
89
  def polymorphic_association?
222
90
  RelationshipHelpers.polymorphic_association?(@resource_class, @relationship_name)
223
91
  end
224
92
 
225
- def remove_relationship(relationship_data)
226
- # Check if this is an ActiveStorage attachment first
227
- if active_storage_attachment?(@relationship_name, @resource.class)
228
- # For ActiveStorage, we need to determine if it's has_many or has_one
229
- attachment_reflection = @resource.class.reflect_on_attachment(@relationship_name)
230
- if attachment_reflection&.macro == :has_many_attached
231
- remove_from_many_relationship(nil, relationship_data)
232
- else
233
- remove_from_one_relationship(nil, relationship_data)
234
- end
235
- return
236
- end
237
-
238
- association = @resource.class.reflect_on_association(@relationship_name)
239
- raise ArgumentError, "Association not found: #{@relationship_name}" unless association
240
-
241
- ensure_relationship_writable!(association)
242
-
243
- if association.collection?
244
- remove_from_many_relationship(association, relationship_data)
245
- else
246
- remove_from_one_relationship(association, relationship_data)
247
- end
248
- rescue ActiveRecord::NotNullViolation, ActiveRecord::RecordInvalid => e
249
- raise ArgumentError, "Cannot remove relationship: #{e.message}"
250
- end
251
-
252
- def remove_from_many_relationship(association, relationship_data)
253
- raise ArgumentError, "Expected array for to-many relationship removal" unless relationship_data.is_a?(Array)
254
-
255
- return if relationship_data.empty?
256
-
257
- # Check if this is an ActiveStorage attachment
258
- if active_storage_attachment?(@relationship_name, @resource.class)
259
- remove_active_storage_attachments(relationship_data)
260
- return
261
- end
262
-
263
- collection = @resource.public_send(@relationship_name)
264
- collection_ids = collection.pluck(:id)
265
- foreign_key = association.foreign_key
266
-
267
- ActiveRecord::Base.transaction do
268
- relationship_data.each do |identifier|
269
- resource = RelationshipHelpers.resolve_and_find_related_resource(
270
- identifier,
271
- association:,
272
- resource_class: @resource_class,
273
- relationship_name: @relationship_name
274
- )
275
- resource_id = resource.id
276
-
277
- next unless collection_ids.include?(resource_id)
278
-
279
- resource.update!(foreign_key => nil)
280
- end
281
- end
282
- end
283
-
284
- def remove_active_storage_attachments(relationship_data)
285
- attachment_proxy = @resource.public_send(@relationship_name)
286
- return unless attachment_proxy.attached?
287
-
288
- blob_ids = relationship_data.map do |identifier|
289
- type = RelationshipHelpers.extract_type_from_identifier(identifier)
290
- id = RelationshipHelpers.extract_id_from_identifier(identifier)
291
-
292
- unless self.class.active_storage_blob_type?(type)
293
- raise ArgumentError, "Expected active_storage_blobs type for ActiveStorage attachment removal, got #{type}"
294
- end
295
-
296
- id.to_i
297
- end
298
-
299
- # Find attachments that link these blobs to the resource
300
- # For has_many_attached, we need to access the underlying attachments
301
- attachments_to_remove = attachment_proxy.attachments.where(blob_id: blob_ids)
302
-
303
- # Purge/detach the attachments
304
- attachments_to_remove.each(&:purge)
305
- end
306
-
307
- def remove_from_one_relationship(association, relationship_data)
308
- if relationship_data.is_a?(Array)
309
- raise ArgumentError, "Expected single resource identifier for to-one relationship"
310
- end
311
-
312
- return if relationship_data.nil?
313
-
314
- # Check if this is an ActiveStorage attachment
315
- if active_storage_attachment?(@relationship_name, @resource.class)
316
- remove_active_storage_attachment(relationship_data)
317
- return
318
- end
319
-
320
- related_resource = find_related_resource(relationship_data, association)
321
- current_resource = @resource.public_send(@relationship_name)
322
-
323
- return unless current_resource == related_resource
324
-
325
- @resource.public_send("#{@relationship_name}=", nil)
326
- @resource.save!
327
- end
328
-
329
- def remove_active_storage_attachment(relationship_data)
330
- attachment_proxy = @resource.public_send(@relationship_name)
331
- return unless attachment_proxy.attached?
332
-
333
- type = RelationshipHelpers.extract_type_from_identifier(relationship_data)
334
- id = RelationshipHelpers.extract_id_from_identifier(relationship_data)
335
-
336
- unless self.class.active_storage_blob_type?(type)
337
- raise ArgumentError, "Expected active_storage_blobs type for ActiveStorage attachment removal, got #{type}"
338
- end
339
-
340
- blob_id = id.to_i
341
-
342
- # Find the attachment that links this blob to the resource
343
- attachment = attachment_proxy.attachments.find_by(blob_id:)
344
-
345
- # Purge/detach the attachment
346
- attachment&.purge
347
- end
348
-
349
- def apply_sorting_to_relationship(related, association)
350
- sorts = parse_sort_param
351
- return related if sorts.empty?
352
-
353
- related_model = association.klass
354
- related_resource_class = ResourceLoader.find_for_model(related_model)
355
-
356
- # Separate database columns from virtual attributes
357
- db_sorts = []
358
- virtual_sorts = []
359
-
360
- sorts.each do |sort_field|
361
- field = RelationshipHelpers.extract_sort_field_name(sort_field)
362
- if related_model.column_names.include?(field.to_s)
363
- db_sorts << sort_field
364
- else
365
- virtual_sorts << sort_field
366
- end
367
- end
368
-
369
- # Apply database sorting first
370
- db_sorts.each do |sort_field|
371
- direction = RelationshipHelpers.extract_sort_direction(sort_field)
372
- field = RelationshipHelpers.extract_sort_field_name(sort_field)
373
- related = related.order(field => direction)
374
- end
375
-
376
- # Apply virtual attribute sorting in Ruby if needed
377
- if virtual_sorts.any?
378
- records = related.to_a
379
- records = apply_virtual_attribute_sorting_to_relationship(records, virtual_sorts, related_resource_class)
380
- related = records
381
- end
382
-
383
- related
384
- end
385
-
386
- def apply_virtual_attribute_sorting_to_relationship(records, virtual_sorts, resource_class)
387
- records.sort do |a, b|
388
- compare_records_by_virtual_sorts_for_relationship(a, b, virtual_sorts, resource_class)
389
- end
390
- end
391
-
392
- def compare_records_by_virtual_sorts_for_relationship(record_a, record_b, virtual_sorts, resource_class)
393
- virtual_sorts.each do |sort_field|
394
- direction = RelationshipHelpers.extract_sort_direction(sort_field)
395
- field = RelationshipHelpers.extract_sort_field_name(sort_field)
396
-
397
- # Get values using resource getter
398
- resource_instance_a = resource_class.new(record_a, {})
399
- resource_instance_b = resource_class.new(record_b, {})
400
-
401
- value_a = get_virtual_attribute_value_for_relationship(resource_instance_a, field)
402
- value_b = get_virtual_attribute_value_for_relationship(resource_instance_b, field)
403
-
404
- # Compare values
405
- comparison = compare_values_for_relationship(value_a, value_b)
406
- next if comparison.zero? # Equal, check next sort field
407
-
408
- # Apply direction
409
- return direction == :desc ? -comparison : comparison
410
- end
411
-
412
- 0 # All fields equal
413
- end
414
-
415
- def get_virtual_attribute_value_for_relationship(resource_instance, field)
416
- field_sym = field.to_sym
417
- return resource_instance.public_send(field_sym) if resource_instance.respond_to?(field_sym, false)
418
-
419
- nil
420
- end
421
-
422
- def compare_values_for_relationship(value_a, value_b)
423
- # Handle nil values
424
- return 0 if value_a.nil? && value_b.nil?
425
- return -1 if value_a.nil?
426
- return 1 if value_b.nil?
427
-
428
- # Compare using standard Ruby comparison
429
- value_a <=> value_b
430
- end
431
-
432
- def validate_sort_param
433
- sorts = parse_sort_param
434
- return if sorts.empty?
435
-
436
- association = @resource.class.reflect_on_association(@relationship_name)
437
- return unless association&.collection?
438
-
439
- # Find resource class for the related model to check virtual attributes
440
- related_resource_class = ResourceLoader.find_for_model(association.klass)
441
- valid_fields = valid_sort_fields_for_resource(related_resource_class, association.klass)
442
- invalid_fields = invalid_sort_fields_for_columns(sorts, valid_fields)
443
- return if invalid_fields.empty?
444
-
445
- render_parameter_errors(
446
- invalid_fields,
447
- title: "Invalid Sort Field",
448
- detail_proc: ->(field) { "Invalid sort field requested: #{field}" },
449
- source_proc: ->(_field) { { parameter: "sort" } }
450
- )
451
- end
452
-
453
- def emit_relationship_event(action, relationship_data)
454
- resource_type_name = params[:resource_type] || @resource_name.pluralize
455
-
456
- related_ids = extract_related_ids(relationship_data)
457
- related_type = extract_related_type(relationship_data)
458
-
459
- JSONAPI::Instrumentation.relationship_event(
460
- action:,
461
- resource_type: resource_type_name,
462
- resource_id: @resource.id,
463
- relationship_name: @relationship_name.to_s,
464
- related_ids:,
465
- related_type:
466
- )
467
- end
468
-
469
- def extract_related_ids(relationship_data)
470
- return nil if relationship_data.nil?
471
-
472
- if relationship_data.is_a?(Array)
473
- relationship_data.map { |item| item[:id] }.compact
474
- else
475
- [relationship_data[:id]].compact
476
- end
477
- end
478
-
479
- def extract_related_type(relationship_data)
480
- return nil if relationship_data.nil?
481
-
482
- if relationship_data.is_a?(Array)
483
- first_item = relationship_data.first
484
- return nil unless first_item
485
-
486
- first_item[:type]
487
- else
488
- relationship_data[:type]
489
- end
490
- end
491
-
492
93
  def ensure_relationship_writable!(association)
493
- if self.class.respond_to?(:active_storage_attachment?) &&
494
- active_storage_attachment?(@relationship_name, @resource.class)
495
- return
496
- end
94
+ return if active_storage_writable?(association)
497
95
 
498
96
  relationship_def = find_relationship_definition
499
97
  readonly = relationship_def && (relationship_def[:options] || {})[:readonly] == true
500
98
 
501
99
  JSONAPI::RelationshipGuard.ensure_writable!(association, @relationship_name, readonly:)
502
100
  end
101
+
102
+ def active_storage_writable?(association)
103
+ self.class.respond_to?(:active_storage_attachment?) &&
104
+ active_storage_attachment?(@relationship_name, @resource.class) &&
105
+ association.nil?
106
+ end
503
107
  end
504
108
  end
@@ -7,7 +7,7 @@ module JSONAPI
7
7
 
8
8
  def initialize(params = [])
9
9
  @params = params
10
- super("Parameter not allowed: #{Array(params).join(', ')}")
10
+ super("Parameter not allowed: #{Array(params).join(", ")}")
11
11
  end
12
12
  end
13
13
  end
@@ -53,23 +53,60 @@ module JSONAPI
53
53
  # This allows empty controllers inheriting from ApplicationController to work automatically
54
54
  # We don't mix into ActionController::API by default to avoid Rails filtering action methods
55
55
  # (Rails treats public methods on abstract base classes as internal methods)
56
- config.to_prepare do
57
- next unless JSONAPI.configuration.base_controller_overridden?
56
+ #
57
+ # We use after_initialize because:
58
+ # 1. App controllers (e.g., JSONAPIController) must be autoloaded first
59
+ # 2. Rails 8 freezes autoload paths during initialization, and config.to_prepare runs
60
+ # before app controllers are available, causing FrozenError or NameError
61
+ # 3. We register with reloader.to_prepare for code reloading in development
62
+ config.after_initialize do |app|
63
+ # Register for code reloading in development
64
+ app.reloader.to_prepare do
65
+ Railtie.setup_base_controllers
66
+ end
67
+
68
+ # Run once immediately after initialization
69
+ # In eager_load environments (production, test with config.eager_load = true),
70
+ # controllers are already loaded. In development, this triggers autoloading
71
+ # which is now safe since initialization is complete.
72
+ Railtie.setup_base_controllers
73
+ end
58
74
 
59
- base_controller_class = JSONAPI.configuration.resolved_base_controller_class
75
+ class << self
76
+ def setup_base_controllers
77
+ return unless JSONAPI.configuration.base_controller_overridden?
78
+
79
+ base_controller_class = resolve_base_controller_class
80
+ return unless base_controller_class
81
+
82
+ rebuild_controllers_if_needed(base_controller_class)
83
+ include_jsonapi_concerns(base_controller_class)
84
+ end
85
+
86
+ private
87
+
88
+ def resolve_base_controller_class
89
+ class_name = JSONAPI.configuration.instance_variable_get(:@base_controller_class)
90
+ class_name.constantize
91
+ rescue NameError
92
+ # Controller not yet loaded - this can happen in test environments
93
+ # where eager_load is false and autoloading hasn't run yet.
94
+ nil
95
+ end
96
+
97
+ def rebuild_controllers_if_needed(base_controller_class)
98
+ return if JSONAPI::BaseController.superclass == base_controller_class
60
99
 
61
- # Ensure BaseController inherits from the configured base controller
62
- # This is necessary because BaseController may be defined before the configuration is set
63
- if JSONAPI::BaseController.superclass != base_controller_class
64
100
  JSONAPI.send(:remove_const, :BaseController)
65
101
  load "json_api/controllers/base_controller.rb"
66
- # Also reload RelationshipsController so it picks up the new BaseController
67
102
  JSONAPI.send(:remove_const, :RelationshipsController)
68
103
  load "json_api/controllers/relationships_controller.rb"
69
104
  end
70
105
 
71
- base_controller_class.include(JSONAPI::ControllerHelpers)
72
- base_controller_class.include(JSONAPI::ResourceActions)
106
+ def include_jsonapi_concerns(base_controller_class)
107
+ base_controller_class.include(JSONAPI::ControllerHelpers)
108
+ base_controller_class.include(JSONAPI::ResourceActions)
109
+ end
73
110
  end
74
111
  end
75
112
  end
@@ -2,10 +2,18 @@
2
2
 
3
3
  module JSONAPI
4
4
  class ActiveStorageBlobResource < Resource
5
- attributes :filename, :content_type, :byte_size, :checksum
5
+ attributes :filename, :content_type, :byte_size, :checksum, :url
6
6
 
7
7
  def self.model_class
8
8
  ::ActiveStorage::Blob
9
9
  end
10
+
11
+ def url
12
+ return nil unless resource.persisted?
13
+
14
+ Rails.application.routes.url_helpers.rails_blob_path(resource, only_path: true)
15
+ rescue StandardError
16
+ "/rails/active_storage/blobs/#{resource.signed_id}/#{resource.filename}"
17
+ end
10
18
  end
11
19
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module AttributesDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def attributes(*attrs)
10
+ @attributes ||= []
11
+ @attributes.concat(attrs.map(&:to_sym))
12
+ @attributes.uniq!
13
+ end
14
+
15
+ def creatable_fields(*fields)
16
+ @creatable_fields ||= []
17
+ @creatable_fields.concat(fields.map(&:to_sym))
18
+ @creatable_fields.uniq!
19
+ end
20
+
21
+ def updatable_fields(*fields)
22
+ @updatable_fields ||= []
23
+ @updatable_fields.concat(fields.map(&:to_sym))
24
+ @updatable_fields.uniq!
25
+ end
26
+ end
27
+
28
+ module FieldResolution
29
+ def permitted_attributes
30
+ declared_attributes = instance_variable_defined?(:@attributes)
31
+ attrs = @attributes || []
32
+ attrs = superclass.permitted_attributes + attrs if should_inherit_attributes?(declared_attributes)
33
+ attrs.uniq
34
+ end
35
+
36
+ def permitted_creatable_fields
37
+ resolve_field_list(:@creatable_fields, :permitted_creatable_fields)
38
+ end
39
+
40
+ def permitted_updatable_fields
41
+ resolve_field_list(:@updatable_fields, :permitted_updatable_fields)
42
+ end
43
+
44
+ def resolve_field_list(ivar, method)
45
+ return (instance_variable_get(ivar) || []).uniq if instance_variable_defined?(ivar)
46
+ return superclass.public_send(method).uniq if inherits_field?(ivar, method)
47
+
48
+ permitted_attributes.uniq
49
+ end
50
+
51
+ def inherits_field?(ivar, method)
52
+ superclass != JSONAPI::Resource &&
53
+ superclass.respond_to?(method) &&
54
+ superclass.instance_variable_defined?(ivar)
55
+ end
56
+
57
+ def should_inherit_attributes?(declared_attributes)
58
+ !declared_attributes &&
59
+ superclass != JSONAPI::Resource &&
60
+ superclass.respond_to?(:permitted_attributes)
61
+ end
62
+ end
63
+
64
+ included do
65
+ extend FieldResolution
66
+ end
67
+ end
68
+ end
69
+ end