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,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_api/support/relationship_guard"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
class RelationshipsController < BaseController
|
|
7
|
+
skip_before_action :validate_resource_type!, only: %i[update destroy]
|
|
8
|
+
skip_before_action :validate_resource_id!, only: %i[update destroy]
|
|
9
|
+
|
|
10
|
+
before_action :set_resource_name
|
|
11
|
+
before_action :set_resource_class
|
|
12
|
+
before_action :set_resource
|
|
13
|
+
before_action :set_relationship_name
|
|
14
|
+
before_action :validate_relationship_exists
|
|
15
|
+
before_action :validate_sort_param, only: [:show]
|
|
16
|
+
skip_before_action :validate_resource_type!, :validate_resource_id!
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
|
|
20
|
+
relationship_data = serialize_relationship_data
|
|
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
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
authorize_resource_action!(@resource, action: :update, context: { relationship: @relationship_name })
|
|
32
|
+
relationship_data = parse_relationship_data
|
|
33
|
+
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
|
|
45
|
+
rescue ArgumentError => e
|
|
46
|
+
render_invalid_relationship_error(e)
|
|
47
|
+
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
48
|
+
render_parameter_not_allowed_error(e)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destroy
|
|
52
|
+
authorize_resource_action!(@resource, action: :update, context: { relationship: @relationship_name })
|
|
53
|
+
relationship_data = parse_relationship_data
|
|
54
|
+
return head :no_content if relationship_data.nil?
|
|
55
|
+
|
|
56
|
+
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
|
|
72
|
+
rescue ArgumentError => e
|
|
73
|
+
render_invalid_relationship_error(e)
|
|
74
|
+
rescue JSONAPI::Exceptions::ParameterNotAllowed => e
|
|
75
|
+
render_parameter_not_allowed_error(e)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def set_relationship_name
|
|
81
|
+
@relationship_name = params[:relationship_name].to_sym
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_relationship_exists
|
|
85
|
+
relationship_def = find_relationship_definition
|
|
86
|
+
return if relationship_def
|
|
87
|
+
|
|
88
|
+
render_jsonapi_error(
|
|
89
|
+
status: 404,
|
|
90
|
+
title: "Relationship Not Found",
|
|
91
|
+
detail: "Relationship '#{@relationship_name}' not found on #{@resource_name}"
|
|
92
|
+
) and return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def find_relationship_definition
|
|
96
|
+
RelationshipHelpers.find_relationship_definition(@resource_class, @relationship_name)
|
|
97
|
+
end
|
|
98
|
+
|
|
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
|
+
def parse_relationship_data
|
|
158
|
+
raw_data = params[:data]
|
|
159
|
+
return nil if raw_data.nil?
|
|
160
|
+
|
|
161
|
+
# Handle arrays directly (for to-many relationships)
|
|
162
|
+
return [] if raw_data.is_a?(Array) && raw_data.empty?
|
|
163
|
+
|
|
164
|
+
return raw_data.map { |item| ParamHelpers.deep_symbolize_params(item) } if raw_data.is_a?(Array)
|
|
165
|
+
|
|
166
|
+
# Handle hash/object (for to-one relationships or single resource identifiers)
|
|
167
|
+
ParamHelpers.deep_symbolize_params(raw_data)
|
|
168
|
+
end
|
|
169
|
+
|
|
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
|
+
def polymorphic_association?
|
|
222
|
+
RelationshipHelpers.polymorphic_association?(@resource_class, @relationship_name)
|
|
223
|
+
end
|
|
224
|
+
|
|
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
|
+
def ensure_relationship_writable!(association)
|
|
493
|
+
if self.class.respond_to?(:active_storage_attachment?) &&
|
|
494
|
+
active_storage_attachment?(@relationship_name, @resource.class)
|
|
495
|
+
return
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
relationship_def = find_relationship_definition
|
|
499
|
+
readonly = relationship_def && (relationship_def[:options] || {})[:readonly] == true
|
|
500
|
+
|
|
501
|
+
JSONAPI::RelationshipGuard.ensure_writable!(association, @relationship_name, readonly:)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Errors
|
|
5
|
+
class ParameterNotAllowed < JSONAPI::Error
|
|
6
|
+
attr_reader :params
|
|
7
|
+
|
|
8
|
+
def initialize(params = [])
|
|
9
|
+
@params = params
|
|
10
|
+
super("Parameter not allowed: #{Array(params).join(', ')}")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Backward compatibility alias
|
|
16
|
+
module Exceptions
|
|
17
|
+
ParameterNotAllowed = Errors::ParameterNotAllowed
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
config.before_initialize do
|
|
8
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
9
|
+
inflect.acronym "JSON"
|
|
10
|
+
inflect.acronym "API"
|
|
11
|
+
# Ensure json_api converts to JSONAPI
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
initializer "json_api.mime_type" do |_app|
|
|
16
|
+
Mime::Type.register "application/vnd.api+json", :jsonapi
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "json_api.routes" do |_app|
|
|
20
|
+
require "json_api/routing"
|
|
21
|
+
ActionDispatch::Routing::Mapper.include JSONAPI::Routing
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Removed eager_load_namespaces registration - JSONAPI module doesn't implement eager_load!
|
|
25
|
+
# Controllers and resources are autoloaded via Zeitwerk
|
|
26
|
+
|
|
27
|
+
initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
|
|
28
|
+
ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
|
|
29
|
+
ActiveSupport::JSON.decode(raw_post)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
raise ActionDispatch::Http::Parameters::ParseError, e.message
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
initializer "json_api.controllers.include_base", before: :add_routing_paths do |_app|
|
|
36
|
+
mixin = lambda do |base|
|
|
37
|
+
next unless JSONAPI.configuration.base_controller_overridden?
|
|
38
|
+
|
|
39
|
+
# Use class name comparison instead of object equality to avoid timing issues
|
|
40
|
+
# when ApplicationController loads before JSONAPIController
|
|
41
|
+
expected_class_name = JSONAPI.configuration.instance_variable_get(:@base_controller_class)
|
|
42
|
+
next unless base.name == expected_class_name
|
|
43
|
+
|
|
44
|
+
base.include(JSONAPI::ControllerHelpers)
|
|
45
|
+
base.include(JSONAPI::ResourceActions)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
ActiveSupport.on_load(:action_controller_base, &mixin)
|
|
49
|
+
ActiveSupport.on_load(:action_controller_api, &mixin)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inject JSON:API concerns into the configured base controller class only when it's been overridden
|
|
53
|
+
# This allows empty controllers inheriting from ApplicationController to work automatically
|
|
54
|
+
# We don't mix into ActionController::API by default to avoid Rails filtering action methods
|
|
55
|
+
# (Rails treats public methods on abstract base classes as internal methods)
|
|
56
|
+
config.to_prepare do
|
|
57
|
+
next unless JSONAPI.configuration.base_controller_overridden?
|
|
58
|
+
|
|
59
|
+
base_controller_class = JSONAPI.configuration.resolved_base_controller_class
|
|
60
|
+
|
|
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
|
+
JSONAPI.send(:remove_const, :BaseController)
|
|
65
|
+
load "json_api/controllers/base_controller.rb"
|
|
66
|
+
# Also reload RelationshipsController so it picks up the new BaseController
|
|
67
|
+
JSONAPI.send(:remove_const, :RelationshipsController)
|
|
68
|
+
load "json_api/controllers/relationships_controller.rb"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
base_controller_class.include(JSONAPI::ControllerHelpers)
|
|
72
|
+
base_controller_class.include(JSONAPI::ResourceActions)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|