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