attio 0.2.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/.github/workflows/release.yml +1 -45
- data/.gitignore +1 -0
- data/CHANGELOG.md +69 -0
- data/CLAUDE.md +391 -0
- data/Gemfile.lock +1 -1
- data/README.md +370 -24
- data/lib/attio/circuit_breaker.rb +299 -0
- data/lib/attio/client.rb +43 -1
- data/lib/attio/connection_pool.rb +190 -35
- data/lib/attio/enhanced_client.rb +257 -0
- data/lib/attio/errors.rb +30 -2
- data/lib/attio/http_client.rb +58 -3
- data/lib/attio/observability.rb +424 -0
- data/lib/attio/rate_limiter.rb +212 -0
- data/lib/attio/resources/base.rb +70 -2
- data/lib/attio/resources/bulk.rb +290 -0
- data/lib/attio/resources/deals.rb +183 -0
- data/lib/attio/resources/records.rb +29 -2
- data/lib/attio/resources/workspace_members.rb +103 -0
- data/lib/attio/version.rb +1 -1
- data/lib/attio/webhooks.rb +220 -0
- data/lib/attio.rb +12 -0
- metadata +10 -1
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module Resources
|
5
|
+
# Workspace Members resource for managing workspace member access
|
6
|
+
#
|
7
|
+
# @example List all workspace members
|
8
|
+
# client.workspace_members.list
|
9
|
+
#
|
10
|
+
# @example Get a specific member
|
11
|
+
# client.workspace_members.get(member_id: "user_123")
|
12
|
+
#
|
13
|
+
# @example Invite a new member
|
14
|
+
# client.workspace_members.invite(
|
15
|
+
# email: "new.member@example.com",
|
16
|
+
# role: "member"
|
17
|
+
# )
|
18
|
+
class WorkspaceMembers < Base
|
19
|
+
# List all workspace members
|
20
|
+
#
|
21
|
+
# @param params [Hash] Optional query parameters
|
22
|
+
# @option params [Integer] :limit Maximum number of results
|
23
|
+
# @option params [String] :offset Pagination offset
|
24
|
+
# @return [Hash] The API response
|
25
|
+
def list(params = {})
|
26
|
+
request(:get, "workspace_members", params)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Get a specific workspace member
|
30
|
+
#
|
31
|
+
# @param member_id [String] The member ID
|
32
|
+
# @return [Hash] The member data
|
33
|
+
def get(member_id:)
|
34
|
+
validate_id!(member_id, "Member")
|
35
|
+
request(:get, "workspace_members/#{member_id}")
|
36
|
+
end
|
37
|
+
|
38
|
+
# Invite a new member to the workspace
|
39
|
+
#
|
40
|
+
# @param email [String] The email address to invite
|
41
|
+
# @param role [String] The role to assign (admin, member, guest)
|
42
|
+
# @param data [Hash] Additional member data
|
43
|
+
# @return [Hash] The created invitation
|
44
|
+
def invite(email:, role: "member", data: {})
|
45
|
+
validate_required_string!(email, "Email")
|
46
|
+
validate_required_string!(role, "Role")
|
47
|
+
|
48
|
+
raise ArgumentError, "Role must be one of: admin, member, guest" unless %w[admin member guest].include?(role)
|
49
|
+
|
50
|
+
body = data.merge(email: email, role: role)
|
51
|
+
request(:post, "workspace_members/invitations", body)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Update a workspace member's role or permissions
|
55
|
+
#
|
56
|
+
# @param member_id [String] The member ID to update
|
57
|
+
# @param data [Hash] The data to update
|
58
|
+
# @option data [String] :role The new role
|
59
|
+
# @option data [Hash] :permissions Custom permissions
|
60
|
+
# @return [Hash] The updated member
|
61
|
+
def update(member_id:, data:)
|
62
|
+
validate_id!(member_id, "Member")
|
63
|
+
validate_required_hash!(data, "Data")
|
64
|
+
|
65
|
+
request(:patch, "workspace_members/#{member_id}", data)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Remove a member from the workspace
|
69
|
+
#
|
70
|
+
# @param member_id [String] The member ID to remove
|
71
|
+
# @return [Hash] Confirmation of removal
|
72
|
+
def remove(member_id:)
|
73
|
+
validate_id!(member_id, "Member")
|
74
|
+
request(:delete, "workspace_members/#{member_id}")
|
75
|
+
end
|
76
|
+
|
77
|
+
# Accept a workspace invitation
|
78
|
+
#
|
79
|
+
# @param invitation_token [String] The invitation token
|
80
|
+
# @return [Hash] The workspace member data
|
81
|
+
def accept_invitation(invitation_token:)
|
82
|
+
validate_required_string!(invitation_token, "Invitation token")
|
83
|
+
request(:post, "workspace_members/invitations/#{invitation_token}/accept")
|
84
|
+
end
|
85
|
+
|
86
|
+
# Resend an invitation
|
87
|
+
#
|
88
|
+
# @param member_id [String] The member ID with pending invitation
|
89
|
+
# @return [Hash] Confirmation of resent invitation
|
90
|
+
def resend_invitation(member_id:)
|
91
|
+
validate_id!(member_id, "Member")
|
92
|
+
request(:post, "workspace_members/#{member_id}/resend_invitation")
|
93
|
+
end
|
94
|
+
|
95
|
+
# Get current member (self)
|
96
|
+
#
|
97
|
+
# @return [Hash] The current authenticated member's data
|
98
|
+
def me
|
99
|
+
request(:get, "workspace_members/me")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
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,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"
|
@@ -18,6 +19,17 @@ require "attio/resources/notes"
|
|
18
19
|
require "attio/resources/tasks"
|
19
20
|
require "attio/resources/comments"
|
20
21
|
require "attio/resources/threads"
|
22
|
+
require "attio/resources/workspace_members"
|
23
|
+
require "attio/resources/deals"
|
24
|
+
require "attio/resources/bulk"
|
25
|
+
|
26
|
+
# Enterprise features
|
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"
|
21
33
|
|
22
34
|
# The main Attio module provides access to the Attio API client.
|
23
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
|
@@ -48,6 +48,7 @@ files:
|
|
48
48
|
- ".rubocop.yml"
|
49
49
|
- ".yardopts"
|
50
50
|
- CHANGELOG.md
|
51
|
+
- CLAUDE.md
|
51
52
|
- CODE_OF_CONDUCT.md
|
52
53
|
- CONCEPTS.md
|
53
54
|
- CONTRIBUTING.md
|
@@ -109,14 +110,20 @@ files:
|
|
109
110
|
- examples/full_workflow.rb
|
110
111
|
- examples/notes_and_tasks.rb
|
111
112
|
- lib/attio.rb
|
113
|
+
- lib/attio/circuit_breaker.rb
|
112
114
|
- lib/attio/client.rb
|
113
115
|
- lib/attio/connection_pool.rb
|
116
|
+
- lib/attio/enhanced_client.rb
|
114
117
|
- lib/attio/errors.rb
|
115
118
|
- lib/attio/http_client.rb
|
116
119
|
- lib/attio/logger.rb
|
120
|
+
- lib/attio/observability.rb
|
121
|
+
- lib/attio/rate_limiter.rb
|
117
122
|
- lib/attio/resources/attributes.rb
|
118
123
|
- lib/attio/resources/base.rb
|
124
|
+
- lib/attio/resources/bulk.rb
|
119
125
|
- lib/attio/resources/comments.rb
|
126
|
+
- lib/attio/resources/deals.rb
|
120
127
|
- lib/attio/resources/lists.rb
|
121
128
|
- lib/attio/resources/notes.rb
|
122
129
|
- lib/attio/resources/objects.rb
|
@@ -124,9 +131,11 @@ files:
|
|
124
131
|
- lib/attio/resources/tasks.rb
|
125
132
|
- lib/attio/resources/threads.rb
|
126
133
|
- lib/attio/resources/users.rb
|
134
|
+
- lib/attio/resources/workspace_members.rb
|
127
135
|
- lib/attio/resources/workspaces.rb
|
128
136
|
- lib/attio/retry_handler.rb
|
129
137
|
- lib/attio/version.rb
|
138
|
+
- lib/attio/webhooks.rb
|
130
139
|
homepage: https://github.com/idl3/attio
|
131
140
|
licenses:
|
132
141
|
- MIT
|