jpie 0.4.4 → 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 -28
- 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 -167
- 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/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/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/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/rspec.rb +0 -71
- data/lib/jpie/serializer.rb +0 -205
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
class Resource
|
|
5
|
+
class << self
|
|
6
|
+
def resource_for_model(model_class)
|
|
7
|
+
resource_const = "#{model_class.name}Resource"
|
|
8
|
+
resource_const.safe_constantize if resource_const.respond_to?(:safe_constantize) || defined?(ActiveSupport)
|
|
9
|
+
rescue NameError
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def attributes(*attrs)
|
|
14
|
+
@attributes ||= []
|
|
15
|
+
@attributes.concat(attrs.map(&:to_sym))
|
|
16
|
+
@attributes.uniq!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def creatable_fields(*fields)
|
|
20
|
+
@creatable_fields ||= []
|
|
21
|
+
@creatable_fields.concat(fields.map(&:to_sym))
|
|
22
|
+
@creatable_fields.uniq!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def updatable_fields(*fields)
|
|
26
|
+
@updatable_fields ||= []
|
|
27
|
+
@updatable_fields.concat(fields.map(&:to_sym))
|
|
28
|
+
@updatable_fields.uniq!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def filters(*filter_names)
|
|
32
|
+
@filters ||= []
|
|
33
|
+
@filters.concat(filter_names.map(&:to_sym))
|
|
34
|
+
@filters.uniq!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def permitted_filters_through
|
|
38
|
+
relationship_names
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sortable_fields(*field_names)
|
|
42
|
+
@sortable_fields ||= []
|
|
43
|
+
@sortable_fields.concat(field_names.map(&:to_sym))
|
|
44
|
+
@sortable_fields.uniq!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def meta(hash = nil, &block)
|
|
48
|
+
@meta = hash || block
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resource_meta
|
|
52
|
+
if instance_variable_defined?(:@meta)
|
|
53
|
+
@meta
|
|
54
|
+
elsif superclass != JSONAPI::Resource && superclass.respond_to?(:resource_meta)
|
|
55
|
+
superclass.resource_meta
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def has_one(name, meta: nil, **options)
|
|
60
|
+
@relationships ||= []
|
|
61
|
+
|
|
62
|
+
unless options.key?(:polymorphic)
|
|
63
|
+
model_klass = reflection_model_class
|
|
64
|
+
if model_klass.respond_to?(:reflect_on_association)
|
|
65
|
+
reflection = model_klass.reflect_on_association(name)
|
|
66
|
+
options[:polymorphic] = reflection&.polymorphic?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@relationships << { name: name.to_sym, type: :has_one, meta:, options: }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def has_many(name, meta: nil, **options)
|
|
74
|
+
@relationships ||= []
|
|
75
|
+
|
|
76
|
+
if options[:append_only] && options[:purge_on_nil] == true
|
|
77
|
+
raise ArgumentError, "Cannot use append_only: true with purge_on_nil: true"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
options[:purge_on_nil] = false if options[:append_only] && !options.key?(:purge_on_nil)
|
|
81
|
+
|
|
82
|
+
unless options.key?(:polymorphic)
|
|
83
|
+
model_klass = reflection_model_class
|
|
84
|
+
if model_klass.respond_to?(:reflect_on_association)
|
|
85
|
+
reflection = model_klass.reflect_on_association(name)
|
|
86
|
+
options[:polymorphic] = reflection&.polymorphic?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@relationships << { name: name.to_sym, type: :has_many, meta:, options: }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def belongs_to(name, meta: nil, **options)
|
|
94
|
+
@relationships ||= []
|
|
95
|
+
|
|
96
|
+
unless options.key?(:polymorphic)
|
|
97
|
+
model_klass = reflection_model_class
|
|
98
|
+
if model_klass.respond_to?(:reflect_on_association)
|
|
99
|
+
reflection = model_klass.reflect_on_association(name)
|
|
100
|
+
options[:polymorphic] = reflection&.polymorphic?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@relationships << { name: name.to_sym, type: :belongs_to, meta:, options: }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def permitted_attributes
|
|
108
|
+
# For STI subclasses, merge with parent class attributes unless the subclass declares its own
|
|
109
|
+
declared_attributes = instance_variable_defined?(:@attributes)
|
|
110
|
+
attrs = @attributes || []
|
|
111
|
+
if !declared_attributes &&
|
|
112
|
+
superclass != JSONAPI::Resource &&
|
|
113
|
+
superclass.respond_to?(:permitted_attributes)
|
|
114
|
+
attrs = superclass.permitted_attributes + attrs
|
|
115
|
+
end
|
|
116
|
+
attrs.uniq
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def permitted_creatable_fields
|
|
120
|
+
if instance_variable_defined?(:@creatable_fields)
|
|
121
|
+
fields = @creatable_fields || []
|
|
122
|
+
elsif superclass != JSONAPI::Resource &&
|
|
123
|
+
superclass.respond_to?(:permitted_creatable_fields) &&
|
|
124
|
+
superclass.instance_variable_defined?(:@creatable_fields)
|
|
125
|
+
parent_fields = superclass.permitted_creatable_fields
|
|
126
|
+
fields = parent_fields
|
|
127
|
+
else
|
|
128
|
+
fields = permitted_attributes
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
fields.uniq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def permitted_updatable_fields
|
|
135
|
+
if instance_variable_defined?(:@updatable_fields)
|
|
136
|
+
fields = @updatable_fields || []
|
|
137
|
+
elsif superclass != JSONAPI::Resource &&
|
|
138
|
+
superclass.respond_to?(:permitted_updatable_fields) &&
|
|
139
|
+
superclass.instance_variable_defined?(:@updatable_fields)
|
|
140
|
+
parent_fields = superclass.permitted_updatable_fields
|
|
141
|
+
fields = parent_fields
|
|
142
|
+
else
|
|
143
|
+
fields = permitted_attributes
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
fields.uniq
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def permitted_filters
|
|
150
|
+
# For STI subclasses, merge with parent class filters unless the subclass declares its own
|
|
151
|
+
declared_filters = instance_variable_defined?(:@filters)
|
|
152
|
+
filter_list = @filters || []
|
|
153
|
+
if !declared_filters &&
|
|
154
|
+
superclass != JSONAPI::Resource &&
|
|
155
|
+
superclass.respond_to?(:permitted_filters)
|
|
156
|
+
filter_list = superclass.permitted_filters + filter_list
|
|
157
|
+
end
|
|
158
|
+
filter_list.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def permitted_sortable_fields
|
|
162
|
+
# Include both attributes (which are sortable) and sort-only fields
|
|
163
|
+
# For STI subclasses, merge with parent class sortable fields when no subclass DSL is declared
|
|
164
|
+
declared_sortable = instance_variable_defined?(:@sortable_fields)
|
|
165
|
+
declared_attributes = instance_variable_defined?(:@attributes)
|
|
166
|
+
sort_fields = @sortable_fields || []
|
|
167
|
+
if !declared_sortable &&
|
|
168
|
+
!declared_attributes &&
|
|
169
|
+
superclass != JSONAPI::Resource &&
|
|
170
|
+
superclass.respond_to?(:permitted_sortable_fields)
|
|
171
|
+
parent_sort_fields = superclass.permitted_sortable_fields
|
|
172
|
+
# Only merge parent's sort-only fields, not attributes (attributes are already included via permitted_attributes)
|
|
173
|
+
parent_attributes = superclass.permitted_attributes
|
|
174
|
+
parent_sort_only = parent_sort_fields - parent_attributes
|
|
175
|
+
sort_fields = parent_sort_only + sort_fields
|
|
176
|
+
end
|
|
177
|
+
# Combine with attributes (all attributes are sortable)
|
|
178
|
+
(permitted_attributes + sort_fields).uniq
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def relationship_definitions
|
|
182
|
+
# For STI subclasses, merge with parent class relationships
|
|
183
|
+
declared_relationships = instance_variable_defined?(:@relationships)
|
|
184
|
+
rels = @relationships || []
|
|
185
|
+
if !declared_relationships &&
|
|
186
|
+
superclass != JSONAPI::Resource &&
|
|
187
|
+
superclass.respond_to?(:relationship_definitions)
|
|
188
|
+
rels = superclass.relationship_definitions + rels
|
|
189
|
+
end
|
|
190
|
+
rels.uniq { |r| r[:name] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def relationship_names
|
|
194
|
+
relationship_definitions.map { |r| r[:name] }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def model_class
|
|
198
|
+
name.sub(/Resource$/, "").classify.constantize
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def safe_model_class
|
|
202
|
+
return nil unless respond_to?(:name) && name
|
|
203
|
+
return nil unless defined?(ActiveSupport)
|
|
204
|
+
|
|
205
|
+
name.sub(/Resource$/, "").classify.safe_constantize
|
|
206
|
+
rescue NoMethodError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def reflection_model_class
|
|
211
|
+
model_class
|
|
212
|
+
rescue NameError
|
|
213
|
+
safe_model_class
|
|
214
|
+
rescue StandardError
|
|
215
|
+
safe_model_class
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Instance method to initialize resource with model instance
|
|
220
|
+
# This allows the serializer to instantiate resources directly
|
|
221
|
+
def initialize(record = nil, context = {})
|
|
222
|
+
@record = record
|
|
223
|
+
@context = context
|
|
224
|
+
@transformed_params = {}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Accessor for the underlying model instance
|
|
228
|
+
# @record is the preferred internal name, but :resource is kept for backward compatibility
|
|
229
|
+
attr_reader :record
|
|
230
|
+
alias resource record
|
|
231
|
+
|
|
232
|
+
# Returns transformed params accumulated by resource setters during deserialization
|
|
233
|
+
# Resources can override this method or use the default implementation
|
|
234
|
+
def transformed_params
|
|
235
|
+
@transformed_params || {}
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
class ResourceLoader
|
|
5
|
+
class MissingResourceClass < JSONAPI::Error
|
|
6
|
+
def initialize(resource_type)
|
|
7
|
+
super("Resource class for '#{resource_type}' not found. Define #{resource_type.singularize.classify}Resource < JSONAPI::Resource")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.find(resource_type)
|
|
12
|
+
resource_class_name = "#{resource_type.singularize.classify}Resource"
|
|
13
|
+
resource_class_name.constantize
|
|
14
|
+
rescue NameError
|
|
15
|
+
raise MissingResourceClass, resource_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.find_for_model(model_class)
|
|
19
|
+
# Handle ActiveStorage::Blob specially
|
|
20
|
+
return ActiveStorageBlobResource if defined?(::ActiveStorage) && model_class == ::ActiveStorage::Blob
|
|
21
|
+
|
|
22
|
+
# For STI subclasses, try the specific subclass resource first
|
|
23
|
+
resource_type = model_class.name.underscore.pluralize
|
|
24
|
+
begin
|
|
25
|
+
find(resource_type)
|
|
26
|
+
rescue MissingResourceClass
|
|
27
|
+
# For STI subclasses, fall back to base class resource
|
|
28
|
+
raise unless model_class.respond_to?(:base_class) && model_class.base_class != model_class
|
|
29
|
+
|
|
30
|
+
base_resource_type = model_class.base_class.name.underscore.pluralize
|
|
31
|
+
find(base_resource_type)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Routing
|
|
5
|
+
def jsonapi_resources(resource, controller: nil, defaults: {}, sti: false, **options, &block)
|
|
6
|
+
resource_name = resource.to_s
|
|
7
|
+
|
|
8
|
+
# Smart Controller Detection:
|
|
9
|
+
# If no controller is specified, try to find one in the current scope.
|
|
10
|
+
# If we find "Trust::VendorsController", use it (by passing nil to 'resources').
|
|
11
|
+
# If NOT found, default to the generic "json_api/resources".
|
|
12
|
+
if controller.nil?
|
|
13
|
+
scoped_module = @scope[:module]
|
|
14
|
+
potential_controller_name = if scoped_module
|
|
15
|
+
"#{scoped_module.to_s.camelize}::#{resource_name.pluralize.camelize}Controller"
|
|
16
|
+
else
|
|
17
|
+
"#{resource_name.pluralize.camelize}Controller"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
# Check if the controller exists constant
|
|
22
|
+
potential_controller_name.constantize
|
|
23
|
+
# It exists! Leave controller as nil so Rails uses standard routing conventions
|
|
24
|
+
rescue NameError
|
|
25
|
+
# It doesn't exist, fallback to generic controller
|
|
26
|
+
controller = "json_api/resources"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Validate that resource class exists at boot time
|
|
31
|
+
JSONAPI::ResourceLoader.find(resource_name)
|
|
32
|
+
|
|
33
|
+
defaults = defaults.merge(format: :jsonapi, resource_type: resource_name)
|
|
34
|
+
|
|
35
|
+
options[:only] = :index if sti
|
|
36
|
+
|
|
37
|
+
resources(resource, controller:, defaults:, **options) do
|
|
38
|
+
member do
|
|
39
|
+
get "relationships/:relationship_name", to: "json_api/relationships#show", as: :relationship
|
|
40
|
+
patch "relationships/:relationship_name", to: "json_api/relationships#update"
|
|
41
|
+
delete "relationships/:relationship_name", to: "json_api/relationships#destroy"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
instance_eval(&block) if block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return unless sti
|
|
48
|
+
|
|
49
|
+
if sti.is_a?(Array)
|
|
50
|
+
sti.each do |sub_resource_name|
|
|
51
|
+
jsonapi_resources(sub_resource_name, defaults:)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
begin
|
|
55
|
+
resource_class = JSONAPI::ResourceLoader.find(resource_name)
|
|
56
|
+
model_class = resource_class.model_class
|
|
57
|
+
|
|
58
|
+
if model_class.respond_to?(:descendants)
|
|
59
|
+
model_class.descendants.each do |subclass|
|
|
60
|
+
sub_resource_name = subclass.name.underscore.pluralize.to_sym
|
|
61
|
+
next if sub_resource_name == resource.to_sym
|
|
62
|
+
|
|
63
|
+
jsonapi_resources(sub_resource_name, defaults:)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
rescue NameError, JSONAPI::ResourceLoader::MissingResourceClass
|
|
67
|
+
# Silently skip if model/resource not found
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_api/support/relationship_guard"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
class Deserializer
|
|
7
|
+
include ActiveStorageSupport
|
|
8
|
+
|
|
9
|
+
def initialize(params, model_class:, action: :create)
|
|
10
|
+
@params = ParamHelpers.deep_symbolize_params(params)
|
|
11
|
+
@model_class = model_class
|
|
12
|
+
@definition = ResourceLoader.find_for_model(model_class)
|
|
13
|
+
@action = action.to_sym
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def attributes
|
|
17
|
+
attrs = extract_attributes_from_params
|
|
18
|
+
attrs = attrs.transform_keys(&:to_sym) if attrs.respond_to?(:transform_keys)
|
|
19
|
+
permitted_attrs = permitted_attributes_for_action
|
|
20
|
+
attrs.slice(*permitted_attrs)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def extract_attributes_from_params
|
|
24
|
+
@params.dig(:data, :attributes) || @params[:attributes] || {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def permitted_attributes_for_action
|
|
28
|
+
fields = if @action == :create
|
|
29
|
+
@definition.permitted_creatable_fields
|
|
30
|
+
else
|
|
31
|
+
@definition.permitted_updatable_fields
|
|
32
|
+
end
|
|
33
|
+
fields.map(&:to_sym)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def relationships
|
|
37
|
+
# Handle both nested (:data => {:relationships => ...}) and flat (:relationships => ...) structures
|
|
38
|
+
rels = @params.dig(:data, :relationships) || @params[:relationships] || {}
|
|
39
|
+
rels = rels.to_h if rels.respond_to?(:to_h)
|
|
40
|
+
rels.is_a?(Hash) ? rels : {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def relationship_ids(relationship_name)
|
|
44
|
+
relationship = find_relationship(relationship_name)
|
|
45
|
+
return [] unless relationship
|
|
46
|
+
|
|
47
|
+
data = extract_relationship_data(relationship)
|
|
48
|
+
return [] unless data
|
|
49
|
+
|
|
50
|
+
extract_ids_from_data(data)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find_relationship(relationship_name)
|
|
54
|
+
relationships[relationship_name.to_sym]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_relationship_data(relationship)
|
|
58
|
+
relationship[:data]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_ids_from_data(data)
|
|
62
|
+
if data.is_a?(Array)
|
|
63
|
+
data.map { |r| extract_id_from_identifier(r) }
|
|
64
|
+
else
|
|
65
|
+
[extract_id_from_identifier(data)]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def extract_id_from_identifier(identifier)
|
|
70
|
+
RelationshipHelpers.extract_id_from_identifier(identifier)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def relationship_id(relationship_name)
|
|
74
|
+
relationship_ids(relationship_name).first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_model_attributes
|
|
78
|
+
attrs = attributes.dup
|
|
79
|
+
attrs = apply_virtual_attribute_transformers(attrs)
|
|
80
|
+
attrs = process_relationships(attrs)
|
|
81
|
+
attrs.transform_keys(&:to_s)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def apply_virtual_attribute_transformers(attrs)
|
|
85
|
+
transformed_params, attributes_with_setters = invoke_setter_methods(attrs)
|
|
86
|
+
attributes_with_setters.each { |attr_sym| attrs.delete(attr_sym) }
|
|
87
|
+
merge_transformed_params(attrs, transformed_params)
|
|
88
|
+
attrs
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def merge_transformed_params(attrs, transformed_params)
|
|
92
|
+
return attrs unless transformed_params.is_a?(Hash) && transformed_params.any?
|
|
93
|
+
|
|
94
|
+
transformed_params_symbolized = transformed_params.transform_keys(&:to_sym)
|
|
95
|
+
attrs.merge!(transformed_params_symbolized)
|
|
96
|
+
attrs
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def process_relationships(attrs)
|
|
100
|
+
permitted_relationships = @definition.relationship_names.map(&:to_s)
|
|
101
|
+
|
|
102
|
+
relationships.each do |key, value|
|
|
103
|
+
association_name = key.to_s
|
|
104
|
+
next unless permitted_relationships.include?(association_name)
|
|
105
|
+
|
|
106
|
+
process_relationship(attrs, association_name, value)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
attrs
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def invoke_setter_methods(attrs)
|
|
113
|
+
definition_instance = create_definition_instance_for_setters
|
|
114
|
+
return [{}, []] unless definition_instance.respond_to?(:transformed_params, true)
|
|
115
|
+
|
|
116
|
+
attributes_with_setters = call_setters(attrs, definition_instance)
|
|
117
|
+
[definition_instance.transformed_params, attributes_with_setters]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def create_definition_instance_for_setters
|
|
121
|
+
@definition.new(nil, {})
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def call_setters(attrs, definition_instance)
|
|
125
|
+
attributes_with_setters = []
|
|
126
|
+
attrs.each do |attr_sym, attr_value|
|
|
127
|
+
next unless has_setter?(definition_instance, attr_sym)
|
|
128
|
+
|
|
129
|
+
definition_instance.public_send(:"#{attr_sym}=", attr_value)
|
|
130
|
+
attributes_with_setters << attr_sym
|
|
131
|
+
end
|
|
132
|
+
attributes_with_setters
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def has_setter?(definition_instance, attr_sym)
|
|
136
|
+
definition_instance.respond_to?(:"#{attr_sym}=", false)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def process_relationship(attrs, association_name, value)
|
|
140
|
+
value_hash = normalize_relationship_value(value)
|
|
141
|
+
data = extract_data_from_value(value_hash)
|
|
142
|
+
param_name = association_param_name(association_name)
|
|
143
|
+
|
|
144
|
+
ensure_relationship_writable!(association_name)
|
|
145
|
+
|
|
146
|
+
return handle_null_relationship(attrs, param_name, association_name) if data.nil?
|
|
147
|
+
return handle_empty_array_relationship(attrs, param_name, association_name) if empty_array?(data)
|
|
148
|
+
|
|
149
|
+
validate_relationship_data_format!(data, association_name)
|
|
150
|
+
process_relationship_data(attrs, association_name, param_name, data)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def normalize_relationship_value(value)
|
|
154
|
+
value.is_a?(Hash) ? value : value.to_h
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_data_from_value(value_hash)
|
|
158
|
+
value_hash[:data]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def empty_array?(data)
|
|
162
|
+
data.is_a?(Array) && data.empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def handle_null_relationship(attrs, param_name, association_name)
|
|
166
|
+
# Handle ActiveStorage attachments specially
|
|
167
|
+
if active_storage_attachment?(association_name)
|
|
168
|
+
attrs[association_name.to_s] = nil
|
|
169
|
+
else
|
|
170
|
+
attrs["#{param_name}_id"] = nil
|
|
171
|
+
attrs["#{param_name}_type"] = nil if polymorphic_association?(association_name)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def handle_empty_array_relationship(attrs, param_name, association_name)
|
|
176
|
+
# Check if this is an ActiveStorage attachment
|
|
177
|
+
if active_storage_attachment?(association_name)
|
|
178
|
+
attrs[association_name.to_s] = []
|
|
179
|
+
else
|
|
180
|
+
attrs["#{param_name.singularize}_ids"] = []
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_relationship_data_format!(data, association_name)
|
|
185
|
+
return if valid_relationship_data?(data)
|
|
186
|
+
|
|
187
|
+
raise ArgumentError, "Invalid relationship data for #{association_name}: missing type or id"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def process_relationship_data(attrs, association_name, param_name, data)
|
|
191
|
+
if data.is_a?(Array)
|
|
192
|
+
process_to_many_relationship(attrs, association_name, param_name, data)
|
|
193
|
+
else
|
|
194
|
+
process_to_one_relationship(attrs, association_name, param_name, data)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def process_to_many_relationship(attrs, association_name, param_name, data)
|
|
199
|
+
ids = data.map { |r| extract_id(r) }
|
|
200
|
+
types = data.map { |r| extract_type(r) }
|
|
201
|
+
|
|
202
|
+
# Check if this is an ActiveStorage attachment
|
|
203
|
+
if types.any? && self.class.active_storage_blob_type?(types.first)
|
|
204
|
+
process_active_storage_attachment(attrs, association_name, ids, singular: false)
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
validate_relationship_type(association_name, types.first) unless polymorphic_association?(association_name)
|
|
209
|
+
attrs["#{param_name.singularize}_ids"] = ids
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def process_to_one_relationship(attrs, association_name, param_name, data)
|
|
213
|
+
id = extract_id(data)
|
|
214
|
+
type = extract_type(data)
|
|
215
|
+
|
|
216
|
+
if self.class.active_storage_blob_type?(type)
|
|
217
|
+
return process_active_storage_attachment(attrs, association_name, id, singular: true)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
process_regular_to_one_relationship(attrs, association_name, param_name, id, type)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def process_regular_to_one_relationship(attrs, association_name, param_name, id, type)
|
|
224
|
+
if polymorphic_association?(association_name)
|
|
225
|
+
process_polymorphic_relationship(attrs, association_name, param_name, id, type)
|
|
226
|
+
else
|
|
227
|
+
process_non_polymorphic_relationship(attrs, association_name, param_name, id, type)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def process_polymorphic_relationship(attrs, association_name, param_name, id, type)
|
|
232
|
+
class_name = validate_and_get_class_name(type, association_name)
|
|
233
|
+
attrs["#{param_name}_id"] = id
|
|
234
|
+
attrs["#{param_name}_type"] = class_name
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def validate_and_get_class_name(type, association_name)
|
|
238
|
+
class_name = RelationshipHelpers.type_to_class_name(type)
|
|
239
|
+
class_name.constantize
|
|
240
|
+
class_name
|
|
241
|
+
rescue NameError
|
|
242
|
+
raise ArgumentError,
|
|
243
|
+
"Invalid relationship type for #{association_name}: " \
|
|
244
|
+
"'#{type}' does not correspond to a valid model class"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def process_non_polymorphic_relationship(attrs, association_name, param_name, id, type)
|
|
248
|
+
validate_relationship_type(association_name, type)
|
|
249
|
+
attrs["#{param_name}_id"] = id
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def association_param_name(association_name)
|
|
255
|
+
return association_name.singularize unless @model_class
|
|
256
|
+
|
|
257
|
+
association = @model_class.reflect_on_association(association_name.to_sym)
|
|
258
|
+
# If association doesn't exist, return the singularized name as fallback
|
|
259
|
+
return association_name.singularize unless association
|
|
260
|
+
|
|
261
|
+
# Use the actual association name (which is already singular for belongs_to, singular for has_many)
|
|
262
|
+
association.name.to_s
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def polymorphic_association?(association_name)
|
|
266
|
+
RelationshipHelpers.polymorphic_association?(@definition, association_name)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def validate_relationship_type(association_name, type)
|
|
270
|
+
relationship_def = find_relationship_definition(association_name)
|
|
271
|
+
return unless relationship_def
|
|
272
|
+
|
|
273
|
+
association = @model_class.reflect_on_association(association_name.to_sym)
|
|
274
|
+
return unless association
|
|
275
|
+
|
|
276
|
+
if relationship_def[:options][:polymorphic]
|
|
277
|
+
validate_polymorphic_type(association_name, type)
|
|
278
|
+
else
|
|
279
|
+
validate_non_polymorphic_type(association_name, type, association)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def find_relationship_definition(association_name)
|
|
284
|
+
RelationshipHelpers.find_relationship_definition(@definition, association_name)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_polymorphic_type(association_name, type)
|
|
288
|
+
ResourceLoader.find(type)
|
|
289
|
+
rescue ResourceLoader::MissingResourceClass
|
|
290
|
+
raise ArgumentError,
|
|
291
|
+
"Invalid relationship type for #{association_name}: '#{type}' does not have a resource class defined"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def validate_non_polymorphic_type(association_name, type, association)
|
|
295
|
+
expected_type = RelationshipHelpers.model_type_name(association.klass)
|
|
296
|
+
return if type == expected_type
|
|
297
|
+
|
|
298
|
+
raise ArgumentError, "Invalid relationship type for #{association_name}: expected #{expected_type}, got #{type}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def extract_id(resource_identifier)
|
|
302
|
+
id = RelationshipHelpers.extract_id_from_identifier(resource_identifier)
|
|
303
|
+
raise ArgumentError, "Missing id in relationship data" unless id
|
|
304
|
+
|
|
305
|
+
id
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def extract_type(resource_identifier)
|
|
309
|
+
type = RelationshipHelpers.extract_type_from_identifier(resource_identifier)
|
|
310
|
+
raise ArgumentError, "Missing type in relationship data" unless type
|
|
311
|
+
|
|
312
|
+
type
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def valid_relationship_data?(data)
|
|
316
|
+
if data.is_a?(Array)
|
|
317
|
+
data.all? { |r| valid_resource_identifier?(r) }
|
|
318
|
+
else
|
|
319
|
+
valid_resource_identifier?(data)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def valid_resource_identifier?(identifier)
|
|
324
|
+
return false unless identifier.is_a?(Hash)
|
|
325
|
+
|
|
326
|
+
has_id?(identifier) && has_type?(identifier)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def has_id?(identifier)
|
|
330
|
+
identifier[:id]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def has_type?(identifier)
|
|
334
|
+
identifier[:type]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def resource_model_class
|
|
338
|
+
@model_class
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def active_storage_attachment?(association_name)
|
|
342
|
+
return false unless @model_class
|
|
343
|
+
|
|
344
|
+
self.class.active_storage_attachment?(association_name, @model_class)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def ensure_relationship_writable!(association_name)
|
|
348
|
+
return if active_storage_attachment?(association_name)
|
|
349
|
+
|
|
350
|
+
association = @model_class.reflect_on_association(association_name.to_sym)
|
|
351
|
+
readonly = relationship_options_for(association_name)[:readonly] == true
|
|
352
|
+
JSONAPI::RelationshipGuard.ensure_writable!(association, association_name, readonly:) if association
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def relationship_options_for(association_name)
|
|
356
|
+
relationship_def = RelationshipHelpers.find_relationship_definition(@definition, association_name)
|
|
357
|
+
return {} unless relationship_def
|
|
358
|
+
|
|
359
|
+
relationship_def[:options] || {}
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|