attio 0.2.0 → 0.4.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.
@@ -54,6 +54,16 @@ module Attio
54
54
  raise ArgumentError, "#{field_name} is required"
55
55
  end
56
56
 
57
+ # Validates that a hash parameter is present
58
+ # @param value [Hash] The hash to validate
59
+ # @param field_name [String] The field name for the error message
60
+ # @raise [ArgumentError] if value is nil or not a hash
61
+ private def validate_required_hash!(value, field_name)
62
+ return if value.is_a?(Hash) && !value.nil?
63
+
64
+ raise ArgumentError, "#{field_name} must be a hash"
65
+ end
66
+
57
67
  # Validates parent object and record ID together
58
68
  # @param parent_object [String] The parent object type
59
69
  # @param parent_record_id [String] The parent record ID
@@ -63,14 +73,15 @@ module Attio
63
73
  validate_required_string!(parent_record_id, "Parent record ID")
64
74
  end
65
75
 
66
- private def request(method, path, params = {})
76
+ private def request(method, path, params = {}, _headers = {})
77
+ # Path is already safely constructed by the resource methods
67
78
  connection = client.connection
68
79
 
69
80
  case method
70
81
  when :get
71
82
  handle_get_request(connection, path, params)
72
83
  when :post
73
- connection.post(path, params)
84
+ handle_post_request(connection, path, params)
74
85
  when :patch
75
86
  connection.patch(path, params)
76
87
  when :put
@@ -86,9 +97,66 @@ module Attio
86
97
  params.empty? ? connection.get(path) : connection.get(path, params)
87
98
  end
88
99
 
100
+ private def handle_post_request(connection, path, params)
101
+ params.empty? ? connection.post(path) : connection.post(path, params)
102
+ end
103
+
89
104
  private def handle_delete_request(connection, path, params)
90
105
  params.empty? ? connection.delete(path) : connection.delete(path, params)
91
106
  end
107
+
108
+ # Paginate through all results for a given endpoint
109
+ # @param path [String] The API endpoint path
110
+ # @param params [Hash] Query parameters including filters
111
+ # @param page_size [Integer] Number of items per page (default: 50)
112
+ # @return [Enumerator] Yields each item from all pages
113
+ private def paginate(path, params = {}, page_size: 50)
114
+ Enumerator.new do |yielder|
115
+ offset = 0
116
+ loop do
117
+ page_params = params.merge(limit: page_size, offset: offset)
118
+ # Use POST for query endpoints, GET for others
119
+ method = path.end_with?("/query") ? :post : :get
120
+ response = request(method, path, page_params)
121
+
122
+ data = response["data"] || []
123
+ data.each { |item| yielder << item }
124
+
125
+ # Stop if we got fewer items than requested (last page)
126
+ break if data.size < page_size
127
+
128
+ offset += page_size
129
+ end
130
+ end
131
+ end
132
+
133
+ # Build query parameters with filtering and sorting support
134
+ # @param options [Hash] Options including filter, sort, limit, offset
135
+ # @return [Hash] Formatted query parameters
136
+ private def build_query_params(options = {})
137
+ params = {}
138
+
139
+ # Add filtering
140
+ add_filter_param(params, options[:filter]) if options[:filter]
141
+
142
+ # Add standard parameters
143
+ %i[sort limit offset].each do |key|
144
+ params[key] = options[key] if options[key]
145
+ end
146
+
147
+ # Add any other parameters
148
+ options.each do |key, value|
149
+ next if %i[filter sort limit offset].include?(key)
150
+
151
+ params[key] = value
152
+ end
153
+
154
+ params
155
+ end
156
+
157
+ private def add_filter_param(params, filter)
158
+ params[:filter] = filter.is_a?(String) ? filter : filter.to_json
159
+ end
92
160
  end
93
161
  end
94
162
  end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Resources
