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
@@ -0,0 +1,539 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
# Base class for all API resources
|
5
|
+
# Provides standard CRUD operations in a clean, Ruby-like way
|
6
|
+
class APIResource
|
7
|
+
include Enumerable
|
8
|
+
|
9
|
+
attr_reader :id, :created_at, :metadata
|
10
|
+
|
11
|
+
# Keys to skip when processing attributes from API responses
|
12
|
+
SKIP_KEYS = %i[id created_at _metadata].freeze
|
13
|
+
|
14
|
+
def initialize(attributes = {}, opts = {})
|
15
|
+
@attributes = {}
|
16
|
+
@original_attributes = {}
|
17
|
+
@changed_attributes = Set.new
|
18
|
+
@opts = opts
|
19
|
+
@metadata = {}
|
20
|
+
|
21
|
+
# Normalize attributes to use symbol keys
|
22
|
+
normalized_attrs = normalize_attributes(attributes)
|
23
|
+
|
24
|
+
# Extract metadata and system fields
|
25
|
+
if normalized_attrs.is_a?(Hash)
|
26
|
+
# Handle Attio's nested ID structure
|
27
|
+
@id = extract_id(normalized_attrs[:id])
|
28
|
+
@created_at = parse_timestamp(normalized_attrs[:created_at])
|
29
|
+
@metadata = normalized_attrs[:_metadata] || {}
|
30
|
+
|
31
|
+
# Process all attributes
|
32
|
+
normalized_attrs.each do |key, value|
|
33
|
+
next if SKIP_KEYS.include?(key)
|
34
|
+
|
35
|
+
@attributes[key] = process_attribute_value(value)
|
36
|
+
@original_attributes[key] = deep_copy(process_attribute_value(value))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Attribute access
|
42
|
+
# @param key [String, Symbol] The attribute key to retrieve
|
43
|
+
# @return [Object] The value of the attribute
|
44
|
+
def [](key)
|
45
|
+
@attributes[key.to_sym]
|
46
|
+
end
|
47
|
+
|
48
|
+
# Set an attribute value and track changes
|
49
|
+
# @param key [String, Symbol] The attribute key to set
|
50
|
+
# @param value [Object] The value to set
|
51
|
+
def []=(key, value)
|
52
|
+
key = key.to_sym
|
53
|
+
old_value = @attributes[key]
|
54
|
+
new_value = process_attribute_value(value)
|
55
|
+
|
56
|
+
return if old_value == new_value
|
57
|
+
|
58
|
+
@attributes[key] = new_value
|
59
|
+
@changed_attributes.add(key)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Fetch an attribute value with an optional default
|
63
|
+
# @param key [String, Symbol] The attribute key to fetch
|
64
|
+
# @param default [Object] The default value if key is not found
|
65
|
+
# @return [Object] The attribute value or default
|
66
|
+
def fetch(key, default = nil)
|
67
|
+
@attributes.fetch(key.to_sym, default)
|
68
|
+
end
|
69
|
+
|
70
|
+
def key?(key)
|
71
|
+
@attributes.key?(key.to_sym)
|
72
|
+
end
|
73
|
+
alias_method :has_key?, :key?
|
74
|
+
alias_method :include?, :key?
|
75
|
+
|
76
|
+
# Dirty tracking
|
77
|
+
def changed?
|
78
|
+
!@changed_attributes.empty?
|
79
|
+
end
|
80
|
+
|
81
|
+
# Get list of changed attribute names
|
82
|
+
# @return [Array<String>] Array of changed attribute names as strings
|
83
|
+
def changed
|
84
|
+
@changed_attributes.map(&:to_s)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Get changes with before and after values
|
88
|
+
# @return [Hash] Hash mapping attribute names to [old_value, new_value] arrays
|
89
|
+
def changes
|
90
|
+
@changed_attributes.each_with_object({}) do |key, hash|
|
91
|
+
hash[key.to_s] = [@original_attributes[key], @attributes[key]]
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Get only the changed attributes and their new values
|
96
|
+
# @return [Hash] Hash of changed attributes with their current values
|
97
|
+
def changed_attributes
|
98
|
+
@changed_attributes.each_with_object({}) do |key, hash|
|
99
|
+
hash[key] = @attributes[key]
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Clear all tracked changes and update original attributes
|
104
|
+
# @return [void]
|
105
|
+
def reset_changes!
|
106
|
+
@changed_attributes.clear
|
107
|
+
@original_attributes = deep_copy(@attributes)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Revert all changes back to original attribute values
|
111
|
+
# @return [void]
|
112
|
+
def revert!
|
113
|
+
@attributes = deep_copy(@original_attributes)
|
114
|
+
@changed_attributes.clear
|
115
|
+
end
|
116
|
+
|
117
|
+
# Serialization
|
118
|
+
def to_h
|
119
|
+
{
|
120
|
+
id: id,
|
121
|
+
created_at: created_at&.iso8601,
|
122
|
+
**@attributes
|
123
|
+
}.compact
|
124
|
+
end
|
125
|
+
alias_method :to_hash, :to_h
|
126
|
+
|
127
|
+
# Convert resource to JSON string
|
128
|
+
# @param opts [Hash] Options to pass to JSON.generate
|
129
|
+
# @return [String] JSON representation of the resource
|
130
|
+
def to_json(*opts)
|
131
|
+
JSON.generate(to_h, *opts)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Human-readable representation of the resource
|
135
|
+
# @return [String] Inspection string with class name, ID, and attributes
|
136
|
+
def inspect
|
137
|
+
attrs = @attributes.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
138
|
+
"#<#{self.class.name}:#{object_id} id=#{id.inspect} #{attrs}>"
|
139
|
+
end
|
140
|
+
|
141
|
+
# Enumerable support
|
142
|
+
def each(&)
|
143
|
+
return enum_for(:each) unless block_given?
|
144
|
+
@attributes.each(&)
|
145
|
+
end
|
146
|
+
|
147
|
+
# Get all attribute keys
|
148
|
+
# @return [Array<Symbol>] Array of attribute keys as symbols
|
149
|
+
def keys
|
150
|
+
@attributes.keys
|
151
|
+
end
|
152
|
+
|
153
|
+
# Get all attribute values
|
154
|
+
# @return [Array] Array of attribute values
|
155
|
+
def values
|
156
|
+
@attributes.values
|
157
|
+
end
|
158
|
+
|
159
|
+
# Comparison
|
160
|
+
def ==(other)
|
161
|
+
other.is_a?(self.class) && id == other.id && @attributes == other.instance_variable_get(:@attributes)
|
162
|
+
end
|
163
|
+
alias_method :eql?, :==
|
164
|
+
|
165
|
+
# Generate hash code for use in Hash keys and Set members
|
166
|
+
# @return [Integer] Hash code based on class, ID, and attributes
|
167
|
+
def hash
|
168
|
+
[self.class, id, @attributes].hash
|
169
|
+
end
|
170
|
+
|
171
|
+
# Update attributes
|
172
|
+
def update_attributes(attributes)
|
173
|
+
attributes.each do |key, value|
|
174
|
+
self[key] = value
|
175
|
+
end
|
176
|
+
self
|
177
|
+
end
|
178
|
+
|
179
|
+
# Check if resource has been persisted
|
180
|
+
def persisted?
|
181
|
+
!id.nil?
|
182
|
+
end
|
183
|
+
|
184
|
+
# Update from API response
|
185
|
+
def update_from(response)
|
186
|
+
normalized = normalize_attributes(response)
|
187
|
+
@id = normalized[:id] if normalized[:id]
|
188
|
+
@created_at = parse_timestamp(normalized[:created_at]) if normalized[:created_at]
|
189
|
+
|
190
|
+
normalized.each do |key, value|
|
191
|
+
next if SKIP_KEYS.include?(key)
|
192
|
+
@attributes[key] = process_attribute_value(value)
|
193
|
+
end
|
194
|
+
|
195
|
+
reset_changes!
|
196
|
+
self
|
197
|
+
end
|
198
|
+
|
199
|
+
# Resource path helpers
|
200
|
+
# Get the base API path for this resource type
|
201
|
+
# @return [String] The API path (e.g., "/v2/objects")
|
202
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
203
|
+
def self.resource_path
|
204
|
+
raise NotImplementedError, "Subclasses must implement resource_path"
|
205
|
+
end
|
206
|
+
|
207
|
+
# Get the resource name derived from the class name
|
208
|
+
# @return [String] The lowercase resource name
|
209
|
+
def self.resource_name
|
210
|
+
name.split("::").last.downcase
|
211
|
+
end
|
212
|
+
|
213
|
+
# Get the full API path for this specific resource instance
|
214
|
+
# @return [String] The full API path including the resource ID
|
215
|
+
def resource_path
|
216
|
+
"#{self.class.resource_path}/#{id}"
|
217
|
+
end
|
218
|
+
|
219
|
+
# Default save implementation
|
220
|
+
def save(**)
|
221
|
+
if persisted?
|
222
|
+
self.class.update(id, changed_attributes, **)
|
223
|
+
else
|
224
|
+
raise InvalidRequestError, "Cannot save a resource without an ID"
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Default destroy implementation
|
229
|
+
def destroy(**)
|
230
|
+
raise InvalidRequestError, "Cannot destroy a resource without an ID" unless persisted?
|
231
|
+
self.class.delete(id, **)
|
232
|
+
true
|
233
|
+
end
|
234
|
+
alias_method :delete, :destroy
|
235
|
+
|
236
|
+
class << self
|
237
|
+
# Define which operations this resource supports
|
238
|
+
# Example: api_operations :list, :create, :retrieve, :update, :delete
|
239
|
+
def api_operations(*operations)
|
240
|
+
@supported_operations = operations
|
241
|
+
|
242
|
+
operations.each do |operation|
|
243
|
+
case operation
|
244
|
+
when :list
|
245
|
+
define_list_operation
|
246
|
+
when :create
|
247
|
+
define_create_operation
|
248
|
+
when :retrieve
|
249
|
+
define_retrieve_operation
|
250
|
+
when :update
|
251
|
+
define_update_operation
|
252
|
+
when :delete
|
253
|
+
define_delete_operation
|
254
|
+
else
|
255
|
+
raise ArgumentError, "Unknown operation: #{operation}"
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
# Define attribute accessors for known attributes
|
261
|
+
def attr_attio(*attributes)
|
262
|
+
attributes.each do |attr|
|
263
|
+
# Reader method
|
264
|
+
define_method(attr) do
|
265
|
+
self[attr]
|
266
|
+
end
|
267
|
+
|
268
|
+
# Writer method
|
269
|
+
define_method("#{attr}=") do |value|
|
270
|
+
self[attr] = value
|
271
|
+
end
|
272
|
+
|
273
|
+
# Predicate method
|
274
|
+
define_method("#{attr}?") do
|
275
|
+
!!self[attr]
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# Execute HTTP request
|
281
|
+
def execute_request(method, path, params = {}, opts = {})
|
282
|
+
client = Attio.client(api_key: opts[:api_key])
|
283
|
+
|
284
|
+
case method
|
285
|
+
when :GET
|
286
|
+
client.get(path, params)
|
287
|
+
when :POST
|
288
|
+
client.post(path, params)
|
289
|
+
when :PUT
|
290
|
+
client.put(path, params)
|
291
|
+
when :PATCH
|
292
|
+
client.patch(path, params)
|
293
|
+
when :DELETE
|
294
|
+
client.delete(path)
|
295
|
+
else
|
296
|
+
raise ArgumentError, "Unsupported method: #{method}"
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
# Get the ID parameter name (usually "id", but sometimes needs prefix)
|
301
|
+
def id_param_name(id = nil)
|
302
|
+
:id
|
303
|
+
end
|
304
|
+
|
305
|
+
# Validate an ID parameter
|
306
|
+
def validate_id!(id)
|
307
|
+
raise ArgumentError, "ID is required" if id.nil? || id.to_s.empty?
|
308
|
+
end
|
309
|
+
|
310
|
+
# Hook for subclasses to prepare params before create
|
311
|
+
def prepare_params_for_create(params)
|
312
|
+
params
|
313
|
+
end
|
314
|
+
|
315
|
+
# Hook for subclasses to prepare params before update
|
316
|
+
def prepare_params_for_update(params)
|
317
|
+
params
|
318
|
+
end
|
319
|
+
|
320
|
+
private
|
321
|
+
|
322
|
+
def define_list_operation
|
323
|
+
define_singleton_method :list do |params = {}, **opts|
|
324
|
+
response = execute_request(:GET, resource_path, params, opts)
|
325
|
+
ListObject.new(response, self, params, opts)
|
326
|
+
end
|
327
|
+
|
328
|
+
singleton_class.send(:alias_method, :all, :list)
|
329
|
+
end
|
330
|
+
|
331
|
+
def define_create_operation
|
332
|
+
define_singleton_method :create do |params = {}, **opts|
|
333
|
+
prepared_params = prepare_params_for_create(params)
|
334
|
+
response = execute_request(:POST, resource_path, prepared_params, opts)
|
335
|
+
new(response["data"] || response, opts)
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
def define_retrieve_operation
|
340
|
+
define_singleton_method :retrieve do |id, **opts|
|
341
|
+
validate_id!(id)
|
342
|
+
response = execute_request(:GET, "#{resource_path}/#{id}", {}, opts)
|
343
|
+
new(response["data"] || response, opts)
|
344
|
+
end
|
345
|
+
|
346
|
+
singleton_class.send(:alias_method, :get, :retrieve)
|
347
|
+
singleton_class.send(:alias_method, :find, :retrieve)
|
348
|
+
end
|
349
|
+
|
350
|
+
def define_update_operation
|
351
|
+
define_singleton_method :update do |id, params = {}, **opts|
|
352
|
+
validate_id!(id)
|
353
|
+
prepared_params = prepare_params_for_update(params)
|
354
|
+
response = execute_request(:PATCH, "#{resource_path}/#{id}", prepared_params, opts)
|
355
|
+
new(response[:data] || response, opts)
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
def define_delete_operation
|
360
|
+
define_singleton_method :delete do |id, **opts|
|
361
|
+
validate_id!(id)
|
362
|
+
execute_request(:DELETE, "#{resource_path}/#{id}", {}, opts)
|
363
|
+
true
|
364
|
+
end
|
365
|
+
|
366
|
+
singleton_class.send(:alias_method, :destroy, :delete)
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
# ListObject for handling paginated responses
|
371
|
+
# Container for API list responses with pagination support
|
372
|
+
class ListObject
|
373
|
+
include Enumerable
|
374
|
+
|
375
|
+
attr_reader :data, :has_more, :cursor, :resource_class
|
376
|
+
|
377
|
+
def initialize(response, resource_class, params = {}, opts = {})
|
378
|
+
@resource_class = resource_class
|
379
|
+
@params = params
|
380
|
+
@opts = opts
|
381
|
+
@data = []
|
382
|
+
@has_more = false
|
383
|
+
@cursor = nil
|
384
|
+
|
385
|
+
if response.is_a?(Hash)
|
386
|
+
raw_data = response["data"] || []
|
387
|
+
@data = raw_data.map { |attrs| resource_class.new(attrs, opts) }
|
388
|
+
@has_more = response["has_more"] || false
|
389
|
+
@cursor = response["cursor"]
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
# Iterate over each resource in the current page
|
394
|
+
# @yield [APIResource] Each resource instance
|
395
|
+
# @return [Enumerator] If no block given
|
396
|
+
def each(&)
|
397
|
+
@data.each(&)
|
398
|
+
end
|
399
|
+
|
400
|
+
def empty?
|
401
|
+
@data.empty?
|
402
|
+
end
|
403
|
+
|
404
|
+
# Get the number of items in the current page
|
405
|
+
# @return [Integer] Number of items
|
406
|
+
def length
|
407
|
+
@data.length
|
408
|
+
end
|
409
|
+
alias_method :size, :length
|
410
|
+
alias_method :count, :length
|
411
|
+
|
412
|
+
# Get the first item in the current page
|
413
|
+
# @return [APIResource, nil] The first resource or nil if empty
|
414
|
+
def first
|
415
|
+
@data.first
|
416
|
+
end
|
417
|
+
|
418
|
+
# Get the last item in the current page
|
419
|
+
# @return [APIResource, nil] The last resource or nil if empty
|
420
|
+
def last
|
421
|
+
@data.last
|
422
|
+
end
|
423
|
+
|
424
|
+
# Access item by index
|
425
|
+
# @param index [Integer] The index of the item to retrieve
|
426
|
+
# @return [APIResource, nil] The resource at the given index
|
427
|
+
def [](index)
|
428
|
+
@data[index]
|
429
|
+
end
|
430
|
+
|
431
|
+
# Fetch the next page of results
|
432
|
+
# @return [ListObject, nil] The next page or nil if no more pages
|
433
|
+
def next_page
|
434
|
+
return nil unless has_more? && cursor
|
435
|
+
|
436
|
+
@resource_class.list(@params.merge(cursor: cursor), **@opts)
|
437
|
+
end
|
438
|
+
|
439
|
+
def has_more?
|
440
|
+
@has_more == true
|
441
|
+
end
|
442
|
+
|
443
|
+
# Automatically fetch and iterate through all pages
|
444
|
+
# @yield [APIResource] Each resource across all pages
|
445
|
+
# @return [Enumerator] If no block given
|
446
|
+
def auto_paging_each(&block)
|
447
|
+
return enum_for(:auto_paging_each) unless block_given?
|
448
|
+
|
449
|
+
page = self
|
450
|
+
loop do
|
451
|
+
page.each(&block)
|
452
|
+
break unless page.has_more?
|
453
|
+
page = page.next_page
|
454
|
+
break unless page
|
455
|
+
end
|
456
|
+
end
|
457
|
+
|
458
|
+
# Convert current page to array
|
459
|
+
# @return [Array<APIResource>] Array of resources in current page
|
460
|
+
def to_a
|
461
|
+
@data
|
462
|
+
end
|
463
|
+
|
464
|
+
# Human-readable representation of the list
|
465
|
+
# @return [String] Inspection string with data and pagination info
|
466
|
+
def inspect
|
467
|
+
"#<#{self.class.name} data=#{@data.inspect} has_more=#{@has_more}>"
|
468
|
+
end
|
469
|
+
end
|
470
|
+
|
471
|
+
protected
|
472
|
+
|
473
|
+
def normalize_attributes(attributes)
|
474
|
+
return attributes unless attributes.is_a?(Hash)
|
475
|
+
attributes.transform_keys(&:to_sym)
|
476
|
+
end
|
477
|
+
|
478
|
+
def process_attribute_value(value)
|
479
|
+
case value
|
480
|
+
when Hash
|
481
|
+
if value.key?(:value) || value.key?("value")
|
482
|
+
# Handle Attio attribute format
|
483
|
+
value[:value] || value["value"]
|
484
|
+
else
|
485
|
+
# Regular hash
|
486
|
+
value.transform_keys(&:to_sym)
|
487
|
+
end
|
488
|
+
when Array
|
489
|
+
value.map { |v| process_attribute_value(v) }
|
490
|
+
else
|
491
|
+
value
|
492
|
+
end
|
493
|
+
end
|
494
|
+
|
495
|
+
def parse_timestamp(value)
|
496
|
+
return nil if value.nil?
|
497
|
+
|
498
|
+
case value
|
499
|
+
when Time
|
500
|
+
value
|
501
|
+
when String
|
502
|
+
Time.parse(value)
|
503
|
+
when Integer
|
504
|
+
Time.at(value)
|
505
|
+
end
|
506
|
+
rescue ArgumentError
|
507
|
+
nil
|
508
|
+
end
|
509
|
+
|
510
|
+
def extract_id(id_value)
|
511
|
+
case id_value
|
512
|
+
when Hash
|
513
|
+
# Handle Attio's nested ID structure
|
514
|
+
# Objects have { workspace_id: "...", object_id: "..." }
|
515
|
+
# Records have { workspace_id: "...", object_id: "...", record_id: "..." }
|
516
|
+
when String
|
517
|
+
# Simple string ID
|
518
|
+
end
|
519
|
+
id_value
|
520
|
+
end
|
521
|
+
|
522
|
+
def deep_copy(obj)
|
523
|
+
case obj
|
524
|
+
when Hash
|
525
|
+
obj.transform_values { |v| deep_copy(v) }
|
526
|
+
when Array
|
527
|
+
obj.map { |v| deep_copy(v) }
|
528
|
+
when Set
|
529
|
+
Set.new(obj.map { |v| deep_copy(v) })
|
530
|
+
else
|
531
|
+
begin
|
532
|
+
obj.dup
|
533
|
+
rescue
|
534
|
+
obj
|
535
|
+
end
|
536
|
+
end
|
537
|
+
end
|
538
|
+
end
|
539
|
+
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module Builders
|
5
|
+
# Builder class for constructing person name attributes
|
6
|
+
# Provides a fluent interface for building complex name structures
|
7
|
+
#
|
8
|
+
# @example Basic usage
|
9
|
+
# name = Attio::Builders::NameBuilder.new
|
10
|
+
# .first("John")
|
11
|
+
# .last("Doe")
|
12
|
+
# .build
|
13
|
+
# # => [{first_name: "John", last_name: "Doe", full_name: "John Doe"}]
|
14
|
+
#
|
15
|
+
# @example With middle name and suffix
|
16
|
+
# name = Attio::Builders::NameBuilder.new
|
17
|
+
# .first("John")
|
18
|
+
# .middle("Michael")
|
19
|
+
# .last("Doe")
|
20
|
+
# .suffix("Jr.")
|
21
|
+
# .build
|
22
|
+
class NameBuilder
|
23
|
+
def initialize
|
24
|
+
@name_data = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
# Set the first name
|
28
|
+
# @param name [String] The first name
|
29
|
+
# @return [NameBuilder] self for chaining
|
30
|
+
def first(name)
|
31
|
+
@name_data[:first_name] = name
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
# Set the middle name
|
36
|
+
# @param name [String] The middle name
|
37
|
+
# @return [NameBuilder] self for chaining
|
38
|
+
def middle(name)
|
39
|
+
@name_data[:middle_name] = name
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
# Set the last name
|
44
|
+
# @param name [String] The last name
|
45
|
+
# @return [NameBuilder] self for chaining
|
46
|
+
def last(name)
|
47
|
+
@name_data[:last_name] = name
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
51
|
+
# Set the prefix (e.g., "Dr.", "Mr.", "Ms.")
|
52
|
+
# @param prefix [String] The name prefix
|
53
|
+
# @return [NameBuilder] self for chaining
|
54
|
+
def prefix(prefix)
|
55
|
+
@name_data[:prefix] = prefix
|
56
|
+
self
|
57
|
+
end
|
58
|
+
|
59
|
+
# Set the suffix (e.g., "Jr.", "III", "PhD")
|
60
|
+
# @param suffix [String] The name suffix
|
61
|
+
# @return [NameBuilder] self for chaining
|
62
|
+
def suffix(suffix)
|
63
|
+
@name_data[:suffix] = suffix
|
64
|
+
self
|
65
|
+
end
|
66
|
+
|
67
|
+
# Set a custom full name (overrides auto-generation)
|
68
|
+
# @param name [String] The full name
|
69
|
+
# @return [NameBuilder] self for chaining
|
70
|
+
def full(name)
|
71
|
+
@name_data[:full_name] = name
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
# Build the name array structure expected by Attio
|
76
|
+
# @return [Array<Hash>] The name data in Attio's expected format
|
77
|
+
def build
|
78
|
+
# Generate full name if not explicitly set
|
79
|
+
unless @name_data[:full_name]
|
80
|
+
parts = []
|
81
|
+
parts << @name_data[:prefix] if @name_data[:prefix]
|
82
|
+
parts << @name_data[:first_name] if @name_data[:first_name]
|
83
|
+
parts << @name_data[:middle_name] if @name_data[:middle_name]
|
84
|
+
parts << @name_data[:last_name] if @name_data[:last_name]
|
85
|
+
parts << @name_data[:suffix] if @name_data[:suffix]
|
86
|
+
|
87
|
+
@name_data[:full_name] = parts.join(" ") unless parts.empty?
|
88
|
+
end
|
89
|
+
|
90
|
+
# Return as array with single hash (Attio's expected format)
|
91
|
+
[@name_data]
|
92
|
+
end
|
93
|
+
|
94
|
+
# Parse a full name string into components
|
95
|
+
# This is a simple parser and may not handle all edge cases
|
96
|
+
# @param full_name [String] The full name to parse
|
97
|
+
# @return [NameBuilder] self for chaining
|
98
|
+
def parse(full_name)
|
99
|
+
return self unless full_name
|
100
|
+
|
101
|
+
parts = full_name.strip.split(/\s+/)
|
102
|
+
|
103
|
+
# Simple parsing logic - can be enhanced
|
104
|
+
case parts.length
|
105
|
+
when 1
|
106
|
+
@name_data[:first_name] = parts[0]
|
107
|
+
when 2
|
108
|
+
@name_data[:first_name] = parts[0]
|
109
|
+
@name_data[:last_name] = parts[1]
|
110
|
+
when 3
|
111
|
+
# Check for common prefixes
|
112
|
+
if %w[Dr Mr Mrs Ms Miss Prof].include?(parts[0])
|
113
|
+
@name_data[:prefix] = parts[0]
|
114
|
+
@name_data[:first_name] = parts[1]
|
115
|
+
@name_data[:last_name] = parts[2]
|
116
|
+
# Check for common suffixes
|
117
|
+
elsif %w[Jr Sr III II PhD MD].include?(parts[2])
|
118
|
+
@name_data[:first_name] = parts[0]
|
119
|
+
@name_data[:last_name] = parts[1]
|
120
|
+
@name_data[:suffix] = parts[2]
|
121
|
+
else
|
122
|
+
# Assume first middle last
|
123
|
+
@name_data[:first_name] = parts[0]
|
124
|
+
@name_data[:middle_name] = parts[1]
|
125
|
+
@name_data[:last_name] = parts[2]
|
126
|
+
end
|
127
|
+
else
|
128
|
+
# For 4+ parts, make educated guesses
|
129
|
+
# This is simplified - real implementation would be more sophisticated
|
130
|
+
if %w[Dr Mr Mrs Ms Miss Prof].include?(parts[0])
|
131
|
+
@name_data[:prefix] = parts.shift
|
132
|
+
end
|
133
|
+
|
134
|
+
if parts.length > 2 && %w[Jr Sr III II PhD MD].include?(parts.last)
|
135
|
+
@name_data[:suffix] = parts.pop
|
136
|
+
end
|
137
|
+
|
138
|
+
if parts.length >= 3
|
139
|
+
@name_data[:first_name] = parts[0]
|
140
|
+
@name_data[:last_name] = parts[-1]
|
141
|
+
@name_data[:middle_name] = parts[1..-2].join(" ") if parts.length > 2
|
142
|
+
elsif parts.length == 2
|
143
|
+
@name_data[:first_name] = parts[0]
|
144
|
+
@name_data[:last_name] = parts[1]
|
145
|
+
elsif parts.length == 1
|
146
|
+
@name_data[:first_name] = parts[0]
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
@name_data[:full_name] = full_name
|
151
|
+
self
|
152
|
+
end
|
153
|
+
|
154
|
+
# Create a name builder from various input formats
|
155
|
+
# @param input [String, Hash, NameBuilder] The input to convert
|
156
|
+
# @return [Array<Hash>] The name data in Attio's expected format
|
157
|
+
def self.build(input)
|
158
|
+
case input
|
159
|
+
when String
|
160
|
+
new.parse(input).build
|
161
|
+
when Hash
|
162
|
+
builder = new
|
163
|
+
builder.first(input[:first] || input[:first_name]) if input[:first] || input[:first_name]
|
164
|
+
builder.middle(input[:middle] || input[:middle_name]) if input[:middle] || input[:middle_name]
|
165
|
+
builder.last(input[:last] || input[:last_name]) if input[:last] || input[:last_name]
|
166
|
+
builder.prefix(input[:prefix]) if input[:prefix]
|
167
|
+
builder.suffix(input[:suffix]) if input[:suffix]
|
168
|
+
builder.full(input[:full] || input[:full_name]) if input[:full] || input[:full_name]
|
169
|
+
builder.build
|
170
|
+
when NameBuilder
|
171
|
+
input.build
|
172
|
+
when Array
|
173
|
+
# If it's already in the right format, return it
|
174
|
+
input
|
175
|
+
else
|
176
|
+
raise ArgumentError, "Invalid input type for NameBuilder: #{input.class}"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|