jpie 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +82 -38
- data/Gemfile +13 -10
- data/Gemfile.lock +18 -1
- data/README.md +675 -1235
- data/Rakefile +22 -0
- 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/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/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 +44 -1
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json_api/support/relationship_guard"
|
|
4
|
+
require_relative "concerns/relationships/serialization"
|
|
5
|
+
require_relative "concerns/relationships/updating"
|
|
6
|
+
require_relative "concerns/relationships/removal"
|
|
7
|
+
require_relative "concerns/relationships/sorting"
|
|
8
|
+
require_relative "concerns/relationships/events"
|
|
9
|
+
require_relative "concerns/relationships/response_helpers"
|
|
4
10
|
|
|
5
11
|
module JSONAPI
|
|
6
12
|
class RelationshipsController < BaseController
|
|
13
|
+
include Relationships::Serialization
|
|
14
|
+
include Relationships::Updating
|
|
15
|
+
include Relationships::Removal
|
|
16
|
+
include Relationships::Sorting
|
|
17
|
+
include Relationships::Events
|
|
18
|
+
include Relationships::ResponseHelpers
|
|
19
|
+
|
|
7
20
|
skip_before_action :validate_resource_type!, only: %i[update destroy]
|
|
8
21
|
skip_before_action :validate_resource_id!, only: %i[update destroy]
|
|
9
22
|
|
|
@@ -17,31 +30,14 @@ module JSONAPI
|
|
|
17
30
|
|
|
18
31
|
def show
|
|
19
32
|
authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
|
|
20
|
-
|
|
21
|
-
links = serialize_relationship_links
|
|
22
|
-
meta = serialize_relationship_meta
|
|
23
|
-
|
|
24
|
-
response_data = { data: relationship_data, links: }
|
|
25
|
-
response_data[:meta] = meta if meta.present?
|
|
26
|
-
|
|
27
|
-
render json: response_data, status: :ok
|
|
33
|
+
render json: build_show_response, status: :ok
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def update
|
|
31
37
|
authorize_resource_action!(@resource, action: :update, context: { relationship: @relationship_name })
|
|
32
38
|
relationship_data = parse_relationship_data
|
|
33
39
|
update_relationship(relationship_data)
|
|
34
|
-
|
|
35
|
-
if @resource.save
|
|
36
|
-
emit_relationship_event(:updated, relationship_data)
|
|
37
|
-
render json: {
|
|
38
|
-
data: serialize_relationship_data,
|
|
39
|
-
links: serialize_relationship_links,
|
|
40
|
-
meta: serialize_relationship_meta
|
|
41
|
-
}.compact, status: :ok
|
|
42
|
-
else
|
|
43
|
-
render_validation_errors(@resource)
|
|
44
|
-
end
|
|
40
|
+
save_and_render_relationship(relationship_data)
|
|
45
41
|
rescue ArgumentError => e
|
|
46
42
|
render_invalid_relationship_error(e)
|
|
47
43
|
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
@@ -54,21 +50,7 @@ module JSONAPI
|
|
|
54
50
|
return head :no_content if relationship_data.nil?
|
|
55
51
|
|
|
56
52
|
remove_relationship(relationship_data)
|
|
57
|
-
|
|
58
|
-
# For to-many relationships, resources are updated directly in remove_from_many_relationship
|
|
59
|
-
# For to-one relationships, the parent resource needs to be saved
|
|
60
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
61
|
-
if association && !association.collection?
|
|
62
|
-
if @resource.save
|
|
63
|
-
emit_relationship_event(:removed, relationship_data)
|
|
64
|
-
head :no_content
|
|
65
|
-
else
|
|
66
|
-
render_validation_errors(@resource)
|
|
67
|
-
end
|
|
68
|
-
else
|
|
69
|
-
emit_relationship_event(:removed, relationship_data)
|
|
70
|
-
head :no_content
|
|
71
|
-
end
|
|
53
|
+
finalize_relationship_removal(relationship_data)
|
|
72
54
|
rescue ArgumentError => e
|
|
73
55
|
render_invalid_relationship_error(e)
|
|
74
56
|
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
@@ -82,423 +64,45 @@ module JSONAPI
|
|
|
82
64
|
end
|
|
83
65
|
|
|
84
66
|
def validate_relationship_exists
|
|
85
|
-
|
|
86
|
-
return if relationship_def
|
|
67
|
+
return if find_relationship_definition
|
|
87
68
|
|
|
88
69
|
render_jsonapi_error(
|
|
89
70
|
status: 404,
|
|
90
71
|
title: "Relationship Not Found",
|
|
91
|
-
detail: "Relationship '#{@relationship_name}' not found on #{@resource_name}"
|
|
92
|
-
)
|
|
72
|
+
detail: "Relationship '#{@relationship_name}' not found on #{@resource_name}",
|
|
73
|
+
)
|
|
93
74
|
end
|
|
94
75
|
|
|
95
76
|
def find_relationship_definition
|
|
96
77
|
RelationshipHelpers.find_relationship_definition(@resource_class, @relationship_name)
|
|
97
78
|
end
|
|
98
79
|
|
|
99
|
-
def serialize_relationship_data
|
|
100
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
101
|
-
return nil unless association
|
|
102
|
-
|
|
103
|
-
related = @resource.public_send(@relationship_name)
|
|
104
|
-
|
|
105
|
-
# Apply sorting for collection relationships
|
|
106
|
-
if association.collection? && related.respond_to?(:order)
|
|
107
|
-
related = apply_sorting_to_relationship(related, association)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
if association.collection?
|
|
111
|
-
serialize_collection_relationship(related, association)
|
|
112
|
-
elsif related
|
|
113
|
-
serialize_single_relationship(related, association)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def serialize_collection_relationship(related, association)
|
|
118
|
-
return [] if related.nil?
|
|
119
|
-
|
|
120
|
-
related.map { |r| serialize_resource_identifier(r, association) }
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def serialize_single_relationship(related, association)
|
|
124
|
-
serialize_resource_identifier(related, association)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def serialize_resource_identifier(resource_instance, association)
|
|
128
|
-
RelationshipHelpers.serialize_resource_identifier(
|
|
129
|
-
resource_instance,
|
|
130
|
-
association:,
|
|
131
|
-
resource_class: @resource_class
|
|
132
|
-
)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def serialize_relationship_links
|
|
136
|
-
{
|
|
137
|
-
self: relationship_self_url,
|
|
138
|
-
related: relationship_related_url
|
|
139
|
-
}
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def relationship_self_url
|
|
143
|
-
"/#{params[:resource_type]}/#{params[:id]}/relationships/#{params[:relationship_name]}"
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def relationship_related_url
|
|
147
|
-
"/#{params[:resource_type]}/#{params[:id]}/#{params[:relationship_name]}"
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def serialize_relationship_meta
|
|
151
|
-
relationship_def = find_relationship_definition
|
|
152
|
-
return nil unless relationship_def
|
|
153
|
-
|
|
154
|
-
relationship_def[:meta]
|
|
155
|
-
end
|
|
156
|
-
|
|
157
80
|
def parse_relationship_data
|
|
158
81
|
raw_data = params[:data]
|
|
159
82
|
return nil if raw_data.nil?
|
|
160
|
-
|
|
161
|
-
# Handle arrays directly (for to-many relationships)
|
|
162
83
|
return [] if raw_data.is_a?(Array) && raw_data.empty?
|
|
163
|
-
|
|
164
84
|
return raw_data.map { |item| ParamHelpers.deep_symbolize_params(item) } if raw_data.is_a?(Array)
|
|
165
85
|
|
|
166
|
-
# Handle hash/object (for to-one relationships or single resource identifiers)
|
|
167
86
|
ParamHelpers.deep_symbolize_params(raw_data)
|
|
168
87
|
end
|
|
169
88
|
|
|
170
|
-
def update_relationship(relationship_data)
|
|
171
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
172
|
-
raise ArgumentError, "Association not found: #{@relationship_name}" unless association
|
|
173
|
-
|
|
174
|
-
ensure_relationship_writable!(association)
|
|
175
|
-
|
|
176
|
-
if association.collection?
|
|
177
|
-
update_to_many_relationship(association, relationship_data)
|
|
178
|
-
else
|
|
179
|
-
update_to_one_relationship(association, relationship_data)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def update_to_many_relationship(association, relationship_data)
|
|
184
|
-
if relationship_data.nil? || (relationship_data.is_a?(Array) && relationship_data.empty?)
|
|
185
|
-
@resource.public_send("#{@relationship_name}=", [])
|
|
186
|
-
return
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
raise ArgumentError, "Expected array for to-many relationship" unless relationship_data.is_a?(Array)
|
|
190
|
-
|
|
191
|
-
related_resources = relationship_data.map do |identifier|
|
|
192
|
-
find_related_resource(identifier, association)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
@resource.public_send("#{@relationship_name}=", related_resources)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def update_to_one_relationship(association, relationship_data)
|
|
199
|
-
if relationship_data.nil?
|
|
200
|
-
@resource.public_send("#{@relationship_name}=", nil)
|
|
201
|
-
return
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
if relationship_data.is_a?(Array)
|
|
205
|
-
raise ArgumentError, "Expected single resource identifier for to-one relationship"
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
related_resource = find_related_resource(relationship_data, association)
|
|
209
|
-
@resource.public_send("#{@relationship_name}=", related_resource)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def find_related_resource(identifier, association)
|
|
213
|
-
RelationshipHelpers.resolve_and_find_related_resource(
|
|
214
|
-
identifier,
|
|
215
|
-
association:,
|
|
216
|
-
resource_class: @resource_class,
|
|
217
|
-
relationship_name: @relationship_name
|
|
218
|
-
)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
89
|
def polymorphic_association?
|
|
222
90
|
RelationshipHelpers.polymorphic_association?(@resource_class, @relationship_name)
|
|
223
91
|
end
|
|
224
92
|
|
|
225
|
-
def remove_relationship(relationship_data)
|
|
226
|
-
# Check if this is an ActiveStorage attachment first
|
|
227
|
-
if active_storage_attachment?(@relationship_name, @resource.class)
|
|
228
|
-
# For ActiveStorage, we need to determine if it's has_many or has_one
|
|
229
|
-
attachment_reflection = @resource.class.reflect_on_attachment(@relationship_name)
|
|
230
|
-
if attachment_reflection&.macro == :has_many_attached
|
|
231
|
-
remove_from_many_relationship(nil, relationship_data)
|
|
232
|
-
else
|
|
233
|
-
remove_from_one_relationship(nil, relationship_data)
|
|
234
|
-
end
|
|
235
|
-
return
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
239
|
-
raise ArgumentError, "Association not found: #{@relationship_name}" unless association
|
|
240
|
-
|
|
241
|
-
ensure_relationship_writable!(association)
|
|
242
|
-
|
|
243
|
-
if association.collection?
|
|
244
|
-
remove_from_many_relationship(association, relationship_data)
|
|
245
|
-
else
|
|
246
|
-
remove_from_one_relationship(association, relationship_data)
|
|
247
|
-
end
|
|
248
|
-
rescue ActiveRecord::NotNullViolation, ActiveRecord::RecordInvalid => e
|
|
249
|
-
raise ArgumentError, "Cannot remove relationship: #{e.message}"
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def remove_from_many_relationship(association, relationship_data)
|
|
253
|
-
raise ArgumentError, "Expected array for to-many relationship removal" unless relationship_data.is_a?(Array)
|
|
254
|
-
|
|
255
|
-
return if relationship_data.empty?
|
|
256
|
-
|
|
257
|
-
# Check if this is an ActiveStorage attachment
|
|
258
|
-
if active_storage_attachment?(@relationship_name, @resource.class)
|
|
259
|
-
remove_active_storage_attachments(relationship_data)
|
|
260
|
-
return
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
collection = @resource.public_send(@relationship_name)
|
|
264
|
-
collection_ids = collection.pluck(:id)
|
|
265
|
-
foreign_key = association.foreign_key
|
|
266
|
-
|
|
267
|
-
ActiveRecord::Base.transaction do
|
|
268
|
-
relationship_data.each do |identifier|
|
|
269
|
-
resource = RelationshipHelpers.resolve_and_find_related_resource(
|
|
270
|
-
identifier,
|
|
271
|
-
association:,
|
|
272
|
-
resource_class: @resource_class,
|
|
273
|
-
relationship_name: @relationship_name
|
|
274
|
-
)
|
|
275
|
-
resource_id = resource.id
|
|
276
|
-
|
|
277
|
-
next unless collection_ids.include?(resource_id)
|
|
278
|
-
|
|
279
|
-
resource.update!(foreign_key => nil)
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def remove_active_storage_attachments(relationship_data)
|
|
285
|
-
attachment_proxy = @resource.public_send(@relationship_name)
|
|
286
|
-
return unless attachment_proxy.attached?
|
|
287
|
-
|
|
288
|
-
blob_ids = relationship_data.map do |identifier|
|
|
289
|
-
type = RelationshipHelpers.extract_type_from_identifier(identifier)
|
|
290
|
-
id = RelationshipHelpers.extract_id_from_identifier(identifier)
|
|
291
|
-
|
|
292
|
-
unless self.class.active_storage_blob_type?(type)
|
|
293
|
-
raise ArgumentError, "Expected active_storage_blobs type for ActiveStorage attachment removal, got #{type}"
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
id.to_i
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
# Find attachments that link these blobs to the resource
|
|
300
|
-
# For has_many_attached, we need to access the underlying attachments
|
|
301
|
-
attachments_to_remove = attachment_proxy.attachments.where(blob_id: blob_ids)
|
|
302
|
-
|
|
303
|
-
# Purge/detach the attachments
|
|
304
|
-
attachments_to_remove.each(&:purge)
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
def remove_from_one_relationship(association, relationship_data)
|
|
308
|
-
if relationship_data.is_a?(Array)
|
|
309
|
-
raise ArgumentError, "Expected single resource identifier for to-one relationship"
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
return if relationship_data.nil?
|
|
313
|
-
|
|
314
|
-
# Check if this is an ActiveStorage attachment
|
|
315
|
-
if active_storage_attachment?(@relationship_name, @resource.class)
|
|
316
|
-
remove_active_storage_attachment(relationship_data)
|
|
317
|
-
return
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
related_resource = find_related_resource(relationship_data, association)
|
|
321
|
-
current_resource = @resource.public_send(@relationship_name)
|
|
322
|
-
|
|
323
|
-
return unless current_resource == related_resource
|
|
324
|
-
|
|
325
|
-
@resource.public_send("#{@relationship_name}=", nil)
|
|
326
|
-
@resource.save!
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def remove_active_storage_attachment(relationship_data)
|
|
330
|
-
attachment_proxy = @resource.public_send(@relationship_name)
|
|
331
|
-
return unless attachment_proxy.attached?
|
|
332
|
-
|
|
333
|
-
type = RelationshipHelpers.extract_type_from_identifier(relationship_data)
|
|
334
|
-
id = RelationshipHelpers.extract_id_from_identifier(relationship_data)
|
|
335
|
-
|
|
336
|
-
unless self.class.active_storage_blob_type?(type)
|
|
337
|
-
raise ArgumentError, "Expected active_storage_blobs type for ActiveStorage attachment removal, got #{type}"
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
blob_id = id.to_i
|
|
341
|
-
|
|
342
|
-
# Find the attachment that links this blob to the resource
|
|
343
|
-
attachment = attachment_proxy.attachments.find_by(blob_id:)
|
|
344
|
-
|
|
345
|
-
# Purge/detach the attachment
|
|
346
|
-
attachment&.purge
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
def apply_sorting_to_relationship(related, association)
|
|
350
|
-
sorts = parse_sort_param
|
|
351
|
-
return related if sorts.empty?
|
|
352
|
-
|
|
353
|
-
related_model = association.klass
|
|
354
|
-
related_resource_class = ResourceLoader.find_for_model(related_model)
|
|
355
|
-
|
|
356
|
-
# Separate database columns from virtual attributes
|
|
357
|
-
db_sorts = []
|
|
358
|
-
virtual_sorts = []
|
|
359
|
-
|
|
360
|
-
sorts.each do |sort_field|
|
|
361
|
-
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
362
|
-
if related_model.column_names.include?(field.to_s)
|
|
363
|
-
db_sorts << sort_field
|
|
364
|
-
else
|
|
365
|
-
virtual_sorts << sort_field
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# Apply database sorting first
|
|
370
|
-
db_sorts.each do |sort_field|
|
|
371
|
-
direction = RelationshipHelpers.extract_sort_direction(sort_field)
|
|
372
|
-
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
373
|
-
related = related.order(field => direction)
|
|
374
|
-
end
|
|
375
|
-
|
|
376
|
-
# Apply virtual attribute sorting in Ruby if needed
|
|
377
|
-
if virtual_sorts.any?
|
|
378
|
-
records = related.to_a
|
|
379
|
-
records = apply_virtual_attribute_sorting_to_relationship(records, virtual_sorts, related_resource_class)
|
|
380
|
-
related = records
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
related
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
def apply_virtual_attribute_sorting_to_relationship(records, virtual_sorts, resource_class)
|
|
387
|
-
records.sort do |a, b|
|
|
388
|
-
compare_records_by_virtual_sorts_for_relationship(a, b, virtual_sorts, resource_class)
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def compare_records_by_virtual_sorts_for_relationship(record_a, record_b, virtual_sorts, resource_class)
|
|
393
|
-
virtual_sorts.each do |sort_field|
|
|
394
|
-
direction = RelationshipHelpers.extract_sort_direction(sort_field)
|
|
395
|
-
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
396
|
-
|
|
397
|
-
# Get values using resource getter
|
|
398
|
-
resource_instance_a = resource_class.new(record_a, {})
|
|
399
|
-
resource_instance_b = resource_class.new(record_b, {})
|
|
400
|
-
|
|
401
|
-
value_a = get_virtual_attribute_value_for_relationship(resource_instance_a, field)
|
|
402
|
-
value_b = get_virtual_attribute_value_for_relationship(resource_instance_b, field)
|
|
403
|
-
|
|
404
|
-
# Compare values
|
|
405
|
-
comparison = compare_values_for_relationship(value_a, value_b)
|
|
406
|
-
next if comparison.zero? # Equal, check next sort field
|
|
407
|
-
|
|
408
|
-
# Apply direction
|
|
409
|
-
return direction == :desc ? -comparison : comparison
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
0 # All fields equal
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
def get_virtual_attribute_value_for_relationship(resource_instance, field)
|
|
416
|
-
field_sym = field.to_sym
|
|
417
|
-
return resource_instance.public_send(field_sym) if resource_instance.respond_to?(field_sym, false)
|
|
418
|
-
|
|
419
|
-
nil
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
def compare_values_for_relationship(value_a, value_b)
|
|
423
|
-
# Handle nil values
|
|
424
|
-
return 0 if value_a.nil? && value_b.nil?
|
|
425
|
-
return -1 if value_a.nil?
|
|
426
|
-
return 1 if value_b.nil?
|
|
427
|
-
|
|
428
|
-
# Compare using standard Ruby comparison
|
|
429
|
-
value_a <=> value_b
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
def validate_sort_param
|
|
433
|
-
sorts = parse_sort_param
|
|
434
|
-
return if sorts.empty?
|
|
435
|
-
|
|
436
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
437
|
-
return unless association&.collection?
|
|
438
|
-
|
|
439
|
-
# Find resource class for the related model to check virtual attributes
|
|
440
|
-
related_resource_class = ResourceLoader.find_for_model(association.klass)
|
|
441
|
-
valid_fields = valid_sort_fields_for_resource(related_resource_class, association.klass)
|
|
442
|
-
invalid_fields = invalid_sort_fields_for_columns(sorts, valid_fields)
|
|
443
|
-
return if invalid_fields.empty?
|
|
444
|
-
|
|
445
|
-
render_parameter_errors(
|
|
446
|
-
invalid_fields,
|
|
447
|
-
title: "Invalid Sort Field",
|
|
448
|
-
detail_proc: ->(field) { "Invalid sort field requested: #{field}" },
|
|
449
|
-
source_proc: ->(_field) { { parameter: "sort" } }
|
|
450
|
-
)
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def emit_relationship_event(action, relationship_data)
|
|
454
|
-
resource_type_name = params[:resource_type] || @resource_name.pluralize
|
|
455
|
-
|
|
456
|
-
related_ids = extract_related_ids(relationship_data)
|
|
457
|
-
related_type = extract_related_type(relationship_data)
|
|
458
|
-
|
|
459
|
-
JSONAPI::Instrumentation.relationship_event(
|
|
460
|
-
action:,
|
|
461
|
-
resource_type: resource_type_name,
|
|
462
|
-
resource_id: @resource.id,
|
|
463
|
-
relationship_name: @relationship_name.to_s,
|
|
464
|
-
related_ids:,
|
|
465
|
-
related_type:
|
|
466
|
-
)
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
def extract_related_ids(relationship_data)
|
|
470
|
-
return nil if relationship_data.nil?
|
|
471
|
-
|
|
472
|
-
if relationship_data.is_a?(Array)
|
|
473
|
-
relationship_data.map { |item| item[:id] }.compact
|
|
474
|
-
else
|
|
475
|
-
[relationship_data[:id]].compact
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
def extract_related_type(relationship_data)
|
|
480
|
-
return nil if relationship_data.nil?
|
|
481
|
-
|
|
482
|
-
if relationship_data.is_a?(Array)
|
|
483
|
-
first_item = relationship_data.first
|
|
484
|
-
return nil unless first_item
|
|
485
|
-
|
|
486
|
-
first_item[:type]
|
|
487
|
-
else
|
|
488
|
-
relationship_data[:type]
|
|
489
|
-
end
|
|
490
|
-
end
|
|
491
|
-
|
|
492
93
|
def ensure_relationship_writable!(association)
|
|
493
|
-
if
|
|
494
|
-
active_storage_attachment?(@relationship_name, @resource.class)
|
|
495
|
-
return
|
|
496
|
-
end
|
|
94
|
+
return if active_storage_writable?(association)
|
|
497
95
|
|
|
498
96
|
relationship_def = find_relationship_definition
|
|
499
97
|
readonly = relationship_def && (relationship_def[:options] || {})[:readonly] == true
|
|
500
98
|
|
|
501
99
|
JSONAPI::RelationshipGuard.ensure_writable!(association, @relationship_name, readonly:)
|
|
502
100
|
end
|
|
101
|
+
|
|
102
|
+
def active_storage_writable?(association)
|
|
103
|
+
self.class.respond_to?(:active_storage_attachment?) &&
|
|
104
|
+
active_storage_attachment?(@relationship_name, @resource.class) &&
|
|
105
|
+
association.nil?
|
|
106
|
+
end
|
|
503
107
|
end
|
|
504
108
|
end
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -53,23 +53,60 @@ module JSONAPI
|
|
|
53
53
|
# This allows empty controllers inheriting from ApplicationController to work automatically
|
|
54
54
|
# We don't mix into ActionController::API by default to avoid Rails filtering action methods
|
|
55
55
|
# (Rails treats public methods on abstract base classes as internal methods)
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
#
|
|
57
|
+
# We use after_initialize because:
|
|
58
|
+
# 1. App controllers (e.g., JSONAPIController) must be autoloaded first
|
|
59
|
+
# 2. Rails 8 freezes autoload paths during initialization, and config.to_prepare runs
|
|
60
|
+
# before app controllers are available, causing FrozenError or NameError
|
|
61
|
+
# 3. We register with reloader.to_prepare for code reloading in development
|
|
62
|
+
config.after_initialize do |app|
|
|
63
|
+
# Register for code reloading in development
|
|
64
|
+
app.reloader.to_prepare do
|
|
65
|
+
Railtie.setup_base_controllers
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Run once immediately after initialization
|
|
69
|
+
# In eager_load environments (production, test with config.eager_load = true),
|
|
70
|
+
# controllers are already loaded. In development, this triggers autoloading
|
|
71
|
+
# which is now safe since initialization is complete.
|
|
72
|
+
Railtie.setup_base_controllers
|
|
73
|
+
end
|
|
58
74
|
|
|
59
|
-
|
|
75
|
+
class << self
|
|
76
|
+
def setup_base_controllers
|
|
77
|
+
return unless JSONAPI.configuration.base_controller_overridden?
|
|
78
|
+
|
|
79
|
+
base_controller_class = resolve_base_controller_class
|
|
80
|
+
return unless base_controller_class
|
|
81
|
+
|
|
82
|
+
rebuild_controllers_if_needed(base_controller_class)
|
|
83
|
+
include_jsonapi_concerns(base_controller_class)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def resolve_base_controller_class
|
|
89
|
+
class_name = JSONAPI.configuration.instance_variable_get(:@base_controller_class)
|
|
90
|
+
class_name.constantize
|
|
91
|
+
rescue NameError
|
|
92
|
+
# Controller not yet loaded - this can happen in test environments
|
|
93
|
+
# where eager_load is false and autoloading hasn't run yet.
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def rebuild_controllers_if_needed(base_controller_class)
|
|
98
|
+
return if JSONAPI::BaseController.superclass == base_controller_class
|
|
60
99
|
|
|
61
|
-
# Ensure BaseController inherits from the configured base controller
|
|
62
|
-
# This is necessary because BaseController may be defined before the configuration is set
|
|
63
|
-
if JSONAPI::BaseController.superclass != base_controller_class
|
|
64
100
|
JSONAPI.send(:remove_const, :BaseController)
|
|
65
101
|
load "json_api/controllers/base_controller.rb"
|
|
66
|
-
# Also reload RelationshipsController so it picks up the new BaseController
|
|
67
102
|
JSONAPI.send(:remove_const, :RelationshipsController)
|
|
68
103
|
load "json_api/controllers/relationships_controller.rb"
|
|
69
104
|
end
|
|
70
105
|
|
|
71
|
-
base_controller_class
|
|
72
|
-
|
|
106
|
+
def include_jsonapi_concerns(base_controller_class)
|
|
107
|
+
base_controller_class.include(JSONAPI::ControllerHelpers)
|
|
108
|
+
base_controller_class.include(JSONAPI::ResourceActions)
|
|
109
|
+
end
|
|
73
110
|
end
|
|
74
111
|
end
|
|
75
112
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Resources
|
|
5
|
+
module AttributesDsl
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def attributes(*attrs)
|
|
10
|
+
@attributes ||= []
|
|
11
|
+
@attributes.concat(attrs.map(&:to_sym))
|
|
12
|
+
@attributes.uniq!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def creatable_fields(*fields)
|
|
16
|
+
@creatable_fields ||= []
|
|
17
|
+
@creatable_fields.concat(fields.map(&:to_sym))
|
|
18
|
+
@creatable_fields.uniq!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def updatable_fields(*fields)
|
|
22
|
+
@updatable_fields ||= []
|
|
23
|
+
@updatable_fields.concat(fields.map(&:to_sym))
|
|
24
|
+
@updatable_fields.uniq!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module FieldResolution
|
|
29
|
+
def permitted_attributes
|
|
30
|
+
declared_attributes = instance_variable_defined?(:@attributes)
|
|
31
|
+
attrs = @attributes || []
|
|
32
|
+
attrs = superclass.permitted_attributes + attrs if should_inherit_attributes?(declared_attributes)
|
|
33
|
+
attrs.uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def permitted_creatable_fields
|
|
37
|
+
resolve_field_list(:@creatable_fields, :permitted_creatable_fields)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def permitted_updatable_fields
|
|
41
|
+
resolve_field_list(:@updatable_fields, :permitted_updatable_fields)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolve_field_list(ivar, method)
|
|
45
|
+
return (instance_variable_get(ivar) || []).uniq if instance_variable_defined?(ivar)
|
|
46
|
+
return superclass.public_send(method).uniq if inherits_field?(ivar, method)
|
|
47
|
+
|
|
48
|
+
permitted_attributes.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inherits_field?(ivar, method)
|
|
52
|
+
superclass != JSONAPI::Resource &&
|
|
53
|
+
superclass.respond_to?(method) &&
|
|
54
|
+
superclass.instance_variable_defined?(ivar)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def should_inherit_attributes?(declared_attributes)
|
|
58
|
+
!declared_attributes &&
|
|
59
|
+
superclass != JSONAPI::Resource &&
|
|
60
|
+
superclass.respond_to?(:permitted_attributes)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
included do
|
|
65
|
+
extend FieldResolution
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Resources
|
|
5
|
+
module FiltersDsl
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def filters(*filter_names)
|
|
10
|
+
@filters ||= []
|
|
11
|
+
@filters.concat(filter_names.map(&:to_sym))
|
|
12
|
+
@filters.uniq!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def permitted_filters_through
|
|
16
|
+
relationship_names
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def permitted_filters
|
|
20
|
+
declared_filters = instance_variable_defined?(:@filters)
|
|
21
|
+
filter_list = @filters || []
|
|
22
|
+
if !declared_filters &&
|
|
23
|
+
superclass != JSONAPI::Resource &&
|
|
24
|
+
superclass.respond_to?(:permitted_filters)
|
|
25
|
+
filter_list = superclass.permitted_filters + filter_list
|
|
26
|
+
end
|
|
27
|
+
filter_list.uniq
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|