attio-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +164 -0
  4. data/.simplecov +17 -0
  5. data/.yardopts +9 -0
  6. data/CHANGELOG.md +27 -0
  7. data/CONTRIBUTING.md +333 -0
  8. data/INTEGRATION_TEST_STATUS.md +149 -0
  9. data/LICENSE +21 -0
  10. data/README.md +638 -0
  11. data/Rakefile +8 -0
  12. data/attio-ruby.gemspec +61 -0
  13. data/docs/CODECOV_SETUP.md +34 -0
  14. data/examples/basic_usage.rb +149 -0
  15. data/examples/oauth_flow.rb +843 -0
  16. data/examples/oauth_flow_README.md +84 -0
  17. data/examples/typed_records_example.rb +167 -0
  18. data/examples/webhook_server.rb +463 -0
  19. data/lib/attio/api_resource.rb +539 -0
  20. data/lib/attio/builders/name_builder.rb +181 -0
  21. data/lib/attio/client.rb +160 -0
  22. data/lib/attio/errors.rb +126 -0
  23. data/lib/attio/internal/record.rb +359 -0
  24. data/lib/attio/oauth/client.rb +219 -0
  25. data/lib/attio/oauth/scope_validator.rb +162 -0
  26. data/lib/attio/oauth/token.rb +158 -0
  27. data/lib/attio/resources/attribute.rb +332 -0
  28. data/lib/attio/resources/comment.rb +114 -0
  29. data/lib/attio/resources/company.rb +224 -0
  30. data/lib/attio/resources/entry.rb +208 -0
  31. data/lib/attio/resources/list.rb +196 -0
  32. data/lib/attio/resources/meta.rb +113 -0
  33. data/lib/attio/resources/note.rb +213 -0
  34. data/lib/attio/resources/object.rb +66 -0
  35. data/lib/attio/resources/person.rb +294 -0
  36. data/lib/attio/resources/task.rb +147 -0
  37. data/lib/attio/resources/thread.rb +99 -0
  38. data/lib/attio/resources/typed_record.rb +98 -0
  39. data/lib/attio/resources/webhook.rb +224 -0
  40. data/lib/attio/resources/workspace_member.rb +136 -0
  41. data/lib/attio/util/configuration.rb +166 -0
  42. data/lib/attio/util/id_extractor.rb +115 -0
  43. data/lib/attio/util/webhook_signature.rb +175 -0
  44. data/lib/attio/version.rb +6 -0
  45. data/lib/attio/webhook/event.rb +114 -0
  46. data/lib/attio/webhook/signature_verifier.rb +73 -0
  47. data/lib/attio.rb +123 -0
  48. metadata +402 -0
@@ -0,0 +1,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