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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +3 -51
- data/CHANGELOG.md +62 -1
- data/CLAUDE.md +360 -0
- data/CONCEPTS.md +428 -0
- data/Gemfile.lock +3 -1
- data/README.md +304 -7
- data/examples/advanced_filtering.rb +178 -0
- data/examples/basic_usage.rb +110 -0
- data/examples/collaboration_example.rb +173 -0
- data/examples/full_workflow.rb +348 -0
- data/examples/notes_and_tasks.rb +200 -0
- data/lib/attio/client.rb +86 -0
- data/lib/attio/errors.rb +30 -2
- data/lib/attio/http_client.rb +16 -4
- data/lib/attio/rate_limiter.rb +212 -0
- data/lib/attio/resources/base.rb +70 -6
- data/lib/attio/resources/bulk.rb +290 -0
- data/lib/attio/resources/comments.rb +147 -0
- data/lib/attio/resources/deals.rb +183 -0
- data/lib/attio/resources/lists.rb +9 -21
- data/lib/attio/resources/meta.rb +72 -0
- data/lib/attio/resources/notes.rb +110 -0
- data/lib/attio/resources/records.rb +11 -24
- data/lib/attio/resources/tasks.rb +131 -0
- data/lib/attio/resources/threads.rb +154 -0
- data/lib/attio/resources/workspace_members.rb +103 -0
- data/lib/attio/version.rb +1 -1
- data/lib/attio.rb +9 -0
- metadata +17 -1
@@ -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
|
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
|
-
|
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
|
data/lib/attio/http_client.rb
CHANGED
@@ -21,8 +21,12 @@ module Attio
|
|
21
21
|
@timeout = timeout
|
22
22
|
end
|
23
23
|
|
24
|
-
def get(path, params =
|
25
|
-
|
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
|
-
|
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
|
data/lib/attio/resources/base.rb
CHANGED
@@ -24,22 +24,86 @@ module Attio
|
|
24
24
|
@client = client
|
25
25
|
end
|
26
26
|
|
27
|
-
|
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
|
-
|
82
|
+
handle_get_request(connection, path, params)
|
31
83
|
when :post
|
32
|
-
|
84
|
+
handle_post_request(connection, path, params)
|
33
85
|
when :patch
|
34
|
-
|
86
|
+
connection.patch(path, params)
|
35
87
|
when :put
|
36
|
-
|
88
|
+
connection.put(path, params)
|
37
89
|
when :delete
|
38
|
-
|
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
|