attio 0.1.3 → 0.3.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.
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "attio"
6
+ require "date"
7
+
8
+ # Example demonstrating Notes and Tasks functionality
9
+ #
10
+ # This example shows how to:
11
+ # - Create and manage notes on records
12
+ # - Create and manage tasks
13
+ # - Work with task assignments and due dates
14
+ # - Track task completion
15
+
16
+ client = Attio.client(api_key: ENV.fetch("ATTIO_API_KEY"))
17
+
18
+ puts "šŸ““ Attio Notes and Tasks Example"
19
+ puts "=" * 50
20
+
21
+ begin
22
+ # First, find or create a person to work with
23
+ puts "\nšŸ‘¤ Setting up test person..."
24
+ test_person = client.records.create(
25
+ object: "people",
26
+ data: {
27
+ name: "Jane Smith",
28
+ email: "jane.smith@example.com",
29
+ title: "Product Manager",
30
+ }
31
+ )
32
+ person_id = test_person["data"]["id"]
33
+ puts " Created test person: Jane Smith (#{person_id})"
34
+
35
+ # Working with Notes
36
+ puts "\nšŸ“ Creating Notes:"
37
+
38
+ # Create a meeting note
39
+ meeting_note = client.notes.create(
40
+ parent_object: "people",
41
+ parent_record_id: person_id,
42
+ title: "Initial Meeting - Product Requirements",
43
+ content: <<~MARKDOWN
44
+ ## Meeting Summary
45
+ **Date:** #{Date.today}
46
+ **Attendees:** Jane Smith, Development Team
47
+
48
+ ### Discussion Points:
49
+ 1. **Q1 Product Roadmap**
50
+ - Feature A: User authentication improvements
51
+ - Feature B: New dashboard design
52
+ - Feature C: API v2 development
53
+
54
+ 2. **Timeline**
55
+ - Sprint 1: Jan 15-29
56
+ - Sprint 2: Jan 30-Feb 12
57
+ - Release: Feb 15
58
+
59
+ 3. **Action Items**
60
+ - [ ] Create detailed specs for Feature A
61
+ - [ ] Schedule design review for Feature B
62
+ - [ ] Allocate resources for API development
63
+
64
+ ### Next Steps:
65
+ Follow-up meeting scheduled for next week.
66
+ MARKDOWN
67
+ )
68
+ puts " āœ… Created meeting note: #{meeting_note['data']['title']}"
69
+
70
+ # Create a follow-up note
71
+ followup_note = client.notes.create(
72
+ parent_object: "people",
73
+ parent_record_id: person_id,
74
+ title: "Follow-up: Action Items",
75
+ content: "Confirmed timeline and resource allocation. Jane will provide detailed specs by EOW."
76
+ )
77
+ puts " āœ… Created follow-up note"
78
+
79
+ # List all notes for the person
80
+ notes = client.notes.list(
81
+ parent_object: "people",
82
+ parent_record_id: person_id
83
+ )
84
+ puts "\n šŸ“‹ Notes for Jane Smith:"
85
+ notes["data"].each do |note|
86
+ puts " - #{note['title']} (created: #{note['created_at']})"
87
+ end
88
+
89
+ # Working with Tasks
90
+ puts "\nāœ… Creating Tasks:"
91
+
92
+ # Create tasks based on action items
93
+ task1 = client.tasks.create(
94
+ parent_object: "people",
95
+ parent_record_id: person_id,
96
+ title: "Create detailed specs for Feature A",
97
+ description: "Write comprehensive specifications for the user authentication improvements",
98
+ due_date: (Date.today + 7).iso8601,
99
+ priority: 1,
100
+ status: "pending"
101
+ )
102
+ puts " āœ… Created task: #{task1['data']['title']}"
103
+
104
+ task2 = client.tasks.create(
105
+ parent_object: "people",
106
+ parent_record_id: person_id,
107
+ title: "Schedule design review for Feature B",
108
+ description: "Coordinate with design team and schedule review meeting",
109
+ due_date: (Date.today + 3).iso8601,
110
+ priority: 2,
111
+ status: "pending"
112
+ )
113
+ puts " āœ… Created task: #{task2['data']['title']}"
114
+
115
+ task3 = client.tasks.create(
116
+ parent_object: "people",
117
+ parent_record_id: person_id,
118
+ title: "Allocate resources for API development",
119
+ description: "Determine team members and timeline for API v2",
120
+ due_date: (Date.today + 10).iso8601,
121
+ priority: 3,
122
+ status: "pending"
123
+ )
124
+ puts " āœ… Created task: #{task3['data']['title']}"
125
+
126
+ # List pending tasks
127
+ puts "\n šŸ“‹ Pending Tasks:"
128
+ pending_tasks = client.tasks.list(
129
+ status: "pending",
130
+ parent_object: "people",
131
+ parent_record_id: person_id
132
+ )
133
+ pending_tasks["data"].each do |task|
134
+ puts " - #{task['title']} (due: #{task['due_date']})"
135
+ end
136
+
137
+ # Complete a task
138
+ puts "\n āœ… Completing task..."
139
+ completed_task = client.tasks.complete(
140
+ id: task2["data"]["id"],
141
+ completed_at: DateTime.now.iso8601
142
+ )
143
+ puts " Task completed: #{completed_task['data']['title']}"
144
+
145
+ # Update a task
146
+ puts "\n šŸ“ Updating task..."
147
+ updated_task = client.tasks.update(
148
+ id: task1["data"]["id"],
149
+ description: "Updated: Include security requirements in the specifications",
150
+ priority: 1
151
+ )
152
+ puts " Task updated: #{updated_task['data']['title']}"
153
+
154
+ # Get task details
155
+ puts "\n šŸ” Task Details:"
156
+ task_detail = client.tasks.get(id: task1["data"]["id"])
157
+ puts " Title: #{task_detail['data']['title']}"
158
+ puts " Description: #{task_detail['data']['description']}"
159
+ puts " Due Date: #{task_detail['data']['due_date']}"
160
+ puts " Status: #{task_detail['data']['status']}"
161
+
162
+ # Update a note
163
+ puts "\n šŸ“ Updating note..."
164
+ client.notes.update(
165
+ id: followup_note["data"]["id"],
166
+ content: "Updated: Specs delivered. Design review scheduled for Friday."
167
+ )
168
+ puts " Note updated successfully"
169
+
170
+ # Cleanup
171
+ puts "\n🧹 Cleaning up..."
172
+
173
+ # Delete tasks
174
+ [task1, task2, task3].each do |task|
175
+ client.tasks.delete(id: task["data"]["id"])
176
+ end
177
+ puts " āœ… Deleted tasks"
178
+
179
+ # Delete notes
180
+ [meeting_note, followup_note].each do |note|
181
+ client.notes.delete(id: note["data"]["id"])
182
+ end
183
+ puts " āœ… Deleted notes"
184
+
185
+ # Delete test person
186
+ client.records.delete(object: "people", id: person_id)
187
+ puts " āœ… Deleted test person"
188
+ rescue Attio::Error => e
189
+ puts "āŒ Error: #{e.message}"
190
+ # Cleanup on error
191
+ if defined?(person_id)
192
+ begin
193
+ client.records.delete(object: "people", id: person_id)
194
+ rescue StandardError
195
+ nil
196
+ end
197
+ end
198
+ end
199
+
200
+ puts "\n✨ Example completed!"
data/lib/attio/client.rb CHANGED
@@ -116,5 +116,91 @@ module Attio
116
116
  def users
