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.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +82 -38
- data/Gemfile +12 -10
- data/Gemfile.lock +10 -1
- data/README.md +675 -1235
- data/jpie.gemspec +15 -15
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +0 -1
- data/lib/json_api/active_storage/deserialization.rb +32 -22
- data/lib/json_api/active_storage/detection.rb +36 -41
- data/lib/json_api/active_storage/serialization.rb +13 -11
- data/lib/json_api/configuration.rb +4 -5
- data/lib/json_api/controllers/base_controller.rb +3 -3
- data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
- data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
- data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
- data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
- data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
- data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
- data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
- data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
- data/lib/json_api/controllers/relationships_controller.rb +26 -422
- data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
- data/lib/json_api/railtie.rb +46 -9
- data/lib/json_api/resources/active_storage_blob_resource.rb +9 -1
- data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
- data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
- data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
- data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
- data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
- data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
- data/lib/json_api/resources/resource.rb +13 -219
- data/lib/json_api/routing.rb +56 -47
- data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
- data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
- data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
- data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
- data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
- data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
- data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
- data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
- data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
- data/lib/json_api/serialization/deserializer.rb +10 -346
- data/lib/json_api/serialization/serializer.rb +17 -260
- data/lib/json_api/support/active_storage_support.rb +10 -13
- data/lib/json_api/support/collection_query.rb +14 -370
- data/lib/json_api/support/concerns/condition_building.rb +57 -0
- data/lib/json_api/support/concerns/nested_filters.rb +130 -0
- data/lib/json_api/support/concerns/pagination.rb +30 -0
- data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
- data/lib/json_api/support/concerns/regular_filters.rb +81 -0
- data/lib/json_api/support/concerns/sorting.rb +88 -0
- data/lib/json_api/support/instrumentation.rb +13 -12
- data/lib/json_api/support/param_helpers.rb +9 -6
- data/lib/json_api/support/relationship_helpers.rb +4 -2
- data/lib/json_api/support/resource_identifier.rb +29 -29
- data/lib/json_api/support/responders.rb +5 -5
- data/lib/json_api/version.rb +1 -1
- metadata +51 -1
|
@@ -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
|