attio 0.3.0 → 0.5.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 +2 -0
- data/CHANGELOG.md +83 -2
- data/CLAUDE.md +35 -4
- data/Gemfile.lock +1 -1
- data/META_IMPLEMENTATION_PLAN.md +205 -0
- data/README.md +361 -37
- data/lib/attio/circuit_breaker.rb +299 -0
- data/lib/attio/client.rb +11 -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/attributes.rb +244 -0
- data/lib/attio/resources/base.rb +53 -0
- data/lib/attio/resources/bulk.rb +1 -1
- data/lib/attio/resources/lists.rb +195 -0
- data/lib/attio/resources/meta.rb +103 -42
- data/lib/attio/resources/objects.rb +104 -0
- data/lib/attio/resources/records.rb +97 -2
- data/lib/attio/resources/workspaces.rb +11 -2
- data/lib/attio/version.rb +1 -1
- data/lib/attio/webhooks.rb +220 -0
- data/lib/attio.rb +9 -1
- metadata +6 -1
@@ -5,16 +5,25 @@ module Attio
|
|
5
5
|
# API resource for managing workspace information
|
6
6
|
#
|
7
7
|
# Workspaces are the top-level organizational unit in Attio.
|
8
|
+
# Note: The workspace information is retrieved via the Meta API (/v2/self)
|
8
9
|
#
|
9
10
|
# @example Getting workspace information
|
10
11
|
# client.workspaces.get
|
11
12
|
class Workspaces < Base
|
13
|
+
# Get current workspace information
|
14
|
+
#
|
15
|
+
# This method retrieves workspace info from the /v2/self endpoint
|
16
|
+
# which provides workspace context along with token information.
|
17
|
+
#
|
18
|
+
# @return [Hash] Workspace information from the self endpoint
|
12
19
|
def get
|
13
|
-
request(:get, "
|
20
|
+
request(:get, "self")
|
14
21
|
end
|
15
22
|
|
23
|
+
# @deprecated Use client.workspace_members.list instead
|
16
24
|
def members(**params)
|
17
|
-
|
25
|
+
warn "[DEPRECATION] `workspaces.members` is deprecated. Use `workspace_members.list` instead."
|
26
|
+
request(:get, "workspace_members", params)
|
18
27
|
end
|
19
28
|
end
|
20
29
|
end
|
data/lib/attio/version.rb
CHANGED
@@ -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,7 +7,9 @@ require "attio/errors"
|
|
7
7
|
require "attio/http_client"
|
8
8
|
require "attio/client"
|
9
9
|
|
10
|
+
# Resources
|
10
11
|
require "attio/resources/base"
|
12
|
+
require "attio/resources/meta"
|
11
13
|
require "attio/resources/records"
|
12
14
|
require "attio/resources/objects"
|
13
15
|
require "attio/resources/lists"
|
@@ -20,9 +22,15 @@ require "attio/resources/comments"
|
|
20
22
|
require "attio/resources/threads"
|
21
23
|
require "attio/resources/workspace_members"
|
22
24
|
require "attio/resources/deals"
|
23
|
-
require "attio/resources/meta"
|
24
25
|
require "attio/resources/bulk"
|
26
|
+
|
27
|
+
# Enterprise features
|
25
28
|
require "attio/rate_limiter"
|
29
|
+
require "attio/webhooks"
|
30
|
+
require "attio/connection_pool"
|
31
|
+
require "attio/circuit_breaker"
|
32
|
+
require "attio/observability"
|
33
|
+
require "attio/enhanced_client"
|
26
34
|
|
27
35
|
# The main Attio module provides access to the Attio API client.
|
28
36
|
#
|
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.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ernest Sim
|
@@ -55,6 +55,7 @@ files:
|
|
55
55
|
- Gemfile
|
56
56
|
- Gemfile.lock
|
57
57
|
- LICENSE.txt
|
58
|
+
- META_IMPLEMENTATION_PLAN.md
|
58
59
|
- README.md
|
59
60
|
- Rakefile
|
60
61
|
- SECURITY.md
|
@@ -110,11 +111,14 @@ files:
|
|
110
111
|
- examples/full_workflow.rb
|
111
112
|
- examples/notes_and_tasks.rb
|
112
113
|
- lib/attio.rb
|
114
|
+
- lib/attio/circuit_breaker.rb
|
113
115
|
- lib/attio/client.rb
|
114
116
|
- lib/attio/connection_pool.rb
|
117
|
+
- lib/attio/enhanced_client.rb
|
115
118
|
- lib/attio/errors.rb
|
116
119
|
- lib/attio/http_client.rb
|
117
120
|
- lib/attio/logger.rb
|
121
|
+
- lib/attio/observability.rb
|
118
122
|
- lib/attio/rate_limiter.rb
|
119
123
|
- lib/attio/resources/attributes.rb
|
120
124
|
- lib/attio/resources/base.rb
|
@@ -133,6 +137,7 @@ files:
|
|
133
137
|
- lib/attio/resources/workspaces.rb
|
134
138
|
- lib/attio/retry_handler.rb
|
135
139
|
- lib/attio/version.rb
|
140
|
+
- lib/attio/webhooks.rb
|
136
141
|
homepage: https://github.com/idl3/attio
|
137
142
|
licenses:
|
138
143
|
- MIT
|