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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Represents an entry in an Attio list
7
+ # Entries link records to lists with custom attribute values
8
+ class Entry < APIResource
9
+ attr_reader :parent_record_id, :parent_object, :list_id
10
+ attr_accessor :entry_values
11
+
12
+ def initialize(attributes = {}, opts = {})
13
+ super
14
+
15
+ normalized_attrs = normalize_attributes(attributes)
16
+
17
+ # Extract specific entry attributes
18
+ @parent_record_id = normalized_attrs[:parent_record_id]
19
+ @parent_object = normalized_attrs[:parent_object]
20
+ @entry_values = normalized_attrs[:entry_values] || {}
21
+
22
+ # Extract list_id from nested ID structure
23
+ if normalized_attrs[:id].is_a?(Hash)
24
+ @list_id = normalized_attrs[:id][:list_id]
25
+ end
26
+ end
27
+
28
+ class << self
29
+ # API endpoint path for entries (nested under lists)
30
+ # @return [String] The API path
31
+ def resource_path
32
+ "lists"
33
+ end
34
+
35
+ # List entries for a list
36
+ def list(list: nil, **params)
37
+ validate_list_identifier!(list)
38
+
39
+ response = execute_request(:POST, "#{resource_path}/#{list}/entries/query", params, {})
40
+ APIResource::ListObject.new(response, self, params.merge(list: list), params)
41
+ end
42
+ alias_method :all, :list
43
+
44
+ # Create a new entry
45
+ def create(list: nil, parent_record_id: nil, parent_object: nil, entry_values: nil, **opts)
46
+ validate_list_identifier!(list)
47
+ validate_parent_params!(parent_record_id, parent_object)
48
+
49
+ request_params = {
50
+ data: {
51
+ parent_record_id: parent_record_id,
52
+ parent_object: parent_object,
53
+ entry_values: entry_values || {}
54
+ }
55
+ }
56
+
57
+ response = execute_request(:POST, "#{resource_path}/#{list}/entries", request_params, opts)
58
+ new(response["data"] || response, opts)
59
+ end
60
+
61
+ # Retrieve a specific entry
62
+ def retrieve(list: nil, entry_id: nil, **opts)
63
+ validate_list_identifier!(list)
64
+ validate_entry_id!(entry_id)
65
+
66
+ response = execute_request(:GET, "#{resource_path}/#{list}/entries/#{entry_id}", {}, opts)
67
+ new(response["data"] || response, opts)
68
+ end
69
+ alias_method :get, :retrieve
70
+ alias_method :find, :retrieve
71
+
72
+ # Update an entry
73
+ def update(list: nil, entry_id: nil, entry_values: nil, mode: nil, **opts)
74
+ validate_list_identifier!(list)
75
+ validate_entry_id!(entry_id)
76
+
77
+ request_params = {
78
+ data: {
79
+ entry_values: entry_values || {}
80
+ }
81
+ }
82
+
83
+ # Add mode parameter for append operations
84
+ if mode == "append"
85
+ request_params[:mode] = "append"
86
+ end
87
+
88
+ response = execute_request(:PATCH, "#{resource_path}/#{list}/entries/#{entry_id}", request_params, opts)
89
+ new(response["data"] || response, opts)
90
+ end
91
+
92
+ # Delete an entry
93
+ def delete(list: nil, entry_id: nil, **opts)
94
+ validate_list_identifier!(list)
95
+ validate_entry_id!(entry_id)
96
+
97
+ execute_request(:DELETE, "#{resource_path}/#{list}/entries/#{entry_id}", {}, opts)
98
+ true
99
+ end
100
+ alias_method :destroy, :delete
101
+
102
+ # Assert an entry by parent record
103
+ def assert_by_parent(list: nil, parent_record_id: nil, parent_object: nil, entry_values: nil, **opts)
104
+ validate_list_identifier!(list)
105
+ validate_parent_params!(parent_record_id, parent_object)
106
+
107
+ request_params = {
108
+ data: {
109
+ parent_record_id: parent_record_id,
110
+ parent_object: parent_object,
111
+ entry_values: entry_values || {}
112
+ }
113
+ }
114
+
115
+ response = execute_request(:PUT, "#{resource_path}/#{list}/entries", request_params, opts)
116
+ new(response["data"] || response, opts)
117
+ end
118
+
119
+ # List attribute values for an entry
120
+ def list_attribute_values(list: nil, entry_id: nil, attribute_id: nil, **opts)
121
+ validate_list_identifier!(list)
122
+ validate_entry_id!(entry_id)
123
+ raise ArgumentError, "Attribute ID is required" if attribute_id.nil? || attribute_id.to_s.empty?
124
+
125
+ response = execute_request(:GET, "#{resource_path}/#{list}/entries/#{entry_id}/attributes/#{attribute_id}/values", {}, opts)
126
+ response["data"] || []
127
+ end
128
+
129
+ private
130
+
131
+ def validate_list_identifier!(list)
132
+ raise ArgumentError, "List identifier is required" if list.nil? || list.to_s.empty?
133
+ end
134
+
135
+ def validate_entry_id!(entry_id)
136
+ raise ArgumentError, "Entry ID is required" if entry_id.nil? || entry_id.to_s.empty?
137
+ end
138
+
139
+ def validate_parent_params!(parent_record_id, parent_object)
140
+ if parent_record_id.nil? || parent_object.nil?
141
+ raise ArgumentError, "parent_record_id and parent_object are required"
142
+ end
143
+ end
144
+ end
145
+
146
+ # Instance methods
147
+
148
+ def save(**opts)
149
+ raise InvalidRequestError, "Cannot save an entry without an ID" unless persisted?
150
+ raise InvalidRequestError, "Cannot save without list context" unless list_id
151
+
152
+ # For Entry, we always save the full entry_values
153
+ params = {
154
+ data: {
155
+ entry_values: entry_values
156
+ }
157
+ }
158
+
159
+ response = self.class.send(:execute_request, :PATCH, resource_path, params, opts)
160
+ update_from(response[:data] || response)
161
+ reset_changes!
162
+ self
163
+ end
164
+
165
+ def destroy(**opts)
166
+ raise InvalidRequestError, "Cannot destroy an entry without an ID" unless persisted?
167
+ raise InvalidRequestError, "Cannot destroy without list context" unless list_id
168
+
169
+ entry_id = extract_entry_id
170
+ self.class.send(:execute_request, :DELETE, "lists/#{list_id}/entries/#{entry_id}", {}, opts)
171
+ @attributes.clear
172
+ @changed_attributes.clear
173
+ @id = nil
174
+ true
175
+ end
176
+
177
+ def resource_path
178
+ raise InvalidRequestError, "Cannot generate path without list context" unless list_id
179
+ entry_id = extract_entry_id
180
+ "lists/#{list_id}/entries/#{entry_id}"
181
+ end
182
+
183
+ private
184
+
185
+ def extract_entry_id
186
+ case id
187
+ when Hash
188
+ id[:entry_id] || id["entry_id"]
189
+ else
190
+ id
191
+ end
192
+ end
193
+
194
+ def to_h
195
+ {
196
+ id: id,
197
+ parent_record_id: parent_record_id,
198
+ parent_object: parent_object,
199
+ created_at: created_at&.iso8601,
200
+ entry_values: entry_values
201
+ }.compact
202
+ end
203
+
204
+ def inspect
205
+ "#<#{self.class.name}:#{object_id} id=#{id.inspect} parent=#{parent_object}##{parent_record_id} values=#{entry_values.inspect}>"
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require_relative "../api_resource"
5
+
6
+ module Attio
7
+ # Represents a list in Attio for organizing records
8
+ class List < APIResource
9
+ api_operations :list, :retrieve, :create, :update
10
+
11
+ # API endpoint path for lists
12
+ # @return [String] The API path
13
+ def self.resource_path
14
+ "lists"
15
+ end
16
+
17
+ # Define known attributes with proper accessors
18
+ attr_attio :name, :workspace_access
19
+
20
+ # Read-only attributes
21
+ attr_reader :api_slug, :attio_object_id, :object_api_slug,
22
+ :created_by_actor, :workspace_id, :parent_object, :filters
23
+
24
+ # Get the parent object as a string
25
+ def object
26
+ # parent_object is returned as an array from the API
27
+ return nil unless @parent_object
28
+ @parent_object.is_a?(Array) ? @parent_object.first : @parent_object
29
+ end
30
+
31
+ def initialize(attributes = {}, opts = {})
32
+ super
33
+
34
+ # Now we can safely use symbol keys only since parent normalized them
35
+ normalized_attrs = normalize_attributes(attributes)
36
+ @api_slug = normalized_attrs[:api_slug]
37
+ @name = normalized_attrs[:name]
38
+ @attio_object_id = normalized_attrs[:object_id]
39
+ @object_api_slug = normalized_attrs[:object_api_slug]
40
+ @created_by_actor = normalized_attrs[:created_by_actor]
41
+ @workspace_id = normalized_attrs[:workspace_id]
42
+ @workspace_access = normalized_attrs[:workspace_access]
43
+ @parent_object = normalized_attrs[:parent_object] || normalized_attrs[:object]
44
+ @filters = normalized_attrs[:filters]
45
+ end
46
+
47
+ def resource_path
48
+ raise InvalidRequestError, "Cannot generate path without an ID" unless persisted?
49
+ list_id = id.is_a?(Hash) ? (id[:list_id] || id["list_id"]) : id
50
+ "#{self.class.resource_path}/#{list_id}"
51
+ end
52
+
53
+ # Override the default id extraction for API paths
54
+ def id_for_path
55
+ return nil unless persisted?
56
+ id.is_a?(Hash) ? (id[:list_id] || id["list_id"]) : id
57
+ end
58
+
59
+ # Override save to handle nested ID
60
+ def save(**)
61
+ raise InvalidRequestError, "Cannot save a list without an ID" unless persisted?
62
+ return self unless changed?
63
+
64
+ list_id = id.is_a?(Hash) ? (id[:list_id] || id["list_id"]) : id
65
+ self.class.update(list_id, changed_attributes, **)
66
+ end
67
+
68
+ # Lists cannot be deleted via API
69
+ def destroy(**opts)
70
+ raise NotImplementedError, "Lists cannot be deleted via the Attio API"
71
+ end
72
+
73
+ # Get all entries in this list
74
+ def entries(params = {}, **opts)
75
+ list_id = id.is_a?(Hash) ? (id[:list_id] || id["list_id"]) : id
76
+ client = Attio.client(api_key: opts[:api_key])
77
+ # Use POST query endpoint to get entries
78
+ response = client.post("lists/#{list_id}/entries/query", params)
79
+ response["data"] || []
80
+ end
81
+
82
+ # Add a record to this list
83
+ def add_record(record_id, **opts)
84
+ list_id = id.is_a?(Hash) ? id["list_id"] : id
85
+ client = Attio.client(api_key: opts[:api_key])
86
+
87
+ # The API expects parent_record_id, parent_object, and entry_values
88
+ request_data = {
89
+ data: {
90
+ parent_record_id: record_id,
91
+ parent_object: object, # Get the parent object from the list
92
+ entry_values: {}
93
+ }
94
+ }
95
+
96
+ response = client.post("lists/#{list_id}/entries", request_data)
97
+ # Return the entry data
98
+ response["data"] || response
99
+ end
100
+
101
+ # Remove a record from this list
102
+ def remove_record(entry_id, **opts)
103
+ list_id = id.is_a?(Hash) ? id["list_id"] : id
104
+ client = Attio.client(api_key: opts[:api_key])
105
+ client.delete("lists/#{list_id}/entries/#{entry_id}")
106
+ end
107
+
108
+ # Check if a record is in this list
109
+ def contains_record?(record_id, **)
110
+ entries({record_id: record_id}, **).any?
111
+ end
112
+
113
+ # Get the count of entries
114
+ def entry_count(**)
115
+ # Just get the entries and count them
116
+ entries(**).length
117
+ end
118
+
119
+ # Convert list to hash representation
120
+ # @return [Hash] List data as a hash
121
+ def to_h
122
+ super.merge(
123
+ api_slug: api_slug,
124
+ name: name,
125
+ object_id: attio_object_id,
126
+ object_api_slug: object_api_slug,
127
+ created_by_actor: created_by_actor,
128
+ workspace_id: workspace_id,
129
+ workspace_access: workspace_access
130
+ ).compact
131
+ end
132
+
133
+ class << self
134
+ # Override retrieve to handle complex IDs
135
+ def retrieve(id, **opts)
136
+ list_id = id.is_a?(Hash) ? id["list_id"] : id
137
+ response = execute_request(:GET, "#{resource_path}/#{list_id}", {}, opts)
138
+ new(response["data"] || response, opts)
139
+ end
140
+
141
+ # Override create to handle keyword arguments properly
142
+ def create(**kwargs)
143
+ # Extract options from kwargs
144
+ opts = {}
145
+ opts[:api_key] = kwargs.delete(:api_key) if kwargs.key?(:api_key)
146
+
147
+ prepared_params = prepare_params_for_create(kwargs)
148
+ response = execute_request(:POST, resource_path, prepared_params, opts)
149
+ new(response["data"] || response, opts)
150
+ end
151
+
152
+ # Override create to handle special parameters
153
+ def prepare_params_for_create(params)
154
+ validate_object_identifier!(params[:object])
155
+
156
+ # Generate api_slug from name if not provided
157
+ api_slug = params[:api_slug] || params[:name].downcase.gsub(/[^a-z0-9]+/, "_")
158
+
159
+ {
160
+ data: {
161
+ name: params[:name],
162
+ parent_object: params[:object],
163
+ api_slug: api_slug,
164
+ workspace_access: params[:workspace_access] || "full-access",
165
+ workspace_member_access: params[:workspace_member_access] || [],
166
+ filters: params[:filters]
167
+ }.compact
168
+ }
169
+ end
170
+
171
+ # Override update to handle data wrapper
172
+ def prepare_params_for_update(params)
173
+ {
174
+ data: params
175
+ }
176
+ end
177
+
178
+ # Find list by API slug
179
+ def find_by_slug(slug, **opts)
180
+ list(**opts).find { |lst| lst.api_slug == slug } ||
181
+ raise(NotFoundError, "List with slug '#{slug}' not found")
182
+ end
183
+
184
+ # Get lists for a specific object
185
+ def for_object(object, params = {}, **)
186
+ list(params.merge(object: object), **)
187
+ end
188
+
189
+ private
190
+
191
+ def validate_object_identifier!(object)
192
+ raise ArgumentError, "Object identifier is required" if object.nil? || object.to_s.empty?
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../api_resource"
4
+
5
+ module Attio
6
+ # Provides metadata about the current API token and workspace
7
+ class Meta < APIResource
8
+ # Meta only supports the identify endpoint (no CRUD operations)
9
+
10
+ def self.resource_path
11
+ "self"
12
+ end
13
+
14
+ # Get information about the current token and workspace
15
+ def self.identify(**opts)
16
+ response = execute_request(:GET, resource_path, {}, opts)
17
+ new(response["data"] || response, opts)
18
+ end
19
+
20
+ class << self
21
+ # Convenient aliases
22
+ alias_method :self, :identify
23
+ alias_method :current, :identify
24
+ end
25
+
26
+ # Define attribute accessors
27
+ attr_attio :workspace, :token, :actor
28
+
29
+ # Convenience methods for workspace info
30
+ def workspace_id
31
+ workspace&.dig(:id)
32
+ end
33
+
34
+ # Get the workspace name
35
+ # @return [String, nil] The workspace name
36
+ def workspace_name
37
+ workspace&.dig(:name)
38
+ end
39
+
40
+ # Get the workspace slug
41
+ # @return [String, nil] The workspace slug
42
+ def workspace_slug
43
+ workspace&.dig(:slug)
44
+ end
45
+
46
+ # Convenience methods for token info
47
+ def token_id
48
+ token&.dig(:id)
49
+ end
50
+
51
+ # Get the token name
52
+ # @return [String, nil] The token name
53
+ def token_name
54
+ token&.dig(:name)
55
+ end
56
+
57
+ # Get the token type
58
+ # @return [String, nil] The token type
59
+ def token_type
60
+ token&.dig(:type)
61
+ end
62
+
63
+ # Get the token's OAuth scopes
64
+ # @return [Array<String>] Array of scope strings
65
+ def scopes
66
+ token&.dig(:scopes) || []
67
+ end
68
+
69
+ # Check if token has a specific scope
70
+ def has_scope?(scope)
71
+ scope_str = scope.to_s.tr("_", ":")
72
+ scopes.include?(scope_str)
73
+ end
74
+
75
+ # Check read/write permissions
76
+ def can_read?(resource)
77
+ has_scope?("#{resource}:read") || has_scope?("#{resource}:read-write")
78
+ end
79
+
80
+ def can_write?(resource)
81
+ has_scope?("#{resource}:write") || has_scope?("#{resource}:read-write")
82
+ end
83
+
84
+ # Meta is read-only
85
+ def immutable?
86
+ true
87
+ end
88
+
89
+ # Override save to raise error since meta is read-only
90
+ def save(**opts)
91
+ raise InvalidRequestError, "Meta information is read-only"
92
+ end
93
+
94
+ # Override destroy to raise error since meta is read-only
95
+ def destroy(**opts)
96
+ raise InvalidRequestError, "Meta information is read-only"
97
+ end
98
+
99
+ private
100
+
101
+ def to_h
102
+ {
103
+ workspace: workspace,
104
+ token: token,
105
+ actor: actor
106
+ }.compact
107
+ end
108
+
109
+ def inspect
110
+ "#<#{self.class.name}:#{object_id} workspace=#{workspace_slug.inspect} token=#{token_name.inspect}>"
111
+ end
112
+ end
113
+ end