attio 0.1.1 → 0.2.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +39 -15
  3. data/.github/workflows/coverage.yml +67 -0
  4. data/.github/workflows/pr_checks.yml +25 -7
  5. data/.github/workflows/release.yml +27 -13
  6. data/.github/workflows/tests.yml +67 -0
  7. data/.rubocop.yml +362 -90
  8. data/CHANGELOG.md +49 -1
  9. data/CONCEPTS.md +428 -0
  10. data/CONTRIBUTING.md +4 -4
  11. data/Gemfile +8 -5
  12. data/Gemfile.lock +4 -4
  13. data/README.md +164 -2
  14. data/Rakefile +8 -6
  15. data/attio.gemspec +6 -7
  16. data/danger/Dangerfile +22 -34
  17. data/docs/example.rb +30 -29
  18. data/examples/advanced_filtering.rb +178 -0
  19. data/examples/basic_usage.rb +110 -0
  20. data/examples/collaboration_example.rb +173 -0
  21. data/examples/full_workflow.rb +348 -0
  22. data/examples/notes_and_tasks.rb +200 -0
  23. data/lib/attio/client.rb +67 -29
  24. data/lib/attio/connection_pool.rb +26 -14
  25. data/lib/attio/errors.rb +4 -2
  26. data/lib/attio/http_client.rb +70 -41
  27. data/lib/attio/logger.rb +37 -27
  28. data/lib/attio/resources/attributes.rb +12 -5
  29. data/lib/attio/resources/base.rb +66 -27
  30. data/lib/attio/resources/comments.rb +147 -0
  31. data/lib/attio/resources/lists.rb +21 -24
  32. data/lib/attio/resources/notes.rb +110 -0
  33. data/lib/attio/resources/objects.rb +11 -4
  34. data/lib/attio/resources/records.rb +49 -67
  35. data/lib/attio/resources/tasks.rb +131 -0
  36. data/lib/attio/resources/threads.rb +154 -0
  37. data/lib/attio/resources/users.rb +10 -4
  38. data/lib/attio/resources/workspaces.rb +9 -1
  39. data/lib/attio/retry_handler.rb +19 -11
  40. data/lib/attio/version.rb +3 -1
  41. data/lib/attio.rb +15 -9
  42. metadata +13 -18
  43. data/run_tests.rb +0 -52
  44. data/test_basic.rb +0 -51
  45. data/test_typhoeus.rb +0 -31
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "attio"
6
+ require "date"
7
+
8
+ # Example demonstrating Notes and Tasks functionality
9
+ #
10
+ # This example shows how to:
11
+ # - Create and manage notes on records
12
+ # - Create and manage tasks
13
+ # - Work with task assignments and due dates
14
+ # - Track task completion
15
+
16
+ client = Attio.client(api_key: ENV.fetch("ATTIO_API_KEY"))
17
+
18
+ puts "šŸ““ Attio Notes and Tasks Example"
19
+ puts "=" * 50
20
+
21
+ begin
22
+ # First, find or create a person to work with
23
+ puts "\nšŸ‘¤ Setting up test person..."
24
+ test_person = client.records.create(
25
+ object: "people",
26
+ data: {
27
+ name: "Jane Smith",
28
+ email: "jane.smith@example.com",
29
+ title: "Product Manager",
30
+ }
31
+ )
32
+ person_id = test_person["data"]["id"]
33
+ puts " Created test person: Jane Smith (#{person_id})"
34
+
35
+ # Working with Notes
36
+ puts "\nšŸ“ Creating Notes:"
37
+
38
+ # Create a meeting note
39
+ meeting_note = client.notes.create(
40
+ parent_object: "people",
41
+ parent_record_id: person_id,
42
+ title: "Initial Meeting - Product Requirements",
43
+ content: <<~MARKDOWN
44
+ ## Meeting Summary
45
+ **Date:** #{Date.today}
46
+ **Attendees:** Jane Smith, Development Team
47
+
48
+ ### Discussion Points:
49
+ 1. **Q1 Product Roadmap**
50
+ - Feature A: User authentication improvements
51
+ - Feature B: New dashboard design
52
+ - Feature C: API v2 development
53
+
54
+ 2. **Timeline**
55
+ - Sprint 1: Jan 15-29
56
+ - Sprint 2: Jan 30-Feb 12
57
+ - Release: Feb 15
58
+
59
+ 3. **Action Items**
60
+ - [ ] Create detailed specs for Feature A
61
+ - [ ] Schedule design review for Feature B
62
+ - [ ] Allocate resources for API development
63
+
64
+ ### Next Steps:
65
+ Follow-up meeting scheduled for next week.
66
+ MARKDOWN
67
+ )
68
+ puts " āœ… Created meeting note: #{meeting_note['data']['title']}"
69
+
70
+ # Create a follow-up note
71
+ followup_note = client.notes.create(
72
+ parent_object: "people",
73
+ parent_record_id: person_id,
74
+ title: "Follow-up: Action Items",
75
+ content: "Confirmed timeline and resource allocation. Jane will provide detailed specs by EOW."
76
+ )
77
+ puts " āœ… Created follow-up note"
78
+
79
+ # List all notes for the person
80
+ notes = client.notes.list(
81
+ parent_object: "people",
82
+ parent_record_id: person_id
83
+ )
84
+ puts "\n šŸ“‹ Notes for Jane Smith:"
85
+ notes["data"].each do |note|
86
+ puts " - #{note['title']} (created: #{note['created_at']})"
87
+ end
88
+
89
+ # Working with Tasks
90
+ puts "\nāœ… Creating Tasks:"
91
+
92
+ # Create tasks based on action items
93
+ task1 = client.tasks.create(
94
+ parent_object: "people",
95
+ parent_record_id: person_id,
96
+ title: "Create detailed specs for Feature A",
97
+ description: "Write comprehensive specifications for the user authentication improvements",
98
+ due_date: (Date.today + 7).iso8601,
99
+ priority: 1,
100
+ status: "pending"
101
+ )
102
+ puts " āœ… Created task: #{task1['data']['title']}"
103
+
104
+ task2 = client.tasks.create(
105
+ parent_object: "people",
106
+ parent_record_id: person_id,
107
+ title: "Schedule design review for Feature B",
108
+ description: "Coordinate with design team and schedule review meeting",
109
+ due_date: (Date.today + 3).iso8601,
110
+ priority: 2,
111
+ status: "pending"
112
+ )
113
+ puts " āœ… Created task: #{task2['data']['title']}"
114
+
115
+ task3 = client.tasks.create(
116
+ parent_object: "people",
117
+ parent_record_id: person_id,
118
+ title: "Allocate resources for API development",
119
+ description: "Determine team members and timeline for API v2",
120
+ due_date: (Date.today + 10).iso8601,
121
+ priority: 3,
122
+ status: "pending"
123
+ )
124
+ puts " āœ… Created task: #{task3['data']['title']}"
125
+
126
+ # List pending tasks
127
+ puts "\n šŸ“‹ Pending Tasks:"
128
+ pending_tasks = client.tasks.list(
129
+ status: "pending",
130
+ parent_object: "people",
131
+ parent_record_id: person_id
132
+ )
133
+ pending_tasks["data"].each do |task|
134
+ puts " - #{task['title']} (due: #{task['due_date']})"
135
+ end
136
+
137
+ # Complete a task
138
+ puts "\n āœ… Completing task..."
139
+ completed_task = client.tasks.complete(
140
+ id: task2["data"]["id"],
141
+ completed_at: DateTime.now.iso8601
142
+ )
143
+ puts " Task completed: #{completed_task['data']['title']}"
144
+
145
+ # Update a task
146
+ puts "\n šŸ“ Updating task..."
147
+ updated_task = client.tasks.update(
148
+ id: task1["data"]["id"],
149
+ description: "Updated: Include security requirements in the specifications",
150
+ priority: 1
151
+ )
152
+ puts " Task updated: #{updated_task['data']['title']}"
153
+
154
+ # Get task details
155
+ puts "\n šŸ” Task Details:"
156
+ task_detail = client.tasks.get(id: task1["data"]["id"])
157
+ puts " Title: #{task_detail['data']['title']}"
158
+ puts " Description: #{task_detail['data']['description']}"
159
+ puts " Due Date: #{task_detail['data']['due_date']}"
160
+ puts " Status: #{task_detail['data']['status']}"
161
+
162
+ # Update a note
163
+ puts "\n šŸ“ Updating note..."
164
+ client.notes.update(
165
+ id: followup_note["data"]["id"],
166
+ content: "Updated: Specs delivered. Design review scheduled for Friday."
167
+ )
168
+ puts " Note updated successfully"
169
+
170
+ # Cleanup
171
+ puts "\n🧹 Cleaning up..."
172
+
173
+ # Delete tasks
174
+ [task1, task2, task3].each do |task|
175
+ client.tasks.delete(id: task["data"]["id"])
176
+ end
177
+ puts " āœ… Deleted tasks"
178
+
179
+ # Delete notes
180
+ [meeting_note, followup_note].each do |note|
181
+ client.notes.delete(id: note["data"]["id"])
182
+ end
183
+ puts " āœ… Deleted notes"
184
+
185
+ # Delete test person
186
+ client.records.delete(object: "people", id: person_id)
187
+ puts " āœ… Deleted test person"
188
+ rescue Attio::Error => e
189
+ puts "āŒ Error: #{e.message}"
190
+ # Cleanup on error
191
+ if defined?(person_id)
192
+ begin
193
+ client.records.delete(object: "people", id: person_id)
194
+ rescue StandardError
195
+ nil
196
+ end
197
+ end
198
+ end
199
+
200
+ puts "\n✨ Example completed!"
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,12 +109,48 @@ 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
114
116
  def users
