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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +164 -0
- data/.simplecov +17 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +333 -0
- data/INTEGRATION_TEST_STATUS.md +149 -0
- data/LICENSE +21 -0
- data/README.md +638 -0
- data/Rakefile +8 -0
- data/attio-ruby.gemspec +61 -0
- data/docs/CODECOV_SETUP.md +34 -0
- data/examples/basic_usage.rb +149 -0
- data/examples/oauth_flow.rb +843 -0
- data/examples/oauth_flow_README.md +84 -0
- data/examples/typed_records_example.rb +167 -0
- data/examples/webhook_server.rb +463 -0
- data/lib/attio/api_resource.rb +539 -0
- data/lib/attio/builders/name_builder.rb +181 -0
- data/lib/attio/client.rb +160 -0
- data/lib/attio/errors.rb +126 -0
- data/lib/attio/internal/record.rb +359 -0
- data/lib/attio/oauth/client.rb +219 -0
- data/lib/attio/oauth/scope_validator.rb +162 -0
- data/lib/attio/oauth/token.rb +158 -0
- data/lib/attio/resources/attribute.rb +332 -0
- data/lib/attio/resources/comment.rb +114 -0
- data/lib/attio/resources/company.rb +224 -0
- data/lib/attio/resources/entry.rb +208 -0
- data/lib/attio/resources/list.rb +196 -0
- data/lib/attio/resources/meta.rb +113 -0
- data/lib/attio/resources/note.rb +213 -0
- data/lib/attio/resources/object.rb +66 -0
- data/lib/attio/resources/person.rb +294 -0
- data/lib/attio/resources/task.rb +147 -0
- data/lib/attio/resources/thread.rb +99 -0
- data/lib/attio/resources/typed_record.rb +98 -0
- data/lib/attio/resources/webhook.rb +224 -0
- data/lib/attio/resources/workspace_member.rb +136 -0
- data/lib/attio/util/configuration.rb +166 -0
- data/lib/attio/util/id_extractor.rb +115 -0
- data/lib/attio/util/webhook_signature.rb +175 -0
- data/lib/attio/version.rb +6 -0
- data/lib/attio/webhook/event.rb +114 -0
- data/lib/attio/webhook/signature_verifier.rb +73 -0
- data/lib/attio.rb +123 -0
- metadata +402 -0
data/lib/attio/client.rb
ADDED
@@ -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
|
data/lib/attio/errors.rb
ADDED
@@ -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
|