jpie 0.4.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +26 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +76 -107
  6. data/.travis.yml +7 -0
  7. data/Gemfile +23 -0
  8. data/Gemfile.lock +321 -0
  9. data/README.md +1508 -136
  10. data/Rakefile +3 -14
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/jpie.gemspec +21 -38
  14. data/kiln/app/resources/user_message_resource.rb +4 -0
  15. data/lib/jpie.rb +3 -25
  16. data/lib/json_api/active_storage/deserialization.rb +116 -0
  17. data/lib/json_api/active_storage/detection.rb +69 -0
  18. data/lib/json_api/active_storage/serialization.rb +34 -0
  19. data/lib/json_api/configuration.rb +57 -0
  20. data/lib/json_api/controllers/base_controller.rb +26 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  22. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  23. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  24. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  25. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  26. data/lib/json_api/controllers/concerns/controller_helpers.rb +19 -0
  27. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  28. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  29. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  30. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  31. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  32. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  33. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  36. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  37. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  38. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  39. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  40. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  44. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  45. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  46. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  47. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  48. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  49. data/lib/json_api/controllers/concerns/resource_actions.rb +106 -0
  50. data/lib/json_api/controllers/relationships_controller.rb +108 -0
  51. data/lib/json_api/controllers/resources_controller.rb +6 -0
  52. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  53. data/lib/json_api/railtie.rb +112 -0
  54. data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
  55. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  56. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  57. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  58. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  59. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  60. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  61. data/lib/json_api/resources/resource.rb +32 -0
  62. data/lib/json_api/resources/resource_loader.rb +35 -0
  63. data/lib/json_api/routing.rb +81 -0
  64. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  65. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  66. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  67. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  68. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  69. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  70. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  71. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  72. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  73. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  74. data/lib/json_api/serialization/deserializer.rb +26 -0
  75. data/lib/json_api/serialization/serializer.rb +77 -0
  76. data/lib/json_api/support/active_storage_support.rb +82 -0
  77. data/lib/json_api/support/collection_query.rb +50 -0
  78. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  79. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  80. data/lib/json_api/support/concerns/pagination.rb +30 -0
  81. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  82. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  83. data/lib/json_api/support/concerns/sorting.rb +88 -0
  84. data/lib/json_api/support/instrumentation.rb +43 -0
  85. data/lib/json_api/support/param_helpers.rb +54 -0
  86. data/lib/json_api/support/relationship_guard.rb +16 -0
  87. data/lib/json_api/support/relationship_helpers.rb +76 -0
  88. data/lib/json_api/support/resource_identifier.rb +87 -0
  89. data/lib/json_api/support/responders.rb +100 -0
  90. data/lib/json_api/support/response_helpers.rb +10 -0
  91. data/lib/json_api/support/sort_parsing.rb +21 -0
  92. data/lib/json_api/support/type_conversion.rb +21 -0
  93. data/lib/json_api/testing/test_helper.rb +76 -0
  94. data/lib/json_api/testing.rb +3 -0
  95. data/lib/{jpie → json_api}/version.rb +2 -2
  96. data/lib/json_api.rb +50 -0
  97. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  98. metadata +100 -169
  99. data/.cursor/rules/dependencies.mdc +0 -19
  100. data/.cursor/rules/examples.mdc +0 -16
  101. data/.cursor/rules/git.mdc +0 -14
  102. data/.cursor/rules/project_structure.mdc +0 -30
  103. data/.cursor/rules/publish_gem.mdc +0 -73
  104. data/.cursor/rules/security.mdc +0 -14
  105. data/.cursor/rules/style.mdc +0 -15
  106. data/.cursor/rules/testing.mdc +0 -16
  107. data/.overcommit.yml +0 -35
  108. data/CHANGELOG.md +0 -164
  109. data/LICENSE.txt +0 -21
  110. data/PUBLISHING.md +0 -111
  111. data/examples/basic_example.md +0 -146
  112. data/examples/including_related_resources.md +0 -491
  113. data/examples/pagination.md +0 -303
  114. data/examples/relationships.md +0 -114
  115. data/examples/resource_attribute_configuration.md +0 -147
  116. data/examples/resource_meta_configuration.md +0 -244
  117. data/examples/rspec_testing.md +0 -130
  118. data/examples/single_table_inheritance.md +0 -160
  119. data/lib/jpie/configuration.rb +0 -12
  120. data/lib/jpie/controller/crud_actions.rb +0 -141
  121. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  122. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  123. data/lib/jpie/controller/error_handling.rb +0 -23
  124. data/lib/jpie/controller/json_api_validation.rb +0 -193
  125. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  126. data/lib/jpie/controller/related_actions.rb +0 -45
  127. data/lib/jpie/controller/relationship_actions.rb +0 -291
  128. data/lib/jpie/controller/relationship_validation.rb +0 -117
  129. data/lib/jpie/controller/rendering.rb +0 -154
  130. data/lib/jpie/controller.rb +0 -45
  131. data/lib/jpie/deserializer.rb +0 -110
  132. data/lib/jpie/errors.rb +0 -117
  133. data/lib/jpie/generators/resource_generator.rb +0 -116
  134. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  135. data/lib/jpie/railtie.rb +0 -42
  136. data/lib/jpie/resource/attributable.rb +0 -112
  137. data/lib/jpie/resource/inferrable.rb +0 -43
  138. data/lib/jpie/resource/sortable.rb +0 -93
  139. data/lib/jpie/resource.rb +0 -147
  140. data/lib/jpie/routing.rb +0 -59
  141. data/lib/jpie/serializer.rb +0 -205
@@ -0,0 +1,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