115
117
  @users ||= Resources::Users.new(self)
116
118
  end
119
+
120
+ # Access to the Notes API resource.
121
+ #
122
+ # @return [Resources::Notes] Notes resource instance
123
+ # @example
124
+ # notes = client.notes.list(parent_object: 'people', parent_record_id: 'rec_123')
125
+ def notes
126
+ @notes ||= Resources::Notes.new(self)
127
+ end
128
+
129
+ # Access to the Tasks API resource.
130
+ #
131
+ # @return [Resources::Tasks] Tasks resource instance
132
+ # @example
133
+ # tasks = client.tasks.list(status: 'pending')
134
+ def tasks
135
+ @tasks ||= Resources::Tasks.new(self)
136
+ end
137
+
138
+ # Access to the Comments API resource.
139
+ #
140
+ # @return [Resources::Comments] Comments resource instance
141
+ # @example
142
+ # comments = client.comments.list(thread_id: 'thread_123')
143
+ def comments
144
+ @comments ||= Resources::Comments.new(self)
145
+ end
146
+
147
+ # Access to the Threads API resource.
148
+ #
149
+ # @return [Resources::Threads] Threads resource instance
150
+ # @example
151
+ # threads = client.threads.list(parent_object: 'companies', parent_record_id: 'rec_456')
152
+ def threads
153
+ @threads ||= Resources::Threads.new(self)
154
+ end
117
155
  end
