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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Attio
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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.2.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