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,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