118
- end
156
+ 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
 
@@ -13,8 +21,12 @@ module Attio
13
21
  @timeout = timeout
14
22
  end
15
23
 
16
- def get(path, params = {})
17
- execute_request(:get, path, params: params)
24
+ def get(path, params = nil)
25
+ if params && !params.empty?
26
+ execute_request(:get, path, params: params)
27
+ else
28
+ execute_request(:get, path)
29
+ end
18
30
  end
19
31
 
20
32
  def post(path, body = {})
@@ -29,20 +41,22 @@ module Attio
29
41
  execute_request(:put, path, body: body.to_json)
30
42
  end
31
43
 
32
- def delete(path)
33
- execute_request(:delete, path)
44
+ def delete(path, params = nil)
45
+ if params
46
+ execute_request(:delete, path, body: params.to_json)
47
+ else
48
+ execute_request(:delete, path)
49
+ end
34
50
  end
35
51
 
36
- private
37
-
38
- def execute_request(method, path, options = {})
52
+ private def execute_request(method, path, options = {})
39
53
  url = "#{base_url}/#{path}"
40
-
54
+
41
55
  request_options = {
42
56
  method: method,
43
- headers: headers.merge('Content-Type' => 'application/json'),
57
+ headers: headers.merge("Content-Type" => "application/json"),
44
58
  timeout: timeout,
45
- connecttimeout: timeout
59
+ connecttimeout: timeout,
46
60
  }.merge(options)
47
61
 
48
62
  request = Typhoeus::Request.new(url, request_options)
@@ -51,42 +65,57 @@ module Attio
51
65
  handle_response(response)
52
66
  end
53
67
 
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
68
+ private def handle_response(response)
69
+ return handle_connection_error(response) if response.code == 0
70
+ return parse_json(response.body) if (200..299).cover?(response.code)
71
+
72
+ handle_error_response(response)
73
+ end
74
+
75
+ private def handle_connection_error(response)
76
+ raise TimeoutError, "Request timed out" if response.timed_out?
77
+
78
+ raise ConnectionError, "Connection failed: #{response.return_message}"
79
+ end
80
+
81
+ private def handle_error_response(response)
82
+ error_class = error_class_for_status(response.code)
83
+ message = parse_error_message(response)
84
+
85
+ # Add status code to message for generic errors
86
+ message = "Request failed with status #{response.code}: #{message}" if error_class == Error
87
+
88
+ raise error_class, message
78
89
  end
79
90
 
80
- def parse_json(body)
91
+ private def error_class_for_status(status)
92
+ error_map = {
93
+ 401 => AuthenticationError,
94
+ 404 => NotFoundError,
95
+ 422 => ValidationError,
96
+ 429 => RateLimitError,
97
+ }
98
+ return error_map[status] if error_map.key?(status)
99
+ return ServerError if (500..599).cover?(status)
100
+
101
+ Error
102
+ end
103
+
104
+ private def parse_json(body)
81
105
  return {} if body.nil? || body.empty?
106
+
82
107
  JSON.parse(body)
83
108
  rescue JSON::ParserError => e
84
109
  raise Error, "Invalid JSON response: #{e.message}"
85
110
  end
86
111
 
87
- def parse_error_message(response)
88
- body = parse_json(response.body) rescue response.body
89
-
112
+ private def parse_error_message(response)
113
+ body = begin
114
+ parse_json(response.body)
115
+ rescue StandardError
116
+ response.body
117
+ end
118
+
90
119
  if body.is_a?(Hash)
91
120
  body["error"] || body["message"] || body.to_s
92
121
  else
@@ -97,4 +126,4 @@ module Attio
97
126
  class TimeoutError < Error; end
98
127
  class ConnectionError < Error; end
99
128
  end
100
- end
129
+ end