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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +164 -0
- data/.simplecov +17 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +333 -0
- data/INTEGRATION_TEST_STATUS.md +149 -0
- data/LICENSE +21 -0
- data/README.md +638 -0
- data/Rakefile +8 -0
- data/attio-ruby.gemspec +61 -0
- data/docs/CODECOV_SETUP.md +34 -0
- data/examples/basic_usage.rb +149 -0
- data/examples/oauth_flow.rb +843 -0
- data/examples/oauth_flow_README.md +84 -0
- data/examples/typed_records_example.rb +167 -0
- data/examples/webhook_server.rb +463 -0
- data/lib/attio/api_resource.rb +539 -0
- data/lib/attio/builders/name_builder.rb +181 -0
- data/lib/attio/client.rb +160 -0
- data/lib/attio/errors.rb +126 -0
- data/lib/attio/internal/record.rb +359 -0
- data/lib/attio/oauth/client.rb +219 -0
- data/lib/attio/oauth/scope_validator.rb +162 -0
- data/lib/attio/oauth/token.rb +158 -0
- data/lib/attio/resources/attribute.rb +332 -0
- data/lib/attio/resources/comment.rb +114 -0
- data/lib/attio/resources/company.rb +224 -0
- data/lib/attio/resources/entry.rb +208 -0
- data/lib/attio/resources/list.rb +196 -0
- data/lib/attio/resources/meta.rb +113 -0
- data/lib/attio/resources/note.rb +213 -0
- data/lib/attio/resources/object.rb +66 -0
- data/lib/attio/resources/person.rb +294 -0
- data/lib/attio/resources/task.rb +147 -0
- data/lib/attio/resources/thread.rb +99 -0
- data/lib/attio/resources/typed_record.rb +98 -0
- data/lib/attio/resources/webhook.rb +224 -0
- data/lib/attio/resources/workspace_member.rb +136 -0
- data/lib/attio/util/configuration.rb +166 -0
- data/lib/attio/util/id_extractor.rb +115 -0
- data/lib/attio/util/webhook_signature.rb +175 -0
- data/lib/attio/version.rb +6 -0
- data/lib/attio/webhook/event.rb +114 -0
- data/lib/attio/webhook/signature_verifier.rb +73 -0
- data/lib/attio.rb +123 -0
- metadata +402 -0
@@ -0,0 +1,843 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "attio"
|
6
|
+
require "sinatra"
|
7
|
+
require "dotenv/load"
|
8
|
+
require "securerandom"
|
9
|
+
|
10
|
+
# OAuth flow example using Sinatra
|
11
|
+
|
12
|
+
class OAuthApp < Sinatra::Base
|
13
|
+
# Configure for development
|
14
|
+
configure do
|
15
|
+
set :protection, true
|
16
|
+
set :host_authorization, {
|
17
|
+
permitted_hosts: [] # Allow any hostname
|
18
|
+
}
|
19
|
+
set :static, false
|
20
|
+
set :session_secret, "a" * 64 # Simple secret for development
|
21
|
+
set :environment, :development
|
22
|
+
set :show_exceptions, true
|
23
|
+
end
|
24
|
+
# OAuth client configuration
|
25
|
+
def oauth_client(request = nil)
|
26
|
+
# Always use the ngrok URL from environment
|
27
|
+
redirect_uri = ENV.fetch("ATTIO_REDIRECT_URI", "http://localhost:4567/callback")
|
28
|
+
|
29
|
+
Attio::OAuth::Client.new(
|
30
|
+
client_id: ENV.fetch("ATTIO_CLIENT_ID", nil),
|
31
|
+
client_secret: ENV.fetch("ATTIO_CLIENT_SECRET", nil),
|
32
|
+
redirect_uri: redirect_uri
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Store tokens in memory (use a proper store in production)
|
37
|
+
@@token_store = {}
|
38
|
+
|
39
|
+
# Add ngrok warning bypass for all routes
|
40
|
+
before do
|
41
|
+
headers["ngrok-skip-browser-warning"] = "true"
|
42
|
+
end
|
43
|
+
|
44
|
+
get "/" do
|
45
|
+
<<~HTML
|
46
|
+
<h1>Attio OAuth Example</h1>
|
47
|
+
<p>This example demonstrates the OAuth 2.0 flow for Attio.</p>
|
48
|
+
<a href="/auth">Connect to Attio</a>
|
49
|
+
<hr>
|
50
|
+
#{if @@token_store[:access_token]
|
51
|
+
"<p>✅ Connected!</p>
|
52
|
+
<h3>API Tests</h3>
|
53
|
+
<p>
|
54
|
+
<a href='/test'>Basic Test</a> |
|
55
|
+
<a href='/test-all'>Comprehensive Test</a> |
|
56
|
+
<a href='/test-note'>Test Note Creation</a>
|
57
|
+
</p>
|
58
|
+
<h3>Token Management</h3>
|
59
|
+
<p>
|
60
|
+
<a href='/introspect'>Token Info</a> |
|
61
|
+
<a href='/revoke'>Revoke Token</a> |
|
62
|
+
<a href='/logout'>Logout</a>
|
63
|
+
</p>"
|
64
|
+
else
|
65
|
+
"<p>❌ Not connected</p>"
|
66
|
+
end}
|
67
|
+
HTML
|
68
|
+
end
|
69
|
+
|
70
|
+
# Step 1: Redirect to Attio authorization page
|
71
|
+
get "/auth" do
|
72
|
+
client = oauth_client(request)
|
73
|
+
|
74
|
+
puts "\n=== AUTH START DEBUG ==="
|
75
|
+
puts "Redirect URI configured: #{ENV.fetch("ATTIO_REDIRECT_URI", "NOT SET")}"
|
76
|
+
puts "Client ID: #{ENV.fetch("ATTIO_CLIENT_ID", "NOT SET")}"
|
77
|
+
puts "Request host: #{request.host}"
|
78
|
+
puts "Request scheme: #{request.scheme}"
|
79
|
+
|
80
|
+
auth_data = client.authorization_url(
|
81
|
+
scopes: %w[record:read record:write user:read],
|
82
|
+
state: SecureRandom.hex(16)
|
83
|
+
)
|
84
|
+
|
85
|
+
# Store state for verification (use session in production)
|
86
|
+
@@token_store[:state] = auth_data[:state]
|
87
|
+
|
88
|
+
puts "Generated state: #{auth_data[:state]}"
|
89
|
+
puts "Authorization URL: #{auth_data[:url]}"
|
90
|
+
puts "===================\n"
|
91
|
+
|
92
|
+
redirect auth_data[:url]
|
93
|
+
end
|
94
|
+
|
95
|
+
# Step 2: Handle callback from Attio
|
96
|
+
get "/callback" do
|
97
|
+
# Log all callback parameters
|
98
|
+
puts "\n=== CALLBACK DEBUG ==="
|
99
|
+
puts "All params: #{params.inspect}"
|
100
|
+
puts "Request URL: #{request.url}"
|
101
|
+
puts "Request host: #{request.host}"
|
102
|
+
puts "Request scheme: #{request.scheme}"
|
103
|
+
puts "Stored state: #{@@token_store[:state]}"
|
104
|
+
puts "Received state: #{params[:state]}"
|
105
|
+
puts "Authorization code: #{params[:code]}"
|
106
|
+
puts "Error: #{params[:error]}" if params[:error]
|
107
|
+
puts "Error description: #{params[:error_description]}" if params[:error_description]
|
108
|
+
|
109
|
+
# Verify state parameter
|
110
|
+
if params[:state] != @@token_store[:state]
|
111
|
+
puts "STATE MISMATCH! Expected: #{@@token_store[:state]}, Got: #{params[:state]}"
|
112
|
+
return "Error: Invalid state parameter"
|
113
|
+
end
|
114
|
+
|
115
|
+
# Check for errors
|
116
|
+
return "Error: #{params[:error]} - #{params[:error_description]}" if params[:error]
|
117
|
+
|
118
|
+
# Exchange authorization code for token
|
119
|
+
begin
|
120
|
+
client = oauth_client(request)
|
121
|
+
puts "\n=== OAUTH CLIENT DEBUG ==="
|
122
|
+
puts "Client ID: #{ENV.fetch("ATTIO_CLIENT_ID", "NOT SET")}"
|
123
|
+
puts "Client Secret: #{ENV.fetch("ATTIO_CLIENT_SECRET", "NOT SET")[0..5]}..." if ENV["ATTIO_CLIENT_SECRET"]
|
124
|
+
puts "Redirect URI being used: #{ENV.fetch("ATTIO_REDIRECT_URI", "NOT SET")}"
|
125
|
+
puts "Code being exchanged: #{params[:code]}"
|
126
|
+
|
127
|
+
token = client.exchange_code_for_token(code: params[:code])
|
128
|
+
|
129
|
+
# Store token (use secure storage in production)
|
130
|
+
@@token_store[:access_token] = token.access_token
|
131
|
+
@@token_store[:refresh_token] = token.refresh_token
|
132
|
+
@@token_store[:expires_at] = token.expires_at
|
133
|
+
|
134
|
+
puts "\n=== TOKEN RECEIVED ==="
|
135
|
+
puts "Access token: #{token.access_token[0..20]}..."
|
136
|
+
puts "Token scopes: #{token.scope.inspect}"
|
137
|
+
puts "Token scope class: #{token.scope.class}"
|
138
|
+
puts "Raw token data: #{token.to_h.inspect}"
|
139
|
+
puts "=====================\n"
|
140
|
+
|
141
|
+
<<~HTML
|
142
|
+
<h1>Success!</h1>
|
143
|
+
<p>Successfully connected to Attio.</p>
|
144
|
+
<p>Access token: #{token.access_token[0..10]}...</p>
|
145
|
+
<p>Expires at: #{token.expires_at}</p>
|
146
|
+
<p>Scopes: #{if token.scope.nil?
|
147
|
+
"All authorized scopes (not specified in token)"
|
148
|
+
elsif token.scope.is_a?(Array)
|
149
|
+
token.scope.empty? ? "None" : token.scope.join(", ")
|
150
|
+
else
|
151
|
+
token.scope
|
152
|
+
end}</p>
|
153
|
+
<h3>What's Next?</h3>
|
154
|
+
<p>
|
155
|
+
<a href="/test">Run Basic Test</a> |
|
156
|
+
<a href="/test-all">Run Comprehensive Test</a> |
|
157
|
+
<a href="/introspect">View Token Info</a>
|
158
|
+
</p>
|
159
|
+
<p><a href="/">← Back to Home</a></p>
|
160
|
+
HTML
|
161
|
+
rescue => e
|
162
|
+
puts "\n=== OAUTH ERROR DETAILS ==="
|
163
|
+
puts "Error class: #{e.class}"
|
164
|
+
puts "Error message: #{e.message}"
|
165
|
+
puts "Error backtrace:"
|
166
|
+
puts e.backtrace[0..5].join("\n")
|
167
|
+
|
168
|
+
# If it's an HTTP error, try to get more details
|
169
|
+
if e.respond_to?(:response)
|
170
|
+
puts "\nHTTP Response details:"
|
171
|
+
puts "Status: #{e.response[:status] if e.response.is_a?(Hash)}"
|
172
|
+
puts "Body: #{e.response[:body] if e.response.is_a?(Hash)}"
|
173
|
+
end
|
174
|
+
|
175
|
+
<<~HTML
|
176
|
+
<h1>OAuth Error</h1>
|
177
|
+
<p><strong>Error:</strong> #{e.class} - #{e.message}</p>
|
178
|
+
<p>Check the server logs for detailed debugging information.</p>
|
179
|
+
<pre>#{e.backtrace[0..5].join("\n")}</pre>
|
180
|
+
HTML
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Test API access with the token
|
185
|
+
get "/test" do
|
186
|
+
unless @@token_store[:access_token]
|
187
|
+
redirect "/"
|
188
|
+
return
|
189
|
+
end
|
190
|
+
|
191
|
+
# Configure Attio with the OAuth token
|
192
|
+
Attio.configure do |config|
|
193
|
+
config.api_key = @@token_store[:access_token]
|
194
|
+
end
|
195
|
+
|
196
|
+
begin
|
197
|
+
puts "\n=== API TEST DEBUG ==="
|
198
|
+
puts "Using access token: #{@@token_store[:access_token][0..20]}..."
|
199
|
+
puts "Token stored at: #{@@token_store[:expires_at]}"
|
200
|
+
|
201
|
+
results = {}
|
202
|
+
errors = {}
|
203
|
+
|
204
|
+
# Test 1: Get current user/workspace info
|
205
|
+
begin
|
206
|
+
me = Attio::Meta.identify
|
207
|
+
results[:meta] = {workspace: me.workspace_name, token_type: me.token_type}
|
208
|
+
puts "✓ Meta.identify successful"
|
209
|
+
rescue => e
|
210
|
+
errors[:meta] = e.message
|
211
|
+
puts "✗ Meta.identify failed: #{e.message}"
|
212
|
+
end
|
213
|
+
|
214
|
+
# Test 2: List Objects
|
215
|
+
begin
|
216
|
+
objects = Attio::Object.list(limit: 3)
|
217
|
+
results[:objects] = objects.map { |o| o[:api_slug] }
|
218
|
+
puts "✓ Object.list successful (#{objects.count} objects)"
|
219
|
+
rescue => e
|
220
|
+
errors[:objects] = e.message
|
221
|
+
puts "✗ Object.list failed: #{e.message}"
|
222
|
+
end
|
223
|
+
|
224
|
+
# Test 3: List People Records
|
225
|
+
begin
|
226
|
+
people = Attio::Record.list(object: "people", limit: 3)
|
227
|
+
results[:people] = people.count
|
228
|
+
puts "✓ Record.list (people) successful (#{people.count} records)"
|
229
|
+
rescue => e
|
230
|
+
errors[:people] = e.message
|
231
|
+
puts "✗ Record.list (people) failed: #{e.message}"
|
232
|
+
end
|
233
|
+
|
234
|
+
# Test 4: List Companies Records
|
235
|
+
begin
|
236
|
+
companies = Attio::Record.list(object: "companies", limit: 3)
|
237
|
+
results[:companies] = companies.count
|
238
|
+
puts "✓ Record.list (companies) successful (#{companies.count} records)"
|
239
|
+
rescue => e
|
240
|
+
errors[:companies] = e.message
|
241
|
+
puts "✗ Record.list (companies) failed: #{e.message}"
|
242
|
+
end
|
243
|
+
|
244
|
+
# Test 5: List Lists
|
245
|
+
begin
|
246
|
+
lists = Attio::List.list(limit: 3)
|
247
|
+
results[:lists] = lists.map { |l| l[:name] }
|
248
|
+
puts "✓ List.list successful (#{lists.count} lists)"
|
249
|
+
rescue => e
|
250
|
+
errors[:lists] = e.message
|
251
|
+
puts "✗ List.list failed: #{e.message}"
|
252
|
+
end
|
253
|
+
|
254
|
+
# Test 6: List Workspace Members
|
255
|
+
begin
|
256
|
+
members = Attio::WorkspaceMember.list(limit: 3)
|
257
|
+
results[:members] = members.count
|
258
|
+
puts "✓ WorkspaceMember.list successful (#{members.count} members)"
|
259
|
+
rescue => e
|
260
|
+
errors[:members] = e.message
|
261
|
+
puts "✗ WorkspaceMember.list failed: #{e.message}"
|
262
|
+
end
|
263
|
+
|
264
|
+
# Test 7: List Webhooks
|
265
|
+
begin
|
266
|
+
webhooks = Attio::Webhook.list(limit: 3)
|
267
|
+
results[:webhooks] = webhooks.count
|
268
|
+
puts "✓ Webhook.list successful (#{webhooks.count} webhooks)"
|
269
|
+
rescue => e
|
270
|
+
errors[:webhooks] = e.message
|
271
|
+
puts "✗ Webhook.list failed: #{e.message}"
|
272
|
+
end
|
273
|
+
|
274
|
+
# Test 8: List Notes
|
275
|
+
begin
|
276
|
+
# Try to get notes for the first person if we have one
|
277
|
+
if results[:people] && results[:people] > 0 && people.first
|
278
|
+
notes = Attio::Note.list(
|
279
|
+
parent_object: "people",
|
280
|
+
parent_record_id: people.first.id["record_id"],
|
281
|
+
limit: 3
|
282
|
+
)
|
283
|
+
results[:notes] = notes.count
|
284
|
+
puts "✓ Note.list successful (#{notes.count} notes)"
|
285
|
+
else
|
286
|
+
results[:notes] = "No people to test with"
|
287
|
+
end
|
288
|
+
rescue => e
|
289
|
+
errors[:notes] = e.message
|
290
|
+
puts "✗ Note.list failed: #{e.message}"
|
291
|
+
end
|
292
|
+
|
293
|
+
puts "===================\n"
|
294
|
+
|
295
|
+
# Generate HTML results
|
296
|
+
html_results = results.map do |key, value|
|
297
|
+
status = errors[key] ? "❌" : "✅"
|
298
|
+
details = errors[key] || value.to_s
|
299
|
+
"<tr><td>#{status}</td><td>#{key.to_s.capitalize}</td><td>#{details}</td></tr>"
|
300
|
+
end.join("\n")
|
301
|
+
|
302
|
+
<<~HTML
|
303
|
+
<h1>Comprehensive API Test Results</h1>
|
304
|
+
#{if results[:meta]
|
305
|
+
"<h2>Workspace: #{results[:meta][:workspace]}</h2>"
|
306
|
+
else
|
307
|
+
"<h2>Could not retrieve workspace info</h2>"
|
308
|
+
end}
|
309
|
+
|
310
|
+
<table border="1" cellpadding="5" cellspacing="0">
|
311
|
+
<thead>
|
312
|
+
<tr>
|
313
|
+
<th>Status</th>
|
314
|
+
<th>Endpoint</th>
|
315
|
+
<th>Result</th>
|
316
|
+
</tr>
|
317
|
+
</thead>
|
318
|
+
<tbody>
|
319
|
+
#{html_results}
|
320
|
+
</tbody>
|
321
|
+
</table>
|
322
|
+
|
323
|
+
<p>Tests passed: #{results.count - errors.count} / #{results.count}</p>
|
324
|
+
|
325
|
+
<p><a href="/">Home</a> | <a href="/logout">Logout</a></p>
|
326
|
+
HTML
|
327
|
+
rescue Attio::PermissionError => e
|
328
|
+
puts "\n=== PERMISSION ERROR ==="
|
329
|
+
puts "Error: #{e.message}"
|
330
|
+
puts "This suggests the token doesn't have the required scopes"
|
331
|
+
puts "===================\n"
|
332
|
+
|
333
|
+
<<~HTML
|
334
|
+
<h1>Permission Error</h1>
|
335
|
+
<p>Error: #{e.message}</p>
|
336
|
+
<p>The access token doesn't have the required permissions.</p>
|
337
|
+
<p>This usually means the OAuth app needs the proper scopes configured in Attio.</p>
|
338
|
+
<p><a href="/">Home</a> | <a href="/logout">Logout</a></p>
|
339
|
+
HTML
|
340
|
+
rescue Attio::AuthenticationError
|
341
|
+
# Token might be expired, try to refresh
|
342
|
+
if @@token_store[:refresh_token]
|
343
|
+
begin
|
344
|
+
new_token = oauth_client(request).refresh_token(@@token_store[:refresh_token])
|
345
|
+
@@token_store[:access_token] = new_token.access_token
|
346
|
+
@@token_store[:refresh_token] = new_token.refresh_token if new_token.refresh_token
|
347
|
+
redirect "/test"
|
348
|
+
rescue
|
349
|
+
"Token refresh failed. <a href='/auth'>Re-authenticate</a> | <a href='/logout'>Logout</a>"
|
350
|
+
end
|
351
|
+
else
|
352
|
+
"Authentication failed. <a href='/auth'>Re-authenticate</a> | <a href='/logout'>Logout</a>"
|
353
|
+
end
|
354
|
+
rescue => e
|
355
|
+
"Error: #{e.message}"
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
# Comprehensive API test
|
360
|
+
get "/test-all" do
|
361
|
+
unless @@token_store[:access_token]
|
362
|
+
redirect "/"
|
363
|
+
return
|
364
|
+
end
|
365
|
+
|
366
|
+
Attio.configure do |config|
|
367
|
+
config.api_key = @@token_store[:access_token]
|
368
|
+
end
|
369
|
+
|
370
|
+
results = {}
|
371
|
+
errors = {}
|
372
|
+
created_resources = []
|
373
|
+
|
374
|
+
begin
|
375
|
+
# Get current user info first
|
376
|
+
me = Attio::Meta.identify
|
377
|
+
results[:meta] = {workspace: me.workspace_name, token_type: me.token_type}
|
378
|
+
|
379
|
+
# Test 1: List Objects
|
380
|
+
begin
|
381
|
+
objects = Attio::Object.list(limit: 3)
|
382
|
+
results[:list_objects] = "#{objects.count} objects"
|
383
|
+
puts "✓ Object.list successful"
|
384
|
+
rescue => e
|
385
|
+
errors[:list_objects] = e.message
|
386
|
+
puts "✗ Object.list failed: #{e.message}"
|
387
|
+
end
|
388
|
+
|
389
|
+
# Test 2: List Attributes
|
390
|
+
begin
|
391
|
+
attributes = Attio::Attribute.for_object("people", limit: 5)
|
392
|
+
results[:list_attributes] = "#{attributes.count} attributes"
|
393
|
+
puts "✓ Attribute.for_object successful"
|
394
|
+
rescue => e
|
395
|
+
errors[:list_attributes] = e.message
|
396
|
+
puts "✗ Attribute.for_object failed: #{e.message}"
|
397
|
+
end
|
398
|
+
|
399
|
+
# Test 3: Create a Person
|
400
|
+
person = nil
|
401
|
+
begin
|
402
|
+
person = Attio::Record.create(
|
403
|
+
object: "people",
|
404
|
+
values: {
|
405
|
+
name: [{
|
406
|
+
first_name: "OAuth",
|
407
|
+
last_name: "Test #{Time.now.to_i}",
|
408
|
+
full_name: "OAuth Test #{Time.now.to_i}"
|
409
|
+
}],
|
410
|
+
email_addresses: ["oauth-#{Time.now.to_i}@example.com"]
|
411
|
+
}
|
412
|
+
)
|
413
|
+
created_resources << {type: "person", id: person.id["record_id"]}
|
414
|
+
results[:create_person] = "Created person ID: #{person.id["record_id"][0..10]}..."
|
415
|
+
puts "✓ Record.create (person) successful"
|
416
|
+
rescue => e
|
417
|
+
errors[:create_person] = e.message
|
418
|
+
puts "✗ Record.create failed: #{e.message}"
|
419
|
+
end
|
420
|
+
|
421
|
+
# Test 4: Create a Note
|
422
|
+
if person
|
423
|
+
begin
|
424
|
+
puts "\nDEBUG Note.create:"
|
425
|
+
puts " person.id: #{person.id.inspect}"
|
426
|
+
puts " person.id['record_id']: #{person.id["record_id"].inspect}"
|
427
|
+
|
428
|
+
note_params = {
|
429
|
+
parent_object: "people",
|
430
|
+
parent_record_id: person.id["record_id"],
|
431
|
+
content: "Test note created via OAuth at #{Time.now}",
|
432
|
+
format: "plaintext"
|
433
|
+
}
|
434
|
+
puts " note_params: #{note_params.inspect}"
|
435
|
+
|
436
|
+
Attio::Note.create(note_params)
|
437
|
+
results[:create_note] = "Created note on person"
|
438
|
+
puts "✓ Note.create successful"
|
439
|
+
rescue => e
|
440
|
+
errors[:create_note] = e.message
|
441
|
+
puts "✗ Note.create failed: #{e.message}"
|
442
|
+
puts " Error class: #{e.class}"
|
443
|
+
puts " Error backtrace: #{e.backtrace[0..2].join("\n ")}" if e.backtrace
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
# Test 5: List and Create Tasks
|
448
|
+
begin
|
449
|
+
# Create a task
|
450
|
+
task = Attio::Task.create(
|
451
|
+
content: "OAuth test task - #{Time.now}",
|
452
|
+
deadline_at: Time.now + 86400
|
453
|
+
)
|
454
|
+
created_resources << {type: "task", id: task.id}
|
455
|
+
results[:create_task] = "Created task"
|
456
|
+
|
457
|
+
# List tasks
|
458
|
+
tasks = Attio::Task.list(limit: 5)
|
459
|
+
results[:list_tasks] = "#{tasks.count} tasks found"
|
460
|
+
puts "✓ Task operations successful"
|
461
|
+
rescue => e
|
462
|
+
errors[:task_ops] = e.message
|
463
|
+
puts "✗ Task operations failed: #{e.message}"
|
464
|
+
end
|
465
|
+
|
466
|
+
# Test 6: List Threads
|
467
|
+
threads = nil
|
468
|
+
begin
|
469
|
+
# Threads require either record_id or entry_id to query
|
470
|
+
if person
|
471
|
+
threads = Attio::Thread.list(
|
472
|
+
object: "people",
|
473
|
+
record_id: person.id["record_id"],
|
474
|
+
limit: 3
|
475
|
+
)
|
476
|
+
results[:list_threads] = "#{threads.count} threads for person"
|
477
|
+
puts "✓ Thread.list successful"
|
478
|
+
else
|
479
|
+
results[:list_threads] = "Skipped - no person to query"
|
480
|
+
end
|
481
|
+
rescue => e
|
482
|
+
errors[:list_threads] = e.message
|
483
|
+
puts "✗ Thread.list failed: #{e.message}"
|
484
|
+
end
|
485
|
+
|
486
|
+
# Test 7: Create Comment (if thread exists)
|
487
|
+
if threads&.first && me.actor
|
488
|
+
begin
|
489
|
+
Attio::Comment.create(
|
490
|
+
thread_id: threads.first.id,
|
491
|
+
content: "OAuth test comment - #{Time.now}",
|
492
|
+
author: {
|
493
|
+
type: "workspace-member",
|
494
|
+
id: me.actor["id"]
|
495
|
+
}
|
496
|
+
)
|
497
|
+
results[:create_comment] = "Created comment in thread"
|
498
|
+
puts "✓ Comment.create successful"
|
499
|
+
rescue => e
|
500
|
+
errors[:create_comment] = e.message
|
501
|
+
puts "✗ Comment.create failed: #{e.message}"
|
502
|
+
end
|
503
|
+
end
|
504
|
+
|
505
|
+
# Test 8: List and Work with Lists
|
506
|
+
lists = nil
|
507
|
+
begin
|
508
|
+
lists = Attio::List.list(limit: 3)
|
509
|
+
results[:list_lists] = "#{lists.count} lists"
|
510
|
+
puts "✓ List.list successful"
|
511
|
+
rescue => e
|
512
|
+
errors[:list_lists] = e.message
|
513
|
+
puts "✗ List.list failed: #{e.message}"
|
514
|
+
end
|
515
|
+
|
516
|
+
# Test 9: Work with List Entries
|
517
|
+
if lists&.first && person
|
518
|
+
begin
|
519
|
+
# Extract list_id from the nested ID structure
|
520
|
+
list_id = lists.first.id.is_a?(Hash) ? lists.first.id["list_id"] : lists.first.id
|
521
|
+
|
522
|
+
# Check if the list supports people objects
|
523
|
+
# If it doesn't, we'll get an error, but let's try anyway
|
524
|
+
begin
|
525
|
+
# Add person to list
|
526
|
+
entry = Attio::Entry.create(
|
527
|
+
list: list_id,
|
528
|
+
parent_object: "people",
|
529
|
+
parent_record_id: person.id["record_id"]
|
530
|
+
)
|
531
|
+
created_resources << {type: "entry", id: entry.id, list_id: list_id}
|
532
|
+
results[:create_entry] = "Added person to list"
|
533
|
+
rescue Attio::BadRequestError => e
|
534
|
+
if e.message.include?("does not allow")
|
535
|
+
results[:create_entry] = "List doesn't support people objects"
|
536
|
+
else
|
537
|
+
raise e
|
538
|
+
end
|
539
|
+
end
|
540
|
+
|
541
|
+
# List entries regardless of whether we could add one
|
542
|
+
entries = Attio::Entry.list(list: list_id, limit: 5)
|
543
|
+
results[:list_entries] = "#{entries.count} entries in list"
|
544
|
+
puts "✓ Entry operations successful"
|
545
|
+
rescue => e
|
546
|
+
errors[:entry_ops] = e.message
|
547
|
+
puts "✗ Entry operations failed: #{e.message}"
|
548
|
+
end
|
549
|
+
end
|
550
|
+
|
551
|
+
# Test 10: Workspace Members
|
552
|
+
begin
|
553
|
+
members = Attio::WorkspaceMember.list(limit: 5)
|
554
|
+
results[:list_members] = "#{members.count} workspace members"
|
555
|
+
puts "✓ WorkspaceMember.list successful"
|
556
|
+
rescue => e
|
557
|
+
errors[:list_members] = e.message
|
558
|
+
puts "✗ WorkspaceMember.list failed: #{e.message}"
|
559
|
+
end
|
560
|
+
|
561
|
+
# Test 11: Webhooks
|
562
|
+
begin
|
563
|
+
webhooks = Attio::Webhook.list(limit: 5)
|
564
|
+
results[:list_webhooks] = "#{webhooks.count} webhooks"
|
565
|
+
puts "✓ Webhook.list successful"
|
566
|
+
rescue => e
|
567
|
+
errors[:list_webhooks] = e.message
|
568
|
+
puts "✗ Webhook.list failed: #{e.message}"
|
569
|
+
end
|
570
|
+
|
571
|
+
# Test 12: Update Operations
|
572
|
+
if person
|
573
|
+
begin
|
574
|
+
Attio::Record.update(
|
575
|
+
object: "people",
|
576
|
+
record_id: person.id["record_id"],
|
577
|
+
data: {
|
578
|
+
values: {
|
579
|
+
name: [{
|
580
|
+
first_name: "OAuth",
|
581
|
+
last_name: "Test Updated #{Time.now.to_i}",
|
582
|
+
full_name: "OAuth Test Updated #{Time.now.to_i}"
|
583
|
+
}]
|
584
|
+
}
|
585
|
+
}
|
586
|
+
)
|
587
|
+
results[:update_record] = "Updated person name"
|
588
|
+
puts "✓ Record.update successful"
|
589
|
+
rescue => e
|
590
|
+
errors[:update_record] = e.message
|
591
|
+
puts "✗ Record.update failed: #{e.message}"
|
592
|
+
end
|
593
|
+
end
|
594
|
+
|
595
|
+
# Test 13: Error Handling
|
596
|
+
begin
|
597
|
+
# Use a properly formatted UUID that doesn't exist
|
598
|
+
Attio::Record.retrieve(object: "people", record_id: "00000000-0000-0000-0000-000000000000")
|
599
|
+
rescue Attio::NotFoundError => e
|
600
|
+
results[:error_handling] = "404 errors handled correctly"
|
601
|
+
puts "✓ Error handling working correctly"
|
602
|
+
rescue => e
|
603
|
+
errors[:error_handling] = "Unexpected error type: #{e.class}"
|
604
|
+
puts "✗ Error handling issue: #{e.message}"
|
605
|
+
end
|
606
|
+
|
607
|
+
# Test 14: Token Introspection
|
608
|
+
begin
|
609
|
+
token_info = oauth_client(request).introspect_token(@@token_store[:access_token])
|
610
|
+
results[:token_introspection] = token_info[:active] ? "Token is active" : "Token is inactive"
|
611
|
+
puts "✓ Token introspection successful"
|
612
|
+
rescue => e
|
613
|
+
errors[:token_introspection] = e.message
|
614
|
+
puts "✗ Token introspection failed: #{e.message}"
|
615
|
+
end
|
616
|
+
|
617
|
+
# Test 15: Cleanup created resources
|
618
|
+
cleanup_count = 0
|
619
|
+
created_resources.each do |resource|
|
620
|
+
case resource[:type]
|
621
|
+
when "person"
|
622
|
+
# Need to retrieve the record first to call destroy on the instance
|
623
|
+
record = Attio::Record.retrieve(object: "people", record_id: resource[:id])
|
624
|
+
record.destroy
|
625
|
+
cleanup_count += 1
|
626
|
+
when "task"
|
627
|
+
# Extract task_id from the nested ID structure
|
628
|
+
task_id = if resource[:id].is_a?(Hash)
|
629
|
+
resource[:id]["task_id"] || resource[:id]
|
630
|
+
else
|
631
|
+
resource[:id]
|
632
|
+
end
|
633
|
+
task = Attio::Task.retrieve(task_id)
|
634
|
+
task.destroy
|
635
|
+
cleanup_count += 1
|
636
|
+
when "entry"
|
637
|
+
entry = Attio::Entry.retrieve(list: resource[:list_id], entry_id: resource[:id])
|
638
|
+
entry.destroy
|
639
|
+
cleanup_count += 1
|
640
|
+
end
|
641
|
+
rescue => e
|
642
|
+
puts "Failed to cleanup #{resource[:type]}: #{e.message}"
|
643
|
+
end
|
644
|
+
results[:cleanup] = "Cleaned up #{cleanup_count} test resources"
|
645
|
+
|
646
|
+
# Generate HTML report
|
647
|
+
total_tests = results.count + errors.count
|
648
|
+
passed_tests = results.count
|
649
|
+
|
650
|
+
test_rows = results.merge(errors).map do |key, value|
|
651
|
+
status = errors[key] ? "❌" : "✅"
|
652
|
+
result = errors[key] || value
|
653
|
+
category = case key.to_s
|
654
|
+
when /list_/ then "List Operations"
|
655
|
+
when /create_/ then "Create Operations"
|
656
|
+
when /update_/ then "Update Operations"
|
657
|
+
when /token_/ then "Token Operations"
|
658
|
+
else "Other"
|
659
|
+
end
|
660
|
+
|
661
|
+
"<tr>
|
662
|
+
<td>#{status}</td>
|
663
|
+
<td>#{category}</td>
|
664
|
+
<td>#{key.to_s.split("_").map(&:capitalize).join(" ")}</td>
|
665
|
+
<td>#{result}</td>
|
666
|
+
</tr>"
|
667
|
+
end.join("\n")
|
668
|
+
|
669
|
+
<<~HTML
|
670
|
+
<h1>Comprehensive API Test Results</h1>
|
671
|
+
<h2>Workspace: #{results[:meta][:workspace] if results[:meta]}</h2>
|
672
|
+
|
673
|
+
<div style="background: #f0f0f0; padding: 10px; margin: 10px 0;">
|
674
|
+
<strong>Summary:</strong> #{passed_tests} / #{total_tests} tests passed
|
675
|
+
#{errors.any? ? "<br><strong style='color: red;'>#{errors.count} tests failed</strong>" : "<br><strong style='color: green;'>All tests passed! 🎉</strong>"}
|
676
|
+
</div>
|
677
|
+
|
678
|
+
<table border="1" cellpadding="5" cellspacing="0" style="width: 100%;">
|
679
|
+
<thead>
|
680
|
+
<tr style="background: #e0e0e0;">
|
681
|
+
<th width="50">Status</th>
|
682
|
+
<th width="150">Category</th>
|
683
|
+
<th width="200">Test</th>
|
684
|
+
<th>Result</th>
|
685
|
+
</tr>
|
686
|
+
</thead>
|
687
|
+
<tbody>
|
688
|
+
#{test_rows}
|
689
|
+
</tbody>
|
690
|
+
</table>
|
691
|
+
|
692
|
+
<h3>Test Categories</h3>
|
693
|
+
<ul>
|
694
|
+
<li><strong>List Operations:</strong> Reading data from various endpoints</li>
|
695
|
+
<li><strong>Create Operations:</strong> Creating new resources</li>
|
696
|
+
<li><strong>Update Operations:</strong> Modifying existing resources</li>
|
697
|
+
<li><strong>Token Operations:</strong> OAuth token management</li>
|
698
|
+
<li><strong>Other:</strong> Error handling, cleanup, etc.</li>
|
699
|
+
</ul>
|
700
|
+
|
701
|
+
<p style="margin-top: 20px;">
|
702
|
+
<a href="/">Home</a> |
|
703
|
+
<a href="/test">Basic Test</a> |
|
704
|
+
<a href="/logout">Logout</a>
|
705
|
+
</p>
|
706
|
+
HTML
|
707
|
+
rescue => e
|
708
|
+
<<~HTML
|
709
|
+
<h1>Test Error</h1>
|
710
|
+
<p>An unexpected error occurred while running tests:</p>
|
711
|
+
<pre>#{e.class}: #{e.message}
|
712
|
+
#{e.backtrace[0..5].join("\n")}</pre>
|
713
|
+
<p><a href="/">Home</a> | <a href="/logout">Logout</a></p>
|
714
|
+
HTML
|
715
|
+
end
|
716
|
+
end
|
717
|
+
|
718
|
+
# Test Note creation specifically
|
719
|
+
get "/test-note" do
|
720
|
+
unless @@token_store[:access_token]
|
721
|
+
redirect "/"
|
722
|
+
return
|
723
|
+
end
|
724
|
+
|
725
|
+
Attio.configure do |config|
|
726
|
+
config.api_key = @@token_store[:access_token]
|
727
|
+
end
|
728
|
+
|
729
|
+
begin
|
730
|
+
# Create a test person first
|
731
|
+
timestamp = Time.now.to_i
|
732
|
+
person = Attio::Record.create(
|
733
|
+
object: "people",
|
734
|
+
values: {
|
735
|
+
name: [{
|
736
|
+
first_name: "Note",
|
737
|
+
last_name: "Test#{timestamp}",
|
738
|
+
full_name: "Note Test#{timestamp}"
|
739
|
+
}],
|
740
|
+
email_addresses: ["note-test-#{timestamp}@example.com"]
|
741
|
+
}
|
742
|
+
)
|
743
|
+
|
744
|
+
result_html = "<h1>Note Creation Test</h1>"
|
745
|
+
result_html += "<h2>Step 1: Created Person</h2>"
|
746
|
+
result_html += "<pre>ID: #{person.id.inspect}\nrecord_id: #{person.id["record_id"]}</pre>"
|
747
|
+
|
748
|
+
# Try to create a note
|
749
|
+
begin
|
750
|
+
note = Attio::Note.create({
|
751
|
+
parent_object: "people",
|
752
|
+
parent_record_id: person.id["record_id"],
|
753
|
+
content: "Test note created at #{Time.now}",
|
754
|
+
format: "plaintext"
|
755
|
+
})
|
756
|
+
|
757
|
+
result_html += "<h2>Step 2: Created Note ✅</h2>"
|
758
|
+
result_html += "<pre>Note ID: #{note.id.inspect}\nContent: #{note.content}</pre>"
|
759
|
+
rescue => e
|
760
|
+
result_html += "<h2>Step 2: Note Creation Failed ❌</h2>"
|
761
|
+
result_html += "<pre>Error: #{e.class} - #{e.message}\n"
|
762
|
+
result_html += "Backtrace:\n#{e.backtrace[0..5].join("\n")}</pre>" if e.backtrace
|
763
|
+
end
|
764
|
+
|
765
|
+
# Clean up
|
766
|
+
begin
|
767
|
+
person.destroy
|
768
|
+
rescue
|
769
|
+
nil
|
770
|
+
end
|
771
|
+
result_html += "<h2>Step 3: Cleanup Complete</h2>"
|
772
|
+
|
773
|
+
result_html += '<p><a href="/">← Back to Home</a></p>'
|
774
|
+
result_html
|
775
|
+
rescue => e
|
776
|
+
<<~HTML
|
777
|
+
<h1>Test Error</h1>
|
778
|
+
<pre>#{e.class}: #{e.message}
|
779
|
+
#{e.backtrace[0..5].join("\n") if e.backtrace}</pre>
|
780
|
+
<p><a href="/">← Back to Home</a></p>
|
781
|
+
HTML
|
782
|
+
end
|
783
|
+
end
|
784
|
+
|
785
|
+
# Logout - Clear local tokens
|
786
|
+
get "/logout" do
|
787
|
+
@@token_store.clear
|
788
|
+
redirect "/"
|
789
|
+
end
|
790
|
+
|
791
|
+
# Revoke token (actually revokes on Attio's side)
|
792
|
+
get "/revoke" do
|
793
|
+
if @@token_store[:access_token]
|
794
|
+
success = oauth_client(request).revoke_token(@@token_store[:access_token])
|
795
|
+
@@token_store.clear
|
796
|
+
<<~HTML
|
797
|
+
<h1>Token Revoked</h1>
|
798
|
+
<p>#{success ? "✅ Token successfully revoked on Attio's servers." : "⚠️ Token revocation may have failed."}</p>
|
799
|
+
<p>The local session has been cleared.</p>
|
800
|
+
<p><a href="/">← Back to Home</a></p>
|
801
|
+
HTML
|
802
|
+
else
|
803
|
+
redirect "/"
|
804
|
+
end
|
805
|
+
end
|
806
|
+
|
807
|
+
# Token introspection
|
808
|
+
get "/introspect" do
|
809
|
+
if @@token_store[:access_token]
|
810
|
+
info = oauth_client(request).introspect_token(@@token_store[:access_token])
|
811
|
+
<<~HTML
|
812
|
+
<h1>Token Information</h1>
|
813
|
+
<pre>#{JSON.pretty_generate(info)}</pre>
|
814
|
+
<a href="/">Back</a>
|
815
|
+
HTML
|
816
|
+
else
|
817
|
+
redirect "/"
|
818
|
+
end
|
819
|
+
end
|
820
|
+
|
821
|
+
# Configure the app
|
822
|
+
set :port, 4567
|
823
|
+
set :bind, "0.0.0.0"
|
824
|
+
set :environment, :development
|
825
|
+
set :sessions, false # Disable sessions to avoid the encryptor issue
|
826
|
+
end
|
827
|
+
|
828
|
+
# Run the app
|
829
|
+
if __FILE__ == $0
|
830
|
+
puts "=== Attio OAuth Example ==="
|
831
|
+
puts "Starting server on http://localhost:4567"
|
832
|
+
puts "Also accessible via: #{ENV["ATTIO_REDIRECT_URI"].sub("/callback", "") if ENV["ATTIO_REDIRECT_URI"]}"
|
833
|
+
puts
|
834
|
+
puts "Make sure you have set up:"
|
835
|
+
puts "1. ATTIO_CLIENT_ID and ATTIO_CLIENT_SECRET in .env"
|
836
|
+
puts "2. Redirect URIs in Attio app settings:"
|
837
|
+
puts " - http://localhost:4567/callback"
|
838
|
+
puts " - https://landscaping.ngrok.dev/callback (if using ngrok)"
|
839
|
+
puts
|
840
|
+
|
841
|
+
# Run Sinatra
|
842
|
+
OAuthApp.run!
|
843
|
+
end
|