117
117
  @users ||= Resources::Users.new(self)
118
118
  end
119
+
120
+ # Access to the Notes API resource.
121
+ #
122
+ # @return [Resources::Notes] Notes resource instance
123
+ # @example
124
+ # notes = client.notes.list(parent_object: 'people', parent_record_id: 'rec_123')
125
+ def notes
126
+ @notes ||= Resources::Notes.new(self)
127
+ end
128
+
129
+ # Access to the Tasks API resource.
130
+ #
131
+ # @return [Resources::Tasks] Tasks resource instance
132
+ # @example
133
+ # tasks = client.tasks.list(status: 'pending')
134
+ def tasks
135
+ @tasks ||= Resources::Tasks.new(self)
136
+ end
137
+
138
+ # Access to the Comments API resource.
139
+ #
140
+ # @return [Resources::Comments] Comments resource instance
141
+ # @example
142
+ # comments = client.comments.list(thread_id: 'thread_123')
143
+ def comments
144
+ @comments ||= Resources::Comments.new(self)
145
+ end
146
+
147
+ # Access to the Threads API resource.
148
+ #
149
+ # @return [Resources::Threads] Threads resource instance
150
+ # @example
151
+ # threads = client.threads.list(parent_object: 'companies', parent_record_id: 'rec_456')
152
+ def threads
153
+ @threads ||= Resources::Threads.new(self)
154
+ end
155
+
156
+ # Access to the Workspace Members API resource.
157
+ #
158
+ # @return [Resources::WorkspaceMembers] Workspace Members resource instance
159
+ # @example
160
+ # members = client.workspace_members.list
161
+ def workspace_members
162
+ @workspace_members ||= Resources::WorkspaceMembers.new(self)
163
+ end
164
+
165
+ # Access to the Deals API resource.
166
+ #
167
+ # @return [Resources::Deals] Deals resource instance
168
+ # @example
169
+ # deals = client.deals.list
170
+ def deals
171
+ @deals ||= Resources::Deals.new(self)
172
+ end
173
+
174
+ # Access to the Meta API resource.
175
+ #
176
+ # @return [Resources::Meta] Meta resource instance
177
+ # @example
178
+ # info = client.meta.identify
179
+ def meta
180
+ @meta ||= Resources::Meta.new(self)
181
+ end
182
+
183
+ # Access to the Bulk Operations API resource.
184
+ #
185
+ # @return [Resources::Bulk] Bulk operations resource instance
186
+ # @example
187
+ # client.bulk.create_records(object: 'companies', records: [...])
188
+ def bulk
189
+ @bulk ||= Resources::Bulk.new(self)
190
+ end
191
+
192
+ # Get or set the rate limiter for this client.
193
+ #
194
+ # @param limiter [RateLimiter] Optional rate limiter to set
195
+ # @return [RateLimiter] The rate limiter instance
196
+ # @example
197
+ # client.rate_limiter = Attio::RateLimiter.new(max_requests: 100)
198
+ def rate_limiter(limiter = nil)
199
+ if limiter
200
+ @rate_limiter = limiter
201
+ else
202
+ @rate_limiter ||= RateLimiter.new
203
+ end
204
+ end
119
205
  end
