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.
@@ -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.3.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
@@ -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