attio-ruby 0.1.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +164 -0
  4. data/.simplecov +17 -0
  5. data/.yardopts +9 -0
  6. data/CHANGELOG.md +27 -0
  7. data/CONTRIBUTING.md +333 -0
  8. data/INTEGRATION_TEST_STATUS.md +149 -0
  9. data/LICENSE +21 -0
  10. data/README.md +638 -0
  11. data/Rakefile +8 -0
  12. data/attio-ruby.gemspec +61 -0
  13. data/docs/CODECOV_SETUP.md +34 -0
  14. data/examples/basic_usage.rb +149 -0
  15. data/examples/oauth_flow.rb +843 -0
  16. data/examples/oauth_flow_README.md +84 -0
  17. data/examples/typed_records_example.rb +167 -0
  18. data/examples/webhook_server.rb +463 -0
  19. data/lib/attio/api_resource.rb +539 -0
  20. data/lib/attio/builders/name_builder.rb +181 -0
  21. data/lib/attio/client.rb +160 -0
  22. data/lib/attio/errors.rb +126 -0
  23. data/lib/attio/internal/record.rb +359 -0
  24. data/lib/attio/oauth/client.rb +219 -0
  25. data/lib/attio/oauth/scope_validator.rb +162 -0
  26. data/lib/attio/oauth/token.rb +158 -0
  27. data/lib/attio/resources/attribute.rb +332 -0
  28. data/lib/attio/resources/comment.rb +114 -0
  29. data/lib/attio/resources/company.rb +224 -0
  30. data/lib/attio/resources/entry.rb +208 -0
  31. data/lib/attio/resources/list.rb +196 -0
  32. data/lib/attio/resources/meta.rb +113 -0
  33. data/lib/attio/resources/note.rb +213 -0
  34. data/lib/attio/resources/object.rb +66 -0
  35. data/lib/attio/resources/person.rb +294 -0
  36. data/lib/attio/resources/task.rb +147 -0
  37. data/lib/attio/resources/thread.rb +99 -0
  38. data/lib/attio/resources/typed_record.rb +98 -0
  39. data/lib/attio/resources/webhook.rb +224 -0
  40. data/lib/attio/resources/workspace_member.rb +136 -0
  41. data/lib/attio/util/configuration.rb +166 -0
  42. data/lib/attio/util/id_extractor.rb +115 -0
  43. data/lib/attio/util/webhook_signature.rb +175 -0
  44. data/lib/attio/version.rb +6 -0
  45. data/lib/attio/webhook/event.rb +114 -0
  46. data/lib/attio/webhook/signature_verifier.rb +73 -0
  47. data/lib/attio.rb +123 -0
  48. metadata +402 -0
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+
6
+ module Attio
7
+ # HTTP client for making API requests to Attio
8
+ # Handles authentication, retries, and error responses
9
+ class Client
10
+ def initialize(api_key: nil)
11
+ @api_key = api_key || Attio.configuration.api_key
12
+ raise AuthenticationError, "No API key provided" unless @api_key
13
+ end
14
+
15
+ # Perform a GET request
16
+ # @param path [String] The API endpoint path
17
+ # @param params [Hash] Query parameters
18
+ # @return [Hash] Parsed JSON response
19
+ # @raise [Error] On API errors
20
+ def get(path, params = {})
21
+ request(:get, path, params)
22
+ end
23
+
24
+ # Perform a POST request
25
+ # @param path [String] The API endpoint path
26
+ # @param body [Hash] Request body to be sent as JSON
27
+ # @return [Hash] Parsed JSON response
28
+ # @raise [Error] On API errors
29
+ def post(path, body = {})
30
+ request(:post, path, body)
31
+ end
32
+
33
+ # Perform a PUT request
34
+ # @param path [String] The API endpoint path
35
+ # @param body [Hash] Request body to be sent as JSON
36
+ # @return [Hash] Parsed JSON response
37
+ # @raise [Error] On API errors
38
+ def put(path, body = {})
39
+ request(:put, path, body)
40
+ end
41
+
42
+ # Perform a PATCH request
43
+ # @param path [String] The API endpoint path
44
+ # @param body [Hash] Request body to be sent as JSON
45
+ # @return [Hash] Parsed JSON response
46
+ # @raise [Error] On API errors
47
+ def patch(path, body = {})
48
+ request(:patch, path, body)
49
+ end
50
+
51
+ # Perform a DELETE request
52
+ # @param path [String] The API endpoint path
53
+ # @return [Hash] Parsed JSON response
54
+ # @raise [Error] On API errors
55
+ def delete(path)
56
+ request(:delete, path)
57
+ end
58
+
59
+ private
60
+
61
+ def request(method, path, params_or_body = {})
62
+ response = connection.send(method) do |req|
63
+ req.url path
64
+
65
+ case method
66
+ when :get, :delete
67
+ req.params = params_or_body if params_or_body.any?
68
+ else
69
+ req.body = params_or_body.to_json
70
+ end
71
+ end
72
+
73
+ handle_response(response)
74
+ rescue Faraday::Error => e
75
+ handle_error(e)
76
+ end
77
+
78
+ def connection
79
+ @connection ||= Faraday.new(
80
+ url: base_url,
81
+ headers: default_headers
82
+ ) do |faraday|
83
+ faraday.request :json
84
+ faraday.response :json, content_type: /\bjson$/
85
+
86
+ faraday.request :retry,
87
+ max: Attio.configuration.max_retries,
88
+ interval: 0.5,
89
+ backoff_factor: 2,
90
+ exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
91
+
92
+ faraday.response :logger, Attio.configuration.logger if Attio.configuration.debug
93
+
94
+ faraday.options.timeout = Attio.configuration.timeout
95
+ faraday.options.open_timeout = Attio.configuration.open_timeout
96
+
97
+ faraday.ssl.verify = Attio.configuration.verify_ssl_certs
98
+ faraday.ssl.ca_file = Attio.configuration.ca_bundle_path if Attio.configuration.ca_bundle_path
99
+ end
100
+ end
101
+
102
+ def base_url
103
+ "#{Attio.configuration.api_base}/#{Attio.configuration.api_version}"
104
+ end
105
+
106
+ def default_headers
107
+ {
108
+ "Authorization" => "Bearer #{@api_key}",
109
+ "User-Agent" => "Attio Ruby/#{Attio::VERSION}",
110
+ "Accept" => "application/json",
111
+ "Content-Type" => "application/json"
112
+ }
113
+ end
114
+
115
+ def handle_response(response)
116
+ case response.status
117
+ when 200..299
118
+ response.body
119
+ when 400
120
+ error_message = response.body["error"] || response.body["message"] || "Bad request"
121
+ raise BadRequestError.new("Bad request: #{error_message}", response_to_hash(response))
122
+ when 401
123
+ raise AuthenticationError.new("Authentication failed", response_to_hash(response))
124
+ when 403
125
+ raise ForbiddenError.new("Access forbidden", response_to_hash(response))
126
+ when 404
127
+ raise NotFoundError.new("Resource not found", response_to_hash(response))
128
+ when 409
129
+ raise ConflictError.new("Resource conflict", response_to_hash(response))
130
+ when 422
131
+ raise UnprocessableEntityError.new("Unprocessable entity", response_to_hash(response))
132
+ when 429
133
+ raise RateLimitError.new("Rate limit exceeded", response_to_hash(response))
134
+ when 500..599
135
+ raise ServerError.new("Server error", response_to_hash(response))
136
+ else
137
+ raise Error.new("Unexpected response status: #{response.status}", response_to_hash(response))
138
+ end
139
+ end
140
+
141
+ def handle_error(error)
142
+ case error
143
+ when Faraday::TimeoutError
144
+ raise TimeoutError, "Request timed out"
145
+ when Faraday::ConnectionFailed
146
+ raise ConnectionError, "Connection failed: #{error.message}"
147
+ else
148
+ raise ConnectionError, "Request failed: #{error.message}"
149
+ end
150
+ end
151
+
152
+ def response_to_hash(response)
153
+ {
154
+ status: response.status,
155
+ headers: response.headers,
156
+ body: response.body
157
+ }
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ # Base error class for all Attio errors
5
+ class Error < StandardError
6
+ attr_reader :response, :code, :request_id
7
+
8
+ def initialize(message, response = nil)
9
+ @response = response
10
+
11
+ if response
12
+ @code = response[:status]
13
+ @request_id = extract_request_id(response)
14
+
15
+ # Try to extract a better error message from the response
16
+ if response[:body].is_a?(Hash)
17
+ api_message = response[:body][:error] || response[:body][:message]
18
+ message = "#{message}: #{api_message}" if api_message
19
+ end
20
+ end
21
+
22
+ super(message)
23
+ end
24
+
25
+ private
26
+
27
+ def extract_request_id(response)
28
+ return nil unless response[:headers]
29
+ response[:headers]["x-request-id"] || response[:headers]["X-Request-Id"]
30
+ end
31
+ end
32
+
33
+ # Client errors (4xx)
34
+ class ClientError < Error; end
35
+
36
+ # Specific client errors
37
+ class BadRequestError < ClientError; end # 400
38
+
39
+ class AuthenticationError < ClientError; end # 401
40
+
41
+ class ForbiddenError < ClientError; end # 403
42
+
43
+ class NotFoundError < ClientError; end # 404
44
+
45
+ class ConflictError < ClientError; end # 409
46
+
47
+ class UnprocessableEntityError < ClientError; end # 422
48
+
49
+ class RateLimitError < ClientError # 429
50
+ attr_reader :retry_after
51
+
52
+ def initialize(message, response = nil)
53
+ super
54
+ @retry_after = extract_retry_after(response) if response
55
+ end
56
+
57
+ private
58
+
59
+ def extract_retry_after(response)
60
+ return nil unless response[:headers]
61
+ value = response[:headers]["retry-after"] || response[:headers]["Retry-After"]
62
+ value&.to_i
63
+ end
64
+ end
65
+
66
+ # Server errors (5xx)
67
+ class ServerError < Error; end
68
+
69
+ # Connection errors
70
+ class ConnectionError < Error; end
71
+
72
+ # Request timeout error
73
+ class TimeoutError < ConnectionError; end
74
+
75
+ # Network-level connection error
76
+ class NetworkError < ConnectionError; end
77
+
78
+ # Configuration errors
79
+ class ConfigurationError < Error; end
80
+
81
+ # Request errors
82
+ class InvalidRequestError < ClientError; end
83
+
84
+ # Factory module for creating appropriate error instances
85
+ module ErrorFactory
86
+ # Create an error instance from an HTTP response
87
+ # @param response [Hash] Response hash with :status, :body, and :headers
88
+ # @param message [String, nil] Optional custom error message
89
+ # @return [Error] Appropriate error instance based on status code
90
+ def self.from_response(response, message = nil)
91
+ status = response[:status].to_i
92
+ message ||= "API request failed with status #{status}"
93
+
94
+ case status
95
+ when 400 then BadRequestError.new(message, response)
96
+ when 401 then AuthenticationError.new(message, response)
97
+ when 403 then ForbiddenError.new(message, response)
98
+ when 404 then NotFoundError.new(message, response)
99
+ when 409 then ConflictError.new(message, response)
100
+ when 422 then UnprocessableEntityError.new(message, response)
101
+ when 429 then RateLimitError.new(message, response)
102
+ when 400..499 then ClientError.new(message, response)
103
+ when 500..599 then ServerError.new(message, response)
104
+ else
105
+ Error.new(message, response)
106
+ end
107
+ end
108
+
109
+ # Create an error instance from a caught exception
110
+ # @param exception [Exception] The caught exception
111
+ # @param context [Hash] Additional context (currently unused)
112
+ # @return [Error] Appropriate error instance based on exception type
113
+ def self.from_exception(exception, context = {})
114
+ case exception
115
+ when Faraday::TimeoutError, Net::ReadTimeout, Net::OpenTimeout
116
+ TimeoutError.new("Request timed out: #{exception.message}")
117
+ when Faraday::ConnectionFailed, SocketError, Errno::ECONNREFUSED
118
+ NetworkError.new("Network error: #{exception.message}")
119
+ when Faraday::ClientError
120
+ from_response({status: exception.response_status, body: exception.response_body})
121
+ else
122
+ ConnectionError.new("Connection error: #{exception.message}")
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # @api private
7
+ module Internal
8
+ # Base class for record-based resources (Person, Company, etc.)
9
+ # This class handles the complex Attio Record API and should not be used directly.
10
+ # Use Person, Company, or TypedRecord instead.
11
+ #
12
+ # @api private
13
+ class Record < APIResource
14
+ # Record doesn't use standard CRUD operations due to object parameter requirement
15
+ # We'll define custom methods instead
16
+ api_operations :delete
17
+
18
+ # API endpoint path for records (nested under objects)
19
+ # @return [String] The API path
20
+ def self.resource_path
21
+ "objects"
22
+ end
23
+
24
+ attr_reader :attio_object_id, :object_api_slug
25
+
26
+ def initialize(attributes = {}, opts = {})
27
+ super
28
+
29
+ normalized_attrs = normalize_attributes(attributes)
30
+ @attio_object_id = normalized_attrs[:object_id]
31
+ @object_api_slug = normalized_attrs[:object_api_slug]
32
+
33
+ # Process values into attributes
34
+ if normalized_attrs[:values]
35
+ process_values(normalized_attrs[:values])
36
+ end
37
+ end
38
+
39
+ class << self
40
+ # List records for an object
41
+ def list(object:, **opts)
42
+ validate_object_identifier!(object)
43
+
44
+ # Extract query parameters from opts
45
+ query_params = build_query_params(opts)
46
+
47
+ response = execute_request(:POST, "#{resource_path}/#{object}/records/query", query_params, opts)
48
+
49
+ APIResource::ListObject.new(response, self, opts.merge(object: object), opts)
50
+ end
51
+ alias_method :all, :list
52
+
53
+ # Create a new record
54
+ def create(object: nil, values: nil, data: nil, **opts)
55
+ # Handle both parameter styles
56
+ if values
57
+ # Test style: create(object: "people", values: {...})
58
+ validate_object_identifier!(object)
59
+ validate_values!(values)
60
+ normalized_values = values
61
+ elsif data && data[:values]
62
+ # API style: create(object: "people", data: { values: {...} })
63
+ validate_object_identifier!(object)
64
+ validate_values!(data[:values])
65
+ normalized_values = data[:values]
66
+ else
67
+ raise ArgumentError, "Must provide object and either values or data.values"
68
+ end
69
+
70
+ normalized = normalize_values(normalized_values)
71
+ puts "DEBUG: Normalized values: #{normalized.inspect}" if ENV["ATTIO_DEBUG"]
72
+
73
+ request_params = {
74
+ data: {
75
+ values: normalized
76
+ }
77
+ }
78
+
79
+ response = execute_request(:POST, "#{resource_path}/#{object}/records", request_params, opts)
80
+
81
+ # Ensure object info is included
82
+ record_data = response["data"] || {}
83
+ record_data[:object_api_slug] ||= object if record_data.is_a?(Hash)
84
+
85
+ new(record_data, opts)
86
+ end
87
+
88
+ # Retrieve a specific record
89
+ def retrieve(record_id: nil, object: nil, **opts)
90
+ validate_object_identifier!(object)
91
+
92
+ # Extract simple ID if it's a nested hash
93
+ simple_record_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
94
+ validate_id!(simple_record_id)
95
+
96
+ response = execute_request(:GET, "#{resource_path}/#{object}/records/#{simple_record_id}", {}, opts)
97
+
98
+ record_data = response["data"] || {}
99
+ record_data[:object_api_slug] ||= object
100
+
101
+ new(record_data, opts)
102
+ end
103
+ alias_method :get, :retrieve
104
+ alias_method :find, :retrieve
105
+
106
+ # Update a record
107
+ def update(record_id: nil, object: nil, data: nil, **opts)
108
+ validate_object_identifier!(object)
109
+
110
+ # Extract simple ID if it's a nested hash
111
+ simple_record_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
112
+ validate_id!(simple_record_id)
113
+
114
+ request_params = {
115
+ data: {
116
+ values: normalize_values(data[:values] || data)
117
+ }
118
+ }
119
+
120
+ response = execute_request(:PUT, "#{resource_path}/#{object}/records/#{simple_record_id}", request_params, opts)
121
+
122
+ record_data = response["data"] || {}
123
+ record_data[:object_api_slug] ||= object
124
+
125
+ new(record_data, opts)
126
+ end
127
+
128
+ # Search records
129
+ # Note: The Attio API doesn't have a search endpoint, so we use filtering
130
+ # This provides a basic search across common text fields
131
+ def search(query, object:, **opts)
132
+ # For now, just pass through to list with the query
133
+ # Subclasses should override this to provide proper search filters
134
+ list(object: object, **opts)
135
+ end
136
+
137
+ private
138
+
139
+ def validate_object_identifier!(object)
140
+ raise ArgumentError, "Object identifier is required" if object.nil? || object.to_s.empty?
141
+ end
142
+
143
+ def validate_values!(values)
144
+ raise ArgumentError, "Values must be a Hash" unless values.is_a?(Hash)
145
+ end
146
+
147
+ def build_query_params(params)
148
+ query_params = {}
149
+
150
+ query_params[:filter] = build_filter(params[:filter]) if params[:filter]
151
+ query_params[:sort] = build_sort(params[:sort]) if params[:sort]
152
+ query_params[:limit] = params[:limit] if params[:limit]
153
+ query_params[:cursor] = params[:cursor] if params[:cursor]
154
+ # Note: 'q' parameter is not supported by Attio API
155
+
156
+ query_params
157
+ end
158
+
159
+ def build_filter(filter)
160
+ case filter
161
+ when Hash
162
+ filter
163
+ when Array
164
+ {"$and" => filter}
165
+ else
166
+ filter
167
+ end
168
+ end
169
+
170
+ def build_sort(sort)
171
+ case sort
172
+ when String
173
+ parse_sort_string(sort)
174
+ when Hash
175
+ sort
176
+ else
177
+ sort
178
+ end
179
+ end
180
+
181
+ def parse_sort_string(sort_string)
182
+ field, direction = sort_string.split(":")
183
+ {
184
+ field: field,
185
+ direction: direction || "asc"
186
+ }
187
+ end
188
+
189
+ # Attributes that should be sent as simple arrays of strings or simple values
190
+ SIMPLE_ARRAY_ATTRIBUTES = %w[email_addresses domains].freeze
191
+ SIMPLE_VALUE_ATTRIBUTES = %w[description linkedin job_title employee_count].freeze
192
+ # Attributes that are arrays of objects and should be sent as-is
193
+ OBJECT_ARRAY_ATTRIBUTES = %w[phone_numbers primary_location company].freeze
194
+
195
+ def normalize_values(values)
196
+ values.map do |key, value|
197
+ # Check if this is a simple array attribute
198
+ if SIMPLE_ARRAY_ATTRIBUTES.include?(key.to_s) && value.is_a?(Array)
199
+ # For email_addresses and domains, keep strings as-is
200
+ [key, value]
201
+ elsif SIMPLE_VALUE_ATTRIBUTES.include?(key.to_s) && !value.is_a?(Hash) && !value.is_a?(Array)
202
+ # For simple string attributes, send directly
203
+ [key, value]
204
+ elsif OBJECT_ARRAY_ATTRIBUTES.include?(key.to_s) && value.is_a?(Array)
205
+ # For arrays of objects like phone_numbers, etc., keep as-is
206
+ [key, value]
207
+ elsif key.to_s == "name"
208
+ # Special handling for name - keep as-is whether string or array
209
+ # Company names are strings, Person names are arrays of objects
210
+ [key, value]
211
+ else
212
+ normalized_value = case value
213
+ when Array
214
+ value.map { |v| normalize_single_value(v) }
215
+ else
216
+ normalize_single_value(value)
217
+ end
218
+ [key, normalized_value]
219
+ end
220
+ end.to_h
221
+ end
222
+
223
+ def normalize_single_value(value)
224
+ case value
225
+ when Hash
226
+ value
227
+ when NilClass
228
+ nil
229
+ else
230
+ {value: value}
231
+ end
232
+ end
233
+ end
234
+
235
+ # Instance methods
236
+
237
+ # Save changes to the record
238
+ def save(**opts)
239
+ raise InvalidRequestError, "Cannot update a record without an ID" unless persisted?
240
+ raise InvalidRequestError, "Cannot save without object context" unless object_api_slug
241
+
242
+ return self unless changed?
243
+
244
+ params = {
245
+ data: {
246
+ values: prepare_values_for_update
247
+ }
248
+ }
249
+
250
+ response = self.class.send(:execute_request, :PATCH, resource_path, params, opts)
251
+
252
+ update_from(response[:data] || response)
253
+ reset_changes!
254
+ self
255
+ end
256
+
257
+ # Add this record to a list
258
+ def add_to_list(list_id, **)
259
+ list = List.retrieve(list_id, **)
260
+ list.add_record(id, **)
261
+ end
262
+
263
+ # Get lists containing this record
264
+ def lists(**)
265
+ raise InvalidRequestError, "Cannot get lists without an ID" unless persisted?
266
+
267
+ # This is a simplified implementation - in reality you'd need to query the API
268
+ # for lists that contain this record
269
+ List.list(record_id: id, **)
270
+ end
271
+
272
+ def resource_path
273
+ raise InvalidRequestError, "Cannot generate path without object context" unless object_api_slug
274
+ record_id = id.is_a?(Hash) ? id["record_id"] : id
275
+ "#{self.class.resource_path}/#{object_api_slug}/records/#{record_id}"
276
+ end
277
+
278
+ # Override destroy to use correct path
279
+ def destroy(**opts)
280
+ raise InvalidRequestError, "Cannot destroy a record without an ID" unless persisted?
281
+ raise InvalidRequestError, "Cannot destroy without object context" unless object_api_slug
282
+
283
+ self.class.send(:execute_request, :DELETE, resource_path, {}, opts)
284
+ @attributes.clear
285
+ @changed_attributes.clear
286
+ @id = nil
287
+ freeze
288
+ true
289
+ end
290
+
291
+ # Convert record to hash representation
292
+ # @return [Hash] Record data as a hash
293
+ def to_h
294
+ values = @attributes.except(:id, :created_at, :object_id, :object_api_slug)
295
+
296
+ {
297
+ id: id,
298
+ object_id: attio_object_id,
299
+ object_api_slug: object_api_slug,
300
+ created_at: created_at&.iso8601,
301
+ values: values
302
+ }.compact
303
+ end
304
+
305
+ # Human-readable representation of the record
306
+ # @return [String] Inspection string with ID, object, and sample values
307
+ def inspect
308
+ values_preview = @attributes.take(3).map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
309
+ values_preview += "..." if @attributes.size > 3
310
+
311
+ "#<#{self.class.name}:#{object_id} id=#{id.inspect} object=#{object_api_slug.inspect} values={#{values_preview}}>"
312
+ end
313
+
314
+ private
315
+
316
+ def process_values(values)
317
+ return unless values.is_a?(Hash)
318
+
319
+ values.each do |key, value_data|
320
+ extracted_value = extract_value(value_data)
321
+ @attributes[key.to_sym] = extracted_value
322
+ @original_attributes[key.to_sym] = deep_copy(extracted_value)
323
+ end
324
+ end
325
+
326
+ def extract_value(value_data)
327
+ case value_data
328
+ when Array
329
+ extracted = value_data.map { |v| extract_single_value(v) }
330
+ (extracted.length == 1) ? extracted.first : extracted
331
+ else
332
+ extract_single_value(value_data)
333
+ end
334
+ end
335
+
336
+ def extract_single_value(value_data)
337
+ case value_data
338
+ when Hash
339
+ if value_data.key?(:value) || value_data.key?("value")
340
+ value_data[:value] || value_data["value"]
341
+ elsif value_data.key?(:target_object) || value_data.key?("target_object")
342
+ # Reference value
343
+ value_data[:target_object] || value_data["target_object"]
344
+ else
345
+ value_data
346
+ end
347
+ else
348
+ value_data
349
+ end
350
+ end
351
+
352
+ def prepare_values_for_update
353
+ changed_attributes.transform_values do |value|
354
+ self.class.send(:normalize_values, {key: value})[:key]
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end