120
206
  end
data/lib/attio/errors.rb CHANGED
@@ -1,11 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Attio
4
- class Error < StandardError; end
4
+ # Base error class for all Attio errors
5
+ class Error < StandardError
6
+ attr_reader :response, :code
5
7
 
8
+ def initialize(message = nil, response: nil, code: nil)
9
+ @response = response
10
+ @code = code
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ # Raised when authentication fails (401)
6
16
  class AuthenticationError < Error; end
17
+
18
+ # Raised when a resource is not found (404)
7
19
  class NotFoundError < Error; end
20
+
21
+ # Raised when validation fails (400/422)
8
22
  class ValidationError < Error; end
9
- class RateLimitError < Error; end
23
+
24
+ # Raised when rate limit is exceeded (429)
25
+ class RateLimitError < Error
26
+ attr_reader :retry_after
27
+
28
+ def initialize(message = nil, retry_after: nil, **options)
29
+ @retry_after = retry_after
30
+ super(message, **options)
31
+ end
32
+ end
33
+
34
+ # Raised when server error occurs (5xx)
10
35
  class ServerError < Error; end
36
+
37
+ # Raised for generic API errors
38
+ class APIError < Error; end
11
39
  end
@@ -21,8 +21,12 @@ module Attio
21
21
  @timeout = timeout
