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.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +26 -0
- data/.rspec +3 -0
- data/.rubocop.yml +76 -107
- data/.travis.yml +7 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +321 -0
- data/README.md +1508 -136
- data/Rakefile +3 -14
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/jpie.gemspec +21 -38
- data/kiln/app/resources/user_message_resource.rb +4 -0
- data/lib/jpie.rb +3 -25
- data/lib/json_api/active_storage/deserialization.rb +116 -0
- data/lib/json_api/active_storage/detection.rb +69 -0
- data/lib/json_api/active_storage/serialization.rb +34 -0
- data/lib/json_api/configuration.rb +57 -0
- data/lib/json_api/controllers/base_controller.rb +26 -0
- 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 +19 -0
- 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 +106 -0
- data/lib/json_api/controllers/relationships_controller.rb +108 -0
- data/lib/json_api/controllers/resources_controller.rb +6 -0
- data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
- data/lib/json_api/railtie.rb +112 -0
- data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
- 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 +32 -0
- data/lib/json_api/resources/resource_loader.rb +35 -0
- data/lib/json_api/routing.rb +81 -0
- 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 +26 -0
- data/lib/json_api/serialization/serializer.rb +77 -0
- data/lib/json_api/support/active_storage_support.rb +82 -0
- data/lib/json_api/support/collection_query.rb +50 -0
- 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 +43 -0
- data/lib/json_api/support/param_helpers.rb +54 -0
- data/lib/json_api/support/relationship_guard.rb +16 -0
- data/lib/json_api/support/relationship_helpers.rb +76 -0
- data/lib/json_api/support/resource_identifier.rb +87 -0
- data/lib/json_api/support/responders.rb +100 -0
- data/lib/json_api/support/response_helpers.rb +10 -0
- data/lib/json_api/support/sort_parsing.rb +21 -0
- data/lib/json_api/support/type_conversion.rb +21 -0
- data/lib/json_api/testing/test_helper.rb +76 -0
- data/lib/json_api/testing.rb +3 -0
- data/lib/{jpie → json_api}/version.rb +2 -2
- data/lib/json_api.rb +50 -0
- data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
- metadata +100 -169
- data/.cursor/rules/dependencies.mdc +0 -19
- data/.cursor/rules/examples.mdc +0 -16
- data/.cursor/rules/git.mdc +0 -14
- data/.cursor/rules/project_structure.mdc +0 -30
- data/.cursor/rules/publish_gem.mdc +0 -73
- data/.cursor/rules/security.mdc +0 -14
- data/.cursor/rules/style.mdc +0 -15
- data/.cursor/rules/testing.mdc +0 -16
- data/.overcommit.yml +0 -35
- data/CHANGELOG.md +0 -164
- data/LICENSE.txt +0 -21
- data/PUBLISHING.md +0 -111
- data/examples/basic_example.md +0 -146
- data/examples/including_related_resources.md +0 -491
- data/examples/pagination.md +0 -303
- data/examples/relationships.md +0 -114
- data/examples/resource_attribute_configuration.md +0 -147
- data/examples/resource_meta_configuration.md +0 -244
- data/examples/rspec_testing.md +0 -130
- data/examples/single_table_inheritance.md +0 -160
- data/lib/jpie/configuration.rb +0 -12
- data/lib/jpie/controller/crud_actions.rb +0 -141
- data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
- data/lib/jpie/controller/error_handling/handlers.rb +0 -109
- data/lib/jpie/controller/error_handling.rb +0 -23
- data/lib/jpie/controller/json_api_validation.rb +0 -193
- data/lib/jpie/controller/parameter_parsing.rb +0 -78
- data/lib/jpie/controller/related_actions.rb +0 -45
- data/lib/jpie/controller/relationship_actions.rb +0 -291
- data/lib/jpie/controller/relationship_validation.rb +0 -117
- data/lib/jpie/controller/rendering.rb +0 -154
- data/lib/jpie/controller.rb +0 -45
- data/lib/jpie/deserializer.rb +0 -110
- data/lib/jpie/errors.rb +0 -117
- data/lib/jpie/generators/resource_generator.rb +0 -116
- data/lib/jpie/generators/templates/resource.rb.erb +0 -31
- data/lib/jpie/railtie.rb +0 -42
- data/lib/jpie/resource/attributable.rb +0 -112
- data/lib/jpie/resource/inferrable.rb +0 -43
- data/lib/jpie/resource/sortable.rb +0 -93
- data/lib/jpie/resource.rb +0 -147
- data/lib/jpie/routing.rb +0 -59
- data/lib/jpie/serializer.rb +0 -205
|
@@ -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
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource_actions/filter_validation"
|
|
4
|
+
require_relative "resource_actions/field_validation"
|
|
5
|
+
require_relative "resource_actions/preloading"
|
|
6
|
+
require_relative "resource_actions/serialization"
|
|
7
|
+
require_relative "resource_actions/pagination"
|
|
8
|
+
require_relative "resource_actions/type_validation"
|
|
9
|
+
require_relative "resource_actions/crud_helpers"
|
|
10
|
+
require_relative "resource_actions/resource_loading"
|
|
11
|
+
|
|
12
|
+
module JSONAPI
|
|
13
|
+
module ResourceActions
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
include ActiveStorageSupport
|
|
16
|
+
include FilterValidation
|
|
17
|
+
include FieldValidation
|
|
18
|
+
include Preloading
|
|
19
|
+
include Serialization
|
|
20
|
+
include Pagination
|
|
21
|
+
include TypeValidation
|
|
22
|
+
include CrudHelpers
|
|
23
|
+
include ResourceLoading
|
|
24
|
+
|
|
25
|
+
included do
|
|
26
|
+
before_action :load_jsonapi_resource
|
|
27
|
+
before_action :validate_fields_param, only: %i[index show]
|
|
28
|
+
before_action :validate_filter_param, only: [:index]
|
|
29
|
+
before_action :validate_sort_param, only: [:index]
|
|
30
|
+
before_action :validate_include_param, only: %i[index show]
|
|
31
|
+
before_action :validate_resource_type!, only: %i[create update]
|
|
32
|
+
before_action :validate_resource_id!, only: [:update]
|
|
33
|
+
before_action :preload_includes, only: %i[index show]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def index
|
|
37
|
+
scope = apply_authorization_scope(@preloaded_resources || model_class.all, action: :index)
|
|
38
|
+
query = build_query(scope)
|
|
39
|
+
@total_count = query.total_count
|
|
40
|
+
@pagination_applied = query.pagination_applied
|
|
41
|
+
render json: serialize_collection(query.scope), status: :ok
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
resource = @preloaded_resource || @resource
|
|
46
|
+
authorize_resource_action!(resource, action: :show)
|
|
47
|
+
render json: serialize_resource(resource), status: :ok
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create
|
|
51
|
+
resource = build_resource_for_create
|
|
52
|
+
authorize_resource_action!(resource, action: :create)
|
|
53
|
+
attach_active_storage_files(resource, @create_attachments, resource_class: determine_sti_resource_class)
|
|
54
|
+
save_created(resource)
|
|
55
|
+
rescue ArgumentError => e
|
|
56
|
+
render_create_error(e)
|
|
57
|
+
rescue JSONAPI::Exceptions::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
58
|
+
handle_create_exception(e)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_resource_for_create
|
|
62
|
+
sti_class = determine_sti_class
|
|
63
|
+
params_hash, @create_attachments = prepare_create_params(sti_class)
|
|
64
|
+
sti_class.new(params_hash)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_create_exception(error)
|
|
68
|
+
case error
|
|
69
|
+
when JSONAPI::Exceptions::ParameterNotAllowed then render_parameter_not_allowed_error(error)
|
|
70
|
+
when ActiveSupport::MessageVerifier::InvalidSignature then render_signed_id_error(error)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def update
|
|
75
|
+
authorize_resource_action!(@resource, action: :update)
|
|
76
|
+
params_hash, attachments = prepare_update_params
|
|
77
|
+
save_updated(params_hash, attachments)
|
|
78
|
+
rescue ArgumentError => e
|
|
79
|
+
render_invalid_relationship_error(e)
|
|
80
|
+
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
81
|
+
render_parameter_not_allowed_error(e)
|
|
82
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
83
|
+
render_signed_id_error(e)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def destroy
|
|
87
|
+
authorize_resource_action!(@resource, action: :destroy)
|
|
88
|
+
@resource.destroy
|
|
89
|
+
emit_resource_event(:deleted, @resource)
|
|
90
|
+
head :no_content
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def build_query(scope)
|
|
96
|
+
JSONAPI::CollectionQuery.new(
|
|
97
|
+
scope,
|
|
98
|
+
definition: @resource_class,
|
|
99
|
+
model_class: model_class,
|
|
100
|
+
filter_params: parse_filter_param,
|
|
101
|
+
sort_params: parse_sort_param,
|
|
102
|
+
page_params: parse_page_param,
|
|
103
|
+
).execute
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|