attio-ruby 0.1.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +164 -0
  4. data/.simplecov +17 -0
  5. data/.yardopts +9 -0
  6. data/CHANGELOG.md +27 -0
  7. data/CONTRIBUTING.md +333 -0
  8. data/INTEGRATION_TEST_STATUS.md +149 -0
  9. data/LICENSE +21 -0
  10. data/README.md +638 -0
  11. data/Rakefile +8 -0
  12. data/attio-ruby.gemspec +61 -0
  13. data/docs/CODECOV_SETUP.md +34 -0
  14. data/examples/basic_usage.rb +149 -0
  15. data/examples/oauth_flow.rb +843 -0
  16. data/examples/oauth_flow_README.md +84 -0
  17. data/examples/typed_records_example.rb +167 -0
  18. data/examples/webhook_server.rb +463 -0
  19. data/lib/attio/api_resource.rb +539 -0
  20. data/lib/attio/builders/name_builder.rb +181 -0
  21. data/lib/attio/client.rb +160 -0
  22. data/lib/attio/errors.rb +126 -0
  23. data/lib/attio/internal/record.rb +359 -0
  24. data/lib/attio/oauth/client.rb +219 -0
  25. data/lib/attio/oauth/scope_validator.rb +162 -0
  26. data/lib/attio/oauth/token.rb +158 -0
  27. data/lib/attio/resources/attribute.rb +332 -0
  28. data/lib/attio/resources/comment.rb +114 -0
  29. data/lib/attio/resources/company.rb +224 -0
  30. data/lib/attio/resources/entry.rb +208 -0
  31. data/lib/attio/resources/list.rb +196 -0
  32. data/lib/attio/resources/meta.rb +113 -0
  33. data/lib/attio/resources/note.rb +213 -0
  34. data/lib/attio/resources/object.rb +66 -0
  35. data/lib/attio/resources/person.rb +294 -0
  36. data/lib/attio/resources/task.rb +147 -0
  37. data/lib/attio/resources/thread.rb +99 -0
  38. data/lib/attio/resources/typed_record.rb +98 -0
  39. data/lib/attio/resources/webhook.rb +224 -0
  40. data/lib/attio/resources/workspace_member.rb +136 -0
  41. data/lib/attio/util/configuration.rb +166 -0
  42. data/lib/attio/util/id_extractor.rb +115 -0
  43. data/lib/attio/util/webhook_signature.rb +175 -0
  44. data/lib/attio/version.rb +6 -0
  45. data/lib/attio/webhook/event.rb +114 -0
  46. data/lib/attio/webhook/signature_verifier.rb +73 -0
  47. data/lib/attio.rb +123 -0
  48. metadata +402 -0
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "securerandom"
5
+ require "base64"
6
+
7
+ module Attio
8
+ # OAuth authentication module for Attio API
9
+ module OAuth
10
+ # OAuth client for handling the OAuth 2.0 authorization flow
11
+ class Client
12
+ # OAuth endpoints
13
+ OAUTH_BASE_URL = "https://app.attio.com/authorize"
14
+ # Token exchange endpoint
15
+ TOKEN_URL = "https://app.attio.com/oauth/token"
16
+ # Default OAuth scopes requested if none specified
17
+ DEFAULT_SCOPES = %w[
18
+ record:read
19
+ record:write
20
+ object:read
21
+ object:write
22
+ list:read
23
+ list:write
24
+ webhook:read
25
+ webhook:write
26
+ user:read
27
+ ].freeze
28
+
29
+ attr_reader :client_id, :client_secret, :redirect_uri
30
+
31
+ def initialize(client_id:, client_secret:, redirect_uri:)
32
+ @client_id = client_id
33
+ @client_secret = client_secret
34
+ @redirect_uri = redirect_uri
35
+ validate_config!
36
+ end
37
+
38
+ # Generate authorization URL for OAuth flow
39
+ def authorization_url(scopes: DEFAULT_SCOPES, state: nil, extras: {})
40
+ state ||= generate_state
41
+ scopes = validate_scopes(scopes)
42
+
43
+ params = {
44
+ client_id: client_id,
45
+ redirect_uri: redirect_uri,
46
+ response_type: "code",
47
+ scope: scopes.join(" "),
48
+ state: state
49
+ }.merge(extras)
50
+
51
+ uri = URI.parse(OAUTH_BASE_URL)
52
+ uri.query = URI.encode_www_form(params)
53
+
54
+ {
55
+ url: uri.to_s,
56
+ state: state
57
+ }
58
+ end
59
+
60
+ # Exchange authorization code for access token
61
+ def exchange_code_for_token(code:, state: nil)
62
+ raise ArgumentError, "Authorization code is required" if code.nil? || code.empty?
63
+
64
+ params = {
65
+ grant_type: "authorization_code",
66
+ code: code,
67
+ redirect_uri: redirect_uri,
68
+ client_id: client_id,
69
+ client_secret: client_secret
70
+ }
71
+
72
+ response = make_token_request(params)
73
+ Token.new(response.merge(client: self))
74
+ end
75
+
76
+ # Refresh an existing access token
77
+ def refresh_token(refresh_token)
78
+ raise ArgumentError, "Refresh token is required" if refresh_token.nil? || refresh_token.empty?
79
+
80
+ params = {
81
+ grant_type: "refresh_token",
82
+ refresh_token: refresh_token,
83
+ client_id: client_id,
84
+ client_secret: client_secret
85
+ }
86
+
87
+ response = make_token_request(params)
88
+ Token.new(response.merge(client: self))
89
+ end
90
+
91
+ # Revoke a token
92
+ def revoke_token(token)
93
+ token_value = token.is_a?(Token) ? token.access_token : token
94
+
95
+ params = {
96
+ token: token_value,
97
+ client_id: client_id,
98
+ client_secret: client_secret
99
+ }
100
+
101
+ # Use Faraday directly for OAuth endpoints
102
+ conn = create_oauth_connection
103
+ response = conn.post("/oauth/revoke") do |req|
104
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
105
+ req.body = URI.encode_www_form(params)
106
+ end
107
+
108
+ response.success?
109
+ rescue => e
110
+ # Log the error if debug mode is enabled
111
+ warn "OAuth token revocation failed: #{e.message}" if Attio.configuration.debug
112
+ false
113
+ end
114
+
115
+ # Validate token with introspection endpoint
116
+ def introspect_token(token)
117
+ token_value = token.is_a?(Token) ? token.access_token : token
118
+
119
+ params = {
120
+ token: token_value,
121
+ client_id: client_id,
122
+ client_secret: client_secret
123
+ }
124
+
125
+ # Use Faraday directly for OAuth endpoints
126
+ conn = create_oauth_connection
127
+ response = conn.post("/oauth/introspect") do |req|
128
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
129
+ req.body = URI.encode_www_form(params)
130
+ end
131
+
132
+ if response.success?
133
+ response.body
134
+ else
135
+ handle_oauth_error(response)
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def validate_config!
142
+ raise ArgumentError, "client_id is required" if client_id.nil? || client_id.empty?
143
+ raise ArgumentError, "client_secret is required" if client_secret.nil? || client_secret.empty?
144
+ raise ArgumentError, "redirect_uri is required" if redirect_uri.nil? || redirect_uri.empty?
145
+
146
+ unless redirect_uri.start_with?("http://", "https://")
147
+ raise ArgumentError, "redirect_uri must be a valid HTTP(S) URL"
148
+ end
149
+ end
150
+
151
+ def validate_scopes(scopes)
152
+ scopes = Array(scopes).map(&:to_s)
153
+ return DEFAULT_SCOPES if scopes.empty?
154
+
155
+ invalid_scopes = scopes - ScopeValidator::VALID_SCOPES
156
+ unless invalid_scopes.empty?
157
+ raise ArgumentError, "Invalid scopes: #{invalid_scopes.join(", ")}"
158
+ end
159
+
160
+ scopes
161
+ end
162
+
163
+ def generate_state
164
+ SecureRandom.urlsafe_base64(32)
165
+ end
166
+
167
+ def make_token_request(params)
168
+ conn = Faraday.new do |faraday|
169
+ faraday.request :url_encoded
170
+ faraday.response :json, parser_options: {symbolize_names: true}
171
+ faraday.adapter Faraday.default_adapter
172
+ end
173
+
174
+ response = conn.post(TOKEN_URL, params) do |req|
175
+ req.headers["Accept"] = "application/json"
176
+ end
177
+
178
+ if response.success?
179
+ response.body
180
+ else
181
+ handle_oauth_error(response)
182
+ end
183
+ end
184
+
185
+ def create_oauth_connection
186
+ Faraday.new(url: "https://app.attio.com") do |faraday|
187
+ faraday.response :json, parser_options: {symbolize_names: true}
188
+ faraday.adapter Faraday.default_adapter
189
+ end
190
+ end
191
+
192
+ def handle_oauth_error(response)
193
+ error_body = begin
194
+ response.body
195
+ rescue
196
+ {}
197
+ end
198
+ error_message = if error_body.is_a?(Hash)
199
+ error_body[:error_description] || error_body[:error] || "OAuth error"
200
+ else
201
+ "OAuth error"
202
+ end
203
+
204
+ case response.status
205
+ when 400
206
+ raise BadRequestError, error_message
207
+ when 401
208
+ raise AuthenticationError, error_message
209
+ when 403
210
+ raise ForbiddenError, error_message
211
+ when 404
212
+ raise NotFoundError, error_message
213
+ else
214
+ raise Error, "OAuth error: #{error_message} (status: #{response.status})"
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module OAuth
5
+ # Validates and manages OAuth scopes for Attio API
6
+ class ScopeValidator
7
+ # Define all valid scopes with their descriptions
8
+ SCOPE_DEFINITIONS = {
9
+ # Record scopes
10
+ "record:read" => "Read access to records",
11
+ "record:write" => "Write access to records (includes read)",
12
+
13
+ # Object scopes
14
+ "object:read" => "Read access to objects and their configuration",
15
+ "object:write" => "Write access to objects (includes read)",
16
+
17
+ # List scopes
18
+ "list:read" => "Read access to lists and list entries",
19
+ "list:write" => "Write access to lists (includes read)",
20
+
21
+ # Webhook scopes
22
+ "webhook:read" => "Read access to webhooks",
23
+ "webhook:write" => "Write access to webhooks (includes read)",
24
+
25
+ # User scopes
26
+ "user:read" => "Read access to workspace members",
27
+
28
+ # Note scopes
29
+ "note:read" => "Read access to notes",
30
+ "note:write" => "Write access to notes (includes read)",
31
+
32
+ # Attribute scopes
33
+ "attribute:read" => "Read access to attributes",
34
+ "attribute:write" => "Write access to attributes (includes read)",
35
+
36
+ # Comment scopes
37
+ "comment:read" => "Read access to comments",
38
+ "comment:write" => "Write access to comments (includes read)",
39
+
40
+ # Task scopes
41
+ "task:read" => "Read access to tasks",
42
+ "task:write" => "Write access to tasks (includes read)"
43
+ }.freeze
44
+
45
+ # Array of all valid scope strings
46
+ VALID_SCOPES = SCOPE_DEFINITIONS.keys.freeze
47
+
48
+ # Scope hierarchy - write scopes include read scopes
49
+ SCOPE_HIERARCHY = {
50
+ "record:write" => ["record:read"],
51
+ "object:write" => ["object:read"],
52
+ "list:write" => ["list:read"],
53
+ "webhook:write" => ["webhook:read"],
54
+ "note:write" => ["note:read"],
55
+ "attribute:write" => ["attribute:read"],
56
+ "comment:write" => ["comment:read"],
57
+ "task:write" => ["task:read"]
58
+ }.freeze
59
+
60
+ class << self
61
+ # Validate that all provided scopes are valid
62
+ # @param scopes [Array<String>, String] Scopes to validate
63
+ # @return [Array<String>] Validated scopes
64
+ # @raise [InvalidScopeError] If any scope is invalid
65
+ def validate(scopes)
66
+ scopes = Array(scopes).map(&:to_s)
67
+ invalid_scopes = scopes - VALID_SCOPES
68
+
69
+ unless invalid_scopes.empty?
70
+ raise InvalidScopeError, "Invalid scopes: #{invalid_scopes.join(", ")}"
71
+ end
72
+
73
+ scopes
74
+ end
75
+
76
+ # Validate scopes and return true if valid
77
+ # @param scopes [Array<String>, String] Scopes to validate
78
+ # @return [true]
79
+ # @raise [InvalidScopeError] If any scope is invalid
80
+ def validate!(scopes)
81
+ validate(scopes)
82
+ true
83
+ end
84
+
85
+ def valid?(scope)
86
+ VALID_SCOPES.include?(scope.to_s)
87
+ end
88
+
89
+ # Get the description for a scope
90
+ # @param scope [String] The scope to describe
91
+ # @return [String, nil] Description or nil if scope not found
92
+ def description(scope)
93
+ SCOPE_DEFINITIONS[scope.to_s]
94
+ end
95
+
96
+ # Check if a set of scopes includes a specific permission
97
+ def includes?(scopes, required_scope)
98
+ scopes = Array(scopes).map(&:to_s)
99
+ required = required_scope.to_s
100
+
101
+ return true if scopes.include?(required)
102
+
103
+ # Check if any scope in the set provides the required scope
104
+ scopes.any? do |scope|
105
+ implied_scopes = SCOPE_HIERARCHY[scope] || []
106
+ implied_scopes.include?(required)
107
+ end
108
+ end
109
+
110
+ # Expand scopes to include all implied scopes
111
+ def expand(scopes)
112
+ scopes = Array(scopes).map(&:to_s)
113
+ expanded = Set.new(scopes)
114
+
115
+ scopes.each do |scope|
116
+ implied = SCOPE_HIERARCHY[scope] || []
117
+ expanded.merge(implied)
118
+ end
119
+
120
+ expanded.to_a.sort
121
+ end
122
+
123
+ # Get minimal set of scopes (remove redundant read scopes)
124
+ def minimize(scopes)
125
+ scopes = Array(scopes).map(&:to_s)
126
+ minimized = scopes.dup
127
+
128
+ SCOPE_HIERARCHY.each do |write_scope, read_scopes|
129
+ if minimized.include?(write_scope)
130
+ minimized -= read_scopes
131
+ end
132
+ end
133
+
134
+ minimized.sort
135
+ end
136
+
137
+ # Group scopes by resource type
138
+ def group_by_resource(scopes)
139
+ scopes = Array(scopes).map(&:to_s)
140
+ grouped = {}
141
+
142
+ scopes.each do |scope|
143
+ resource = scope.split(":").first
144
+ grouped[resource] ||= []
145
+ grouped[resource] << scope
146
+ end
147
+
148
+ grouped
149
+ end
150
+
151
+ # Check if scopes are sufficient for an operation
152
+ def sufficient_for?(scopes, resource:, operation:)
153
+ required_scope = "#{resource}:#{operation}"
154
+ includes?(scopes, required_scope)
155
+ end
156
+ end
157
+
158
+ # Raised when invalid scopes are provided
159
+ class InvalidScopeError < StandardError; end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module OAuth
5
+ # Represents an OAuth access token with refresh capabilities
6
+ class Token
7
+ attr_reader :access_token, :refresh_token, :token_type, :expires_in,
8
+ :expires_at, :scope, :created_at, :client
9
+
10
+ def initialize(attributes = {})
11
+ # Since this doesn't inherit from Resources::Base, we need to normalize
12
+ normalized_attrs = normalize_attributes(attributes)
13
+ @access_token = normalized_attrs[:access_token]
14
+ @refresh_token = normalized_attrs[:refresh_token]
15
+ @token_type = normalized_attrs[:token_type] || "Bearer"
16
+ @expires_in = normalized_attrs[:expires_in]&.to_i
17
+ @scope = parse_scope(normalized_attrs[:scope])
18
+ @created_at = normalized_attrs[:created_at] || Time.now.utc
19
+ @client = normalized_attrs[:client]
20
+
21
+ calculate_expiration!
22
+ validate!
23
+ end
24
+
25
+ def expired?
26
+ return false if @expires_at.nil?
27
+ Time.now.utc >= @expires_at
28
+ end
29
+
30
+ def expires_soon?(threshold = 300)
31
+ return false if @expires_at.nil?
32
+ Time.now.utc >= (@expires_at - threshold)
33
+ end
34
+
35
+ def refresh!
36
+ raise InvalidTokenError, "No refresh token available" unless @refresh_token
37
+ raise InvalidTokenError, "No OAuth client configured" unless @client
38
+
39
+ new_token = @client.refresh_token(@refresh_token)
40
+ update_from(new_token)
41
+ self
42
+ end
43
+
44
+ def revoke!
45
+ raise InvalidTokenError, "No OAuth client configured" unless @client
46
+
47
+ @client.revoke_token(self)
48
+ @access_token = nil
49
+ @refresh_token = nil
50
+ true
51
+ end
52
+
53
+ # Convert token to hash representation
54
+ # @return [Hash] Token attributes as a hash
55
+ def to_h
56
+ {
57
+ access_token: @access_token,
58
+ refresh_token: @refresh_token,
59
+ token_type: @token_type,
60
+ expires_in: @expires_in,
61
+ expires_at: @expires_at&.iso8601,
62
+ scope: @scope,
63
+ created_at: @created_at.iso8601
64
+ }.compact
65
+ end
66
+
67
+ # Convert token to JSON string
68
+ # @param opts [Hash] Options to pass to JSON.generate
69
+ # @return [String] JSON representation of the token
70
+ def to_json(*opts)
71
+ JSON.generate(to_h, *opts)
72
+ end
73
+
74
+ # Human-readable representation with masked token
75
+ # @return [String] Inspection string with partially masked token
76
+ def inspect
77
+ scope_str = @scope.is_a?(Array) ? @scope.join(" ") : @scope.to_s
78
+ "#<#{self.class.name}:#{object_id} " \
79
+ "token=#{@access_token ? "***" + @access_token[-4..] : "nil"} " \
80
+ "expires_at=#{@expires_at&.iso8601} " \
81
+ "scope=#{scope_str}>"
82
+ end
83
+
84
+ # Authorization header value
85
+ def authorization_header
86
+ "#{@token_type} #{@access_token}"
87
+ end
88
+
89
+ # Check if token has specific scope
90
+ def has_scope?(scope)
91
+ @scope.include?(scope.to_s)
92
+ end
93
+
94
+ # Store token securely (subclasses can override)
95
+ def save
96
+ # Default implementation does nothing
97
+ # Subclasses can implement secure storage
98
+ self
99
+ end
100
+
101
+ # Load token from secure storage (class method)
102
+ def self.load(identifier = nil)
103
+ # Default implementation returns nil
104
+ # Subclasses can implement secure retrieval
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ def calculate_expiration!
111
+ @expires_at = if @expires_in
112
+ @created_at + @expires_in
113
+ end
114
+ end
115
+
116
+ def parse_scope(scope)
117
+ case scope
118
+ when String
119
+ scope.split(" ")
120
+ when Array
121
+ scope.map(&:to_s)
122
+ when NilClass
123
+ # If scope is not provided in the token response, return nil
124
+ # This indicates the token has all scopes that were authorized
125
+ nil
126
+ else
127
+ []
128
+ end
129
+ end
130
+
131
+ def validate!
132
+ raise InvalidTokenError, "Access token is required" if @access_token.nil? || @access_token.empty?
133
+ raise InvalidTokenError, "Invalid token type" unless %w[Bearer bearer].include?(@token_type)
134
+ end
135
+
136
+ def update_from(other_token)
137
+ @access_token = other_token.access_token
138
+ @refresh_token = other_token.refresh_token if other_token.refresh_token
139
+ @token_type = other_token.token_type
140
+ @expires_in = other_token.expires_in
141
+ @expires_at = other_token.expires_at
142
+ @scope = other_token.scope
143
+ @created_at = other_token.created_at
144
+ end
145
+
146
+ def normalize_attributes(attributes)
147
+ return {} unless attributes
148
+
149
+ attributes.each_with_object({}) do |(key, value), hash|
150
+ hash[key.to_sym] = value
151
+ end
152
+ end
153
+
154
+ # Raised when token validation fails
155
+ class InvalidTokenError < StandardError; end
156
+ end
157
+ end
158
+ end