5
+ # Bulk operations for efficient batch processing
6
+ #
7
+ # @example Bulk create records
8
+ # client.bulk.create_records(
9
+ # object: "companies",
10
+ # records: [
11
+ # { name: "Acme Corp", domain: "acme.com" },
12
+ # { name: "Tech Co", domain: "techco.com" }
13
+ # ]
14
+ # )
15
+ #
16
+ # @example Bulk update records
17
+ # client.bulk.update_records(
18
+ # object: "people",
19
+ # updates: [
20
+ # { id: "person_123", data: { title: "CEO" } },
21
+ # { id: "person_456", data: { title: "CTO" } }
22
+ # ]
23
+ # )
24
+ class Bulk < Base
25
+ # Maximum number of records per bulk operation
26
+ MAX_BATCH_SIZE = 100
27
+
28
+ # Bulk create multiple records
29
+ #
30
+ # @param object [String] The object type (companies, people, etc.)
31
+ # @param records [Array<Hash>] Array of record data to create
32
+ # @param options [Hash] Additional options
33
+ # @option options [Boolean] :partial_success Allow partial success (default: false)
34
+ # @option options [Boolean] :return_records Return created records (default: true)
35
+ # @return [Hash] Results including created records and any errors
36
+ def create_records(object:, records:, options: {})
37
+ validate_required_string!(object, "Object type")
38
+ validate_bulk_records!(records, "create")
39
+
40
+ batches = records.each_slice(MAX_BATCH_SIZE).to_a
41
+ results = []
42
+
43
+ batches.each_with_index do |batch, index|
44
+ body = {
45
+ records: batch.map { |record| { data: record } },
46
+ partial_success: options.fetch(:partial_success, false),
47
+ return_records: options.fetch(:return_records, true),
48
+ }
49
+
50
+ result = request(:post, "objects/#{object}/records/bulk", body)
51
+ results << result.merge("batch" => index + 1)
52
+ end
53
+
54
+ merge_batch_results(results)
55
+ end
56
+
57
+ # Bulk update multiple records
58
+ #
59
+ # @param object [String] The object type
60
+ # @param updates [Array<Hash>] Array of updates with :id and :data keys
61
+ # @param options [Hash] Additional options
62
+ # @option options [Boolean] :partial_success Allow partial success (default: false)
63
+ # @option options [Boolean] :return_records Return updated records (default: true)
64
+ # @return [Hash] Results including updated records and any errors
65
+ def update_records(object:, updates:, options: {})
66
+ validate_required_string!(object, "Object type")
67
+ validate_bulk_updates!(updates)
68
+
69
+ batches = updates.each_slice(MAX_BATCH_SIZE).to_a
70
+ results = []
71
+
72
+ batches.each_with_index do |batch, index|
73
+ body = {
74
+ updates: batch,
75
+ partial_success: options.fetch(:partial_success, false),
76
+ return_records: options.fetch(:return_records, true),
77
+ }
78
+
79
+ result = request(:patch, "objects/#{object}/records/bulk", body)
80
+ results << result.merge("batch" => index + 1)
81
+ end
82
+
83
+ merge_batch_results(results)
84
+ end
85
+
86
+ # Bulk delete multiple records
87
+ #
88
+ # @param object [String] The object type
89
+ # @param ids [Array<String>] Array of record IDs to delete
90
+ # @param options [Hash] Additional options
91
+ # @option options [Boolean] :partial_success Allow partial success (default: false)
92
+ # @return [Hash] Results including deletion confirmations and any errors
93
+ def delete_records(object:, ids:, options: {})
94
+ validate_required_string!(object, "Object type")
95
+ validate_bulk_ids!(ids)
96
+
97
+ batches = ids.each_slice(MAX_BATCH_SIZE).to_a
98
+ results = []
99
+
100
+ batches.each_with_index do |batch, index|
101
+ body = {
102
+ ids: batch,
103
+ partial_success: options.fetch(:partial_success, false),
104
+ }
105
+
106
+ result = request(:delete, "objects/#{object}/records/bulk", body)
107
+ results << result.merge("batch" => index + 1)
108
+ end
109
+
110
+ merge_batch_results(results)
111
+ end
112
+
113
+ # Bulk upsert records (create or update based on matching criteria)
114
+ #
115
+ # @param object [String] The object type
116
+ # @param records [Array<Hash>] Records to upsert
117
+ # @param match_attribute [String] Attribute to match on (e.g., "email", "domain")
118
+ # @param options [Hash] Additional options
119
+ # @return [Hash] Results including created/updated records
120
+ def upsert_records(object:, records:, match_attribute:, options: {})
121
+ validate_required_string!(object, "Object type")
122
+ validate_required_string!(match_attribute, "Match attribute")
123
+ validate_bulk_records!(records, "upsert")
124
+
125
+ batches = records.each_slice(MAX_BATCH_SIZE).to_a
126
+ results = []
127
+
128
+ batches.each_with_index do |batch, index|
129
+ body = {
130
+ records: batch.map { |record| { data: record } },
131
+ match_attribute: match_attribute,
132
+ partial_success: options.fetch(:partial_success, false),
133
+ return_records: options.fetch(:return_records, true),
134
+ }
135
+
136
+ result = request(:put, "objects/#{object}/records/bulk", body)
137
+ results << result.merge("batch" => index + 1)
138
+ end
139
+
140
+ merge_batch_results(results)
141
+ end
142
+
143
+ # Bulk add entries to a list
144
+ #
145
+ # @param list_id [String] The list ID
146
+ # @param entries [Array<Hash>] Array of entries to add
147
+ # @param options [Hash] Additional options
148
+ # @return [Hash] Results including added entries
149
+ def add_list_entries(list_id:, entries:, options: {})
150
+ validate_id!(list_id, "List")
151
+ validate_bulk_records!(entries, "add to list")
152
+
153
+ batches = entries.each_slice(MAX_BATCH_SIZE).to_a
154
+ results = []
155
+
156
+ batches.each_with_index do |batch, index|
157
+ body = {
158
+ entries: batch,
159
+ partial_success: options.fetch(:partial_success, false),
160
+ }
161
+
162
+ result = request(:post, "lists/#{list_id}/entries/bulk", body)
163
+ results << result.merge("batch" => index + 1)
164
+ end
165
+
166
+ merge_batch_results(results)
167
+ end
168
+
169
+ # Bulk remove entries from a list
170
+ #
171
+ # @param list_id [String] The list ID
172
+ # @param entry_ids [Array<String>] Array of entry IDs to remove
173
+ # @param options [Hash] Additional options
174
+ # @return [Hash] Results including removal confirmations
175
+ def remove_list_entries(list_id:, entry_ids:, options: {})
176
+ validate_id!(list_id, "List")
177
+ validate_bulk_ids!(entry_ids)
178
+
179
+ batches = entry_ids.each_slice(MAX_BATCH_SIZE).to_a
180
+ results = []
181
+
182
+ batches.each_with_index do |batch, index|
183
+ body = {
184
+ entry_ids: batch,
185
+ partial_success: options.fetch(:partial_success, false),
186
+ }
187
+
188
+ result = request(:delete, "lists/#{list_id}/entries/bulk", body)
189
+ results << result.merge("batch" => index + 1)
190
+ end
191
+
192
+ merge_batch_results(results)
193
+ end
194
+
195
+ private def validate_bulk_records!(records, operation)
196
+ raise ArgumentError, "Records array is required for bulk #{operation}" if records.nil?
197
+ raise ArgumentError, "Records must be an array for bulk #{operation}" unless records.is_a?(Array)
198
+ raise ArgumentError, "Records array cannot be empty for bulk #{operation}" if records.empty?
199
+ raise ArgumentError, "Too many records (max 1000)" if records.size > MAX_BATCH_SIZE * 10
200
+
201
+ records.each_with_index do |record, index|
202
+ raise ArgumentError, "Record at index #{index} must be a hash" unless record.is_a?(Hash)
203
+ end
204
+ end
205
+
206
+ private def validate_bulk_updates!(updates)
207
+ validate_array!(updates, "Updates", "bulk update")
208
+ validate_max_size!(updates, "updates")
209
+
210
+ updates.each_with_index do |update, index|
211
+ validate_update_item!(update, index)
212
+ end
213
+ end
214
+
215
+ private def validate_update_item!(update, index)
216
+ raise ArgumentError, "Update at index #{index} must be a hash" unless update.is_a?(Hash)
217
+ raise ArgumentError, "Update at index #{index} must have an :id" unless update[:id]
218
+ raise ArgumentError, "Update at index #{index} must have :data" unless update[:data]
219
+ end
220
+
221
+ private def validate_bulk_ids!(ids)
222
+ validate_array!(ids, "IDs", "bulk operation")
223
+ validate_max_size!(ids, "IDs")
224
+
225
+ ids.each_with_index do |id, index|
226
+ validate_id_item!(id, index)
227
+ end
228
+ end
229
+
230
+ private def validate_id_item!(id, index)
231
+ return unless id.nil? || id.to_s.strip.empty?
232
+
233
+ raise ArgumentError, "ID at index #{index} cannot be nil or empty"
234
+ end
235
+
236
+ private def validate_array!(array, name, operation)
237
+ raise ArgumentError, "#{name} array is required for #{operation}" if array.nil?
238
+ raise ArgumentError, "#{name} must be an array for #{operation}" unless array.is_a?(Array)
239
+ raise ArgumentError, "#{name} array cannot be empty for #{operation}" if array.empty?
240
+ end
241
+
242
+ private def validate_max_size!(array, name)
243
+ max = MAX_BATCH_SIZE * 10
244
+ return unless array.size > max
245
+
246
+ raise ArgumentError, "Too many #{name} (max #{max})"
247
+ end
248
+
249
+ private def merge_batch_results(results)
250
+ merged = initialize_merged_result(results.size)
251
+
252
+ results.each do |result|
253
+ merge_single_result!(merged, result)
254
+ end
255
+
256
+ merged
257
+ end
258
+
259
+ private def initialize_merged_result(batch_count)
260
+ {
261
+ "success" => true,
262
+ "total_batches" => batch_count,
263
+ "records" => [],
264
+ "errors" => [],
265
+ "statistics" => {
266
+ "created" => 0,
267
+ "updated" => 0,
268
+ "deleted" => 0,
269
+ "failed" => 0,
270
+ },
271
+ }
272
+ end
273
+
274
+ private def merge_single_result!(merged, result)
275
+ merged["records"].concat(result["records"] || [])
276
+ merged["errors"].concat(result["errors"] || [])
277
+ merge_statistics!(merged["statistics"], result["statistics"])
278
+ merged["success"] &&= result["success"] != false
279
+ end
280
+
281
+ private def merge_statistics!(target, source)
282
+ return unless source
283
+
284
+ %w[created updated deleted failed].each do |key|
285
+ target[key] += source[key] || 0
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Resources
5
+ # Deals resource for managing sales opportunities
6
+ #
7
+ # @example List all deals
8
+ # client.deals.list
9
+ #
10
+ # @example Create a new deal
11
+ # client.deals.create(
12
+ # data: {
13
+ # name: "Enterprise Contract",
14
+ # value: 50000,
15
+ # stage_id: "stage_123",
16
+ # company_id: "company_456"
17
+ # }
18
+ # )
19
+ #
20
+ # @example Update deal stage
21
+ # client.deals.update_stage(id: "deal_123", stage_id: "stage_won")
22
+ class Deals < Base
23
+ # List all deals
24
+ #
25
+ # @param params [Hash] Optional query parameters
26
+ # @option params [Hash] :filter Filter conditions
27
+ # @option params [Array<Hash>] :sorts Sort criteria
28
+ # @option params [Integer] :limit Maximum number of results
29
+ # @option params [String] :offset Pagination offset
30
+ # @return [Hash] The API response
31
+ def list(params = {})
32
+ request(:get, "objects/deals/records", params)
33
+ end
34
+
35
+ # Get a specific deal
36
+ #
37
+ # @param id [String] The deal ID
38
+ # @return [Hash] The deal data
39
+ def get(id:)
40
+ validate_id!(id, "Deal")
41
+ request(:get, "objects/deals/records/#{id}")
42
+ end
43
+
44
+ # Create a new deal
45
+ #
46
+ # @param data [Hash] The deal data
47
+ # @option data [String] :name The deal name (required)
48
+ # @option data [Float] :value The deal value
49
+ # @option data [String] :stage_id The stage ID
50
+ # @option data [String] :company_id Associated company ID
51
+ # @option data [String] :owner_id The owner user ID
52
+ # @option data [Date] :close_date Expected close date
53
+ # @option data [String] :currency Currency code (USD, EUR, etc.)
54
+ # @option data [Float] :probability Win probability (0-100)
55
+ # @return [Hash] The created deal
56
+ def create(data:)
57
+ validate_required_hash!(data, "Data")
58
+ validate_required_string!(data[:name], "Deal name")
59
+
60
+ request(:post, "objects/deals/records", { data: data })
61
+ end
62
+
63
+ # Update a deal
64
+ #
65
+ # @param id [String] The deal ID to update
66
+ # @param data [Hash] The data to update
67
+ # @return [Hash] The updated deal
68
+ def update(id:, data:)
69
+ validate_id!(id, "Deal")
70
+ validate_required_hash!(data, "Data")
71
+
72
+ request(:patch, "objects/deals/records/#{id}", { data: data })
73
+ end
74
+
75
+ # Delete a deal
76
+ #
77
+ # @param id [String] The deal ID to delete
78
+ # @return [Hash] Confirmation of deletion
79
+ def delete(id:)
80
+ validate_id!(id, "Deal")
81
+ request(:delete, "objects/deals/records/#{id}")
82
+ end
83
+
84
+ # Update a deal's stage
85
+ #
86
+ # @param id [String] The deal ID
87
+ # @param stage_id [String] The new stage ID
88
+ # @return [Hash] The updated deal
89
+ def update_stage(id:, stage_id:)
90
+ validate_id!(id, "Deal")
91
+ validate_required_string!(stage_id, "Stage")
92
+
93
+ update(id: id, data: { stage_id: stage_id })
94
+ end
95
+
96
+ # Mark a deal as won
97
+ #
98
+ # @param id [String] The deal ID
99
+ # @param won_date [Date, String] The date the deal was won (defaults to today)
100
+ # @param actual_value [Float] The actual value (optional, defaults to deal value)
101
+ # @return [Hash] The updated deal
102
+ def mark_won(id:, won_date: nil, actual_value: nil)
103
+ validate_id!(id, "Deal")
104
+
105
+ data = { status: "won" }
106
+ data[:won_date] = won_date if won_date
107
+ data[:actual_value] = actual_value if actual_value
108
+
109
+ update(id: id, data: data)
110
+ end
111
+
112
+ # Mark a deal as lost
113
+ #
114
+ # @param id [String] The deal ID
115
+ # @param lost_reason [String] The reason for losing the deal
116
+ # @param lost_date [Date, String] The date the deal was lost (defaults to today)
117
+ # @return [Hash] The updated deal
118
+ def mark_lost(id:, lost_reason: nil, lost_date: nil)
119
+ validate_id!(id, "Deal")
120
+
121
+ data = { status: "lost" }
122
+ data[:lost_reason] = lost_reason if lost_reason
123
+ data[:lost_date] = lost_date if lost_date
124
+
125
+ update(id: id, data: data)
126
+ end
127
+
128
+ # List deals by stage
129
+ #
130
+ # @param stage_id [String] The stage ID to filter by
131
+ # @param params [Hash] Additional query parameters
132
+ # @return [Hash] Deals in the specified stage
133
+ def list_by_stage(stage_id:, params: {})
134
+ validate_required_string!(stage_id, "Stage")
135
+
136
+ filter = { stage_id: { "$eq" => stage_id } }
137
+ merged_params = params.merge(filter: filter)
138
+ list(merged_params)
139
+ end
140
+
141
+ # List deals by company
142
+ #
143
+ # @param company_id [String] The company ID to filter by
144
+ # @param params [Hash] Additional query parameters
145
+ # @return [Hash] Deals for the specified company
146
+ def list_by_company(company_id:, params: {})
147
+ validate_required_string!(company_id, "Company")
148
+
149
+ filter = { company_id: { "$eq" => company_id } }
150
+ merged_params = params.merge(filter: filter)
151
+ list(merged_params)
152
+ end
153
+
154
+ # List deals by owner
155
+ #
156
+ # @param owner_id [String] The owner user ID to filter by
157
+ # @param params [Hash] Additional query parameters
158
+ # @return [Hash] Deals owned by the specified user
159
+ def list_by_owner(owner_id:, params: {})
160
+ validate_required_string!(owner_id, "Owner")
161
+
162
+ filter = { owner_id: { "$eq" => owner_id } }
163
+ merged_params = params.merge(filter: filter)
164
+ list(merged_params)
165
+ end
166
+
167
+ # Calculate pipeline value
168
+ #
169
+ # @param stage_id [String] Optional stage ID to filter by
170
+ # @param owner_id [String] Optional owner ID to filter by
171
+ # @return [Hash] Pipeline statistics including total value, count, and average
172
+ def pipeline_value(stage_id: nil, owner_id: nil)
173
+ params = { filter: {} }
174
+ params[:filter][:stage_id] = { "$eq" => stage_id } if stage_id
175
+ params[:filter][:owner_id] = { "$eq" => owner_id } if owner_id
176
+
177
+ # This would typically be a specialized endpoint, but we'll use list
178
+ # and let the client calculate the statistics
179
+ list(params)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -44,9 +44,36 @@ module Attio
44
44
  # filters: { name: { contains: 'John' } },
