jpie 1.0.0 → 1.0.2
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 +13 -10
- data/Gemfile.lock +18 -1
- data/README.md +675 -1235
- data/Rakefile +22 -0
- 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/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/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 +44 -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,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
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module FieldValidation
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def validate_fields_param
|
|
9
|
+
fields = parse_fields_param
|
|
10
|
+
return if fields.empty?
|
|
11
|
+
|
|
12
|
+
error = first_invalid_field(fields)
|
|
13
|
+
render_field_error(error) if error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate_sort_param
|
|
17
|
+
sorts = parse_sort_param
|
|
18
|
+
return if sorts.empty?
|
|
19
|
+
|
|
20
|
+
valid = valid_sort_fields_for_resource(@resource_class, model_class)
|
|
21
|
+
invalid = invalid_sort_fields_for_columns(sorts, valid)
|
|
22
|
+
render_sort_errors(invalid) if invalid.any?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_include_param
|
|
26
|
+
includes = parse_include_param
|
|
27
|
+
return if includes.empty?
|
|
28
|
+
|
|
29
|
+
permitted = @resource_class.relationship_names.map(&:to_s)
|
|
30
|
+
invalid = includes.reject { |p| include_path_valid?(p, permitted) }
|
|
31
|
+
render_include_errors(invalid) if invalid.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def first_invalid_field(fields)
|
|
37
|
+
fields.each do |type, type_fields|
|
|
38
|
+
next if type_fields.nil? || type_fields.empty?
|
|
39
|
+
|
|
40
|
+
error = check_field_type(type.to_s, type_fields)
|
|
41
|
+
return error if error
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_field_type(type, fields)
|
|
47
|
+
return check_blob_fields(fields) if type == "active_storage_blobs"
|
|
48
|
+
return check_resource_fields(fields) if type == resource_type.to_s
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check_blob_fields(fields)
|
|
54
|
+
permitted = %w[id key filename content_type byte_size checksum created_at service_name]
|
|
55
|
+
invalid = fields.reject { |f| permitted.include?(f) }
|
|
56
|
+
invalid.any? ? { type: "active_storage_blobs", invalid: } : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_resource_fields(fields)
|
|
60
|
+
permitted = @resource_class.permitted_attributes.map(&:to_s)
|
|
61
|
+
invalid = fields.reject { |f| permitted.include?(f) }
|
|
62
|
+
invalid.any? ? { type: resource_type.to_s, invalid: } : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def include_path_valid?(path, permitted)
|
|
66
|
+
parts = path.split(".")
|
|
67
|
+
return false unless permitted.include?(parts.first)
|
|
68
|
+
|
|
69
|
+
nested_path_valid?(parts)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def nested_path_valid?(parts)
|
|
73
|
+
current = model_class
|
|
74
|
+
parts.each do |name|
|
|
75
|
+
return true if self.class.active_storage_attachment?(name, current)
|
|
76
|
+
|
|
77
|
+
assoc = current.reflect_on_association(name.to_sym)
|
|
78
|
+
return false unless assoc
|
|
79
|
+
break if assoc.polymorphic?
|
|
80
|
+
|
|
81
|
+
current = assoc.klass
|
|
82
|
+
end
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_field_error(error)
|
|
87
|
+
render_parameter_errors(
|
|
88
|
+
error[:invalid],
|
|
89
|
+
title: "Invalid Field",
|
|
90
|
+
detail_proc: ->(f) { "#{f} is not a valid field for #{error[:type]}" },
|
|
91
|
+
source_proc: ->(f) { { parameter: "fields[#{error[:type]}]=#{f}" } },
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_sort_errors(invalid)
|
|
96
|
+
render_parameter_errors(
|
|
97
|
+
invalid,
|
|
98
|
+
title: "Invalid Sort Field",
|
|
99
|
+
detail_proc: ->(f) { "Invalid sort field requested: #{f}" },
|
|
100
|
+
source_proc: ->(_) { { parameter: "sort" } },
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_include_errors(invalid)
|
|
105
|
+
render_parameter_errors(
|
|
106
|
+
invalid,
|
|
107
|
+
title: "Invalid Include Path",
|
|
108
|
+
detail_proc: ->(p) { "Invalid include path requested: #{p}" },
|
|
109
|
+
source_proc: ->(_) { { parameter: "include" } },
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module FilterValidation
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def validate_filter_param
|
|
9
|
+
filters = parse_filter_param
|
|
10
|
+
return if filters.empty?
|
|
11
|
+
|
|
12
|
+
invalid = filters.keys.reject { |n| filter_path_valid?(n.to_s) }
|
|
13
|
+
render_filter_errors(invalid) if invalid.any?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def filter_path_valid?(name)
|
|
19
|
+
parts = name.split(".")
|
|
20
|
+
check_filter_parts(parts, @resource_class, model_class)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def check_filter_parts(parts, res_cls, mod_cls, allow_related: false)
|
|
24
|
+
return false if parts.empty?
|
|
25
|
+
return single_filter_valid?(parts.first, res_cls, mod_cls, allow_related) if parts.length == 1
|
|
26
|
+
|
|
27
|
+
check_nested_filter(parts, res_cls, mod_cls)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def single_filter_valid?(name, res_cls, mod_cls, allow_related)
|
|
31
|
+
return true if res_cls.permitted_filters.map(&:to_s).include?(name)
|
|
32
|
+
return false unless allow_related
|
|
33
|
+
|
|
34
|
+
related_column_valid?(name, mod_cls)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def related_column_valid?(name, mod_cls)
|
|
38
|
+
col = parse_column_filter(name)
|
|
39
|
+
return true if col && mod_cls.column_for_attribute(col[:column])
|
|
40
|
+
return true if mod_cls.column_names.include?(name)
|
|
41
|
+
|
|
42
|
+
mod_cls.respond_to?(name.to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_nested_filter(parts, res_cls, mod_cls)
|
|
46
|
+
rel, *rest = parts
|
|
47
|
+
return false unless filter_rel_allowed?(res_cls, rel)
|
|
48
|
+
|
|
49
|
+
assoc = mod_cls.reflect_on_association(rel.to_sym)
|
|
50
|
+
return false unless assoc
|
|
51
|
+
return polymorphic_filter_valid?(rest) if assoc.polymorphic?
|
|
52
|
+
|
|
53
|
+
check_related_filter(rest, assoc)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_related_filter(parts, assoc)
|
|
57
|
+
rel_mod = assoc.klass
|
|
58
|
+
rel_res = JSONAPI::Resource.resource_for_model(rel_mod)
|
|
59
|
+
return false unless rel_res
|
|
60
|
+
|
|
61
|
+
check_filter_parts(parts, rel_res, rel_mod, allow_related: true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def filter_rel_allowed?(res_cls, rel)
|
|
65
|
+
permitted = res_cls.permitted_filters_through
|
|
66
|
+
permitted.include?(rel.to_sym) || permitted.map(&:to_s).include?(rel.to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def polymorphic_filter_valid?(parts)
|
|
70
|
+
return false if parts.empty? || parts.length > 1
|
|
71
|
+
|
|
72
|
+
m = parts.first.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
73
|
+
%w[id type].include?(m ? m[1] : parts.first)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_column_filter(name)
|
|
77
|
+
m = name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
78
|
+
m ? { column: m[1], operator: m[2].to_sym } : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_filter_errors(invalid)
|
|
82
|
+
render_parameter_errors(
|
|
83
|
+
invalid,
|
|
84
|
+
title: "Invalid Filter",
|
|
85
|
+
detail_proc: ->(n) { "Invalid filter requested: #{n}" },
|
|
86
|
+
source_proc: ->(n) { { parameter: "filter[#{n}]" } },
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module Pagination
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def build_pagination_links
|
|
11
|
+
page_params = parse_page_param
|
|
12
|
+
current = page_params["number"]&.to_i || 1
|
|
13
|
+
size = calculate_page_size(page_params)
|
|
14
|
+
total = calculate_total_pages(size)
|
|
15
|
+
|
|
16
|
+
links = base_pagination_links(current, size, total)
|
|
17
|
+
links[:prev] = pagination_url(current - 1, size) if current > 1
|
|
18
|
+
links[:next] = pagination_url(current + 1, size) if current < total
|
|
19
|
+
links
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def base_pagination_links(current, size, total)
|
|
23
|
+
{
|
|
24
|
+
self: pagination_url(current, size),
|
|
25
|
+
first: pagination_url(1, size),
|
|
26
|
+
last: pagination_url(total, size),
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def calculate_page_size(page_params)
|
|
31
|
+
size = page_params["size"]&.to_i || JSONAPI.configuration.default_page_size
|
|
32
|
+
[size, JSONAPI.configuration.max_page_size].min
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def calculate_total_pages(size)
|
|
36
|
+
total = (@total_count.to_f / size).ceil
|
|
37
|
+
total.zero? ? 1 : total
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pagination_url(page, size)
|
|
41
|
+
query = request.query_parameters.dup
|
|
42
|
+
query["page"] = { "number" => page, "size" => size }
|
|
43
|
+
"#{request.path}?#{JSONAPI::ParamHelpers.build_query_string(query)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_pagination_meta
|
|
47
|
+
{ total: @total_count }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module Preloading
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def preload_includes
|
|
9
|
+
includes = parse_include_param
|
|
10
|
+
return if includes.empty?
|
|
11
|
+
|
|
12
|
+
includes_hash = build_includes_hash(includes)
|
|
13
|
+
preload_resources(includes_hash)
|
|
14
|
+
rescue ActiveRecord::RecordNotFound
|
|
15
|
+
# Will be handled by set_resource
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def build_includes_hash(includes)
|
|
21
|
+
includes.each_with_object({}) do |path, hash|
|
|
22
|
+
merge_include_path(hash, path)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def merge_include_path(hash, path)
|
|
27
|
+
parts = path.split(".")
|
|
28
|
+
current = hash
|
|
29
|
+
|
|
30
|
+
parts.each_with_index do |part, i|
|
|
31
|
+
sym = part.to_sym
|
|
32
|
+
current[sym] ||= {}
|
|
33
|
+
current = current[sym] if i < parts.length - 1
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def preload_resources(includes_hash)
|
|
38
|
+
filtered = filter_includes_for_preload(includes_hash)
|
|
39
|
+
|
|
40
|
+
case action_name
|
|
41
|
+
when "index" then @preloaded_resources = preload_collection(filtered)
|
|
42
|
+
when "show" then @preloaded_resource = preload_single(filtered)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def filter_includes_for_preload(hash)
|
|
47
|
+
filtered = filter_active_storage_from_includes(hash, model_class)
|
|
48
|
+
filter_polymorphic_from_includes(filtered, model_class)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def preload_collection(includes)
|
|
52
|
+
return model_class.all if includes.empty?
|
|
53
|
+
|
|
54
|
+
model_class.all.includes(includes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def preload_single(includes)
|
|
58
|
+
return model_class.find(params[:id]) if includes.empty?
|
|
59
|
+
|
|
60
|
+
model_class.includes(includes).find(params[:id])
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module ResourceLoading
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def load_jsonapi_resource
|
|
11
|
+
@resource_name = params[:resource_type]&.singularize
|
|
12
|
+
@resource_class = JSONAPI::ResourceLoader.find(@resource_name) if @resource_name
|
|
13
|
+
@model_class = @resource_class.model_class if @resource_class
|
|
14
|
+
@resource = @model_class.find(params[:id]) if params[:id] && @model_class
|
|
15
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
16
|
+
@resource_class = nil
|
|
17
|
+
rescue ActiveRecord::RecordNotFound
|
|
18
|
+
render_record_not_found
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render_record_not_found
|
|
22
|
+
render_jsonapi_error(
|
|
23
|
+
status: 404,
|
|
24
|
+
title: "Record Not Found",
|
|
25
|
+
detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raw_jsonapi_data
|
|
30
|
+
raw = params[:data]
|
|
31
|
+
return {} unless raw
|
|
32
|
+
|
|
33
|
+
symbolize_data(raw)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def symbolize_data(raw)
|
|
37
|
+
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
|
38
|
+
JSONAPI::ParamHelpers.deep_symbolize_params(data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deserialize_params(action = :update, model_class: nil)
|
|
42
|
+
target = model_class || self.model_class
|
|
43
|
+
JSONAPI::Deserializer.new(raw_jsonapi_data, model_class: target, action:).to_model_attributes
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resource_url(resource)
|
|
47
|
+
"/#{resource_type}/#{resource.id}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resource_type
|
|
51
|
+
params[:resource_type] || @resource_name.pluralize
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def emit_resource_event(action, resource, resource_id: nil, resource_type: nil)
|
|
55
|
+
changes = extract_changes(action, resource)
|
|
56
|
+
JSONAPI::Instrumentation.resource_event(
|
|
57
|
+
action:,
|
|
58
|
+
resource_type: resource_type || self.resource_type,
|
|
59
|
+
resource_id: resource_id || resource.id,
|
|
60
|
+
changes:,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_changes(action, resource)
|
|
65
|
+
return {} unless action == :updated && resource.respond_to?(:previous_changes)
|
|
66
|
+
|
|
67
|
+
resource.previous_changes.except("updated_at", "created_at")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module Serialization
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def serialize_resource(resource)
|
|
9
|
+
JSONAPI::Serializer.new(resource).to_hash(
|
|
10
|
+
include: parse_include_param,
|
|
11
|
+
fields: parse_fields_param,
|
|
12
|
+
document_meta: jsonapi_document_meta,
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def serialize_collection(resources)
|
|
17
|
+
includes = parse_include_param
|
|
18
|
+
fields = parse_fields_param
|
|
19
|
+
all_included = []
|
|
20
|
+
processed = Set.new
|
|
21
|
+
|
|
22
|
+
data = resources.map do |r|
|
|
23
|
+
result = serialize_single(r, includes, fields)
|
|
24
|
+
collect_included(result, all_included, processed)
|
|
25
|
+
result[:data]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
build_collection_response(data, all_included)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def serialize_single(resource, includes, fields)
|
|
34
|
+
JSONAPI::Serializer.new(resource).to_hash(include: includes, fields:, document_meta: nil)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def collect_included(result, all_included, processed)
|
|
38
|
+
(result[:included] || []).each do |inc|
|
|
39
|
+
add_unique_included(inc, all_included, processed)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_unique_included(inc, all_included, processed)
|
|
44
|
+
key = "#{inc[:type]}-#{inc[:id]}"
|
|
45
|
+
return if processed.include?(key)
|
|
46
|
+
|
|
47
|
+
all_included << inc
|
|
48
|
+
processed.add(key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_collection_response(data, all_included)
|
|
52
|
+
result = { jsonapi: JSONAPI::Serializer.jsonapi_object, data: }
|
|
53
|
+
result[:included] = all_included if all_included.any?
|
|
54
|
+
|
|
55
|
+
pagination_meta = @pagination_applied ? build_pagination_meta : {}
|
|
56
|
+
result[:links] = build_pagination_links if @pagination_applied
|
|
57
|
+
result[:meta] = jsonapi_document_meta(pagination_meta)
|
|
58
|
+
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
module TypeValidation
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
def validate_resource_type!
|
|
9
|
+
return unless @resource_class
|
|
10
|
+
return if params[:relationship_name].present?
|
|
11
|
+
|
|
12
|
+
requested = jsonapi_type
|
|
13
|
+
expected = resource_type
|
|
14
|
+
return if requested == expected
|
|
15
|
+
return if valid_sti_subtype?(requested)
|
|
16
|
+
|
|
17
|
+
render_type_mismatch_error(expected, requested) and return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_resource_id!
|
|
21
|
+
return unless @resource_class
|
|
22
|
+
|
|
23
|
+
requested = jsonapi_id
|
|
24
|
+
expected = params[:id].to_s
|
|
25
|
+
return if requested == expected
|
|
26
|
+
|
|
27
|
+
render_id_mismatch_error(expected, requested) and return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def valid_sti_subtype?(requested)
|
|
33
|
+
return false unless model_class.respond_to?(:base_class)
|
|
34
|
+
|
|
35
|
+
resource_class = JSONAPI::ResourceLoader.find(requested)
|
|
36
|
+
resource_class.model_class < model_class
|
|
37
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass, NameError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def render_type_mismatch_error(expected, requested)
|
|
42
|
+
detail = type_mismatch_detail(expected, requested)
|
|
43
|
+
render json: type_mismatch_response(detail), status: :conflict
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def type_mismatch_detail(expected, requested)
|
|
47
|
+
return "Missing type member. Expected '#{expected}'" if requested.nil?
|
|
48
|
+
|
|
49
|
+
"Type mismatch: expected '#{expected}', got '#{requested}'"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def type_mismatch_response(detail)
|
|
53
|
+
{
|
|
54
|
+
errors: [{
|
|
55
|
+
status: "409",
|
|
56
|
+
title: "Type Mismatch",
|
|
57
|
+
detail:,
|
|
58
|
+
source: { pointer: "/data/type" },
|
|
59
|
+
}],
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_id_mismatch_error(expected, requested)
|
|
64
|
+
render json: {
|
|
65
|
+
errors: [{
|
|
66
|
+
status: "409",
|
|
67
|
+
title: "ID Mismatch",
|
|
68
|
+
detail: "ID mismatch: expected '#{expected}', got '#{requested}'",
|
|
69
|
+
source: { pointer: "/data/id" },
|
|
70
|
+
}],
|
|
71
|
+
}, status: :conflict
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|