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.
data/attio.gemspec CHANGED
@@ -1,4 +1,6 @@
1
- require_relative 'lib/attio/version'
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 = %q{Ruby client for the Attio API}
10
- spec.description = %q{A Ruby library for interacting with the Attio API, providing easy access to CRM functionality}
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 = Dir.chdir(File.expand_path('..', __FILE__)) do
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
- fail("fdescribe left in tests") if `grep -r fdescribe spec/ `.length > 1
11
- fail("fit left in tests") if `grep -r fit spec/ `.length > 1
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
- unless github.pr_title =~ /^(chore|ci|docs|style|test):/
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
- fail("Debugging code found: #{pattern} in #{file.path}")
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
- file.patch.include?("+ def ") &&
84
- !file.patch.include?("+ def self.") # Skip private class methods
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
- if line.start_with?("+")
103
- sensitive_patterns.each do |pattern|
104
- if line.match?(pattern) && !line.include?("# ") # Not a comment
105
- warn("Potential sensitive information detected", file: file.path, line: index + 1)
106
- end
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 '../lib/attio'
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['ATTIO_API_KEY'] || 'your-api-key-here')
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: 'people',
20
+ object: "people",
20
21
  filters: {
21
- name: { contains: 'John' }
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: 'people',
30
+ object: "people",
30
31
  data: {
31
- name: 'Jane Doe',
32
- email: 'jane.doe@example.com',
33
- phone: '+1-555-0123',
34
- notes: 'Created via Ruby client example'
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: 'people',
42
- id: new_person['data']['id']
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: 'people',
49
- id: person['data']['id'],
49
+ object: "people",
50
+ id: person["data"]["id"],
50
51
  data: {
51
- name: 'Jane Smith',
52
- notes: 'Updated name after marriage'
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: 'companies',
63
+ object: "companies",
63
64
  data: {
64
- name: 'Acme Corporation',
65
- domain: 'acme.com',
66
- industry: 'Technology'
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: 'people',
74
- id: person['data']['id'],
74
+ object: "people",
75
+ id: person["data"]["id"],
75
76
  data: {
76
77
  company: {
77
- target_object: 'companies',
78
- target_record_id: company['data']['id']
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: 'people', id: 'non-existent-id')
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: 'people', id: person['data']['id'])
114
+ client.records.delete(object: "people", id: person["data"]["id"])
114
115
  puts "Deleted person record"
115
116
 
116
- client.records.delete(object: 'companies', id: company['data']['id'])
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".freeze
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
- require 'thread'
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 > 0
35
-
36
- if Time.now >= deadline
37
- raise TimeoutError, "Couldn't acquire connection within #{timeout} seconds"
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 = @available.pop(true) rescue nil
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
@@ -1,7 +1,15 @@
1
- require 'typhoeus'
2
- require 'json'
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('Content-Type' => 'application/json'),
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
- case response.code
56
- when 0
57
- # Timeout or connection error
58
- if response.timed_out?
59
- raise TimeoutError, "Request timed out"
60
- else
61
- raise ConnectionError, "Connection failed: #{response.return_message}"
62
- end
63
- when 200..299
64
- parse_json(response.body)
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 parse_json(body)
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 = parse_json(response.body) rescue response.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