jpie 0.4.5 → 1.0.0
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/.gitignore +21 -0
- data/.rspec +3 -0
- data/.rubocop.yml +35 -110
- data/.travis.yml +7 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +312 -0
- data/README.md +2072 -140
- data/Rakefile +3 -14
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/jpie.gemspec +18 -35
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +3 -24
- data/lib/json_api/active_storage/deserialization.rb +106 -0
- data/lib/json_api/active_storage/detection.rb +74 -0
- data/lib/json_api/active_storage/serialization.rb +32 -0
- data/lib/json_api/configuration.rb +58 -0
- data/lib/json_api/controllers/base_controller.rb +26 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
- data/lib/json_api/controllers/relationships_controller.rb +504 -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 +75 -0
- data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
- data/lib/json_api/resources/resource.rb +238 -0
- data/lib/json_api/resources/resource_loader.rb +35 -0
- data/lib/json_api/routing.rb +72 -0
- data/lib/json_api/serialization/deserializer.rb +362 -0
- data/lib/json_api/serialization/serializer.rb +320 -0
- data/lib/json_api/support/active_storage_support.rb +85 -0
- data/lib/json_api/support/collection_query.rb +406 -0
- data/lib/json_api/support/instrumentation.rb +42 -0
- data/lib/json_api/support/param_helpers.rb +51 -0
- data/lib/json_api/support/relationship_guard.rb +16 -0
- data/lib/json_api/support/relationship_helpers.rb +74 -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 +50 -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,657 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceActions
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
include ActiveStorageSupport
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
before_action :load_jsonapi_resource
|
|
10
|
+
before_action :validate_fields_param, only: %i[index show]
|
|
11
|
+
before_action :validate_filter_param, only: [:index]
|
|
12
|
+
before_action :validate_sort_param, only: [:index]
|
|
13
|
+
before_action :validate_include_param, only: %i[index show]
|
|
14
|
+
before_action :validate_resource_type!, only: %i[create update]
|
|
15
|
+
before_action :validate_resource_id!, only: [:update]
|
|
16
|
+
before_action :preload_includes, only: %i[index show]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def index
|
|
20
|
+
initial_scope = @preloaded_resources || model_class.all
|
|
21
|
+
scoped = apply_authorization_scope(initial_scope, action: :index)
|
|
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
|
+
|
|
32
|
+
@total_count = query.total_count
|
|
33
|
+
@pagination_applied = query.pagination_applied
|
|
34
|
+
|
|
35
|
+
render json: serialize_collection(query.scope), status: :ok
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show
|
|
39
|
+
resource = @preloaded_resource || @resource
|
|
40
|
+
authorize_resource_action!(resource, action: :show)
|
|
41
|
+
render json: serialize_resource(resource), status: :ok
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create
|
|
45
|
+
# Determine STI class from type in payload
|
|
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)
|
|
67
|
+
authorize_resource_action!(resource, action: :create)
|
|
68
|
+
|
|
69
|
+
# Attach files before saving so validation can check files.attached?
|
|
70
|
+
# ActiveStorage allows attaching to unsaved records and will persist attachments when the record is saved
|
|
71
|
+
sti_resource_class = determine_sti_resource_class_for_create
|
|
72
|
+
attach_active_storage_files(resource, attachment_params, resource_class: sti_resource_class)
|
|
73
|
+
|
|
74
|
+
if resource.save
|
|
75
|
+
emit_resource_event(:created, resource)
|
|
76
|
+
render json: serialize_resource(resource), status: :created, location: resource_url(resource)
|
|
77
|
+
else
|
|
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)
|
|
85
|
+
end
|
|
86
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
87
|
+
render_invalid_signed_id_error(e)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update
|
|
91
|
+
authorize_resource_action!(@resource, action: :update)
|
|
92
|
+
params_hash = deserialize_params(:update)
|
|
93
|
+
attachment_params = extract_active_storage_params_from_hash(params_hash, model_class)
|
|
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
|
|
104
|
+
rescue ArgumentError => e
|
|
105
|
+
render_invalid_relationship_error(e)
|
|
106
|
+
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
107
|
+
render_parameter_not_allowed_error(e)
|
|
108
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
109
|
+
render_invalid_signed_id_error(e)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def destroy
|
|
113
|
+
authorize_resource_action!(@resource, action: :destroy)
|
|
114
|
+
resource_id = @resource.id
|
|
115
|
+
resource_type_name = resource_type
|
|
116
|
+
if @resource.destroy
|
|
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
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def load_jsonapi_resource
|
|
181
|
+
return unless params[:resource_type].present?
|
|
182
|
+
|
|
183
|
+
set_resource_name
|
|
184
|
+
set_resource_class
|
|
185
|
+
set_resource if %i[show update destroy].include?(action_name.to_sym)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def raw_jsonapi_data
|
|
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
|
+
)
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
end
|