jpie 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +82 -38
- data/Gemfile +12 -10
- data/Gemfile.lock +10 -1
- data/README.md +675 -1235
- data/jpie.gemspec +15 -15
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +0 -1
- data/lib/json_api/active_storage/deserialization.rb +32 -22
- data/lib/json_api/active_storage/detection.rb +36 -41
- data/lib/json_api/active_storage/serialization.rb +13 -11
- data/lib/json_api/configuration.rb +4 -5
- data/lib/json_api/controllers/base_controller.rb +3 -3
- data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
- data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
- data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
- data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
- data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
- data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
- data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
- data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
- data/lib/json_api/controllers/relationships_controller.rb +26 -422
- data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
- data/lib/json_api/railtie.rb +46 -9
- data/lib/json_api/resources/active_storage_blob_resource.rb +9 -1
- data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
- data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
- data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
- data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
- data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
- data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
- data/lib/json_api/resources/resource.rb +13 -219
- data/lib/json_api/routing.rb +56 -47
- data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
- data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
- data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
- data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
- data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
- data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
- data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
- data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
- data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
- data/lib/json_api/serialization/deserializer.rb +10 -346
- data/lib/json_api/serialization/serializer.rb +17 -260
- data/lib/json_api/support/active_storage_support.rb +10 -13
- data/lib/json_api/support/collection_query.rb +14 -370
- data/lib/json_api/support/concerns/condition_building.rb +57 -0
- data/lib/json_api/support/concerns/nested_filters.rb +130 -0
- data/lib/json_api/support/concerns/pagination.rb +30 -0
- data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
- data/lib/json_api/support/concerns/regular_filters.rb +81 -0
- data/lib/json_api/support/concerns/sorting.rb +88 -0
- data/lib/json_api/support/instrumentation.rb +13 -12
- data/lib/json_api/support/param_helpers.rb +9 -6
- data/lib/json_api/support/relationship_helpers.rb +4 -2
- data/lib/json_api/support/resource_identifier.rb +29 -29
- data/lib/json_api/support/responders.rb +5 -5
- data/lib/json_api/version.rb +1 -1
- metadata +51 -1
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
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
|
+
|
|
3
12
|
module JSONAPI
|
|
4
13
|
module ResourceActions
|
|
5
14
|
extend ActiveSupport::Concern
|
|
6
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
|
|
7
24
|
|
|
8
25
|
included do
|
|
9
26
|
before_action :load_jsonapi_resource
|
|
@@ -17,21 +34,10 @@ module JSONAPI
|
|
|
17
34
|
end
|
|
18
35
|
|
|
19
36
|
def index
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
query = JSONAPI::CollectionQuery.new(
|
|
24
|
-
scoped,
|
|
25
|
-
definition: @resource_class,
|
|
26
|
-
model_class: model_class,
|
|
27
|
-
filter_params: parse_filter_param,
|
|
28
|
-
sort_params: parse_sort_param,
|
|
29
|
-
page_params: parse_page_param
|
|
30
|
-
).execute
|
|
31
|
-
|
|
37
|
+
scope = apply_authorization_scope(@preloaded_resources || model_class.all, action: :index)
|
|
38
|
+
query = build_query(scope)
|
|
32
39
|
@total_count = query.total_count
|
|
33
40
|
@pagination_applied = query.pagination_applied
|
|
34
|
-
|
|
35
41
|
render json: serialize_collection(query.scope), status: :ok
|
|
36
42
|
end
|
|
37
43
|
|
|
@@ -42,616 +48,59 @@ module JSONAPI
|
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
def create
|
|
45
|
-
|
|
46
|
-
sti_class = determine_sti_class_for_create
|
|
47
|
-
|
|
48
|
-
# Use subtype model class for deserialization so it finds the correct resource class
|
|
49
|
-
params_hash = deserialize_params(:create, model_class: sti_class)
|
|
50
|
-
attachment_params = extract_active_storage_params_from_hash(params_hash, sti_class)
|
|
51
|
-
# Remove attachment params from regular params
|
|
52
|
-
attachment_params.each_key { |key| params_hash.delete(key.to_s) }
|
|
53
|
-
|
|
54
|
-
# Remove type from params_hash if present - STI handles type automatically
|
|
55
|
-
params_hash.delete("type")
|
|
56
|
-
params_hash.delete(:type)
|
|
57
|
-
|
|
58
|
-
# For STI base class, ensure type is set explicitly (Rails STI doesn't always set it for base class)
|
|
59
|
-
# Only set type if this is actually an STI base class (has type column and base_class == class)
|
|
60
|
-
if sti_class.respond_to?(:base_class) &&
|
|
61
|
-
sti_class.base_class == sti_class &&
|
|
62
|
-
sti_class.column_names.include?("type")
|
|
63
|
-
params_hash["type"] = sti_class.name
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
resource = sti_class.new(params_hash)
|
|
51
|
+
resource = build_resource_for_create
|
|
67
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
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
render_validation_errors(resource)
|
|
79
|
-
end
|
|
80
|
-
rescue ArgumentError => e
|
|
81
|
-
if e.message.match?(/invalid.*subtype/i)
|
|
82
|
-
render_invalid_subtype_error(e)
|
|
83
|
-
else
|
|
84
|
-
render_invalid_relationship_error(e)
|
|
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)
|
|
85
71
|
end
|
|
86
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
87
|
-
render_invalid_signed_id_error(e)
|
|
88
72
|
end
|
|
89
73
|
|
|
90
74
|
def update
|
|
91
75
|
authorize_resource_action!(@resource, action: :update)
|
|
92
|
-
params_hash =
|
|
93
|
-
|
|
94
|
-
# Remove attachment params from regular params
|
|
95
|
-
attachment_params.each_key { |key| params_hash.delete(key.to_s) }
|
|
96
|
-
|
|
97
|
-
if @resource.update(params_hash)
|
|
98
|
-
attach_active_storage_files(@resource, attachment_params, resource_class: @resource_class)
|
|
99
|
-
emit_resource_event(:updated, @resource)
|
|
100
|
-
render json: serialize_resource(@resource), status: :ok
|
|
101
|
-
else
|
|
102
|
-
render_validation_errors(@resource)
|
|
103
|
-
end
|
|
76
|
+
params_hash, attachments = prepare_update_params
|
|
77
|
+
save_updated(params_hash, attachments)
|
|
104
78
|
rescue ArgumentError => e
|
|
105
79
|
render_invalid_relationship_error(e)
|
|
106
80
|
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
107
81
|
render_parameter_not_allowed_error(e)
|
|
108
82
|
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
109
|
-
|
|
83
|
+
render_signed_id_error(e)
|
|
110
84
|
end
|
|
111
85
|
|
|
112
86
|
def destroy
|
|
113
87
|
authorize_resource_action!(@resource, action: :destroy)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
emit_resource_event(:deleted, @resource, resource_id:, resource_type: resource_type_name)
|
|
118
|
-
head :no_content
|
|
119
|
-
else
|
|
120
|
-
render_validation_errors(@resource)
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def build_resource_from_params
|
|
125
|
-
params_hash = deserialize_params(:create)
|
|
126
|
-
attachment_params = extract_active_storage_params_from_hash(params_hash, model_class)
|
|
127
|
-
# Remove attachment params from regular params
|
|
128
|
-
attachment_params.each_key { |key| params_hash.delete(key.to_s) }
|
|
129
|
-
model_class.new(params_hash)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def deserialize_params(action = :update, model_class: nil)
|
|
133
|
-
params_hash = raw_jsonapi_data
|
|
134
|
-
target_model_class = model_class || self.model_class
|
|
135
|
-
deserializer = JSONAPI::Deserializer.new(params_hash, model_class: target_model_class, action:)
|
|
136
|
-
deserializer.to_model_attributes
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def render_invalid_signed_id_error(error)
|
|
140
|
-
render_jsonapi_error(
|
|
141
|
-
status: 400,
|
|
142
|
-
title: "Invalid Signed ID",
|
|
143
|
-
detail: "Invalid signed blob ID provided: #{error.message}"
|
|
144
|
-
)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def determine_sti_class_for_create(_subtype = nil)
|
|
148
|
-
# Check the type directly from the payload
|
|
149
|
-
type_from_payload = raw_jsonapi_data&.dig(:type)
|
|
150
|
-
|
|
151
|
-
return model_class unless type_from_payload
|
|
152
|
-
|
|
153
|
-
# If the payload type differs from the controller's type,
|
|
154
|
-
# resolve the class for the specific type.
|
|
155
|
-
if type_from_payload == params[:resource_type]
|
|
156
|
-
model_class
|
|
157
|
-
else
|
|
158
|
-
resource_class = JSONAPI::ResourceLoader.find(type_from_payload)
|
|
159
|
-
resource_class.model_class
|
|
160
|
-
end
|
|
161
|
-
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
162
|
-
model_class
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def determine_sti_resource_class_for_create(_subtype = nil)
|
|
166
|
-
type_from_payload = raw_jsonapi_data&.dig(:type)
|
|
167
|
-
return @resource_class unless type_from_payload
|
|
168
|
-
|
|
169
|
-
if type_from_payload == params[:resource_type]
|
|
170
|
-
@resource_class
|
|
171
|
-
else
|
|
172
|
-
JSONAPI::ResourceLoader.find(type_from_payload)
|
|
173
|
-
end
|
|
174
|
-
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
175
|
-
@resource_class
|
|
88
|
+
@resource.destroy
|
|
89
|
+
emit_resource_event(:deleted, @resource)
|
|
90
|
+
head :no_content
|
|
176
91
|
end
|
|
177
92
|
|
|
178
93
|
private
|
|
179
94
|
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
# Access raw params to get relationships and meta before permit filters them
|
|
190
|
-
raw_data = params[:data] || params["data"]
|
|
191
|
-
return {} unless raw_data
|
|
192
|
-
|
|
193
|
-
# Convert to hash with symbolized keys, preserving nested structure including meta
|
|
194
|
-
# Use to_unsafe_h to get all params including unpermitted ones like meta
|
|
195
|
-
if raw_data.is_a?(ActionController::Parameters)
|
|
196
|
-
JSONAPI::ParamHelpers.deep_symbolize_params(raw_data.to_unsafe_h)
|
|
197
|
-
else
|
|
198
|
-
JSONAPI::ParamHelpers.deep_symbolize_params(raw_data)
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def validate_filter_param
|
|
203
|
-
filters = parse_filter_param
|
|
204
|
-
return if filters.empty?
|
|
205
|
-
|
|
206
|
-
invalid_filters = find_invalid_filter_fields(filters)
|
|
207
|
-
return if invalid_filters.empty?
|
|
208
|
-
|
|
209
|
-
render_parameter_errors(
|
|
210
|
-
invalid_filters,
|
|
211
|
-
title: "Invalid Filter",
|
|
212
|
-
detail_proc: ->(filter_name) { "Invalid filter requested: #{filter_name}" },
|
|
213
|
-
source_proc: ->(filter_name) { { parameter: "filter[#{filter_name}]" } }
|
|
214
|
-
)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def find_invalid_filter_fields(filters)
|
|
218
|
-
filters.keys.reject do |filter_name|
|
|
219
|
-
valid_filter_path?(filter_name.to_s, @resource_class, model_class)
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def valid_filter_path?(filter_name, resource_cls, model_cls)
|
|
224
|
-
path_parts = filter_name.split(".")
|
|
225
|
-
validate_filter_parts(path_parts, resource_cls, model_cls)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def validate_filter_parts(path_parts, resource_cls, model_cls, allow_related_columns: false)
|
|
229
|
-
return false if path_parts.empty?
|
|
230
|
-
|
|
231
|
-
if path_parts.length == 1
|
|
232
|
-
filter_name = path_parts.first
|
|
233
|
-
return true if resource_cls.permitted_filters.map(&:to_s).include?(filter_name)
|
|
234
|
-
|
|
235
|
-
if allow_related_columns
|
|
236
|
-
column_filter = parse_column_filter_name(filter_name)
|
|
237
|
-
return true if column_filter && model_cls.column_for_attribute(column_filter[:column])
|
|
238
|
-
|
|
239
|
-
return true if model_cls.column_names.include?(filter_name)
|
|
240
|
-
return true if model_cls.respond_to?(filter_name.to_sym)
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
return false
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
relationship_name, *remaining_parts = path_parts
|
|
247
|
-
return false unless relationship_allowed_for_filtering?(resource_cls, relationship_name)
|
|
248
|
-
|
|
249
|
-
association = model_cls.reflect_on_association(relationship_name.to_sym)
|
|
250
|
-
return false unless association
|
|
251
|
-
|
|
252
|
-
return valid_polymorphic_filter_parts?(remaining_parts) if association.polymorphic?
|
|
253
|
-
|
|
254
|
-
related_model = association.klass
|
|
255
|
-
related_resource_cls = JSONAPI::Resource.resource_for_model(related_model)
|
|
256
|
-
return false unless related_resource_cls
|
|
257
|
-
|
|
258
|
-
validate_filter_parts(
|
|
259
|
-
remaining_parts,
|
|
260
|
-
related_resource_cls,
|
|
261
|
-
related_model,
|
|
262
|
-
allow_related_columns: true
|
|
263
|
-
)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def relationship_allowed_for_filtering?(resource_cls, relationship_name)
|
|
267
|
-
permitted_through = resource_cls.permitted_filters_through
|
|
268
|
-
permitted_through.include?(relationship_name.to_sym) ||
|
|
269
|
-
permitted_through.map(&:to_s).include?(relationship_name.to_s)
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def valid_polymorphic_filter_parts?(parts)
|
|
273
|
-
return false if parts.empty? || parts.length > 1
|
|
274
|
-
|
|
275
|
-
attribute_name = parts.first
|
|
276
|
-
match = attribute_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
277
|
-
base_name = match ? match[1] : attribute_name
|
|
278
|
-
|
|
279
|
-
%w[id type].include?(base_name)
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def parse_column_filter_name(filter_name)
|
|
283
|
-
match = filter_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
284
|
-
return nil unless match
|
|
285
|
-
|
|
286
|
-
{ column: match[1], operator: match[2].to_sym }
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def validate_sort_param
|
|
290
|
-
sorts = parse_sort_param
|
|
291
|
-
return if sorts.empty?
|
|
292
|
-
|
|
293
|
-
valid_fields = valid_sort_fields_for_resource(@resource_class, model_class)
|
|
294
|
-
invalid_fields = invalid_sort_fields_for_columns(sorts, valid_fields)
|
|
295
|
-
return if invalid_fields.empty?
|
|
296
|
-
|
|
297
|
-
render_parameter_errors(
|
|
298
|
-
invalid_fields,
|
|
299
|
-
title: "Invalid Sort Field",
|
|
300
|
-
detail_proc: ->(field) { "Invalid sort field requested: #{field}" },
|
|
301
|
-
source_proc: ->(_field) { { parameter: "sort" } }
|
|
302
|
-
)
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def validate_include_param
|
|
306
|
-
includes = parse_include_param
|
|
307
|
-
return if includes.empty?
|
|
308
|
-
|
|
309
|
-
invalid_paths = find_invalid_include_paths(includes)
|
|
310
|
-
return if invalid_paths.empty?
|
|
311
|
-
|
|
312
|
-
render_parameter_errors(
|
|
313
|
-
invalid_paths,
|
|
314
|
-
title: "Invalid Include Path",
|
|
315
|
-
detail_proc: ->(path) { "Invalid include path requested: #{path}" },
|
|
316
|
-
source_proc: ->(_path) { { parameter: "include" } }
|
|
317
|
-
)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def find_invalid_include_paths(includes)
|
|
321
|
-
permitted_relationships = @resource_class.relationship_names.map(&:to_s)
|
|
322
|
-
invalid_paths = []
|
|
323
|
-
|
|
324
|
-
includes.each do |include_path|
|
|
325
|
-
invalid_paths << include_path unless valid_include_path?(include_path, permitted_relationships)
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
invalid_paths
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
def valid_include_path?(include_path, permitted_relationships)
|
|
332
|
-
path_parts = include_path.split(".")
|
|
333
|
-
first_association = path_parts.first
|
|
334
|
-
|
|
335
|
-
return false unless permitted_relationships.include?(first_association)
|
|
336
|
-
|
|
337
|
-
valid_nested_path?(path_parts)
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def valid_nested_path?(path_parts)
|
|
341
|
-
current_class = model_class
|
|
342
|
-
|
|
343
|
-
path_parts.each do |association_name|
|
|
344
|
-
# Check for ActiveStorage attachments
|
|
345
|
-
if self.class.active_storage_attachment?(association_name, current_class)
|
|
346
|
-
# ActiveStorage attachments point to ActiveStorage::Blob, which is a terminal relationship
|
|
347
|
-
return true
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
association = current_class.reflect_on_association(association_name.to_sym)
|
|
351
|
-
return false unless association
|
|
352
|
-
|
|
353
|
-
break if association.polymorphic?
|
|
354
|
-
|
|
355
|
-
current_class = association.klass
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
true
|
|
359
|
-
end
|
|
360
|
-
|
|
361
|
-
def preload_includes
|
|
362
|
-
includes = parse_include_param
|
|
363
|
-
return if includes.empty?
|
|
364
|
-
|
|
365
|
-
includes_hash = build_includes_hash(includes)
|
|
366
|
-
preload_resources(includes_hash)
|
|
367
|
-
rescue ActiveRecord::RecordNotFound
|
|
368
|
-
# Will be handled by set_resource
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
def build_includes_hash(includes)
|
|
372
|
-
includes_hash = {}
|
|
373
|
-
includes.each { |include_path| merge_include_path(includes_hash, include_path) }
|
|
374
|
-
includes_hash
|
|
375
|
-
end
|
|
376
|
-
|
|
377
|
-
def merge_include_path(includes_hash, include_path)
|
|
378
|
-
path_parts = include_path.split(".")
|
|
379
|
-
current_hash = includes_hash
|
|
380
|
-
|
|
381
|
-
path_parts.each_with_index do |part, index|
|
|
382
|
-
part_sym = part.to_sym
|
|
383
|
-
current_hash[part_sym] ||= {}
|
|
384
|
-
current_hash = current_hash[part_sym] if index < path_parts.length - 1
|
|
385
|
-
end
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
def preload_resources(includes_hash)
|
|
389
|
-
# Filter out ActiveStorage attachments and polymorphic associations from includes_hash
|
|
390
|
-
filtered_includes = filter_active_storage_from_includes(includes_hash, model_class)
|
|
391
|
-
filtered_includes = filter_polymorphic_from_includes(filtered_includes, model_class)
|
|
392
|
-
|
|
393
|
-
case action_name
|
|
394
|
-
when "index"
|
|
395
|
-
@preloaded_resources = filtered_includes.empty? ? model_class.all : model_class.all.includes(filtered_includes)
|
|
396
|
-
when "show"
|
|
397
|
-
@preloaded_resource = filtered_includes.empty? ? model_class.find(params[:id]) : model_class.includes(filtered_includes).find(params[:id])
|
|
398
|
-
end
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
def serialize_resource(resource)
|
|
402
|
-
serializer = JSONAPI::Serializer.new(resource)
|
|
403
|
-
serializer.to_hash(
|
|
404
|
-
include: parse_include_param,
|
|
405
|
-
fields: parse_fields_param,
|
|
406
|
-
document_meta: jsonapi_document_meta
|
|
407
|
-
)
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
def serialize_collection(resources)
|
|
411
|
-
includes = parse_include_param
|
|
412
|
-
fields = parse_fields_param
|
|
413
|
-
all_included = []
|
|
414
|
-
processed = Set.new
|
|
415
|
-
|
|
416
|
-
data = serialize_resources(resources, includes, fields, all_included, processed)
|
|
417
|
-
|
|
418
|
-
build_collection_response(data, all_included)
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def serialize_resources(resources, includes, fields, all_included, processed)
|
|
422
|
-
resources.map do |resource|
|
|
423
|
-
result = serialize_single_resource(resource, includes, fields)
|
|
424
|
-
collect_included_resources(result, all_included, processed)
|
|
425
|
-
result[:data]
|
|
426
|
-
end
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
def serialize_single_resource(resource, includes, fields)
|
|
430
|
-
serializer = JSONAPI::Serializer.new(resource)
|
|
431
|
-
serializer.to_hash(include: includes, fields:, document_meta: nil)
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
def collect_included_resources(result, all_included, processed)
|
|
435
|
-
included_resources = result[:included] || []
|
|
436
|
-
included_resources.each do |included_resource|
|
|
437
|
-
add_unique_included_resource(included_resource, all_included, processed)
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
def add_unique_included_resource(included_resource, all_included, processed)
|
|
442
|
-
resource_key = "#{included_resource[:type]}-#{included_resource[:id]}"
|
|
443
|
-
return if processed.include?(resource_key)
|
|
444
|
-
|
|
445
|
-
all_included << included_resource
|
|
446
|
-
processed.add(resource_key)
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
def build_collection_response(data, all_included)
|
|
450
|
-
result = {
|
|
451
|
-
jsonapi: JSONAPI::Serializer.jsonapi_object,
|
|
452
|
-
data:
|
|
453
|
-
}
|
|
454
|
-
result[:included] = all_included if all_included.any?
|
|
455
|
-
|
|
456
|
-
pagination_meta = {}
|
|
457
|
-
|
|
458
|
-
if @pagination_applied
|
|
459
|
-
result[:links] = build_pagination_links
|
|
460
|
-
pagination_meta = build_pagination_meta
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
result[:meta] = jsonapi_document_meta(pagination_meta)
|
|
464
|
-
|
|
465
|
-
result
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def build_pagination_links
|
|
469
|
-
page_params = parse_page_param
|
|
470
|
-
current_page = page_params["number"]&.to_i || 1
|
|
471
|
-
page_size = calculate_page_size(page_params)
|
|
472
|
-
total_pages = calculate_total_pages(page_size)
|
|
473
|
-
|
|
474
|
-
links = {
|
|
475
|
-
self: build_pagination_url(current_page, page_size),
|
|
476
|
-
first: build_pagination_url(1, page_size),
|
|
477
|
-
last: build_pagination_url(total_pages, page_size)
|
|
478
|
-
}
|
|
479
|
-
links[:prev] = build_pagination_url(current_page - 1, page_size) if current_page > 1
|
|
480
|
-
links[:next] = build_pagination_url(current_page + 1, page_size) if current_page < total_pages
|
|
481
|
-
|
|
482
|
-
links
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
def calculate_page_size(page_params)
|
|
486
|
-
size = page_params["size"]&.to_i || JSONAPI.configuration.default_page_size
|
|
487
|
-
[size, JSONAPI.configuration.max_page_size].min
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
def calculate_total_pages(page_size)
|
|
491
|
-
total_pages = (@total_count.to_f / page_size).ceil
|
|
492
|
-
total_pages.zero? ? 1 : total_pages
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
def build_pagination_url(page_number, page_size)
|
|
496
|
-
base_url = request.path
|
|
497
|
-
query_params = request.query_parameters.dup
|
|
498
|
-
query_params["page"] = { "number" => page_number, "size" => page_size }
|
|
499
|
-
|
|
500
|
-
query_string = build_query_string(query_params)
|
|
501
|
-
query_string.present? ? "#{base_url}?#{query_string}" : base_url
|
|
502
|
-
end
|
|
503
|
-
|
|
504
|
-
def build_query_string(query_params)
|
|
505
|
-
JSONAPI::ParamHelpers.build_query_string(query_params)
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
def build_pagination_meta
|
|
509
|
-
{ total: @total_count }
|
|
510
|
-
end
|
|
511
|
-
|
|
512
|
-
def validate_resource_type!
|
|
513
|
-
# Skip validation if resource loading was skipped
|
|
514
|
-
return unless @resource_class
|
|
515
|
-
# Relationship endpoints carry related resource identifiers, so skip type
|
|
516
|
-
# validation for relationship requests.
|
|
517
|
-
return if params[:relationship_name].present?
|
|
518
|
-
|
|
519
|
-
requested_type = jsonapi_type
|
|
520
|
-
expected_type = resource_type
|
|
521
|
-
|
|
522
|
-
return if requested_type == expected_type
|
|
523
|
-
|
|
524
|
-
# Allow STI subtypes if posting to base endpoint
|
|
525
|
-
if model_class.respond_to?(:base_class)
|
|
526
|
-
# Check if requested type corresponds to a valid subclass
|
|
527
|
-
begin
|
|
528
|
-
requested_resource_class = JSONAPI::ResourceLoader.find(requested_type)
|
|
529
|
-
requested_model_class = requested_resource_class.model_class
|
|
530
|
-
|
|
531
|
-
# Allow if requested class is a subclass of the endpoint's class
|
|
532
|
-
return if requested_model_class < model_class
|
|
533
|
-
rescue JSONAPI::ResourceLoader::MissingResourceClass, NameError
|
|
534
|
-
# Fall through to error rendering
|
|
535
|
-
end
|
|
536
|
-
end
|
|
537
|
-
|
|
538
|
-
render_type_mismatch_error(expected_type, requested_type) and return
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
def validate_resource_id!
|
|
542
|
-
# Skip validation if resource loading was skipped
|
|
543
|
-
return unless @resource_class
|
|
544
|
-
|
|
545
|
-
requested_id = jsonapi_id
|
|
546
|
-
expected_id = params[:id].to_s
|
|
547
|
-
|
|
548
|
-
return if requested_id == expected_id
|
|
549
|
-
|
|
550
|
-
render_id_mismatch_error(expected_id, requested_id) and return
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
def render_type_mismatch_error(expected_type, requested_type)
|
|
554
|
-
detail = requested_type.nil? ? "Missing type member. Expected '#{expected_type}'" : "Type mismatch: expected '#{expected_type}', got '#{requested_type}'"
|
|
555
|
-
|
|
556
|
-
render json: {
|
|
557
|
-
errors: [
|
|
558
|
-
{
|
|
559
|
-
status: "409",
|
|
560
|
-
title: "Type Mismatch",
|
|
561
|
-
detail:,
|
|
562
|
-
source: { pointer: "/data/type" }
|
|
563
|
-
}
|
|
564
|
-
]
|
|
565
|
-
}, status: :conflict
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
def render_id_mismatch_error(expected_id, requested_id)
|
|
569
|
-
render json: {
|
|
570
|
-
errors: [
|
|
571
|
-
{
|
|
572
|
-
status: "409",
|
|
573
|
-
title: "ID Mismatch",
|
|
574
|
-
detail: "ID mismatch: expected '#{expected_id}', got '#{requested_id}'",
|
|
575
|
-
source: { pointer: "/data/id" }
|
|
576
|
-
}
|
|
577
|
-
]
|
|
578
|
-
}, status: :conflict
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
def resource_url(resource)
|
|
582
|
-
"/#{resource_type}/#{resource.id}"
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
def resource_type
|
|
586
|
-
params[:resource_type] || @resource_name.pluralize
|
|
587
|
-
end
|
|
588
|
-
|
|
589
|
-
def validate_fields_param
|
|
590
|
-
fields = parse_fields_param
|
|
591
|
-
return if fields.empty?
|
|
592
|
-
|
|
593
|
-
fields.each do |type, type_fields|
|
|
594
|
-
next if type_fields.nil? || type_fields.empty?
|
|
595
|
-
|
|
596
|
-
# Handle active_storage_blobs specially
|
|
597
|
-
if type.to_s == "active_storage_blobs"
|
|
598
|
-
invalid_fields = find_invalid_blob_fields(type_fields)
|
|
599
|
-
if invalid_fields.any?
|
|
600
|
-
render_parameter_errors(
|
|
601
|
-
invalid_fields,
|
|
602
|
-
title: "Invalid Field",
|
|
603
|
-
detail_proc: ->(field) { "Invalid field requested for active_storage_blobs: #{field}" },
|
|
604
|
-
source_proc: ->(_field) { { parameter: "fields[active_storage_blobs]" } }
|
|
605
|
-
)
|
|
606
|
-
return
|
|
607
|
-
end
|
|
608
|
-
else
|
|
609
|
-
# Only validate fields for the current resource type
|
|
610
|
-
next unless type.to_s == resource_type.to_s
|
|
611
|
-
|
|
612
|
-
invalid_fields = find_invalid_fields(type_fields)
|
|
613
|
-
if invalid_fields.any?
|
|
614
|
-
current_type = resource_type.to_s
|
|
615
|
-
render_parameter_errors(
|
|
616
|
-
invalid_fields,
|
|
617
|
-
title: "Invalid Field",
|
|
618
|
-
detail_proc: ->(field) { "Invalid field requested for #{current_type}: #{field}" },
|
|
619
|
-
source_proc: ->(_field) { { parameter: "fields[#{current_type}]" } }
|
|
620
|
-
)
|
|
621
|
-
return
|
|
622
|
-
end
|
|
623
|
-
end
|
|
624
|
-
end
|
|
625
|
-
end
|
|
626
|
-
|
|
627
|
-
def find_invalid_fields(resource_fields)
|
|
628
|
-
permitted_attributes = @resource_class.permitted_attributes.map(&:to_s)
|
|
629
|
-
resource_fields.reject { |field| permitted_attributes.include?(field.to_s) }
|
|
630
|
-
end
|
|
631
|
-
|
|
632
|
-
def find_invalid_blob_fields(blob_fields)
|
|
633
|
-
return [] unless defined?(::ActiveStorage)
|
|
634
|
-
|
|
635
|
-
permitted_attributes = JSONAPI::ActiveStorageBlobResource.permitted_attributes.map(&:to_s)
|
|
636
|
-
blob_fields.reject { |field| permitted_attributes.include?(field.to_s) }
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
def emit_resource_event(action, resource, resource_id: nil, resource_type: nil)
|
|
640
|
-
resource_id ||= resource.id
|
|
641
|
-
resource_type_name = resource_type || self.resource_type
|
|
642
|
-
|
|
643
|
-
changes = if action == :updated && resource.respond_to?(:previous_changes)
|
|
644
|
-
resource.previous_changes.except("updated_at", "created_at")
|
|
645
|
-
else
|
|
646
|
-
{}
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
JSONAPI::Instrumentation.resource_event(
|
|
650
|
-
action:,
|
|
651
|
-
resource_type: resource_type_name,
|
|
652
|
-
resource_id:,
|
|
653
|
-
changes:
|
|
654
|
-
)
|
|
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
|
|
655
104
|
end
|
|
656
105
|
end
|
|
657
106
|
end
|