22
22
  end
23
23
 
24
- def get(path, params = {})
25
- execute_request(:get, path, params: params)
24
+ def get(path, params = nil)
25
+ if params && !params.empty?
26
+ execute_request(:get, path, params: params)
27
+ else
28
+ execute_request(:get, path)
29
+ end
26
30
  end
27
31
 
28
32
  def post(path, body = {})
@@ -37,8 +41,12 @@ module Attio
37
41
  execute_request(:put, path, body: body.to_json)
38
42
  end
39
43
 
40
- def delete(path)
41
- execute_request(:delete, path)
44
+ def delete(path, params = nil)
45
+ if params
46
+ execute_request(:delete, path, body: params.to_json)
47
+ else
48
+ execute_request(:delete, path)
49
+ end
42
50
  end
43
51
 
44
52
  private def execute_request(method, path, options = {})
@@ -49,6 +57,10 @@ module Attio
49
57
  headers: headers.merge("Content-Type" => "application/json"),
50
58
  timeout: timeout,
51
59
  connecttimeout: timeout,
60
+ # SSL/TLS security settings
61
+ ssl_verifypeer: true,
62
+ ssl_verifyhost: 2,
63
+ followlocation: false, # Prevent following redirects for security
52
64
  }.merge(options)
53
65
 
