attio 0.3.0 → 0.4.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/.gitignore +1 -0
- data/CHANGELOG.md +35 -2
- data/CLAUDE.md +35 -4
- data/Gemfile.lock +1 -1
- data/README.md +234 -31
- data/lib/attio/circuit_breaker.rb +299 -0
- data/lib/attio/client.rb +2 -10
- data/lib/attio/connection_pool.rb +190 -35
- data/lib/attio/enhanced_client.rb +257 -0
- data/lib/attio/http_client.rb +54 -3
- data/lib/attio/observability.rb +424 -0
- data/lib/attio/resources/base.rb +53 -0
- data/lib/attio/resources/bulk.rb +1 -1
- data/lib/attio/resources/records.rb +29 -2
- data/lib/attio/version.rb +1 -1
- data/lib/attio/webhooks.rb +220 -0
- data/lib/attio.rb +8 -1
- metadata +5 -2
- data/lib/attio/resources/meta.rb +0 -72
@@ -0,0 +1,220 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "json"
|
5
|
+
require "time"
|
6
|
+
|
7
|
+
module Attio
|
8
|
+
# Webhook handling for Attio events
|
9
|
+
#
|
10
|
+
# @example Configure webhooks
|
11
|
+
# webhooks = Attio::Webhooks.new(secret: ENV['ATTIO_WEBHOOK_SECRET'])
|
12
|
+
#
|
13
|
+
# webhooks.on('record.created') do |event|
|
14
|
+
# puts "New record: #{event.data['id']}"
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# # In your webhook endpoint
|
18
|
+
# webhooks.process(request.body.read, request.headers)
|
19
|
+
class Webhooks
|
20
|
+
class InvalidSignatureError < StandardError; end
|
21
|
+
class InvalidTimestampError < StandardError; end
|
22
|
+
class MissingHeaderError < StandardError; end
|
23
|
+
|
24
|
+
# Default time window for timestamp validation (5 minutes)
|
25
|
+
DEFAULT_TOLERANCE = 300
|
26
|
+
|
27
|
+
attr_reader :secret, :handlers, :tolerance
|
28
|
+
|
29
|
+
# Initialize webhook handler
|
30
|
+
#
|
31
|
+
# @param secret [String] Webhook signing secret from Attio
|
32
|
+
# @param tolerance [Integer] Maximum age of webhook in seconds
|
33
|
+
def initialize(secret:, tolerance: DEFAULT_TOLERANCE)
|
34
|
+
@secret = secret
|
35
|
+
@tolerance = tolerance
|
36
|
+
@handlers = {}
|
37
|
+
@global_handlers = []
|
38
|
+
end
|
39
|
+
|
40
|
+
# Register an event handler
|
41
|
+
#
|
42
|
+
# @param event_type [String] The event type to handle (e.g., 'record.created')
|
43
|
+
# @yield [event] Block to execute when event is received
|
44
|
+
# @yieldparam event [Event] The webhook event
|
45
|
+
def on(event_type, &block)
|
46
|
+
@handlers[event_type] ||= []
|
47
|
+
@handlers[event_type] << block
|
48
|
+
end
|
49
|
+
|
50
|
+
# Register a global handler for all events
|
51
|
+
#
|
52
|
+
# @yield [event] Block to execute for any event
|
53
|
+
def on_any(&block)
|
54
|
+
@global_handlers << block
|
55
|
+
end
|
56
|
+
|
57
|
+
# Process incoming webhook
|
58
|
+
#
|
59
|
+
# @param payload [String] Raw request body
|
60
|
+
# @param headers [Hash] Request headers
|
61
|
+
# @return [Event] Processed webhook event
|
62
|
+
# @raise [InvalidSignatureError] if signature verification fails
|
63
|
+
# @raise [InvalidTimestampError] if timestamp is too old
|
64
|
+
def process(payload, headers)
|
65
|
+
verify_webhook!(payload, headers)
|
66
|
+
|
67
|
+
event = Event.new(JSON.parse(payload))
|
68
|
+
dispatch_event(event)
|
69
|
+
event
|
70
|
+
end
|
71
|
+
|
72
|
+
# Verify webhook authenticity using HMAC-SHA256
|
73
|
+
#
|
74
|
+
# @param payload [String] Raw request body
|
75
|
+
# @param signature [String] Signature from headers
|
76
|
+
# @return [Boolean] True if valid
|
77
|
+
def verify_signature?(payload, signature)
|
78
|
+
expected = OpenSSL::HMAC.hexdigest(
|
79
|
+
OpenSSL::Digest.new("sha256"),
|
80
|
+
@secret,
|
81
|
+
payload
|
82
|
+
)
|
83
|
+
|
84
|
+
# Use secure comparison to prevent timing attacks
|
85
|
+
secure_compare?(expected, signature)
|
86
|
+
end
|
87
|
+
|
88
|
+
private def verify_webhook!(payload, headers)
|
89
|
+
signature = extract_header(headers, "Attio-Signature")
|
90
|
+
timestamp = extract_header(headers, "Attio-Timestamp")
|
91
|
+
|
92
|
+
# Verify timestamp to prevent replay attacks
|
93
|
+
verify_timestamp!(timestamp)
|
94
|
+
|
95
|
+
# Verify signature
|
96
|
+
signed_payload = "#{timestamp}.#{payload}"
|
97
|
+
return if verify_signature?(signed_payload, signature)
|
98
|
+
|
99
|
+
raise InvalidSignatureError, "Webhook signature verification failed"
|
100
|
+
end
|
101
|
+
|
102
|
+
private def extract_header(headers, name)
|
103
|
+
# Handle different header formats (Rack, Rails, etc.)
|
104
|
+
value = headers[name] ||
|
105
|
+
headers[name.downcase] ||
|
106
|
+
headers[name.upcase.gsub("-", "_")]
|
107
|
+
|
108
|
+
raise MissingHeaderError, "Missing required header: #{name}" unless value
|
109
|
+
|
110
|
+
value
|
111
|
+
end
|
112
|
+
|
113
|
+
private def verify_timestamp!(timestamp)
|
114
|
+
webhook_time = Time.at(timestamp.to_i)
|
115
|
+
current_time = Time.now
|
116
|
+
|
117
|
+
return unless (current_time - webhook_time).abs > tolerance
|
118
|
+
|
119
|
+
raise InvalidTimestampError,
|
120
|
+
"Webhook timestamp outside of tolerance (#{tolerance}s)"
|
121
|
+
end
|
122
|
+
|
123
|
+
private def dispatch_event(event)
|
124
|
+
# Call specific handlers
|
125
|
+
@handlers[event.type].each { |handler| handler.call(event) } if @handlers[event.type]
|
126
|
+
|
127
|
+
# Call global handlers
|
128
|
+
@global_handlers.each { |handler| handler.call(event) }
|
129
|
+
end
|
130
|
+
|
131
|
+
private def secure_compare?(expected, actual)
|
132
|
+
return false unless expected.bytesize == actual.bytesize
|
133
|
+
|
134
|
+
expected_bytes = expected.unpack("C*")
|
135
|
+
actual_bytes = actual.unpack("C*")
|
136
|
+
result = 0
|
137
|
+
|
138
|
+
expected_bytes.zip(actual_bytes) { |x, y| result |= x ^ y }
|
139
|
+
result == 0
|
140
|
+
end
|
141
|
+
|
142
|
+
# Represents a webhook event
|
143
|
+
class Event
|
144
|
+
attr_reader :id, :type, :created_at, :data, :workspace_id, :raw
|
145
|
+
|
146
|
+
def initialize(payload)
|
147
|
+
@raw = payload
|
148
|
+
@id = payload["id"]
|
149
|
+
@type = payload["type"]
|
150
|
+
@created_at = Time.parse(payload["created_at"]) if payload["created_at"]
|
151
|
+
@data = payload["data"] || {}
|
152
|
+
@workspace_id = payload["workspace_id"]
|
153
|
+
end
|
154
|
+
|
155
|
+
# Check if event is of a specific type
|
156
|
+
#
|
157
|
+
# @param type [String] Event type to check
|
158
|
+
# @return [Boolean]
|
159
|
+
def is?(type)
|
160
|
+
@type == type
|
161
|
+
end
|
162
|
+
|
163
|
+
# Get nested data value
|
164
|
+
#
|
165
|
+
# @param path [String] Dot-separated path (e.g., 'record.id')
|
166
|
+
# @return [Object] Value at path
|
167
|
+
def dig(*path)
|
168
|
+
@data.dig(*path)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Webhook server for development/testing
|
174
|
+
class WebhookServer
|
175
|
+
attr_reader :port, :webhooks, :events
|
176
|
+
|
177
|
+
def initialize(port: 3001, secret: "test_secret")
|
178
|
+
@port = port
|
179
|
+
@webhooks = Webhooks.new(secret: secret)
|
180
|
+
@events = []
|
181
|
+
@server = nil
|
182
|
+
|
183
|
+
begin
|
184
|
+
require "webrick"
|
185
|
+
rescue LoadError
|
186
|
+
raise "Please add 'webrick' to your Gemfile to use WebhookServer"
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def start
|
191
|
+
@server = WEBrick::HTTPServer.new(Port: @port, Logger: WEBrick::Log.new(File::NULL))
|
192
|
+
|
193
|
+
@server.mount_proc "/webhooks" do |req, res|
|
194
|
+
if req.request_method == "POST"
|
195
|
+
begin
|
196
|
+
event = @webhooks.process(req.body, req.header)
|
197
|
+
@events << event
|
198
|
+
res.status = 200
|
199
|
+
res.body = JSON.generate(status: "ok", event_id: event.id)
|
200
|
+
rescue StandardError => e
|
201
|
+
res.status = 400
|
202
|
+
res.body = JSON.generate(error: e.message)
|
203
|
+
end
|
204
|
+
else
|
205
|
+
res.status = 405
|
206
|
+
res.body = JSON.generate(error: "Method not allowed")
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
trap("INT") { stop }
|
211
|
+
|
212
|
+
puts "Webhook server listening on http://localhost:#{@port}/webhooks"
|
213
|
+
@server.start
|
214
|
+
end
|
215
|
+
|
216
|
+
def stop
|
217
|
+
@server&.shutdown
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
data/lib/attio.rb
CHANGED
@@ -7,6 +7,7 @@ require "attio/errors"
|
|
7
7
|
require "attio/http_client"
|
8
8
|
require "attio/client"
|
9
9
|
|
10
|
+
# Resources
|
10
11
|
require "attio/resources/base"
|
11
12
|
require "attio/resources/records"
|
12
13
|
require "attio/resources/objects"
|
@@ -20,9 +21,15 @@ require "attio/resources/comments"
|
|
20
21
|
require "attio/resources/threads"
|
21
22
|
require "attio/resources/workspace_members"
|
22
23
|
require "attio/resources/deals"
|
23
|
-
require "attio/resources/meta"
|
24
24
|
require "attio/resources/bulk"
|
25
|
+
|
26
|
+
# Enterprise features
|
25
27
|
require "attio/rate_limiter"
|
28
|
+
require "attio/webhooks"
|
29
|
+
require "attio/connection_pool"
|
30
|
+
require "attio/circuit_breaker"
|
31
|
+
require "attio/observability"
|
32
|
+
require "attio/enhanced_client"
|
26
33
|
|
27
34
|
# The main Attio module provides access to the Attio API client.
|
28
35
|
#
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: attio
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ernest Sim
|
@@ -110,11 +110,14 @@ files:
|
|
110
110
|
- examples/full_workflow.rb
|
111
111
|
- examples/notes_and_tasks.rb
|
112
112
|
- lib/attio.rb
|
113
|
+
- lib/attio/circuit_breaker.rb
|
113
114
|
- lib/attio/client.rb
|
114
115
|
- lib/attio/connection_pool.rb
|
116
|
+
- lib/attio/enhanced_client.rb
|
115
117
|
- lib/attio/errors.rb
|
116
118
|
- lib/attio/http_client.rb
|
117
119
|
- lib/attio/logger.rb
|
120
|
+
- lib/attio/observability.rb
|
118
121
|
- lib/attio/rate_limiter.rb
|
119
122
|
- lib/attio/resources/attributes.rb
|
120
123
|
- lib/attio/resources/base.rb
|
@@ -122,7 +125,6 @@ files:
|
|
122
125
|
- lib/attio/resources/comments.rb
|
123
126
|
- lib/attio/resources/deals.rb
|
124
127
|
- lib/attio/resources/lists.rb
|
125
|
-
- lib/attio/resources/meta.rb
|
126
128
|
- lib/attio/resources/notes.rb
|
127
129
|
- lib/attio/resources/objects.rb
|
128
130
|
- lib/attio/resources/records.rb
|
@@ -133,6 +135,7 @@ files:
|
|
133
135
|
- lib/attio/resources/workspaces.rb
|
134
136
|
- lib/attio/retry_handler.rb
|
135
137
|
- lib/attio/version.rb
|
138
|
+
- lib/attio/webhooks.rb
|
136
139
|
homepage: https://github.com/idl3/attio
|
137
140
|
licenses:
|
138
141
|
- MIT
|
data/lib/attio/resources/meta.rb
DELETED
@@ -1,72 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Attio
|
4
|
-
module Resources
|
5
|
-
# Meta resource for API metadata and identification
|
6
|
-
#
|
7
|
-
# @example Identify the current API key
|
8
|
-
# client.meta.identify
|
9
|
-
# # => { "workspace" => { "id" => "...", "name" => "..." }, "user" => { ... } }
|
10
|
-
#
|
11
|
-
# @example Get API status
|
12
|
-
# client.meta.status
|
13
|
-
# # => { "status" => "operational", "version" => "v2" }
|
14
|
-
class Meta < Base
|
15
|
-
# Identify the current API key and get workspace/user information
|
16
|
-
#
|
17
|
-
# @return [Hash] Information about the authenticated workspace and user
|
18
|
-
def identify
|
19
|
-
request(:get, "meta/identify")
|
20
|
-
end
|
21
|
-
|
22
|
-
# Get API status and version information
|
23
|
-
#
|
24
|
-
# @return [Hash] API status and version details
|
25
|
-
def status
|
26
|
-
request(:get, "meta/status")
|
27
|
-
end
|
28
|
-
|
29
|
-
# Get rate limit information for the current API key
|
30
|
-
#
|
31
|
-
# @return [Hash] Current rate limit status
|
32
|
-
def rate_limits
|
33
|
-
request(:get, "meta/rate_limits")
|
34
|
-
end
|
35
|
-
|
36
|
-
# Get workspace configuration and settings
|
37
|
-
#
|
38
|
-
# @return [Hash] Workspace configuration details
|
39
|
-
def workspace_config
|
40
|
-
request(:get, "meta/workspace_config")
|
41
|
-
end
|
42
|
-
|
43
|
-
# Validate an API key without making changes
|
44
|
-
#
|
45
|
-
# @return [Hash] Validation result with key permissions
|
46
|
-
def validate_key
|
47
|
-
request(:post, "meta/validate", {})
|
48
|
-
end
|
49
|
-
|
50
|
-
# Get available API endpoints and their documentation
|
51
|
-
#
|
52
|
-
# @return [Hash] List of available endpoints with descriptions
|
53
|
-
def endpoints
|
54
|
-
request(:get, "meta/endpoints")
|
55
|
-
end
|
56
|
-
|
57
|
-
# Get workspace usage statistics
|
58
|
-
#
|
59
|
-
# @return [Hash] Usage statistics including record counts, API calls, etc.
|
60
|
-
def usage_stats
|
61
|
-
request(:get, "meta/usage")
|
62
|
-
end
|
63
|
-
|
64
|
-
# Get feature flags and capabilities for the workspace
|
65
|
-
#
|
66
|
-
# @return [Hash] Enabled features and capabilities
|
67
|
-
def features
|
68
|
-
request(:get, "meta/features")
|
69
|
-
end
|
70
|
-
end
|
71
|
-
end
|
72
|
-
end
|