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,84 @@
|
|
1
|
+
# Attio OAuth Flow Example
|
2
|
+
|
3
|
+
This example demonstrates how to implement the OAuth 2.0 authorization code flow with the Attio API.
|
4
|
+
|
5
|
+
## Setup
|
6
|
+
|
7
|
+
1. Create an OAuth app in your Attio workspace settings
|
8
|
+
2. Set up your environment variables in `.env`:
|
9
|
+
```
|
10
|
+
ATTIO_CLIENT_ID=your_client_id
|
11
|
+
ATTIO_CLIENT_SECRET=your_client_secret
|
12
|
+
ATTIO_REDIRECT_URI=http://localhost:4567/callback
|
13
|
+
```
|
14
|
+
|
15
|
+
3. Add redirect URIs to your Attio app:
|
16
|
+
- `http://localhost:4567/callback` (for local development)
|
17
|
+
- `https://your-ngrok-domain.ngrok.dev/callback` (if using ngrok)
|
18
|
+
|
19
|
+
## Running the Example
|
20
|
+
|
21
|
+
```bash
|
22
|
+
# Run the OAuth example server
|
23
|
+
ruby examples/oauth_flow.rb
|
24
|
+
|
25
|
+
# Or with bundler
|
26
|
+
bundle exec ruby examples/oauth_flow.rb
|
27
|
+
|
28
|
+
# Or using rackup
|
29
|
+
bundle exec rackup config.ru -p 4567
|
30
|
+
```
|
31
|
+
|
32
|
+
## Available Routes
|
33
|
+
|
34
|
+
### Public Routes (No Authentication Required)
|
35
|
+
- `GET /` - Home page with login/status
|
36
|
+
- `GET /auth` - Start OAuth flow (redirects to Attio)
|
37
|
+
- `GET /callback` - OAuth callback handler
|
38
|
+
|
39
|
+
### Protected Routes (Requires Authentication)
|
40
|
+
- `GET /test` - Basic API test (lists objects, people, companies, etc.)
|
41
|
+
- `GET /test-all` - Comprehensive API test with CRUD operations
|
42
|
+
- `GET /introspect` - View detailed token information
|
43
|
+
- `GET /revoke` - Revoke token on Attio's servers
|
44
|
+
- `GET /logout` - Clear local session only
|
45
|
+
|
46
|
+
## Features Demonstrated
|
47
|
+
|
48
|
+
1. **OAuth Authorization Flow**
|
49
|
+
- Generating authorization URLs with state parameter
|
50
|
+
- Handling OAuth callbacks
|
51
|
+
- Exchanging authorization codes for tokens
|
52
|
+
- Token storage (in-memory for demo)
|
53
|
+
|
54
|
+
2. **API Testing**
|
55
|
+
- Basic test: Read operations across multiple endpoints
|
56
|
+
- Comprehensive test: Full CRUD operations with cleanup
|
57
|
+
- Error handling and permission checking
|
58
|
+
|
59
|
+
3. **Token Management**
|
60
|
+
- Token introspection
|
61
|
+
- Token revocation
|
62
|
+
- Session management
|
63
|
+
- Token refresh on 401 (automatic retry)
|
64
|
+
|
65
|
+
## Using with ngrok
|
66
|
+
|
67
|
+
If you need to test with ngrok (for example, to test with a real Attio OAuth app):
|
68
|
+
|
69
|
+
```bash
|
70
|
+
# Start ngrok
|
71
|
+
ngrok http 4567
|
72
|
+
|
73
|
+
# Update your .env with the ngrok URL
|
74
|
+
ATTIO_REDIRECT_URI=https://your-subdomain.ngrok.dev/callback
|
75
|
+
```
|
76
|
+
|
77
|
+
## Security Notes
|
78
|
+
|
79
|
+
This example uses in-memory token storage for simplicity. In production:
|
80
|
+
- Use secure session storage
|
81
|
+
- Encrypt tokens at rest
|
82
|
+
- Implement CSRF protection
|
83
|
+
- Use secure cookies with httpOnly flag
|
84
|
+
- Implement proper state validation
|
@@ -0,0 +1,167 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "attio"
|
6
|
+
|
7
|
+
# Configure the client
|
8
|
+
Attio.configure do |config|
|
9
|
+
config.api_key = ENV["ATTIO_API_KEY"]
|
10
|
+
end
|
11
|
+
|
12
|
+
puts "=== Typed Records Example ==="
|
13
|
+
puts
|
14
|
+
|
15
|
+
# Old way vs New way comparison
|
16
|
+
puts "OLD WAY - Using generic Record class:"
|
17
|
+
puts "-------------------------------------"
|
18
|
+
puts <<~OLD
|
19
|
+
# Creating a person (verbose and error-prone)
|
20
|
+
person = Attio::Record.create(
|
21
|
+
object: "people",
|
22
|
+
values: {
|
23
|
+
name: [{
|
24
|
+
first_name: "John",
|
25
|
+
last_name: "Doe",
|
26
|
+
full_name: "John Doe"
|
27
|
+
}],
|
28
|
+
email_addresses: ["john@example.com"],
|
29
|
+
phone_numbers: [{
|
30
|
+
original_phone_number: "+12125551234",
|
31
|
+
country_code: "US"
|
32
|
+
}]
|
33
|
+
}
|
34
|
+
)
|
35
|
+
|
36
|
+
# Listing people
|
37
|
+
people = Attio::Record.list(object: "people", params: { q: "john" })
|
38
|
+
|
39
|
+
# Creating a company
|
40
|
+
company = Attio::Record.create(
|
41
|
+
object: "companies",
|
42
|
+
values: {
|
43
|
+
name: "Acme Corp",
|
44
|
+
domains: ["acme.com"]
|
45
|
+
}
|
46
|
+
)
|
47
|
+
OLD
|
48
|
+
|
49
|
+
puts
|
50
|
+
puts "NEW WAY - Using typed Person and Company classes:"
|
51
|
+
puts "-------------------------------------------------"
|
52
|
+
puts <<~NEW
|
53
|
+
# Creating a person (simple and intuitive)
|
54
|
+
person = Attio::Person.create(
|
55
|
+
first_name: "John",
|
56
|
+
last_name: "Doe",
|
57
|
+
email: "john@example.com",
|
58
|
+
phone: "+12125551234",
|
59
|
+
job_title: "Software Engineer"
|
60
|
+
)
|
61
|
+
|
62
|
+
# Or use the People alias
|
63
|
+
person = Attio::People.create(
|
64
|
+
first_name: "Jane",
|
65
|
+
last_name: "Smith"
|
66
|
+
)
|
67
|
+
|
68
|
+
# Convenient name setters
|
69
|
+
person.set_name(first: "Jane", last: "Johnson")
|
70
|
+
|
71
|
+
# Easy access to attributes
|
72
|
+
puts person.full_name # => "Jane Johnson"
|
73
|
+
puts person.email # => "john@example.com"
|
74
|
+
puts person.phone # => "+12125551234"
|
75
|
+
|
76
|
+
# Searching is simpler
|
77
|
+
people = Attio::Person.search("john")
|
78
|
+
people = Attio::Person.find_by_email("john@example.com")
|
79
|
+
people = Attio::Person.find_by_name("John Doe")
|
80
|
+
|
81
|
+
# Creating a company (no more array wrapping for simple names!)
|
82
|
+
company = Attio::Company.create(
|
83
|
+
name: "Acme Corp",
|
84
|
+
domain: "acme.com",
|
85
|
+
description: "Leading widget manufacturer",
|
86
|
+
employee_count: "50-100"
|
87
|
+
)
|
88
|
+
|
89
|
+
# Or use the Companies alias
|
90
|
+
company = Attio::Companies.create(
|
91
|
+
name: "Tech Startup",
|
92
|
+
domains: ["techstartup.com", "techstartup.io"]
|
93
|
+
)
|
94
|
+
|
95
|
+
# Simple attribute access
|
96
|
+
company.name = "Acme Corporation"
|
97
|
+
company.add_domain("acme.org")
|
98
|
+
|
99
|
+
# Associate person with company
|
100
|
+
person.company = company
|
101
|
+
person.save
|
102
|
+
|
103
|
+
# Find company's team members
|
104
|
+
team = company.team_members
|
105
|
+
|
106
|
+
# Find companies by various criteria
|
107
|
+
company = Attio::Company.find_by_domain("acme.com")
|
108
|
+
company = Attio::Company.find_by_name("Acme")
|
109
|
+
large_companies = Attio::Company.find_by_size(100) # 100+ employees
|
110
|
+
NEW
|
111
|
+
|
112
|
+
# Working example (if API key is set)
|
113
|
+
puts
|
114
|
+
if ENV["ATTIO_API_KEY"]
|
115
|
+
puts "Running live examples..."
|
116
|
+
puts
|
117
|
+
|
118
|
+
begin
|
119
|
+
# Create a person the new way
|
120
|
+
person = Attio::Person.create(
|
121
|
+
first_name: "Test",
|
122
|
+
last_name: "User-#{Time.now.to_i}",
|
123
|
+
email: "test#{Time.now.to_i}@example.com",
|
124
|
+
job_title: "Developer"
|
125
|
+
)
|
126
|
+
|
127
|
+
puts "Created person:"
|
128
|
+
puts " ID: #{person.id["record_id"]}"
|
129
|
+
puts " Name: #{person.full_name}"
|
130
|
+
puts " Email: #{person.email}"
|
131
|
+
puts " Job: #{person[:job_title]}"
|
132
|
+
|
133
|
+
# Create a company the new way
|
134
|
+
company = Attio::Company.create(
|
135
|
+
name: "Test Company #{Time.now.to_i}",
|
136
|
+
domain: "test#{Time.now.to_i}.com"
|
137
|
+
)
|
138
|
+
|
139
|
+
puts
|
140
|
+
puts "Created company:"
|
141
|
+
puts " ID: #{company.id["record_id"]}"
|
142
|
+
puts " Name: #{company.name}"
|
143
|
+
puts " Domain: #{company.domain}"
|
144
|
+
|
145
|
+
# Update person's name using helper
|
146
|
+
person.set_name(first: "Updated", last: "Name")
|
147
|
+
person.save
|
148
|
+
|
149
|
+
puts
|
150
|
+
puts "Updated person name: #{person.full_name}"
|
151
|
+
|
152
|
+
# Search for people
|
153
|
+
results = Attio::Person.search("test")
|
154
|
+
puts
|
155
|
+
puts "Found #{results.count} people matching 'test'"
|
156
|
+
|
157
|
+
# Clean up
|
158
|
+
person.destroy
|
159
|
+
company.destroy
|
160
|
+
puts
|
161
|
+
puts "Cleaned up test data"
|
162
|
+
rescue Attio::Error => e
|
163
|
+
puts "Error: #{e.message}"
|
164
|
+
end
|
165
|
+
else
|
166
|
+
puts "To run live examples, set ATTIO_API_KEY environment variable"
|
167
|
+
end
|
@@ -0,0 +1,463 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "attio"
|
6
|
+
require "sinatra"
|
7
|
+
require "json"
|
8
|
+
require "openssl"
|
9
|
+
require "dotenv/load"
|
10
|
+
|
11
|
+
# Webhook server example for Attio Ruby gem
|
12
|
+
# Demonstrates webhook creation, management, and event handling
|
13
|
+
|
14
|
+
Attio.configure do |config|
|
15
|
+
config.api_key = ENV["ATTIO_API_KEY"]
|
16
|
+
end
|
17
|
+
|
18
|
+
# Store for webhook events (use Redis or database in production)
|
19
|
+
$webhook_events = []
|
20
|
+
|
21
|
+
# Webhook signature verification
|
22
|
+
def verify_webhook_signature(payload_body, signature_header)
|
23
|
+
return false unless signature_header
|
24
|
+
|
25
|
+
# Extract timestamp and signatures
|
26
|
+
elements = signature_header.split(" ")
|
27
|
+
timestamp = nil
|
28
|
+
signatures = []
|
29
|
+
|
30
|
+
elements.each do |element|
|
31
|
+
key, value = element.split("=", 2)
|
32
|
+
case key
|
33
|
+
when "t"
|
34
|
+
timestamp = value
|
35
|
+
when "v1"
|
36
|
+
signatures << value
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
return false unless timestamp
|
41
|
+
|
42
|
+
# Verify timestamp is recent (within 5 minutes)
|
43
|
+
current_time = Time.now.to_i
|
44
|
+
if (current_time - timestamp.to_i).abs > 300
|
45
|
+
return false
|
46
|
+
end
|
47
|
+
|
48
|
+
# Compute expected signature
|
49
|
+
signed_payload = "#{timestamp}.#{payload_body}"
|
50
|
+
expected_signature = OpenSSL::HMAC.hexdigest(
|
51
|
+
"SHA256",
|
52
|
+
ENV["ATTIO_WEBHOOK_SECRET"] || "test_secret",
|
53
|
+
signed_payload
|
54
|
+
)
|
55
|
+
|
56
|
+
# Check if computed signature matches any of the signatures
|
57
|
+
signatures.any? { |sig| Rack::Utils.secure_compare(expected_signature, sig) }
|
58
|
+
end
|
59
|
+
|
60
|
+
# Webhook endpoint
|
61
|
+
post "/webhooks/attio" do
|
62
|
+
# Get raw body for signature verification
|
63
|
+
request.body.rewind
|
64
|
+
payload_body = request.body.read
|
65
|
+
|
66
|
+
# Verify webhook signature
|
67
|
+
signature = request.env["HTTP_ATTIO_SIGNATURE"]
|
68
|
+
|
69
|
+
unless verify_webhook_signature(payload_body, signature)
|
70
|
+
halt 401, {error: "Invalid signature"}.to_json
|
71
|
+
end
|
72
|
+
|
73
|
+
# Parse webhook data
|
74
|
+
begin
|
75
|
+
webhook_data = JSON.parse(payload_body)
|
76
|
+
rescue JSON::ParserError
|
77
|
+
halt 400, {error: "Invalid JSON"}.to_json
|
78
|
+
end
|
79
|
+
|
80
|
+
# Store event
|
81
|
+
event = {
|
82
|
+
id: webhook_data["id"],
|
83
|
+
type: webhook_data["type"],
|
84
|
+
occurred_at: webhook_data["occurred_at"],
|
85
|
+
data: webhook_data["data"],
|
86
|
+
received_at: Time.now.iso8601
|
87
|
+
}
|
88
|
+
|
89
|
+
$webhook_events << event
|
90
|
+
|
91
|
+
# Process webhook based on type
|
92
|
+
case webhook_data["type"]
|
93
|
+
when "record.created"
|
94
|
+
process_record_created(webhook_data["data"])
|
95
|
+
when "record.updated"
|
96
|
+
process_record_updated(webhook_data["data"])
|
97
|
+
when "record.deleted"
|
98
|
+
process_record_deleted(webhook_data["data"])
|
99
|
+
when "list_entry.created"
|
100
|
+
process_list_entry_created(webhook_data["data"])
|
101
|
+
when "note.created"
|
102
|
+
process_note_created(webhook_data["data"])
|
103
|
+
else
|
104
|
+
puts "Unknown webhook type: #{webhook_data["type"]}"
|
105
|
+
end
|
106
|
+
|
107
|
+
# Return success
|
108
|
+
status 200
|
109
|
+
{received: true}.to_json
|
110
|
+
end
|
111
|
+
|
112
|
+
# Webhook event processors
|
113
|
+
# Process record.created webhook events
|
114
|
+
# @param data [Hash] Webhook event data
|
115
|
+
def process_record_created(data)
|
116
|
+
puts "New #{data["object"]} created: #{data["record"]["id"]}"
|
117
|
+
|
118
|
+
# Example: Send welcome email for new people
|
119
|
+
if data["object"] == "people" && data["record"]["email_addresses"]
|
120
|
+
puts " Would send welcome email to: #{data["record"]["email_addresses"]}"
|
121
|
+
end
|
122
|
+
|
123
|
+
# Example: Enrich company data
|
124
|
+
if data["object"] == "companies" && data["record"]["domains"]
|
125
|
+
puts " Would enrich company data for: #{data["record"]["domains"]}"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Process record.updated webhook events
|
130
|
+
# @param data [Hash] Webhook event data
|
131
|
+
def process_record_updated(data)
|
132
|
+
puts "#{data["object"]} updated: #{data["record"]["id"]}"
|
133
|
+
|
134
|
+
# Example: Sync changes to CRM
|
135
|
+
changed_fields = data["changes"]&.keys || []
|
136
|
+
if changed_fields.any?
|
137
|
+
puts " Changed fields: #{changed_fields.join(", ")}"
|
138
|
+
puts " Would sync to external CRM"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Process record.deleted webhook events
|
143
|
+
# @param data [Hash] Webhook event data
|
144
|
+
def process_record_deleted(data)
|
145
|
+
puts "#{data["object"]} deleted: #{data["record_id"]}"
|
146
|
+
|
147
|
+
# Example: Clean up related data
|
148
|
+
puts " Would clean up related data in external systems"
|
149
|
+
end
|
150
|
+
|
151
|
+
# Process list_entry.created webhook events
|
152
|
+
# @param data [Hash] Webhook event data
|
153
|
+
def process_list_entry_created(data)
|
154
|
+
puts "Record added to list: #{data["list"]["name"]}"
|
155
|
+
|
156
|
+
# Example: Trigger marketing automation
|
157
|
+
if /leads|prospects/i.match?(data["list"]["name"])
|
158
|
+
puts " Would trigger marketing automation workflow"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Process note.created webhook events
|
163
|
+
# @param data [Hash] Webhook event data
|
164
|
+
def process_note_created(data)
|
165
|
+
puts "New note created on #{data["parent_object"]}"
|
166
|
+
|
167
|
+
# Example: Notify team members
|
168
|
+
if /urgent|important|asap/i.match?(data["content"])
|
169
|
+
puts " Would notify team members of urgent note"
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Webhook management endpoints
|
174
|
+
get "/" do
|
175
|
+
<<~HTML
|
176
|
+
<h1>Attio Webhook Server</h1>
|
177
|
+
<p>Webhook endpoint: POST /webhooks/attio</p>
|
178
|
+
<p>Events received: #{$webhook_events.size}</p>
|
179
|
+
<hr>
|
180
|
+
<a href="/webhooks">Manage Webhooks</a> |
|
181
|
+
<a href="/events">View Events</a> |
|
182
|
+
<a href="/test">Test Webhook</a>
|
183
|
+
HTML
|
184
|
+
end
|
185
|
+
|
186
|
+
# List webhooks
|
187
|
+
get "/webhooks" do
|
188
|
+
webhooks = Attio::Webhook.list
|
189
|
+
|
190
|
+
html = "<h1>Configured Webhooks</h1>"
|
191
|
+
html += "<a href='/webhooks/new'>Create New Webhook</a><br><br>"
|
192
|
+
|
193
|
+
webhooks.each do |webhook|
|
194
|
+
html += <<~HTML
|
195
|
+
<div style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
|
196
|
+
<strong>#{webhook.name}</strong><br>
|
197
|
+
URL: #{webhook.url}<br>
|
198
|
+
Events: #{webhook.subscriptions.join(", ")}<br>
|
199
|
+
Status: #{webhook.active ? "✅ Active" : "❌ Inactive"}<br>
|
200
|
+
<a href="/webhooks/#{webhook.id}/test">Test</a> |
|
201
|
+
<a href="/webhooks/#{webhook.id}/toggle">#{webhook.active ? "Disable" : "Enable"}</a> |
|
202
|
+
<a href="/webhooks/#{webhook.id}/delete" onclick="return confirm('Delete webhook?')">Delete</a>
|
203
|
+
</div>
|
204
|
+
HTML
|
205
|
+
end
|
206
|
+
|
207
|
+
html += "<br><a href='/'>Back</a>"
|
208
|
+
html
|
209
|
+
end
|
210
|
+
|
211
|
+
# Create webhook form
|
212
|
+
get "/webhooks/new" do
|
213
|
+
<<~HTML
|
214
|
+
<h1>Create Webhook</h1>
|
215
|
+
<form method="post" action="/webhooks/create">
|
216
|
+
<label>Name: <input name="name" value="Test Webhook" required></label><br><br>
|
217
|
+
<label>URL: <input name="url" value="#{request.base_url}/webhooks/attio" required size="50"></label><br><br>
|
218
|
+
|
219
|
+
<label>Events to subscribe:</label><br>
|
220
|
+
<label><input type="checkbox" name="events[]" value="record.created" checked> record.created</label><br>
|
221
|
+
<label><input type="checkbox" name="events[]" value="record.updated" checked> record.updated</label><br>
|
222
|
+
<label><input type="checkbox" name="events[]" value="record.deleted"> record.deleted</label><br>
|
223
|
+
<label><input type="checkbox" name="events[]" value="list_entry.created"> list_entry.created</label><br>
|
224
|
+
<label><input type="checkbox" name="events[]" value="list_entry.deleted"> list_entry.deleted</label><br>
|
225
|
+
<label><input type="checkbox" name="events[]" value="note.created"> note.created</label><br><br>
|
226
|
+
|
227
|
+
<button type="submit">Create Webhook</button>
|
228
|
+
<a href="/webhooks">Cancel</a>
|
229
|
+
</form>
|
230
|
+
HTML
|
231
|
+
end
|
232
|
+
|
233
|
+
# Create webhook
|
234
|
+
post "/webhooks/create" do
|
235
|
+
Attio::Webhook.create(
|
236
|
+
name: params[:name],
|
237
|
+
url: params[:url],
|
238
|
+
subscriptions: params[:events] || []
|
239
|
+
)
|
240
|
+
|
241
|
+
redirect "/webhooks"
|
242
|
+
rescue => e
|
243
|
+
"Error creating webhook: #{e.message}"
|
244
|
+
end
|
245
|
+
|
246
|
+
# Test webhook
|
247
|
+
get "/webhooks/:id/test" do
|
248
|
+
webhook = Attio::Webhook.retrieve(params[:id])
|
249
|
+
|
250
|
+
# Trigger a test event
|
251
|
+
test_data = {
|
252
|
+
id: "test_#{SecureRandom.hex(8)}",
|
253
|
+
type: "record.created",
|
254
|
+
occurred_at: Time.now.iso8601,
|
255
|
+
data: {
|
256
|
+
object: "people",
|
257
|
+
record: {
|
258
|
+
id: "test_person_#{SecureRandom.hex(8)}",
|
259
|
+
name: "Test Person",
|
260
|
+
email_addresses: "test@example.com"
|
261
|
+
}
|
262
|
+
}
|
263
|
+
}
|
264
|
+
|
265
|
+
# In a real scenario, Attio would send this
|
266
|
+
# For testing, we'll simulate it
|
267
|
+
uri = URI(webhook.url)
|
268
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
269
|
+
http.use_ssl = uri.scheme == "https"
|
270
|
+
|
271
|
+
request = Net::HTTP::Post.new(uri.path)
|
272
|
+
request["Content-Type"] = "application/json"
|
273
|
+
request["Attio-Signature"] = "t=#{Time.now.to_i} v1=test_signature"
|
274
|
+
request.body = test_data.to_json
|
275
|
+
|
276
|
+
response = http.request(request)
|
277
|
+
|
278
|
+
<<~HTML
|
279
|
+
<h1>Webhook Test Result</h1>
|
280
|
+
<p>Webhook: #{webhook.name}</p>
|
281
|
+
<p>URL: #{webhook.url}</p>
|
282
|
+
<p>Response Status: #{response.code}</p>
|
283
|
+
<p>Response Body: #{response.body}</p>
|
284
|
+
<a href="/webhooks">Back to Webhooks</a>
|
285
|
+
HTML
|
286
|
+
rescue => e
|
287
|
+
"Error testing webhook: #{e.message}"
|
288
|
+
end
|
289
|
+
|
290
|
+
# Toggle webhook
|
291
|
+
get "/webhooks/:id/toggle" do
|
292
|
+
webhook = Attio::Webhook.retrieve(params[:id])
|
293
|
+
webhook.active = !webhook.active
|
294
|
+
webhook.save
|
295
|
+
|
296
|
+
redirect "/webhooks"
|
297
|
+
end
|
298
|
+
|
299
|
+
# Delete webhook
|
300
|
+
get "/webhooks/:id/delete" do
|
301
|
+
Attio::Webhook.delete(params[:id])
|
302
|
+
redirect "/webhooks"
|
303
|
+
end
|
304
|
+
|
305
|
+
# View events
|
306
|
+
get "/events" do
|
307
|
+
html = "<h1>Webhook Events (#{$webhook_events.size})</h1>"
|
308
|
+
|
309
|
+
if $webhook_events.empty?
|
310
|
+
html += "<p>No events received yet.</p>"
|
311
|
+
else
|
312
|
+
html += "<table border='1' cellpadding='5'>"
|
313
|
+
html += "<tr><th>Time</th><th>Type</th><th>Object</th><th>Data</th></tr>"
|
314
|
+
|
315
|
+
$webhook_events.last(50).reverse_each do |event|
|
316
|
+
html += <<~HTML
|
317
|
+
<tr>
|
318
|
+
<td>#{event[:received_at]}</td>
|
319
|
+
<td>#{event[:type]}</td>
|
320
|
+
<td>#{event[:data]["object"] if event[:data]}</td>
|
321
|
+
<td><pre>#{JSON.pretty_generate(event[:data])}</pre></td>
|
322
|
+
</tr>
|
323
|
+
HTML
|
324
|
+
end
|
325
|
+
|
326
|
+
html += "</table>"
|
327
|
+
end
|
328
|
+
|
329
|
+
html += "<br><a href='/'>Back</a> | <a href='/events/clear'>Clear Events</a>"
|
330
|
+
html
|
331
|
+
end
|
332
|
+
|
333
|
+
# Clear events
|
334
|
+
get "/events/clear" do
|
335
|
+
$webhook_events.clear
|
336
|
+
redirect "/events"
|
337
|
+
end
|
338
|
+
|
339
|
+
# Test webhook locally
|
340
|
+
get "/test" do
|
341
|
+
<<~HTML
|
342
|
+
<h1>Test Webhook Locally</h1>
|
343
|
+
<button onclick="testCreate()">Test Create Event</button>
|
344
|
+
<button onclick="testUpdate()">Test Update Event</button>
|
345
|
+
<button onclick="testDelete()">Test Delete Event</button>
|
346
|
+
<br><br>
|
347
|
+
<div id="result"></div>
|
348
|
+
<br>
|
349
|
+
<a href="/">Back</a>
|
350
|
+
|
351
|
+
<script>
|
352
|
+
function sendTest(data) {
|
353
|
+
fetch('/webhooks/attio', {
|
354
|
+
method: 'POST',
|
355
|
+
headers: {
|
356
|
+
'Content-Type': 'application/json',
|
357
|
+
'Attio-Signature': 't=' + Math.floor(Date.now()/1000) + ' v1=test'
|
358
|
+
},
|
359
|
+
body: JSON.stringify(data)
|
360
|
+
})
|
361
|
+
.then(r => r.json())
|
362
|
+
.then(data => {
|
363
|
+
document.getElementById('result').innerHTML =
|
364
|
+
'<pre>' + JSON.stringify(data, null, 2) + '</pre>';
|
365
|
+
});
|
366
|
+
}
|
367
|
+
|
368
|
+
function testCreate() {
|
369
|
+
sendTest({
|
370
|
+
id: 'evt_' + Date.now(),
|
371
|
+
type: 'record.created',
|
372
|
+
occurred_at: new Date().toISOString(),
|
373
|
+
data: {
|
374
|
+
object: 'people',
|
375
|
+
record: {
|
376
|
+
id: 'person_test',
|
377
|
+
name: 'Test Person',
|
378
|
+
email_addresses: 'test@example.com'
|
379
|
+
}
|
380
|
+
}
|
381
|
+
});
|
382
|
+
}
|
383
|
+
|
384
|
+
function testUpdate() {
|
385
|
+
sendTest({
|
386
|
+
id: 'evt_' + Date.now(),
|
387
|
+
type: 'record.updated',
|
388
|
+
occurred_at: new Date().toISOString(),
|
389
|
+
data: {
|
390
|
+
object: 'people',
|
391
|
+
record: {
|
392
|
+
id: 'person_test',
|
393
|
+
name: 'Updated Person',
|
394
|
+
email_addresses: 'updated@example.com'
|
395
|
+
},
|
396
|
+
changes: {
|
397
|
+
name: { old: 'Test Person', new: 'Updated Person' }
|
398
|
+
}
|
399
|
+
}
|
400
|
+
});
|
401
|
+
}
|
402
|
+
|
403
|
+
function testDelete() {
|
404
|
+
sendTest({
|
405
|
+
id: 'evt_' + Date.now(),
|
406
|
+
type: 'record.deleted',
|
407
|
+
occurred_at: new Date().toISOString(),
|
408
|
+
data: {
|
409
|
+
object: 'people',
|
410
|
+
record_id: 'person_test'
|
411
|
+
}
|
412
|
+
});
|
413
|
+
}
|
414
|
+
</script>
|
415
|
+
HTML
|
416
|
+
end
|
417
|
+
|
418
|
+
# Webhook statistics
|
419
|
+
get "/stats" do
|
420
|
+
event_types = $webhook_events.group_by { |e| e[:type] }
|
421
|
+
|
422
|
+
html = "<h1>Webhook Statistics</h1>"
|
423
|
+
html += "<p>Total events: #{$webhook_events.size}</p>"
|
424
|
+
|
425
|
+
html += "<h2>Events by Type</h2>"
|
426
|
+
html += "<ul>"
|
427
|
+
event_types.each do |type, events|
|
428
|
+
html += "<li>#{type}: #{events.size}</li>"
|
429
|
+
end
|
430
|
+
html += "</ul>"
|
431
|
+
|
432
|
+
html += "<h2>Recent Activity</h2>"
|
433
|
+
html += "<ul>"
|
434
|
+
$webhook_events.last(10).reverse_each do |event|
|
435
|
+
html += "<li>#{event[:received_at]} - #{event[:type]}</li>"
|
436
|
+
end
|
437
|
+
html += "</ul>"
|
438
|
+
|
439
|
+
html += "<br><a href='/'>Back</a>"
|
440
|
+
html
|
441
|
+
end
|
442
|
+
|
443
|
+
# Run the server
|
444
|
+
if __FILE__ == $0
|
445
|
+
puts "=== Attio Webhook Server Example ==="
|
446
|
+
puts "Starting server on http://localhost:4568"
|
447
|
+
puts
|
448
|
+
puts "This example demonstrates:"
|
449
|
+
puts "- Creating and managing webhooks"
|
450
|
+
puts "- Receiving and processing webhook events"
|
451
|
+
puts "- Signature verification"
|
452
|
+
puts "- Event handling patterns"
|
453
|
+
puts
|
454
|
+
puts "Visit http://localhost:4568 to get started"
|
455
|
+
puts
|
456
|
+
|
457
|
+
# Configure Sinatra
|
458
|
+
set :port, 4568
|
459
|
+
set :bind, "0.0.0.0"
|
460
|
+
|
461
|
+
# Run the server
|
462
|
+
run!
|
463
|
+
end
|