54
66
  request = Typhoeus::Request.new(url, request_options)
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ # Rate limiter with intelligent retry and backoff strategies
5
+ #
6
+ # @example Using the rate limiter
7
+ # limiter = Attio::RateLimiter.new(
8
+ # max_requests: 100,
9
+ # window_seconds: 60,
10
+ # max_retries: 3
11
+ # )
12
+ #
13
+ # limiter.execute { client.records.list }
14
+ class RateLimiter
15
+ attr_reader :max_requests, :window_seconds, :max_retries
16
+ attr_accessor :current_limit, :remaining, :reset_at
17
+
18
+ # Initialize a new rate limiter
19
+ #
20
+ # @param max_requests [Integer] Maximum requests per window
21
+ # @param window_seconds [Integer] Time window in seconds
22
+ # @param max_retries [Integer] Maximum retry attempts
23
+ # @param enable_jitter [Boolean] Add jitter to backoff delays
24
+ def initialize(max_requests: 1000, window_seconds: 3600, max_retries: 3, enable_jitter: true)
25
+ @max_requests = max_requests
26
+ @window_seconds = window_seconds
27
+ @max_retries = max_retries
28
+ @enable_jitter = enable_jitter
29
+
30
+ @current_limit = max_requests
31
+ @remaining = max_requests
32
+ @reset_at = Time.now + window_seconds
33
+
34
+ @mutex = Mutex.new
35
+ @request_queue = []
36
+ @request_times = []
37
+ end
38
+
39
+ # Execute a block with rate limiting
40
+ #
41
+ # @yield The block to execute
42
+ # @return The result of the block
43
+ def execute
44
+ raise ArgumentError, "Block required" unless block_given?
45
+
46
+ @mutex.synchronize do
47
+ wait_if_needed
48
+ track_request
49
+ end
50
+
51
+ attempt = 0
52
+ begin
53
+ result = yield
54
+ # Thread-safe header update
55
+ @mutex.synchronize do
56
+ update_from_headers(result) if result.is_a?(Hash) && result["_headers"]
57
+ end
58
+ result
59
+ rescue Attio::RateLimitError => e
60
+ attempt += 1
61
+ raise e unless attempt <= @max_retries
62
+
63
+ wait_time = calculate_backoff(attempt, e)
64
+ sleep(wait_time)
65
+ retry
66
+ end
67
+ end
68
+
69
+ # Check if rate limit is exceeded
70
+ #
71
+ # @return [Boolean] True if rate limit would be exceeded
72
+ def rate_limited?
73
+ @mutex.synchronize do
74
+ cleanup_old_requests
75
+ @request_times.size >= @max_requests
76
+ end
77
+ end
78
+
79
+ # Get current rate limit status
80
+ #
81
+ # @return [Hash] Current status
82
+ def status
83
+ @mutex.synchronize do
84
+ cleanup_old_requests
85
+ {
86
+ limit: @current_limit,
87
+ remaining: [@remaining, @max_requests - @request_times.size].min,
88
+ reset_at: @reset_at,
89
+ reset_in: [@reset_at - Time.now, 0].max.to_i,
90
+ current_usage: @request_times.size,
91
+ }
92
+ end
93
+ end
94
+
95
+ # Update rate limit info from response headers
96
+ # NOTE: This method should be called within a mutex lock
97
+ #
98
+ # @param response [Hash] Response containing headers
99
+ private def update_from_headers(response)
100
+ return unless response.is_a?(Hash)
101
+
102
+ headers = response["_headers"] || {}
103
+
104
+ @current_limit = headers["x-ratelimit-limit"].to_i if headers["x-ratelimit-limit"]
105
+ @remaining = headers["x-ratelimit-remaining"].to_i if headers["x-ratelimit-remaining"]
106
+ @reset_at = Time.at(headers["x-ratelimit-reset"].to_i) if headers["x-ratelimit-reset"]
107
+ end
108
+
109
+ # Reset the rate limiter
110
+ def reset!
111
+ @mutex.synchronize do
112
+ @request_times.clear
113
+ @remaining = @max_requests
114
+ @reset_at = Time.now + @window_seconds
115
+ end
116
+ end
117
+
118
+ # Queue a request for later execution
119
+ #
120
+ # @param priority [Integer] Priority (lower = higher priority)
121
+ # @yield Block to execute
122
+ def queue_request(priority: 5, &block)
123
+ @mutex.synchronize do
124
+ @request_queue << { priority: priority, block: block, queued_at: Time.now }
125
+ @request_queue.sort_by! { |r| [r[:priority], r[:queued_at]] }
126
+ end
127
+ end
128
+
129
+ # Process queued requests
130
+ #
131
+ # @param max_per_batch [Integer] Maximum requests to process
132
+ # @return [Array] Results from processed requests
133
+ def process_queue(max_per_batch: 10)
134
+ results = []
135
+ processed = 0
136
+
137
+ while processed < max_per_batch
138
+ request = @mutex.synchronize { @request_queue.shift }
139
+ break unless request
140
+
141
+ begin
142
+ result = execute(&request[:block])
143
+ results << { success: true, result: result }
144
+ rescue StandardError => e
145
+ results << { success: false, error: e }
146
+ end
147
+
148
+ processed += 1
149
+ end
150
+
151
+ results
152
+ end
153
+
154
+ private def wait_if_needed
155
+ cleanup_old_requests
156
+
157
+ if @request_times.size >= @max_requests
158
+ wait_time = @request_times.first + @window_seconds - Time.now
159
+ if wait_time > 0
160
+ sleep(wait_time)
161
+ cleanup_old_requests
162
+ end
163
+ end
164
+
165
+ return unless @remaining <= 0 && @reset_at > Time.now
166
+
167
+ wait_time = @reset_at - Time.now
168
+ sleep(wait_time) if wait_time > 0
169
+ end
170
+
171
+ private def track_request
172
+ @request_times << Time.now
173
+ @remaining = [@remaining - 1, 0].max
174
+ end
175
+
176
+ private def cleanup_old_requests
177
+ cutoff = Time.now - @window_seconds
178
+ @request_times.reject! { |time| time < cutoff }
179
+ end
180
+
181
+ private def calculate_backoff(attempt, error = nil)
182
+ base_wait = 2**attempt
183
+
184
+ # Use server-provided retry-after if available
185
+ base_wait = error.retry_after if error && error.respond_to?(:retry_after) && error.retry_after
186
+
187
+ # Add jitter to prevent thundering herd
188
+ if @enable_jitter
189
+ jitter = rand * base_wait * 0.1
190
+ base_wait + jitter
191
+ else
192
+ base_wait
193
+ end
194
+ end
195
+ end
196
+
197
+ # Middleware for automatic rate limiting
198
+ class RateLimitMiddleware
199
+ def initialize(app, rate_limiter)
200
+ @app = app
201
+ @rate_limiter = rate_limiter
202
+ end
203
+
204
+ def call(env)
205
+ @rate_limiter.execute do
206
+ response = @app.call(env)
207
+ # Headers are automatically updated within execute block
208
+ response
209
+ end
210
+ end
211
+ end
212
+ end
@@ -24,22 +24,86 @@ module Attio
24
24
  @client = client
