attio 0.1.1 → 0.1.3
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/ci.yml +39 -15
- data/.github/workflows/coverage.yml +67 -0
- data/.github/workflows/pr_checks.yml +25 -7
- data/.github/workflows/release.yml +25 -7
- data/.github/workflows/tests.yml +67 -0
- data/.rubocop.yml +362 -90
- data/CHANGELOG.md +23 -0
- data/CONTRIBUTING.md +4 -4
- data/Gemfile +8 -5
- data/Gemfile.lock +2 -4
- data/README.md +8 -0
- data/Rakefile +8 -6
- data/attio.gemspec +6 -7
- data/danger/Dangerfile +22 -34
- data/docs/example.rb +30 -29
- data/lib/attio/client.rb +31 -29
- data/lib/attio/connection_pool.rb +26 -14
- data/lib/attio/errors.rb +4 -2
- data/lib/attio/http_client.rb +58 -37
- data/lib/attio/logger.rb +37 -27
- data/lib/attio/resources/attributes.rb +12 -5
- data/lib/attio/resources/base.rb +14 -24
- data/lib/attio/resources/lists.rb +16 -7
- data/lib/attio/resources/objects.rb +11 -4
- data/lib/attio/resources/records.rb +42 -47
- data/lib/attio/resources/users.rb +10 -4
- data/lib/attio/resources/workspaces.rb +9 -1
- data/lib/attio/retry_handler.rb +19 -11
- data/lib/attio/version.rb +3 -1
- data/lib/attio.rb +11 -9
- metadata +3 -18
- data/run_tests.rb +0 -52
- data/test_basic.rb +0 -51
- data/test_typhoeus.rb +0 -31
data/attio.gemspec
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/attio/version"
|
2
4
|
|
3
5
|
Gem::Specification.new do |spec|
|
4
6
|
spec.name = "attio"
|
@@ -6,8 +8,8 @@ Gem::Specification.new do |spec|
|
|
6
8
|
spec.authors = ["Ernest Sim"]
|
7
9
|
spec.email = ["ernest.codes@gmail.com"]
|
8
10
|
|
9
|
-
spec.summary =
|
10
|
-
spec.description =
|
11
|
+
spec.summary = "Ruby client for the Attio API"
|
12
|
+
spec.description = "A Ruby library for interacting with the Attio API, providing easy access to CRM functionality"
|
11
13
|
spec.homepage = "https://github.com/idl3/attio"
|
12
14
|
spec.license = "MIT"
|
13
15
|
spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
|
@@ -20,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
20
22
|
|
21
23
|
# Specify which files should be added to the gem when it is released.
|
22
24
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
-
spec.files
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
26
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
27
|
end
|
26
28
|
spec.bindir = "exe"
|
@@ -28,7 +30,4 @@ Gem::Specification.new do |spec|
|
|
28
30
|
spec.require_paths = ["lib"]
|
29
31
|
|
30
32
|
spec.add_dependency "typhoeus", "~> 1.4"
|
31
|
-
|
32
|
-
# Development dependencies
|
33
|
-
spec.add_development_dependency "yard", "~> 0.9"
|
34
33
|
end
|
data/danger/Dangerfile
CHANGED
@@ -7,13 +7,11 @@ warn("PR is classed as Work in Progress") if github.pr_title.include? "WIP"
|
|
7
7
|
warn("Big PR") if git.lines_of_code > 500
|
8
8
|
|
9
9
|
# Don't let testing shortcuts get into main by accident
|
10
|
-
|
11
|
-
|
10
|
+
raise("fdescribe left in tests") if `grep -r fdescribe spec/ `.length > 1
|
11
|
+
raise("fit left in tests") if `grep -r fit spec/ `.length > 1
|
12
12
|
|
13
13
|
# Ensure a clean commit history
|
14
|
-
if git.commits.any? { |c| c.message =~ /^fixup!/ }
|
15
|
-
fail("Please squash fixup! commits before merging")
|
16
|
-
end
|
14
|
+
raise("Please squash fixup! commits before merging") if git.commits.any? { |c| c.message =~ /^fixup!/ }
|
17
15
|
|
18
16
|
# Check for proper conventional commit format
|
19
17
|
if github.pr_title !~ /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build)(\(.+\))?: .+/
|
@@ -27,27 +25,21 @@ end
|
|
27
25
|
|
28
26
|
# Check if package files have been updated
|
29
27
|
package_updated = git.modified_files.include?("attio.gemspec") || git.modified_files.include?("Gemfile")
|
30
|
-
if package_updated
|
31
|
-
message("📦 Package files have been updated")
|
32
|
-
end
|
28
|
+
message("📦 Package files have been updated") if package_updated
|
33
29
|
|
34
30
|
# Encourage changelog updates for non-trivial changes
|
35
31
|
has_app_changes = git.modified_files.any? { |file| file.start_with?("lib/") }
|
36
32
|
has_changelog_changes = git.modified_files.include?("CHANGELOG.md")
|
37
33
|
|
38
|
-
if has_app_changes && !has_changelog_changes
|
34
|
+
if has_app_changes && !has_changelog_changes && github.pr_title !~ /^(chore|ci|docs|style|test):/
|
39
35
|
# Skip for certain PR types
|
40
|
-
|
41
|
-
warn("Consider updating CHANGELOG.md for this change")
|
42
|
-
end
|
36
|
+
warn("Consider updating CHANGELOG.md for this change")
|
43
37
|
end
|
44
38
|
|
45
39
|
# Check for TODO comments in the diff
|
46
40
|
git.diff.each do |file|
|
47
41
|
file.patch.lines.each_with_index do |line, index|
|
48
|
-
if line.start_with?("+") && line.include?("TODO")
|
49
|
-
warn("TODO comment added", file: file.path, line: index + 1)
|
50
|
-
end
|
42
|
+
warn("TODO comment added", file: file.path, line: index + 1) if line.start_with?("+") && line.include?("TODO")
|
51
43
|
end
|
52
44
|
end
|
53
45
|
|
@@ -55,9 +47,7 @@ end
|
|
55
47
|
has_new_features = git.diff.any? { |file| file.patch.include?("+def ") && file.path.start_with?("lib/") }
|
56
48
|
has_test_changes = git.modified_files.any? { |file| file.start_with?("spec/") }
|
57
49
|
|
58
|
-
if has_new_features && !has_test_changes
|
59
|
-
warn("New features should include tests")
|
60
|
-
end
|
50
|
+
warn("New features should include tests") if has_new_features && !has_test_changes
|
61
51
|
|
62
52
|
# Check for debugging code
|
63
53
|
debugging_patterns = [
|
@@ -66,27 +56,25 @@ debugging_patterns = [
|
|
66
56
|
"puts",
|
67
57
|
"p ",
|
68
58
|
"pp ",
|
69
|
-
"console.log"
|
59
|
+
"console.log",
|
70
60
|
]
|
71
61
|
|
72
62
|
git.diff.each do |file|
|
73
63
|
debugging_patterns.each do |pattern|
|
74
64
|
if file.patch.include?("+") && file.patch.include?(pattern)
|
75
|
-
|
65
|
+
raise("Debugging code found: #{pattern} in #{file.path}")
|
76
66
|
end
|
77
67
|
end
|
78
68
|
end
|
79
69
|
|
80
70
|
# Encourage documentation for public API changes
|
81
|
-
public_api_changes = git.diff.any? do |file|
|
82
|
-
file.path.start_with?("lib/") &&
|
83
|
-
|
84
|
-
|
71
|
+
public_api_changes = git.diff.any? do |file|
|
72
|
+
file.path.start_with?("lib/") &&
|
73
|
+
file.patch.include?("+ def ") &&
|
74
|
+
!file.patch.include?("+ def self.") # Skip private class methods
|
85
75
|
end
|
86
76
|
|
87
|
-
if public_api_changes
|
88
|
-
message("📝 Public API changes detected. Consider updating documentation.")
|
89
|
-
end
|
77
|
+
message("📝 Public API changes detected. Consider updating documentation.") if public_api_changes
|
90
78
|
|
91
79
|
# Check for secrets or sensitive information
|
92
80
|
sensitive_patterns = [
|
@@ -94,16 +82,16 @@ sensitive_patterns = [
|
|
94
82
|
/secret/i,
|
95
83
|
/password/i,
|
96
84
|
/token/i,
|
97
|
-
/auth/i
|
85
|
+
/auth/i,
|
98
86
|
]
|
99
87
|
|
100
88
|
git.diff.each do |file|
|
101
89
|
file.patch.lines.each_with_index do |line, index|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
90
|
+
next unless line.start_with?("+")
|
91
|
+
|
92
|
+
sensitive_patterns.each do |pattern|
|
93
|
+
if line.match?(pattern) && !line.include?("# ") # Not a comment
|
94
|
+
warn("Potential sensitive information detected", file: file.path, line: index + 1)
|
107
95
|
end
|
108
96
|
end
|
109
97
|
end
|
@@ -118,4 +106,4 @@ end
|
|
118
106
|
# Remind about version bumping for releases
|
119
107
|
if git.modified_files.include?("lib/attio/version.rb")
|
120
108
|
message("🔖 Version file updated. Don't forget to update CHANGELOG.md and create a release tag.")
|
121
|
-
end
|
109
|
+
end
|
data/docs/example.rb
CHANGED
@@ -1,24 +1,25 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
2
3
|
|
3
4
|
# Example usage of the Attio Ruby client
|
4
|
-
#
|
5
|
+
#
|
5
6
|
# This file demonstrates common use cases and serves as
|
6
7
|
# additional documentation for YARD.
|
7
8
|
|
8
|
-
require_relative
|
9
|
+
require_relative "../lib/attio"
|
9
10
|
|
10
11
|
# Initialize client with API key
|
11
12
|
# In production, use environment variables or secure config
|
12
|
-
client = Attio.client(api_key: ENV[
|
13
|
+
client = Attio.client(api_key: ENV["ATTIO_API_KEY"] || "your-api-key-here")
|
13
14
|
|
14
15
|
# Example 1: Working with People Records
|
15
16
|
puts "=== Working with People Records ==="
|
16
17
|
|
17
18
|
# List people with filters
|
18
19
|
people = client.records.list(
|
19
|
-
object:
|
20
|
+
object: "people",
|
20
21
|
filters: {
|
21
|
-
name: { contains:
|
22
|
+
name: { contains: "John" },
|
22
23
|
},
|
23
24
|
limit: 10
|
24
25
|
)
|
@@ -26,30 +27,30 @@ puts "Found #{people['data'].length} people matching filter"
|
|
26
27
|
|
27
28
|
# Create a new person
|
28
29
|
new_person = client.records.create(
|
29
|
-
object:
|
30
|
+
object: "people",
|
30
31
|
data: {
|
31
|
-
name:
|
32
|
-
email:
|
33
|
-
phone:
|
34
|
-
notes:
|
32
|
+
name: "Jane Doe",
|
33
|
+
email: "jane.doe@example.com",
|
34
|
+
phone: "+1-555-0123",
|
35
|
+
notes: "Created via Ruby client example",
|
35
36
|
}
|
36
37
|
)
|
37
38
|
puts "Created person: #{new_person['data']['name']} (ID: #{new_person['data']['id']})"
|
38
39
|
|
39
40
|
# Get the person we just created
|
40
41
|
person = client.records.get(
|
41
|
-
object:
|
42
|
-
id: new_person[
|
42
|
+
object: "people",
|
43
|
+
id: new_person["data"]["id"]
|
43
44
|
)
|
44
45
|
puts "Retrieved person: #{person['data']['name']}"
|
45
46
|
|
46
47
|
# Update the person
|
47
48
|
updated_person = client.records.update(
|
48
|
-
object:
|
49
|
-
id: person[
|
49
|
+
object: "people",
|
50
|
+
id: person["data"]["id"],
|
50
51
|
data: {
|
51
|
-
name:
|
52
|
-
notes:
|
52
|
+
name: "Jane Smith",
|
53
|
+
notes: "Updated name after marriage",
|
53
54
|
}
|
54
55
|
)
|
55
56
|
puts "Updated person name to: #{updated_person['data']['name']}"
|
@@ -59,24 +60,24 @@ puts "\n=== Working with Company Records ==="
|
|
59
60
|
|
60
61
|
# Create a company
|
61
62
|
company = client.records.create(
|
62
|
-
object:
|
63
|
+
object: "companies",
|
63
64
|
data: {
|
64
|
-
name:
|
65
|
-
domain:
|
66
|
-
industry:
|
65
|
+
name: "Acme Corporation",
|
66
|
+
domain: "acme.com",
|
67
|
+
industry: "Technology",
|
67
68
|
}
|
68
69
|
)
|
69
70
|
puts "Created company: #{company['data']['name']}"
|
70
71
|
|
71
72
|
# Link the person to the company
|
72
73
|
client.records.update(
|
73
|
-
object:
|
74
|
-
id: person[
|
74
|
+
object: "people",
|
75
|
+
id: person["data"]["id"],
|
75
76
|
data: {
|
76
77
|
company: {
|
77
|
-
target_object:
|
78
|
-
target_record_id: company[
|
79
|
-
}
|
78
|
+
target_object: "companies",
|
79
|
+
target_record_id: company["data"]["id"],
|
80
|
+
},
|
80
81
|
}
|
81
82
|
)
|
82
83
|
puts "Linked #{person['data']['name']} to #{company['data']['name']}"
|
@@ -101,7 +102,7 @@ puts "\n=== Error Handling Example ==="
|
|
101
102
|
|
102
103
|
begin
|
103
104
|
# Try to get a non-existent record
|
104
|
-
client.records.get(object:
|
105
|
+
client.records.get(object: "people", id: "non-existent-id")
|
105
106
|
rescue Attio::NotFoundError => e
|
106
107
|
puts "Caught expected error: #{e.message}"
|
107
108
|
rescue Attio::APIError => e
|
@@ -110,10 +111,10 @@ end
|
|
110
111
|
|
111
112
|
# Clean up - delete the records we created
|
112
113
|
puts "\n=== Cleanup ==="
|
113
|
-
client.records.delete(object:
|
114
|
+
client.records.delete(object: "people", id: person["data"]["id"])
|
114
115
|
puts "Deleted person record"
|
115
116
|
|
116
|
-
client.records.delete(object:
|
117
|
+
client.records.delete(object: "companies", id: company["data"]["id"])
|
117
118
|
puts "Deleted company record"
|
118
119
|
|
119
|
-
puts "\nExample completed successfully!"
|
120
|
+
puts "\nExample completed successfully!"
|
data/lib/attio/client.rb
CHANGED
@@ -1,51 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The main client class for interacting with the Attio API.
|
4
|
+
#
|
5
|
+
# This class provides access to all Attio API resources and handles
|
6
|
+
# authentication, connection management, and request routing.
|
7
|
+
#
|
8
|
+
# @example Basic client creation
|
9
|
+
# client = Attio::Client.new(api_key: 'your-api-key')
|
10
|
+
#
|
11
|
+
# @example Custom timeout
|
12
|
+
# client = Attio::Client.new(api_key: 'your-api-key', timeout: 60)
|
13
|
+
#
|
14
|
+
# @author Ernest Sim
|
15
|
+
# @since 1.0.0
|
1
16
|
module Attio
|
2
|
-
# The main client class for interacting with the Attio API.
|
3
|
-
#
|
4
|
-
# This class provides access to all Attio API resources and handles
|
5
|
-
# authentication, connection management, and request routing.
|
6
|
-
#
|
7
|
-
# @example Basic client creation
|
8
|
-
# client = Attio::Client.new(api_key: 'your-api-key')
|
9
|
-
#
|
10
|
-
# @example Custom timeout
|
11
|
-
# client = Attio::Client.new(api_key: 'your-api-key', timeout: 60)
|
12
|
-
#
|
13
|
-
# @author Ernest Sim
|
14
|
-
# @since 1.0.0
|
15
17
|
class Client
|
16
18
|
# The base URL for the Attio API v2
|
17
|
-
API_BASE_URL = "https://api.attio.com/v2"
|
18
|
-
|
19
|
+
API_BASE_URL = "https://api.attio.com/v2"
|
20
|
+
|
19
21
|
# Default request timeout in seconds
|
20
22
|
DEFAULT_TIMEOUT = 30
|
21
23
|
|
22
24
|
# @return [String] The API key used for authentication
|
23
25
|
attr_reader :api_key
|
24
|
-
|
26
|
+
|
25
27
|
# @return [Integer] The request timeout in seconds
|
26
28
|
attr_reader :timeout
|
27
29
|
|
28
30
|
# Initialize a new Attio API client.
|
29
|
-
#
|
31
|
+
#
|
30
32
|
# @param api_key [String] Your Attio API key (required)
|
31
33
|
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
32
34
|
# @raise [ArgumentError] if api_key is nil or empty
|
33
|
-
#
|
35
|
+
#
|
34
36
|
# @example
|
35
37
|
# client = Attio::Client.new(api_key: 'sk-...your-key...')
|
36
38
|
def initialize(api_key:, timeout: DEFAULT_TIMEOUT)
|
37
39
|
raise ArgumentError, "API key is required" if api_key.nil? || api_key.empty?
|
38
|
-
|
40
|
+
|
39
41
|
@api_key = api_key
|
40
42
|
@timeout = timeout
|
41
43
|
end
|
42
44
|
|
43
45
|
# Returns the HTTP connection instance for making API requests.
|
44
|
-
#
|
46
|
+
#
|
45
47
|
# This method creates and configures the HTTP client with proper
|
46
48
|
# authentication headers and settings. The connection is cached
|
47
49
|
# for subsequent requests.
|
48
|
-
#
|
50
|
+
#
|
49
51
|
# @return [HttpClient] The configured HTTP client instance
|
50
52
|
# @api private
|
51
53
|
def connection
|
@@ -55,14 +57,14 @@ module Attio
|
|
55
57
|
"Authorization" => "Bearer #{api_key}",
|
56
58
|
"Accept" => "application/json",
|
57
59
|
"Content-Type" => "application/json",
|
58
|
-
"User-Agent" => "Attio Ruby Client/#{VERSION}"
|
60
|
+
"User-Agent" => "Attio Ruby Client/#{VERSION}",
|
59
61
|
},
|
60
62
|
timeout: timeout
|
61
63
|
)
|
62
64
|
end
|
63
65
|
|
64
66
|
# Access to the Records API resource.
|
65
|
-
#
|
67
|
+
#
|
66
68
|
# @return [Resources::Records] Records resource instance
|
67
69
|
# @example
|
68
70
|
# records = client.records.list(object: 'people')
|
@@ -71,7 +73,7 @@ module Attio
|
|
71
73
|
end
|
72
74
|
|
73
75
|
# Access to the Objects API resource.
|
74
|
-
#
|
76
|
+
#
|
75
77
|
# @return [Resources::Objects] Objects resource instance
|
76
78
|
# @example
|
77
79
|
# objects = client.objects.list
|
@@ -80,7 +82,7 @@ module Attio
|
|
80
82
|
end
|
81
83
|
|
82
84
|
# Access to the Lists API resource.
|
83
|
-
#
|
85
|
+
#
|
84
86
|
# @return [Resources::Lists] Lists resource instance
|
85
87
|
# @example
|
86
88
|
# lists = client.lists.list
|
@@ -89,7 +91,7 @@ module Attio
|
|
89
91
|
end
|
90
92
|
|
91
93
|
# Access to the Workspaces API resource.
|
92
|
-
#
|
94
|
+
#
|
93
95
|
# @return [Resources::Workspaces] Workspaces resource instance
|
94
96
|
# @example
|
95
97
|
# workspaces = client.workspaces.list
|
@@ -98,7 +100,7 @@ module Attio
|
|
98
100
|
end
|
99
101
|
|
100
102
|
# Access to the Attributes API resource.
|
101
|
-
#
|
103
|
+
#
|
102
104
|
# @return [Resources::Attributes] Attributes resource instance
|
103
105
|
# @example
|
104
106
|
# attributes = client.attributes.list
|
@@ -107,7 +109,7 @@ module Attio
|
|
107
109
|
end
|
108
110
|
|
109
111
|
# Access to the Users API resource.
|
110
|
-
#
|
112
|
+
#
|
111
113
|
# @return [Resources::Users] Users resource instance
|
112
114
|
# @example
|
113
115
|
# users = client.users.list
|
@@ -115,4 +117,4 @@ module Attio
|
|
115
117
|
@users ||= Resources::Users.new(self)
|
116
118
|
end
|
117
119
|
end
|
118
|
-
end
|
120
|
+
end
|
@@ -1,6 +1,18 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Attio
|
4
|
+
# Thread-safe connection pool for managing HTTP connections
|
5
|
+
#
|
6
|
+
# This class provides a pool of connections that can be shared
|
7
|
+
# across threads for improved performance and resource management.
|
8
|
+
#
|
9
|
+
# @example Creating a connection pool
|
10
|
+
# pool = ConnectionPool.new(size: 10) { HttpClient.new }
|
11
|
+
#
|
12
|
+
# @example Using a connection from the pool
|
13
|
+
# pool.with_connection do |conn|
|
14
|
+
# conn.get("/endpoint")
|
15
|
+
# end
|
4
16
|
class ConnectionPool
|
5
17
|
DEFAULT_POOL_SIZE = 5
|
6
18
|
DEFAULT_TIMEOUT = 5 # seconds to wait for connection
|
@@ -14,7 +26,7 @@ module Attio
|
|
14
26
|
@key = :"#{object_id}_connection"
|
15
27
|
@block = block
|
16
28
|
@mutex = Mutex.new
|
17
|
-
|
29
|
+
|
18
30
|
size.times { @available << create_connection }
|
19
31
|
end
|
20
32
|
|
@@ -29,14 +41,12 @@ module Attio
|
|
29
41
|
|
30
42
|
def checkout
|
31
43
|
deadline = Time.now + timeout
|
32
|
-
|
44
|
+
|
33
45
|
loop do
|
34
|
-
return @available.pop(true) if @available.size
|
35
|
-
|
36
|
-
if Time.now >= deadline
|
37
|
-
|
38
|
-
end
|
39
|
-
|
46
|
+
return @available.pop(true) if @available.size.positive?
|
47
|
+
|
48
|
+
raise TimeoutError, "Couldn't acquire connection within #{timeout} seconds" if Time.now >= deadline
|
49
|
+
|
40
50
|
sleep(0.01)
|
41
51
|
end
|
42
52
|
rescue ThreadError
|
@@ -52,18 +62,20 @@ module Attio
|
|
52
62
|
def shutdown
|
53
63
|
@mutex.synchronize do
|
54
64
|
@available.close
|
55
|
-
while connection =
|
65
|
+
while (connection = begin
|
66
|
+
@available.pop(true)
|
67
|
+
rescue StandardError
|
68
|
+
nil
|
69
|
+
end)
|
56
70
|
connection.close if connection.respond_to?(:close)
|
57
71
|
end
|
58
72
|
end
|
59
73
|
end
|
60
74
|
|
61
|
-
private
|
62
|
-
|
63
|
-
def create_connection
|
75
|
+
private def create_connection
|
64
76
|
@block.call
|
65
77
|
end
|
66
78
|
|
67
79
|
class TimeoutError < StandardError; end
|
68
80
|
end
|
69
|
-
end
|
81
|
+
end
|
data/lib/attio/errors.rb
CHANGED
@@ -1,9 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Attio
|
2
4
|
class Error < StandardError; end
|
3
|
-
|
5
|
+
|
4
6
|
class AuthenticationError < Error; end
|
5
7
|
class NotFoundError < Error; end
|
6
8
|
class ValidationError < Error; end
|
7
9
|
class RateLimitError < Error; end
|
8
10
|
class ServerError < Error; end
|
9
|
-
end
|
11
|
+
end
|
data/lib/attio/http_client.rb
CHANGED
@@ -1,7 +1,15 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "typhoeus"
|
4
|
+
require "json"
|
3
5
|
|
4
6
|
module Attio
|
7
|
+
# HTTP client for making API requests to Attio
|
8
|
+
#
|
9
|
+
# This class handles the low-level HTTP communication with the Attio API,
|
10
|
+
# including request execution, response parsing, and error handling.
|
11
|
+
#
|
12
|
+
# @api private
|
5
13
|
class HttpClient
|
6
14
|
DEFAULT_TIMEOUT = 30
|
7
15
|
|
@@ -33,16 +41,14 @@ module Attio
|
|
33
41
|
execute_request(:delete, path)
|
34
42
|
end
|
35
43
|
|
36
|
-
private
|
37
|
-
|
38
|
-
def execute_request(method, path, options = {})
|
44
|
+
private def execute_request(method, path, options = {})
|
39
45
|
url = "#{base_url}/#{path}"
|
40
|
-
|
46
|
+
|
41
47
|
request_options = {
|
42
48
|
method: method,
|
43
|
-
headers: headers.merge(
|
49
|
+
headers: headers.merge("Content-Type" => "application/json"),
|
44
50
|
timeout: timeout,
|
45
|
-
connecttimeout: timeout
|
51
|
+
connecttimeout: timeout,
|
46
52
|
}.merge(options)
|
47
53
|
|
48
54
|
request = Typhoeus::Request.new(url, request_options)
|
@@ -51,42 +57,57 @@ module Attio
|
|
51
57
|
handle_response(response)
|
52
58
|
end
|
53
59
|
|
54
|
-
def handle_response(response)
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
when 401
|
66
|
-
raise AuthenticationError, parse_error_message(response)
|
67
|
-
when 404
|
68
|
-
raise NotFoundError, parse_error_message(response)
|
69
|
-
when 422
|
70
|
-
raise ValidationError, parse_error_message(response)
|
71
|
-
when 429
|
72
|
-
raise RateLimitError, parse_error_message(response)
|
73
|
-
when 500..599
|
74
|
-
raise ServerError, parse_error_message(response)
|
75
|
-
else
|
76
|
-
raise Error, "Request failed with status #{response.code}: #{parse_error_message(response)}"
|
77
|
-
end
|
60
|
+
private def handle_response(response)
|
61
|
+
return handle_connection_error(response) if response.code == 0
|
62
|
+
return parse_json(response.body) if (200..299).cover?(response.code)
|
63
|
+
|
64
|
+
handle_error_response(response)
|
65
|
+
end
|
66
|
+
|
67
|
+
private def handle_connection_error(response)
|
68
|
+
raise TimeoutError, "Request timed out" if response.timed_out?
|
69
|
+
|
70
|
+
raise ConnectionError, "Connection failed: #{response.return_message}"
|
78
71
|
end
|
79
72
|
|
80
|
-
def
|
73
|
+
private def handle_error_response(response)
|
74
|
+
error_class = error_class_for_status(response.code)
|
75
|
+
message = parse_error_message(response)
|
76
|
+
|
77
|
+
# Add status code to message for generic errors
|
78
|
+
message = "Request failed with status #{response.code}: #{message}" if error_class == Error
|
79
|
+
|
80
|
+
raise error_class, message
|
81
|
+
end
|
82
|
+
|
83
|
+
private def error_class_for_status(status)
|
84
|
+
error_map = {
|
85
|
+
401 => AuthenticationError,
|
86
|
+
404 => NotFoundError,
|
87
|
+
422 => ValidationError,
|
88
|
+
429 => RateLimitError,
|
89
|
+
}
|
90
|
+
return error_map[status] if error_map.key?(status)
|
91
|
+
return ServerError if (500..599).cover?(status)
|
92
|
+
|
93
|
+
Error
|
94
|
+
end
|
95
|
+
|
96
|
+
private def parse_json(body)
|
81
97
|
return {} if body.nil? || body.empty?
|
98
|
+
|
82
99
|
JSON.parse(body)
|
83
100
|
rescue JSON::ParserError => e
|
84
101
|
raise Error, "Invalid JSON response: #{e.message}"
|
85
102
|
end
|
86
103
|
|
87
|
-
def parse_error_message(response)
|
88
|
-
body =
|
89
|
-
|
104
|
+
private def parse_error_message(response)
|
105
|
+
body = begin
|
106
|
+
parse_json(response.body)
|
107
|
+
rescue StandardError
|
108
|
+
response.body
|
109
|
+
end
|
110
|
+
|
90
111
|
if body.is_a?(Hash)
|
91
112
|
body["error"] || body["message"] || body.to_s
|
92
113
|
else
|
@@ -97,4 +118,4 @@ module Attio
|
|
97
118
|
class TimeoutError < Error; end
|
98
119
|
class ConnectionError < Error; end
|
99
120
|
end
|
100
|
-
end
|
121
|
+
end
|