jpie 0.4.5 → 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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +26 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +76 -107
  6. data/.travis.yml +7 -0
  7. data/Gemfile +23 -0
  8. data/Gemfile.lock +321 -0
  9. data/README.md +1508 -136
  10. data/Rakefile +3 -14
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/jpie.gemspec +21 -38
  14. data/kiln/app/resources/user_message_resource.rb +4 -0
  15. data/lib/jpie.rb +3 -25
  16. data/lib/json_api/active_storage/deserialization.rb +116 -0
  17. data/lib/json_api/active_storage/detection.rb +69 -0
  18. data/lib/json_api/active_storage/serialization.rb +34 -0
  19. data/lib/json_api/configuration.rb +57 -0
  20. data/lib/json_api/controllers/base_controller.rb +26 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  22. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  23. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  24. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  25. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  26. data/lib/json_api/controllers/concerns/controller_helpers.rb +19 -0
  27. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  28. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  29. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  30. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  31. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  32. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  33. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  36. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  37. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  38. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  39. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  40. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  44. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  45. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  46. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  47. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  48. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  49. data/lib/json_api/controllers/concerns/resource_actions.rb +106 -0
  50. data/lib/json_api/controllers/relationships_controller.rb +108 -0
  51. data/lib/json_api/controllers/resources_controller.rb +6 -0
  52. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  53. data/lib/json_api/railtie.rb +112 -0
  54. data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
  55. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  56. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  57. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  58. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  59. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  60. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  61. data/lib/json_api/resources/resource.rb +32 -0
  62. data/lib/json_api/resources/resource_loader.rb +35 -0
  63. data/lib/json_api/routing.rb +81 -0
  64. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  65. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  66. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  67. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  68. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  69. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  70. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  71. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  72. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  73. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  74. data/lib/json_api/serialization/deserializer.rb +26 -0
  75. data/lib/json_api/serialization/serializer.rb +77 -0
  76. data/lib/json_api/support/active_storage_support.rb +82 -0
  77. data/lib/json_api/support/collection_query.rb +50 -0
  78. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  79. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  80. data/lib/json_api/support/concerns/pagination.rb +30 -0
  81. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  82. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  83. data/lib/json_api/support/concerns/sorting.rb +88 -0
  84. data/lib/json_api/support/instrumentation.rb +43 -0
  85. data/lib/json_api/support/param_helpers.rb +54 -0
  86. data/lib/json_api/support/relationship_guard.rb +16 -0
  87. data/lib/json_api/support/relationship_helpers.rb +76 -0
  88. data/lib/json_api/support/resource_identifier.rb +87 -0
  89. data/lib/json_api/support/responders.rb +100 -0
  90. data/lib/json_api/support/response_helpers.rb +10 -0
  91. data/lib/json_api/support/sort_parsing.rb +21 -0
  92. data/lib/json_api/support/type_conversion.rb +21 -0
  93. data/lib/json_api/testing/test_helper.rb +76 -0
  94. data/lib/json_api/testing.rb +3 -0
  95. data/lib/{jpie → json_api}/version.rb +2 -2
  96. data/lib/json_api.rb +50 -0
  97. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  98. metadata +100 -169
  99. data/.cursor/rules/dependencies.mdc +0 -19
  100. data/.cursor/rules/examples.mdc +0 -16
  101. data/.cursor/rules/git.mdc +0 -14
  102. data/.cursor/rules/project_structure.mdc +0 -30
  103. data/.cursor/rules/publish_gem.mdc +0 -73
  104. data/.cursor/rules/security.mdc +0 -14
  105. data/.cursor/rules/style.mdc +0 -15
  106. data/.cursor/rules/testing.mdc +0 -16
  107. data/.overcommit.yml +0 -35
  108. data/CHANGELOG.md +0 -164
  109. data/LICENSE.txt +0 -21
  110. data/PUBLISHING.md +0 -111
  111. data/examples/basic_example.md +0 -146
  112. data/examples/including_related_resources.md +0 -491
  113. data/examples/pagination.md +0 -303
  114. data/examples/relationships.md +0 -114
  115. data/examples/resource_attribute_configuration.md +0 -147
  116. data/examples/resource_meta_configuration.md +0 -244
  117. data/examples/rspec_testing.md +0 -130
  118. data/examples/single_table_inheritance.md +0 -160
  119. data/lib/jpie/configuration.rb +0 -12
  120. data/lib/jpie/controller/crud_actions.rb +0 -141
  121. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  122. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  123. data/lib/jpie/controller/error_handling.rb +0 -23
  124. data/lib/jpie/controller/json_api_validation.rb +0 -193
  125. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  126. data/lib/jpie/controller/related_actions.rb +0 -45
  127. data/lib/jpie/controller/relationship_actions.rb +0 -291
  128. data/lib/jpie/controller/relationship_validation.rb +0 -117
  129. data/lib/jpie/controller/rendering.rb +0 -154
  130. data/lib/jpie/controller.rb +0 -45
  131. data/lib/jpie/deserializer.rb +0 -110
  132. data/lib/jpie/errors.rb +0 -117
  133. data/lib/jpie/generators/resource_generator.rb +0 -116
  134. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  135. data/lib/jpie/railtie.rb +0 -42
  136. data/lib/jpie/resource/attributable.rb +0 -112
  137. data/lib/jpie/resource/inferrable.rb +0 -43
  138. data/lib/jpie/resource/sortable.rb +0 -93
  139. data/lib/jpie/resource.rb +0 -147
  140. data/lib/jpie/routing.rb +0 -59
  141. data/lib/jpie/serializer.rb +0 -205
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Relationships
5
+ module Updating
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def update_relationship(relationship_data)
11
+ association = @resource.class.reflect_on_association(@relationship_name)
12
+ raise ArgumentError, "Association not found: #{@relationship_name}" unless association
13
+
14
+ ensure_relationship_writable!(association)
15
+ apply_relationship_update(association, relationship_data)
16
+ end
17
+
18
+ def apply_relationship_update(association, relationship_data)
19
+ if association.collection?
20
+ update_to_many_relationship(association, relationship_data)
21
+ else
22
+ update_to_one_relationship(association, relationship_data)
23
+ end
24
+ end
25
+
26
+ def update_to_many_relationship(association, relationship_data)
27
+ if relationship_data.nil? || empty_array?(relationship_data)
28
+ @resource.public_send("#{@relationship_name}=", [])
29
+ return
30
+ end
31
+
32
+ raise ArgumentError, "Expected array for to-many relationship" unless relationship_data.is_a?(Array)
33
+
34
+ related_resources = resolve_related_resources(relationship_data, association)
35
+ @resource.public_send("#{@relationship_name}=", related_resources)
36
+ end
37
+
38
+ def empty_array?(data)
39
+ data.is_a?(Array) && data.empty?
40
+ end
41
+
42
+ def resolve_related_resources(relationship_data, association)
43
+ relationship_data.map { |identifier| find_related_resource(identifier, association) }
44
+ end
45
+
46
+ def update_to_one_relationship(association, relationship_data)
47
+ if relationship_data.nil?
48
+ @resource.public_send("#{@relationship_name}=", nil)
49
+ return
50
+ end
51
+
52
+ validate_single_identifier!(relationship_data)
53
+ related_resource = find_related_resource(relationship_data, association)
54
+ @resource.public_send("#{@relationship_name}=", related_resource)
55
+ end
56
+
57
+ def validate_single_identifier!(relationship_data)
58
+ return unless relationship_data.is_a?(Array)
59
+
60
+ raise ArgumentError, "Expected single resource identifier for to-one relationship"
61
+ end
62
+
63
+ def find_related_resource(identifier, association)
64
+ RelationshipHelpers.resolve_and_find_related_resource(
65
+ identifier,
66
+ association:,
67
+ resource_class: @resource_class,
68
+ relationship_name: @relationship_name,
69
+ )
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module RelationshipsController
5
+ module ActiveStorageRemoval
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def remove_active_storage_relationship(relationship_data)
11
+ reflection = @resource.class.reflect_on_attachment(@relationship_name)
12
+ if reflection&.macro == :has_many_attached
13
+ remove_many_active_storage_attachments(relationship_data)
14
+ else
15
+ remove_one_active_storage_attachment(relationship_data)
16
+ end
17
+ end
18
+
19
+ def remove_many_active_storage_attachments(relationship_data)
20
+ raise ArgumentError, "Expected array for to-many relationship removal" unless relationship_data.is_a?(Array)
21
+ return if relationship_data.empty?
22
+
23
+ attachment_proxy = @resource.public_send(@relationship_name)
24
+ return unless attachment_proxy.attached?
25
+
26
+ blob_ids = extract_blob_ids(relationship_data)
27
+ attachments = attachment_proxy.attachments.where(blob_id: blob_ids)
28
+ attachments.each(&:purge)
29
+ end
30
+
31
+ def remove_one_active_storage_attachment(relationship_data)
32
+ validate_single_removal_identifier!(relationship_data)
33
+ return if relationship_data.nil?
34
+
35
+ attachment_proxy = @resource.public_send(@relationship_name)
36
+ return unless attachment_proxy.attached?
37
+
38
+ blob_id = extract_single_blob_id(relationship_data)
39
+ attachment = attachment_proxy.attachments.find_by(blob_id:)
40
+ attachment&.purge
41
+ end
42
+
43
+ def extract_blob_ids(relationship_data)
44
+ relationship_data.map do |identifier|
45
+ extract_blob_id_from_identifier(identifier)
46
+ end
47
+ end
48
+
49
+ def extract_single_blob_id(relationship_data)
50
+ extract_blob_id_from_identifier(relationship_data)
51
+ end
52
+
53
+ def extract_blob_id_from_identifier(identifier)
54
+ type = RelationshipHelpers.extract_type_from_identifier(identifier)
55
+ id = RelationshipHelpers.extract_id_from_identifier(identifier)
56
+ validate_blob_type!(type)
57
+ id.to_i
58
+ end
59
+
60
+ def validate_blob_type!(type)
61
+ return if self.class.active_storage_blob_type?(type)
62
+
63
+ raise ArgumentError, "Expected active_storage_blobs type, got #{type}"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module RelationshipsController
5
+ module Events
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def emit_relationship_event(action, relationship_data)
11
+ resource_type_name = params[:resource_type] || @resource_name.pluralize
12
+
13
+ JSONAPI::Instrumentation.relationship_event(
14
+ action:,
15
+ resource_type: resource_type_name,
16
+ resource_id: @resource.id,
17
+ relationship_name: @relationship_name.to_s,
18
+ related_ids: extract_related_ids(relationship_data),
19
+ related_type: extract_related_type(relationship_data),
20
+ )
21
+ end
22
+
23
+ def extract_related_ids(relationship_data)
24
+ return nil if relationship_data.nil?
25
+
26
+ if relationship_data.is_a?(Array)
27
+ relationship_data.filter_map { |item| item[:id] }
28
+ else
29
+ [relationship_data[:id]].compact
30
+ end
31
+ end
32
+
33
+ def extract_related_type(relationship_data)
34
+ return nil if relationship_data.nil?
35
+
36
+ if relationship_data.is_a?(Array)
37
+ relationship_data.first&.dig(:type)
38
+ else
39
+ relationship_data[:type]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_storage_removal"
4
+
5
+ module JSONAPI
6
+ module RelationshipsController
7
+ module Removal
8
+ extend ActiveSupport::Concern
9
+ include ActiveStorageRemoval
10
+
11
+ private
12
+
13
+ def remove_relationship(relationship_data)
14
+ if active_storage_attachment?(@relationship_name, @resource.class)
15
+ return remove_active_storage_relationship(relationship_data)
16
+ end
17
+
18
+ remove_association_relationship(relationship_data)
19
+ rescue ActiveRecord::NotNullViolation, ActiveRecord::RecordInvalid => e
20
+ raise ArgumentError, "Cannot remove relationship: #{e.message}"
21
+ end
22
+
23
+ def remove_association_relationship(relationship_data)
24
+ association = @resource.class.reflect_on_association(@relationship_name)
25
+ raise ArgumentError, "Association not found: #{@relationship_name}" unless association
26
+
27
+ ensure_relationship_writable!(association)
28
+ dispatch_relationship_removal(association, relationship_data)
29
+ end
30
+
31
+ def dispatch_relationship_removal(association, relationship_data)
32
+ if association.collection?
33
+ remove_from_many_relationship(association, relationship_data)
34
+ else
35
+ remove_from_one_relationship(association, relationship_data)
36
+ end
37
+ end
38
+
39
+ def remove_from_many_relationship(association, relationship_data)
40
+ raise ArgumentError, "Expected array for to-many relationship removal" unless relationship_data.is_a?(Array)
41
+ return if relationship_data.empty?
42
+
43
+ remove_from_collection(association, relationship_data)
44
+ end
45
+
46
+ def remove_from_collection(association, relationship_data)
47
+ collection_ids = @resource.public_send(@relationship_name).pluck(:id)
48
+ foreign_key = association.foreign_key
49
+
50
+ ActiveRecord::Base.transaction do
51
+ relationship_data.each do |identifier|
52
+ remove_resource_from_collection(identifier, association, collection_ids, foreign_key)
53
+ end
54
+ end
55
+ end
56
+
57
+ def remove_resource_from_collection(identifier, association, collection_ids, foreign_key)
58
+ resource = RelationshipHelpers.resolve_and_find_related_resource(
59
+ identifier,
60
+ association:,
61
+ resource_class: @resource_class,
62
+ relationship_name: @relationship_name,
63
+ )
64
+ return unless collection_ids.include?(resource.id)
65
+
66
+ resource.update!(foreign_key => nil)
67
+ end
68
+
69
+ def remove_from_one_relationship(association, relationship_data)
70
+ validate_single_removal_identifier!(relationship_data)
71
+ return if relationship_data.nil?
72
+
73
+ remove_single_association(association, relationship_data)
74
+ end
75
+
76
+ def validate_single_removal_identifier!(relationship_data)
77
+ return unless relationship_data.is_a?(Array)
78
+
79
+ raise ArgumentError, "Expected single resource identifier for to-one relationship"
80
+ end
81
+
82
+ def remove_single_association(association, relationship_data)
83
+ related_resource = find_related_resource(relationship_data, association)
84
+ current_resource = @resource.public_send(@relationship_name)
85
+ return unless current_resource == related_resource
86
+
87
+ @resource.public_send("#{@relationship_name}=", nil)
88
+ @resource.save!
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module RelationshipsController
5
+ module ResponseHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def build_show_response
11
+ response_data = { data: serialize_relationship_data, links: serialize_relationship_links }
12
+ meta = serialize_relationship_meta
13
+ response_data[:meta] = meta if meta.present?
14
+ response_data
15
+ end
16
+
17
+ def save_and_render_relationship(relationship_data)
18
+ if @resource.save
19
+ emit_relationship_event(:updated, relationship_data)
20
+ render_relationship_response
21
+ else
22
+ render_validation_errors(@resource)
23
+ end
24
+ end
25
+
26
+ def render_relationship_response
27
+ render json: {
28
+ data: serialize_relationship_data,
29
+ links: serialize_relationship_links,
30
+ meta: serialize_relationship_meta,
31
+ }.compact, status: :ok
32
+ end
33
+
34
+ def finalize_relationship_removal(relationship_data)
35
+ association = @resource.class.reflect_on_association(@relationship_name)
36
+ return emit_and_respond_no_content(relationship_data) unless association && !association.collection?
37
+
38
+ save_to_one_removal(relationship_data)
39
+ end
40
+
41
+ def save_to_one_removal(relationship_data)
42
+ if @resource.save
43
+ emit_and_respond_no_content(relationship_data)
44
+ else
45
+ render_validation_errors(@resource)
46
+ end
47
+ end
48
+
49
+ def emit_and_respond_no_content(relationship_data)
50
+ emit_relationship_event(:removed, relationship_data)
51
+ head :no_content
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module RelationshipsController
5
+ module Serialization
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def serialize_relationship_data
11
+ association = @resource.class.reflect_on_association(@relationship_name)
12
+ return nil unless association
13
+
14
+ related = fetch_related_data(association)
15
+ serialize_related(related, association)
16
+ end
17
+
18
+ def fetch_related_data(association)
19
+ related = @resource.public_send(@relationship_name)
20
+ return related unless association.collection? && related.respond_to?(:order)
21
+
22
+ apply_sorting_to_relationship(related, association)
23
+ end
24
+
25
+ def serialize_related(related, association)
26
+ return serialize_collection_relationship(related, association) if association.collection?
27
+
28
+ serialize_single_relationship(related, association) if related
29
+ end
30
+
31
+ def serialize_collection_relationship(related, association)
32
+ return [] if related.nil?
33
+
34
+ related.map { |r| serialize_resource_identifier(r, association) }
35
+ end
36
+
37
+ def serialize_single_relationship(related, association)
38
+ serialize_resource_identifier(related, association)
39
+ end
40
+
41
+ def serialize_resource_identifier(resource_instance, association)
42
+ RelationshipHelpers.serialize_resource_identifier(
43
+ resource_instance,
44
+ association:,
45
+ resource_class: @resource_class,
46
+ )
47
+ end
48
+
49
+ def serialize_relationship_links
50
+ {
51
+ self: relationship_self_url,
52
+ related: relationship_related_url,
53
+ }
54
+ end
55
+
56
+ def relationship_self_url
57
+ "/#{params[:resource_type]}/#{params[:id]}/relationships/#{params[:relationship_name]}"
58
+ end
59
+
60
+ def relationship_related_url
61
+ "/#{params[:resource_type]}/#{params[:id]}/#{params[:relationship_name]}"
62
+ end
63
+
64
+ def serialize_relationship_meta
65
+ relationship_def = find_relationship_definition
66
+ return nil unless relationship_def
67
+
68
+ relationship_def[:meta]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module RelationshipsController
5
+ module Sorting
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def apply_sorting_to_relationship(related, association)
11
+ sorts = parse_sort_param
12
+ return related if sorts.empty?
13
+
14
+ related_model = association.klass
15
+ db_sorts, virtual_sorts = partition_sorts_by_type(sorts, related_model)
16
+
17
+ related = apply_db_sorts(related, db_sorts)
18
+ apply_virtual_sorts_if_needed(related, virtual_sorts, related_model)
19
+ end
20
+
21
+ def partition_sorts_by_type(sorts, related_model)
22
+ sorts.partition do |sort_field|
23
+ field = RelationshipHelpers.extract_sort_field_name(sort_field)
24
+ related_model.column_names.include?(field.to_s)
25
+ end
26
+ end
27
+
28
+ def apply_db_sorts(related, db_sorts)
29
+ db_sorts.each do |sort_field|
30
+ direction = RelationshipHelpers.extract_sort_direction(sort_field)
31
+ field = RelationshipHelpers.extract_sort_field_name(sort_field)
32
+ related = related.order(field => direction)
33
+ end
34
+ related
35
+ end
36
+
37
+ def apply_virtual_sorts_if_needed(related, virtual_sorts, related_model)
38
+ return related unless virtual_sorts.any?
39
+
40
+ resource_class = ResourceLoader.find_for_model(related_model)
41
+ sort_records_by_virtual_attributes(related.to_a, virtual_sorts, resource_class)
42
+ end
43
+
44
+ def sort_records_by_virtual_attributes(records, virtual_sorts, resource_class)
45
+ records.sort do |a, b|
46
+ compare_records(a, b, virtual_sorts, resource_class)
47
+ end
48
+ end
49
+
50
+ def compare_records(record_a, record_b, virtual_sorts, resource_class)
51
+ virtual_sorts.each do |sort_field|
52
+ comparison = compare_by_field(record_a, record_b, sort_field, resource_class)
53
+ return comparison unless comparison.zero?
54
+ end
55
+ 0
56
+ end
57
+
58
+ def compare_by_field(record_a, record_b, sort_field, resource_class)
59
+ direction = RelationshipHelpers.extract_sort_direction(sort_field)
60
+ field = RelationshipHelpers.extract_sort_field_name(sort_field)
61
+
62
+ value_a = get_virtual_value(record_a, field, resource_class)
63
+ value_b = get_virtual_value(record_b, field, resource_class)
64
+
65
+ comparison = compare_values(value_a, value_b)
66
+ direction == :desc ? -comparison : comparison
67
+ end
68
+
69
+ def get_virtual_value(record, field, resource_class)
70
+ resource_instance = resource_class.new(record, {})
71
+ field_sym = field.to_sym
72
+ return resource_instance.public_send(field_sym) if resource_instance.respond_to?(field_sym, false)
73
+
74
+ nil
75
+ end
76
+
77
+ def compare_values(value_a, value_b)
78
+ return 0 if value_a.nil? && value_b.nil?
79
+ return -1 if value_a.nil?
80
+ return 1 if value_b.nil?
81
+
82
+ value_a <=> value_b
83
+ end
84
+
85
+ def validate_sort_param
86
+ sorts = parse_sort_param
87
+ return if sorts.empty?
88
+
89
+ association = @resource.class.reflect_on_association(@relationship_name)
90
+ return unless association&.collection?
91
+
92
+ validate_sort_fields_for_association(sorts, association)
93
+ end
94
+
95
+ def validate_sort_fields_for_association(sorts, association)
96
+ resource_class = ResourceLoader.find_for_model(association.klass)
97
+ valid_fields = valid_sort_fields_for_resource(resource_class, association.klass)
98
+ invalid_fields = invalid_sort_fields_for_columns(sorts, valid_fields)
99
+ return if invalid_fields.empty?
100
+
101
+ render_invalid_sort_fields(invalid_fields)
102
+ end
103
+
104
+ def render_invalid_sort_fields(invalid_fields)
105
+ render_parameter_errors(
106
+ invalid_fields,
107
+ title: "Invalid Sort Field",
108
+ detail_proc: ->(field) { "Invalid sort field requested: #{field}" },
109
+ source_proc: ->(_field) { { parameter: "sort" } },
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module RelationshipsController
5
+ module Updating
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def update_relationship(relationship_data)
11
+ association = @resource.class.reflect_on_association(@relationship_name)
12
+ raise ArgumentError, "Association not found: #{@relationship_name}" unless association
13
+
14
+ ensure_relationship_writable!(association)
15
+ apply_relationship_update(association, relationship_data)
16
+ end
17
+
18
+ def apply_relationship_update(association, relationship_data)
19
+ if association.collection?
20
+ update_to_many_relationship(association, relationship_data)
21
+ else
22
+ update_to_one_relationship(association, relationship_data)
23
+ end
24
+ end
25
+
26
+ def update_to_many_relationship(association, relationship_data)
27
+ if relationship_data.nil? || empty_array?(relationship_data)
28
+ @resource.public_send("#{@relationship_name}=", [])
29
+ return
30
+ end
31
+
32
+ raise ArgumentError, "Expected array for to-many relationship" unless relationship_data.is_a?(Array)
33
+
34
+ related_resources = resolve_related_resources(relationship_data, association)
35
+ @resource.public_send("#{@relationship_name}=", related_resources)
36
+ end
37
+
38
+ def empty_array?(data)
39
+ data.is_a?(Array) && data.empty?
40
+ end
41
+
42
+ def resolve_related_resources(relationship_data, association)
43
+ relationship_data.map { |identifier| find_related_resource(identifier, association) }
44
+ end
45
+
46
+ def update_to_one_relationship(association, relationship_data)
47
+ if relationship_data.nil?
48
+ @resource.public_send("#{@relationship_name}=", nil)
49
+ return
50
+ end
51
+
52
+ validate_single_identifier!(relationship_data)
53
+ related_resource = find_related_resource(relationship_data, association)
54
+ @resource.public_send("#{@relationship_name}=", related_resource)
55
+ end
56
+
57
+ def validate_single_identifier!(relationship_data)
58
+ return unless relationship_data.is_a?(Array)
59
+
60
+ raise ArgumentError, "Expected single resource identifier for to-one relationship"
61
+ end
62
+
63
+ def find_related_resource(identifier, association)
64
+ RelationshipHelpers.resolve_and_find_related_resource(
65
+ identifier,
66
+ association:,
67
+ resource_class: @resource_class,
68
+ relationship_name: @relationship_name,
69
+ )
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module ResourceActions
5
+ module CrudHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def prepare_create_params(sti_class)
11
+ hash = deserialize_params(:create, model_class: sti_class)
12
+ attachments = extract_active_storage_params_from_hash(hash, sti_class)
13
+ clean_params(hash, attachments)
14
+ apply_sti_type(sti_class, hash)
15
+ [hash, attachments]
16
+ end
17
+
18
+ def prepare_update_params
19
+ hash = deserialize_params(:update)
20
+ attachments = extract_active_storage_params_from_hash(hash, model_class)
21
+ clean_params(hash, attachments)
22
+ [hash, attachments]
23
+ end
24
+
25
+ def clean_params(hash, attachments)
26
+ attachments.each_key { |k| hash.delete(k.to_s) }
27
+ hash.delete("type")
28
+ hash.delete(:type)
29
+ end
30
+
31
+ def apply_sti_type(klass, params)
32
+ return unless sti_base_with_type_column?(klass)
33
+
34
+ params["type"] = klass.name
35
+ end
36
+
37
+ def sti_base_with_type_column?(klass)
38
+ klass.respond_to?(:base_class) && klass.base_class == klass && klass.column_names.include?("type")
39
+ end
40
+
41
+ def save_created(resource)
42
+ return render_validation_errors(resource) unless resource.save
43
+
44
+ emit_resource_event(:created, resource)
45
+ render json: serialize_resource(resource), status: :created, location: resource_url(resource)
46
+ end
47
+
48
+ def save_updated(params_hash, attachments)
49
+ return render_validation_errors(@resource) unless @resource.update(params_hash)
50
+
51
+ attach_active_storage_files(@resource, attachments, resource_class: @resource_class)
52
+ emit_resource_event(:updated, @resource)
53
+ render json: serialize_resource(@resource), status: :ok
54
+ end
55
+
56
+ def render_create_error(error)
57
+ if error.message.match?(/invalid.*subtype/i)
58
+ render_invalid_subtype_error(error)
59
+ else
60
+ render_invalid_relationship_error(error)
61
+ end
62
+ end
63
+
64
+ def render_update_error(error)
65
+ if error.is_a?(ActiveSupport::MessageVerifier::InvalidSignature)
66
+ render_signed_id_error(error)
67
+ else
68
+ render_invalid_relationship_error(error)
69
+ end
70
+ end
71
+
72
+ def render_signed_id_error(error)
73
+ render_jsonapi_error(
74
+ status: 400,
75
+ title: "Invalid Signed ID",
76
+ detail: "Invalid signed blob ID provided: #{error.message}",
77
+ )
78
+ end
79
+
80
+ def determine_sti_class
81
+ JSONAPI::ResourceLoader.find(jsonapi_type || resource_type).model_class
82
+ rescue JSONAPI::ResourceLoader::MissingResourceClass
83
+ model_class
84
+ end
85
+
86
+ def determine_sti_resource_class
87
+ JSONAPI::ResourceLoader.find(jsonapi_type || resource_type)
88
+ rescue JSONAPI::ResourceLoader::MissingResourceClass
89
+ @resource_class
90
+ end
91
+ end
92
+ end
93
+ end