25
25
  end
26
26
 
27
- private def request(method, path, params = {})
27
+ # Common validation methods that can be used by all resource classes
28
+
29
+ # Validates that an ID parameter is present and not empty
30
+ # @param id [String] The ID to validate
31
+ # @param resource_name [String] The resource name for the error message
32
+ # @raise [ArgumentError] if id is nil or empty
33
+ private def validate_id!(id, resource_name = "Resource")
34
+ return unless id.nil? || id.to_s.strip.empty?
35
+
36
+ raise ArgumentError, "#{resource_name} ID is required"
37
+ end
38
+
39
+ # Validates that data is not empty
40
+ # @param data [Hash] The data to validate
41
+ # @param operation [String] The operation name for the error message
42
+ # @raise [ArgumentError] if data is empty
43
+ private def validate_data!(data, operation = "Operation")
44
+ raise ArgumentError, "#{operation} data is required" if data.nil? || data.empty?
45
+ end
46
+
47
+ # Validates that a string parameter is present and not empty
48
+ # @param value [String] The value to validate
49
+ # @param field_name [String] The field name for the error message
50
+ # @raise [ArgumentError] if value is nil or empty
51
+ private def validate_required_string!(value, field_name)
52
+ return unless value.nil? || value.to_s.strip.empty?
53
+
54
+ raise ArgumentError, "#{field_name} is required"
55
+ end
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
+
67
+ # Validates parent object and record ID together
68
+ # @param parent_object [String] The parent object type
69
+ # @param parent_record_id [String] The parent record ID
70
+ # @raise [ArgumentError] if either is missing
71
+ private def validate_parent!(parent_object, parent_record_id)
72
+ validate_required_string!(parent_object, "Parent object")
73
+ validate_required_string!(parent_record_id, "Parent record ID")
74
+ end
75
+
76
+ private def request(method, path, params = {}, _headers = {})
77
+ # Path is already safely constructed by the resource methods
78
+ connection = client.connection
79
+
28
80
  case method
29
81
  when :get
30
- client.connection.get(path, params)
82
+ handle_get_request(connection, path, params)
31
83
  when :post
32
- client.connection.post(path, params)
84
+ handle_post_request(connection, path, params)
33
85
  when :patch
34
- client.connection.patch(path, params)
86
+ connection.patch(path, params)
35
87
  when :put
36
- client.connection.put(path, params)
88
+ connection.put(path, params)
37
89
  when :delete
38
- client.connection.delete(path)
90
+ handle_delete_request(connection, path, params)
39
91
  else
40
92
  raise ArgumentError, "Unsupported HTTP method: #{method}"
41
93
  end
42
94
  end
95
+
96
+ private def handle_get_request(connection, path, params)
97
+ params.empty? ? connection.get(path) : connection.get(path, params)
98
+ end
99
+
100
+ private def handle_post_request(connection, path, params)
101
+ params.empty? ? connection.post(path) : connection.post(path, params)
102
+ end
103
+
104
+ private def handle_delete_request(connection, path, params)
105
+ params.empty? ? connection.delete(path) : connection.delete(path, params)
106
+ end
43
107
  end
44
108
  end
45
109
  end