45
45
  # limit: 50
46
46
  # )
47
- def list(object:, **params)
47
+ def list(object:, filter: nil, sort: nil, limit: nil, offset: nil, **params)
48
48
  validate_required_string!(object, "Object type")
49
- request(:post, "objects/#{object}/records/query", params)
49
+
50
+ # Build query parameters with filtering and sorting support
51
+ query_params = build_query_params({
52
+ filter: filter,
53
+ sort: sort,
54
+ limit: limit,
55
+ offset: offset,
56
+ **params,
57
+ })
58
+
59
+ request(:post, "objects/#{object}/records/query", query_params)
60
+ end
61
+
62
+ # List all records with automatic pagination
63
+ # @param object [String] The object type to query
64
+ # @param filter [Hash] Filtering criteria
65
+ # @param sort [String] Sorting option
66
+ # @param page_size [Integer] Number of records per page
67
+ # @return [Enumerator] Enumerator that yields each record
68
+ def list_all(object:, filter: nil, sort: nil, page_size: 50)
69
+ validate_required_string!(object, "Object type")
70
+
71
+ query_params = build_query_params({
72
+ filter: filter,
73
+ sort: sort,
74
+ })
75
+
76
+ paginate("objects/#{object}/records/query", query_params, page_size: page_size)
50
77
  end
51
78
 
52
79
  # Retrieve a specific record by ID.