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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Represents a note attached to a record in Attio
7
+ class Note < APIResource
8
+ api_operations :list, :retrieve, :create, :delete
9
+
10
+ # API endpoint path for notes
11
+ # @return [String] The API path
12
+ def self.resource_path
13
+ "notes"
14
+ end
15
+
16
+ # Read-only attributes - notes are immutable
17
+ attr_reader :parent_object, :parent_record_id, :title, :format,
18
+ :created_by_actor, :content_plaintext, :content_markdown, :tags, :metadata
19
+
20
+ # Alias for compatibility
21
+ alias_method :created_by, :created_by_actor
22
+
23
+ # Convenience method to get content based on format
24
+ def content
25
+ case format
26
+ when "plaintext"
27
+ content_plaintext
28
+ when "html", "markdown"
29
+ content_markdown
30
+ else
31
+ content_plaintext
32
+ end
33
+ end
34
+
35
+ def initialize(attributes = {}, opts = {})
36
+ super
37
+ normalized_attrs = normalize_attributes(attributes)
38
+ @parent_object = normalized_attrs[:parent_object]
39
+ @parent_record_id = normalized_attrs[:parent_record_id]
40
+ @title = normalized_attrs[:title]
41
+ @content_plaintext = normalized_attrs[:content_plaintext]
42
+ @content_markdown = normalized_attrs[:content_markdown]
43
+ @tags = normalized_attrs[:tags] || []
44
+ @metadata = normalized_attrs[:metadata] || {}
45
+ @format = normalized_attrs[:format] || "plaintext"
46
+ @created_by_actor = normalized_attrs[:created_by_actor]
47
+ end
48
+
49
+ # Get the parent record
50
+ def parent_record(**)
51
+ return nil unless parent_object && parent_record_id
52
+
53
+ Internal::Record.retrieve(
54
+ object: parent_object,
55
+ record_id: parent_record_id,
56
+ **
57
+ )
58
+ end
59
+
60
+ # Check if note is in HTML format
61
+ def html?
62
+ format == "html"
63
+ end
64
+
65
+ # Check if note is in plaintext format
66
+ def plaintext?
67
+ format == "plaintext"
68
+ end
69
+
70
+ # Get plaintext version of content
71
+ def to_plaintext
72
+ return content_plaintext if content_plaintext
73
+
74
+ # If no plaintext, try to get markdown/html content and strip HTML
75
+ html_content = content_markdown || content
76
+ return nil unless html_content
77
+
78
+ strip_html(html_content)
79
+ end
80
+
81
+ def resource_path
82
+ raise InvalidRequestError, "Cannot generate path without an ID" unless persisted?
83
+ note_id = id.is_a?(Hash) ? (id[:note_id] || id["note_id"]) : id
84
+ "#{self.class.resource_path}/#{note_id}"
85
+ end
86
+
87
+ # Override destroy to handle nested ID
88
+ def destroy(**opts)
89
+ raise InvalidRequestError, "Cannot destroy a note without an ID" unless persisted?
90
+
91
+ note_id = id.is_a?(Hash) ? (id[:note_id] || id["note_id"]) : id
92
+ self.class.delete(note_id, **opts)
93
+ freeze
94
+ true
95
+ end
96
+
97
+ # Notes cannot be updated
98
+ def save(*)
99
+ raise NotImplementedError, "Notes cannot be updated. Create a new note instead."
100
+ end
101
+
102
+ def update(*)
103
+ raise NotImplementedError, "Notes cannot be updated. Create a new note instead."
104
+ end
105
+
106
+ # Convert note to hash representation
107
+ # @return [Hash] Note data as a hash
108
+ def to_h
109
+ super.merge(
110
+ parent_object: parent_object,
111
+ parent_record_id: parent_record_id,
112
+ content: content,
113
+ format: format,
114
+ created_by_actor: created_by_actor,
115
+ content_plaintext: content_plaintext
116
+ ).compact
117
+ end
118
+
119
+ class << self
120
+ # Override retrieve to handle nested ID
121
+ def retrieve(id, **opts)
122
+ note_id = id.is_a?(Hash) ? (id[:note_id] || id["note_id"]) : id
123
+ validate_id!(note_id)
124
+ response = execute_request(:GET, "#{resource_path}/#{note_id}", {}, opts)
125
+ new(response["data"] || response, opts)
126
+ end
127
+
128
+ # Override create to handle validation and parameter mapping
129
+ def create(**kwargs)
130
+ # Extract options from kwargs
131
+ opts = {}
132
+ opts[:api_key] = kwargs.delete(:api_key) if kwargs.key?(:api_key)
133
+
134
+ # Map object/record_id to parent_object/parent_record_id
135
+ normalized_params = {
136
+ parent_object: kwargs[:object] || kwargs[:parent_object],
137
+ parent_record_id: kwargs[:record_id] || kwargs[:parent_record_id],
138
+ title: kwargs[:title] || kwargs[:content] || "Note",
139
+ content: kwargs[:content],
140
+ format: kwargs[:format]
141
+ }
142
+
143
+ prepared_params = prepare_params_for_create(normalized_params)
144
+ response = execute_request(:POST, resource_path, prepared_params, opts)
145
+ new(response["data"] || response, opts)
146
+ end
147
+
148
+ # Override create to handle validation
149
+ def prepare_params_for_create(params)
150
+ validate_parent!(params[:parent_object], params[:parent_record_id])
151
+ validate_content!(params[:content])
152
+ validate_format!(params[:format]) if params[:format]
153
+
154
+ {
155
+ data: {
156
+ title: params[:title],
157
+ parent_object: params[:parent_object],
158
+ parent_record_id: params[:parent_record_id],
159
+ content: params[:content],
160
+ format: params[:format] || "plaintext"
161
+ }
162
+ }
163
+ end
164
+
165
+ # Get notes for a record
166
+ def for_record(params = {}, object:, record_id:, **)
167
+ list(
168
+ params.merge(
169
+ parent_object: object,
170
+ parent_record_id: record_id
171
+ ),
172
+ **
173
+ )
174
+ end
175
+
176
+ private
177
+
178
+ def validate_parent!(parent_object, parent_record_id)
179
+ if parent_object.nil? || parent_object.to_s.empty?
180
+ raise ArgumentError, "parent_object is required"
181
+ end
182
+
183
+ if parent_record_id.nil? || parent_record_id.to_s.empty?
184
+ raise ArgumentError, "parent_record_id is required"
185
+ end
186
+ end
187
+
188
+ def validate_content!(content)
189
+ if content.nil? || content.to_s.strip.empty?
190
+ raise ArgumentError, "content cannot be empty"
191
+ end
192
+ end
193
+
194
+ def validate_format!(format)
195
+ valid_formats = %w[plaintext html]
196
+ unless valid_formats.include?(format.to_s)
197
+ raise ArgumentError, "Invalid format: #{format}. Valid formats: #{valid_formats.join(", ")}"
198
+ end
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def strip_html(html)
205
+ return html unless html.is_a?(String)
206
+
207
+ # Basic HTML stripping (production apps should use a proper HTML parser)
208
+ html.gsub(/<[^>]+>/, " ")
209
+ .gsub(/\s+/, " ")
210
+ .strip
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Represents an object type in Attio (e.g., People, Companies)
7
+ class Object < APIResource
8
+ api_operations :list, :retrieve, :create, :update, :delete
9
+
10
+ # API endpoint path for objects
11
+ # @return [String] The API path
12
+ def self.resource_path
13
+ "objects"
14
+ end
15
+
16
+ # Define known attributes
17
+ attr_reader :api_slug, :singular_noun, :plural_noun, :created_by_actor
18
+
19
+ def initialize(attributes = {}, opts = {})
20
+ super
21
+ normalized_attrs = normalize_attributes(attributes)
22
+ @api_slug = normalized_attrs[:api_slug]
23
+ @singular_noun = normalized_attrs[:singular_noun]
24
+ @plural_noun = normalized_attrs[:plural_noun]
25
+ @created_by_actor = normalized_attrs[:created_by_actor]
26
+ end
27
+
28
+ # Get all attributes for this object
29
+ def attributes(**)
30
+ Attribute.list(parent_object: api_slug || id, **)
31
+ end
32
+
33
+ # Create a new attribute for this object
34
+ def create_attribute(params = {}, **)
35
+ Attribute.create(params.merge(parent_object: api_slug || id), **)
36
+ end
37
+
38
+ # Get records for this object
39
+ def records(params = {}, **)
40
+ Internal::Record.list(object: api_slug || id, **params, **)
41
+ end
42
+
43
+ # Create a record for this object
44
+ def create_record(values = {}, **)
45
+ Internal::Record.create(object: api_slug || id, values: values, **)
46
+ end
47
+
48
+ # Find by API slug
49
+ def self.find_by_slug(slug, **opts)
50
+ retrieve(slug, **opts)
51
+ rescue NotFoundError
52
+ list(**opts).find { |obj| obj.api_slug == slug }
53
+ end
54
+
55
+ # Get standard objects
56
+ def self.people(**)
57
+ find_by_slug("people", **)
58
+ end
59
+
60
+ # Get the standard Companies object
61
+ # @return [Object] The companies object
62
+ def self.companies(**)
63
+ find_by_slug("companies", **)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typed_record"
4
+
5
+ module Attio
6
+ # Represents a person record in Attio
7
+ # Provides convenient methods for working with people and their attributes
8
+ class Person < TypedRecord
9
+ object_type "people"
10
+
11
+ # Set the person's name using a more intuitive interface
12
+ # @param first [String] First name
13
+ # @param last [String] Last name
14
+ # @param middle [String] Middle name (optional)
15
+ # @param full [String] Full name (optional, will be generated if not provided)
16
+ def set_name(first: nil, last: nil, middle: nil, full: nil)
17
+ name_data = {}
18
+ name_data[:first_name] = first if first
19
+ name_data[:last_name] = last if last
20
+ name_data[:middle_name] = middle if middle
21
+
22
+ # Generate full name if not provided
23
+ if full
24
+ name_data[:full_name] = full
25
+ elsif first || last
26
+ parts = [first, middle, last].compact
27
+ name_data[:full_name] = parts.join(" ") unless parts.empty?
28
+ end
29
+
30
+ # Attio expects name as an array with a single hash
31
+ self[:name] = [name_data] unless name_data.empty?
32
+ end
33
+
34
+ # Set the person's name using a hash or string
35
+ # @param name_value [Hash, String] Either a hash with first/last/middle/full keys or a full name string
36
+ def name=(name_value)
37
+ case name_value
38
+ when Hash
39
+ set_name(**name_value)
40
+ when String
41
+ set_name(full: name_value)
42
+ else
43
+ raise ArgumentError, "Name must be a Hash or String"
44
+ end
45
+ end
46
+
47
+ # Get the person's full name
48
+ # @return [String, nil] The full name or nil if not set
49
+ def full_name
50
+ extract_name_field("full_name")
51
+ end
52
+
53
+ # Get the person's first name
54
+ # @return [String, nil] The first name or nil if not set
55
+ def first_name
56
+ extract_name_field("first_name")
57
+ end
58
+
59
+ # Get the person's last name
60
+ # @return [String, nil] The last name or nil if not set
61
+ def last_name
62
+ extract_name_field("last_name")
63
+ end
64
+
65
+ private
66
+
67
+ # Extract a field from the name data structure
68
+ # @param field [String] The field to extract
69
+ # @return [String, nil] The field value or nil
70
+ def extract_name_field(field)
71
+ name_value = self[:name]
72
+ return nil unless name_value
73
+
74
+ name_hash = normalize_to_hash(name_value)
75
+ name_hash[field] || name_hash[field.to_sym]
76
+ end
77
+
78
+ # Extract primary value from various data structures
79
+ # @param value [Array, Hash, Object] The value to extract from
80
+ # @param field [String] The field name for hash extraction
81
+ # @return [String, nil] The extracted value
82
+ def extract_primary_value(value, field)
83
+ case value
84
+ when Array
85
+ return nil if value.empty?
86
+ first_item = value.first
87
+ if first_item.is_a?(Hash)
88
+ first_item[field] || first_item[field.to_sym]
89
+ else
90
+ first_item.to_s
91
+ end
92
+ when Hash
93
+ value[field] || value[field.to_sym]
94
+ else
95
+ value.to_s
96
+ end
97
+ end
98
+
99
+ # Normalize various name formats to a hash
100
+ # @param value [Array, Hash] The value to normalize
101
+ # @return [Hash] The normalized hash
102
+ def normalize_to_hash(value)
103
+ case value
104
+ when Array
105
+ value.first.is_a?(Hash) ? value.first : {}
106
+ when Hash
107
+ value
108
+ else
109
+ {}
110
+ end
111
+ end
112
+
113
+ public
114
+
115
+ # Add an email address
116
+ # @param email [String] The email address to add
117
+ def add_email(email)
118
+ emails = self[:email_addresses] || []
119
+ # Ensure it's an array
120
+ emails = [emails] unless emails.is_a?(Array)
121
+
122
+ # Add the email if it's not already present
123
+ emails << email unless emails.include?(email)
124
+ self[:email_addresses] = emails
125
+ end
126
+
127
+ # Get the primary email address
128
+ # @return [String, nil] The primary email or nil if not set
129
+ def email
130
+ emails = self[:email_addresses]
131
+ return nil unless emails
132
+
133
+ extract_primary_value(emails, "email_address")
134
+ end
135
+
136
+ # Add a phone number
137
+ # @param number [String] The phone number
138
+ # @param country_code [String] The country code (e.g., "US")
139
+ def add_phone(number, country_code: "US")
140
+ phones = self[:phone_numbers] || []
141
+ phones = [phones] unless phones.is_a?(Array)
142
+
143
+ phone_data = {
144
+ original_phone_number: number,
145
+ country_code: country_code
146
+ }
147
+
148
+ phones << phone_data
149
+ self[:phone_numbers] = phones
150
+ end
151
+
152
+ # Get the primary phone number
153
+ # @return [String, nil] The primary phone number or nil if not set
154
+ def phone
155
+ phones = self[:phone_numbers]
156
+ return nil unless phones
157
+
158
+ extract_primary_value(phones, "original_phone_number")
159
+ end
160
+
161
+ # Set the job title
162
+ # @param title [String] The job title
163
+ def job_title=(title)
164
+ self[:job_title] = title
165
+ end
166
+
167
+ # Associate with a company
168
+ # @param company [Company, String] A Company instance or company ID
169
+ def company=(company)
170
+ if company.is_a?(Company)
171
+ # Extract ID properly from company instance
172
+ company_id = company.id.is_a?(Hash) ? company.id["record_id"] : company.id
173
+ self[:company] = [{
174
+ target_object: "companies",
175
+ target_record_id: company_id
176
+ }]
177
+ elsif company.is_a?(String)
178
+ self[:company] = [{
179
+ target_object: "companies",
180
+ target_record_id: company
181
+ }]
182
+ elsif company.nil?
183
+ self[:company] = nil
184
+ else
185
+ raise ArgumentError, "Company must be a Company instance or ID string"
186
+ end
187
+ end
188
+
189
+ class << self
190
+ # Create a person with a simplified interface
191
+ # @param attributes [Hash] Person attributes
192
+ # @option attributes [String] :first_name First name
193
+ # @option attributes [String] :last_name Last name
194
+ # @option attributes [String] :email Email address
195
+ # @option attributes [String] :phone Phone number
196
+ # @option attributes [String] :job_title Job title
197
+ # @option attributes [Hash] :values Raw values hash (for advanced use)
198
+ def create(first_name: nil, last_name: nil, full_name: nil, email: nil, phone: nil,
199
+ job_title: nil, company: nil, values: {}, **opts)
200
+ # Build the values hash
201
+ values[:name] ||= []
202
+ if first_name || last_name || full_name
203
+ name_data = {}
204
+
205
+ # If only full_name is provided, try to parse it
206
+ if full_name && !first_name && !last_name
207
+ parts = full_name.split(" ")
208
+ if parts.length >= 2
209
+ name_data[:first_name] = parts.first
210
+ name_data[:last_name] = parts[1..].join(" ")
211
+ else
212
+ name_data[:first_name] = full_name
213
+ end
214
+ name_data[:full_name] = full_name
215
+ else
216
+ name_data[:first_name] = first_name if first_name
217
+ name_data[:last_name] = last_name if last_name
218
+ name_data[:full_name] = full_name || [first_name, last_name].compact.join(" ")
219
+ end
220
+
221
+ values[:name] = [name_data]
222
+ end
223
+
224
+ values[:email_addresses] = [email] if email && !values[:email_addresses]
225
+
226
+ if phone && !values[:phone_numbers]
227
+ values[:phone_numbers] = [{
228
+ original_phone_number: phone,
229
+ country_code: opts.delete(:country_code) || "US"
230
+ }]
231
+ end
232
+
233
+ values[:job_title] = job_title if job_title && !values[:job_title]
234
+
235
+ if company && !values[:company]
236
+ company_ref = if company.is_a?(Company)
237
+ company_id = company.id.is_a?(Hash) ? company.id["record_id"] : company.id
238
+ {
239
+ target_object: "companies",
240
+ target_record_id: company_id
241
+ }
242
+ elsif company.is_a?(String)
243
+ {
244
+ target_object: "companies",
245
+ target_record_id: company
246
+ }
247
+ end
248
+ values[:company] = [company_ref] if company_ref
249
+ end
250
+
251
+ super(values: values, **opts)
252
+ end
253
+
254
+ # Find people by email
255
+ # @param email [String] Email address to search for
256
+ def find_by_email(email, **opts)
257
+ list(**opts.merge(
258
+ filter: {
259
+ email_addresses: {
260
+ email_address: {
261
+ "$eq": email
262
+ }
263
+ }
264
+ }
265
+ )).first
266
+ end
267
+
268
+ # Search people by query
269
+ # @param query [String] Query to search for
270
+ def search(query, **opts)
271
+ # Search across name fields
272
+ list(**opts.merge(
273
+ filter: {
274
+ "$or": [
275
+ {name: {first_name: {"$contains": query}}},
276
+ {name: {last_name: {"$contains": query}}},
277
+ {name: {full_name: {"$contains": query}}}
278
+ ]
279
+ }
280
+ ))
281
+ end
282
+
283
+ # Find people by name
284
+ # @param name [String] Name to search for
285
+ def find_by_name(name, **opts)
286
+ results = search(name, **opts)
287
+ results.first
288
+ end
289
+ end
290
+ end
291
+
292
+ # Convenience alias
293
+ People = Person
294
+ end