ruby_hubspot_api 0.1.2.1 → 0.2.1

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.
@@ -3,6 +3,9 @@
3
3
  module Hubspot
4
4
  # All interations with the Hubspot API happen here...
5
5
  class ApiClient
6
+ MAX_RETRIES = 3
7
+ RETRY_WAIT_TIME = 1 # seconds
8
+
6
9
  include HTTParty
7
10
  base_uri 'https://api.hubapi.com'
8
11
 
@@ -32,7 +35,7 @@ module Hubspot
32
35
  ensure_configuration!
33
36
  start_time = Time.now
34
37
  response = super(url, options)
35
- log_request(:post, url, response, start_time)
38
+ log_request(:post, url, response, start_time, options)
36
39
  response
37
40
  end
38
41
 
@@ -40,7 +43,7 @@ module Hubspot
40
43
  ensure_configuration!
41
44
  start_time = Time.now
42
45
  response = super(url, options)
43
- log_request(:patch, url, response, start_time)
46
+ log_request(:patch, url, response, start_time, options)
44
47
  response
45
48
  end
46
49
 
@@ -52,23 +55,52 @@ module Hubspot
52
55
  response
53
56
  end
54
57
 
55
- def log_request(http_method, url, response, start_time)
58
+ def log_request(http_method, url, response, start_time, extra = nil)
56
59
  d = Time.now - start_time
57
60
  Hubspot.logger.info("#{http_method.to_s.upcase} #{url} took #{d.round(2)}s with status #{response.code}")
58
- Hubspot.logger.debug("Response body: #{response.body}") if Hubspot.logger.debug?
61
+ return unless Hubspot.logger.debug?
62
+
63
+ Hubspot.logger.debug("Request body: #{extra}") if extra
64
+ Hubspot.logger.debug("Response body: #{response.body}")
59
65
  end
60
66
 
61
- def handle_response(response)
62
- if response.success?
67
+ def handle_response(response, retries = 0)
68
+ case response.code
69
+ when 200..299
63
70
  response.parsed_response
71
+ when 429
72
+ handle_rate_limit(response, retries)
64
73
  else
65
- Hubspot.logger.error("API Error: #{response.code} - #{response.body}")
66
- raise Hubspot.error_from_response(response)
74
+ log_and_raise_error(response)
67
75
  end
68
76
  end
69
77
 
70
78
  private
71
79
 
80
+ def handle_rate_limit(response, retries)
81
+ if retries < MAX_RETRIES
82
+ retry_after = response.headers['Retry-After']&.to_i || RETRY_WAIT_TIME
83
+ Hubspot.logger.warn("Rate limit hit. Retrying in #{retry_after} seconds...")
84
+ sleep(retry_after)
85
+ retry_request(response.request, retries + 1)
86
+ else
87
+ Hubspot.logger.error('Exceeded maximum retries for rate-limited request.')
88
+ raise Hubspot.error_from_response(response)
89
+ end
90
+ end
91
+
92
+ def retry_request(request, retries)
93
+ # Re-issues the original request using the retry logic
94
+ http_method = request.http_method::METHOD.downcase # Use the METHOD constant to get the method string
95
+ response = HTTParty.send(http_method, request.uri, request.options)
96
+ handle_response(response, retries)
97
+ end
98
+
99
+ def log_and_raise_error(response)
100
+ Hubspot.logger.error("API Error: #{response.code} - #{response.body}")
101
+ raise Hubspot.error_from_response(response)
102
+ end
103
+
72
104
  def ensure_configuration!
73
105
  raise NotConfiguredError, 'Hubspot API not configured' unless Hubspot.configured?
74
106
  end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module Hubspot
6
+ # exactly the same as a parsed_response but with the status code preserved
7
+ class BatchResponse < SimpleDelegator
8
+ attr_reader :status_code
9
+
10
+ def initialize(status_code, parsed_response)
11
+ @status_code = status_code
12
+ super(parsed_response) # Delegate to the parsed response object
13
+ end
14
+
15
+ # Check if all responses were successful (status 200)
16
+ def all_successful?
17
+ @status_code == 200
18
+ end
19
+
20
+ # Check if some responses succeeded and some failed (status 207)
21
+ def partial_success?
22
+ @status_code == 207
23
+ end
24
+ end
25
+
26
+ # Class to handle batch updates of resources
27
+ # rubocop:disable Metrics/ClassLength
28
+ class Batch < ApiClient
29
+ attr_accessor :id_property, :resources, :responses
30
+
31
+ CONTACT_LIMIT = 10
32
+ DEFAULT_LIMIT = 100
33
+
34
+ # rubocop:disable Lint/MissingSuper
35
+ def initialize(resources = [], id_property: 'id')
36
+ @resources = []
37
+ @id_property = id_property # Set id_property for the batch (default: 'id')
38
+ @responses = [] # Store multiple BatchResponse objects here
39
+ resources.each { |resource| add_resource(resource) }
40
+ end
41
+ # rubocop:enable Lint/MissingSuper
42
+
43
+ # batch create from the resources
44
+ def create
45
+ save(action: 'create')
46
+ end
47
+
48
+ def update
49
+ # validate_update_conditions
50
+ save(action: 'update')
51
+ end
52
+
53
+ # Upsert method that calls save with upsert action
54
+ def upsert
55
+ validate_upsert_conditions
56
+ save(action: 'upsert')
57
+ end
58
+
59
+ # Archive method
60
+ def archive
61
+ save(action: 'archive')
62
+ end
63
+
64
+ # Check if all responses were successful
65
+ def all_successful?
66
+ @responses.all?(&:all_successful?)
67
+ end
68
+
69
+ # Check if some responses were successful and others failed
70
+ def partial_success?
71
+ @responses.any?(&:partial_success?) && @responses.none?(&:all_successful?)
72
+ end
73
+
74
+ # Check if any responses failed
75
+ def any_failed?
76
+ @responses.any? { |response| !response.all_successful? && !response.partial_success? }
77
+ end
78
+
79
+ def add_resource(resource)
80
+ if @resources.any? && @resources.first.resource_name != resource.resource_name
81
+ raise ArgumentError, 'All resources in a batch must be of the same type'
82
+ end
83
+
84
+ @resources << resource
85
+ end
86
+
87
+ private
88
+
89
+ # rubocop:disable Metrics/MethodLength
90
+ def save(action: 'update')
91
+ @action = action
92
+ resource_type = check_single_resource_type
93
+ inputs = gather_inputs
94
+
95
+ return false if inputs.empty? # Guard clause
96
+
97
+ # Perform the batch updates in chunks based on the resource type's limit
98
+ batch_limit = batch_size_limit(resource_type)
99
+ inputs.each_slice(batch_limit) do |batch_inputs|
100
+ response = batch_request(resource_type, batch_inputs, action)
101
+ @responses << response
102
+ end
103
+
104
+ process_responses unless @action == 'archive'
105
+
106
+ # Check if any responses failed
107
+ !any_failed?
108
+ end
109
+ # rubocop:enable Metrics/MethodLength
110
+
111
+ def check_single_resource_type
112
+ raise 'Batch is empty' if @resources.empty?
113
+
114
+ @resources.first.resource_name
115
+ end
116
+
117
+ # Gather all the changes, ensuring each resource has an id and changes
118
+ def gather_inputs
119
+ return gather_archive_inputs if @action == 'archive'
120
+
121
+ @resources.map do |resource|
122
+ next if resource.changes.empty?
123
+
124
+ {
125
+ id: resource.public_send(@id_property), # Dynamically get the ID based on the batch's id_property
126
+ idProperty: determine_id_property, # Use the helper method to decide whether to include idProperty
127
+ properties: resource.changes # Gather the changes for the resource
128
+ }.compact # Removes nil keys
129
+ end.compact # Removes nil entries
130
+ end
131
+
132
+ # Gather inputs for the archive operation
133
+ def gather_archive_inputs
134
+ @resources.map do |resource|
135
+ {
136
+ id: resource.public_send(@id_property), # Use the ID or the custom property
137
+ idProperty: determine_id_property # Include idProperty if it's not "id"
138
+ }.compact
139
+ end.compact
140
+ end
141
+
142
+ # Only include idProperty if it's not "id"
143
+ def determine_id_property
144
+ @id_property == 'id' ? nil : @id_property
145
+ end
146
+
147
+ # Perform batch request based on the provided action (upsert, update, create, or archive)
148
+ def batch_request(type, inputs, action)
149
+ response = self.class.post("/crm/v3/objects/#{type}/batch/#{action}", body: { inputs: inputs }.to_json)
150
+ BatchResponse.new(response.code, handle_response(response))
151
+ end
152
+
153
+ # Validation for upsert conditions
154
+ def validate_upsert_conditions
155
+ raise ArgumentError, "id_property cannot be 'id' for upsert" if @id_property == 'id'
156
+
157
+ # check if there are any resources without a value from the id_property
158
+ return unless @resources.any? { |resource| resource.public_send(id_property).blank? }
159
+
160
+ raise ArgumentError, "All resources must have a non-blank value for #{@id_property} to perform upsert"
161
+ end
162
+
163
+ # Return the appropriate batch size limit for the resource type
164
+ def batch_size_limit(resource_type)
165
+ resource_type == 'contacts' ? CONTACT_LIMIT : DEFAULT_LIMIT
166
+ end
167
+
168
+ # Process responses from the batch API call
169
+ def process_responses
170
+ @responses.each do |response|
171
+ next unless response['results']
172
+
173
+ process_results(response['results'])
174
+ end
175
+ end
176
+
177
+ # Process each result and update the resource accordingly
178
+ def process_results(results)
179
+ results.each do |result|
180
+ resource = find_resource_from_result(result)
181
+ next unless resource
182
+
183
+ # Set the ID on the resource directly
184
+ resource.id = result['id'].to_i if result['id']
185
+
186
+ # Update the resource properties
187
+ update_resource_properties(resource, result['properties'])
188
+
189
+ # Update metadata like updatedAt
190
+ update_metadata(resource, result['updatedAt'])
191
+ end
192
+ end
193
+
194
+ def find_resource_from_result(result)
195
+ case @action
196
+ when 'update', 'upsert'
197
+ find_resource_from_id(result['id'].to_i)
198
+
199
+ #
200
+ # when specifying idProperty in the upsert request
201
+ # the Hubspot API returns the id value (aka the hs_object_id)
202
+ # instead of the value of the <idProperty> field, so the value of the field
203
+ # is only stored in results['properties'][@id_property] if it changed!
204
+ # so this condition is redundant but left here in case Hubspot updates the response
205
+ #
206
+ # when 'upsert'
207
+ # resource_id = result.dig('properties', @id_property) || result['id']
208
+ # find_resource_from_id_property(resource_id)
209
+ #
210
+ when 'create'
211
+ # For create, check if the resource's changes are entirely contained in the result's properties
212
+ @resources.reject(&:persisted?).find do |resource|
213
+ resource.changes.any? && resource.changes.all? { |key, value| result['properties'][key.to_s] == value }
214
+ end
215
+ end
216
+ end
217
+
218
+ def find_resource_from_id(resource_id)
219
+ @resources.find { |r| r.id == resource_id }
220
+ end
221
+
222
+ # def find_resource_from_id_property(resource_id)
223
+ # @resources.find { |r| r.public_send(@id_property) == resource_id }
224
+ # end
225
+
226
+ def update_resource_properties(resource, properties)
227
+ properties.each do |key, value|
228
+ if resource.changes[key]
229
+ resource.properties[key] = value
230
+ resource.changes.delete(key)
231
+ end
232
+ end
233
+ end
234
+
235
+ def update_metadata(resource, updated_at)
236
+ resource.metadata['updatedAt'] = updated_at if updated_at
237
+ end
238
+
239
+ class << self
240
+ def read(object_class, object_ids = [], id_property: 'id')
241
+ raise ArgumentError, 'Must be a valid Hubspot resource class' unless object_class < Hubspot::Resource
242
+
243
+ # fetch all the matching resources with paging handled
244
+ resources = object_class.batch_read(object_ids, id_property: id_property).all
245
+
246
+ # return instance of Hubspot::Batch with the resources set
247
+ new(resources, id_property: id_property)
248
+ end
249
+ end
250
+ end
251
+ # rubocop:enable Metrics/ClassLength
252
+ end
@@ -3,7 +3,8 @@
3
3
  module Hubspot
4
4
  # To hold Hubspot configuration
5
5
  class Config
6
- attr_accessor :access_token, :portal_id, :client_secret, :logger, :log_level
6
+ attr_accessor :access_token, :portal_id, :client_secret, :logger, :log_level,
7
+ :timeout, :open_timeout, :read_timeout, :write_timeout
7
8
 
8
9
  def initialize
9
10
  @access_token = nil
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './api_client'
4
+ require_relative './exceptions'
5
+
6
+ module Hubspot
7
+ # Enumerable class for handling paged data from the API
8
+ class PagedBatch < ApiClient
9
+ include Enumerable
10
+
11
+ MAX_LIMIT = 100 # HubSpot max items per page
12
+
13
+ # rubocop:disable Lint/MissingSuper
14
+ def initialize(url:, params: {}, resource_class: nil, object_ids: [])
15
+ @url = url
16
+ @params = params
17
+ @resource_class = resource_class
18
+ @object_ids = object_ids
19
+ end
20
+ # rubocop:enable Lint/MissingSuper
21
+
22
+ def each_page
23
+ @object_ids.each_slice(MAX_LIMIT) do |ids|
24
+ response = fetch_page(ids)
25
+ results = response['results'] || []
26
+ mapped_results = @resource_class ? results.map { |result| @resource_class.new(result) } : results
27
+ yield mapped_results unless mapped_results.empty?
28
+ end
29
+ end
30
+
31
+ def all
32
+ results = []
33
+ each_page do |page|
34
+ results.concat(page)
35
+ end
36
+ results
37
+ end
38
+
39
+ def each(&block)
40
+ each_page do |page|
41
+ page.each(&block)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def fetch_page(object_ids)
48
+ params_with_ids = @params.dup
49
+ params_with_ids[:inputs] = object_ids.map { |id| { id: id } }
50
+
51
+ response = self.class.post(@url, body: params_with_ids.to_json)
52
+
53
+ handle_response(response)
54
+ end
55
+ end
56
+ end
@@ -8,9 +8,6 @@ module Hubspot
8
8
  class PagedCollection < ApiClient
9
9
  include Enumerable
10
10
 
11
- RATE_LIMIT_STATUS = 429
12
- MAX_RETRIES = 3
13
- RETRY_WAIT_TIME = 3
14
11
  MAX_LIMIT = 100 # HubSpot max items per page
15
12
 
16
13
  # rubocop:disable Lint/MissingSuper
@@ -68,18 +65,14 @@ module Hubspot
68
65
 
69
66
  private
70
67
 
71
- def fetch_page(offset, attempt = 1, params_override = @params)
72
- params_with_offset = params_override.dup
68
+ def fetch_page(offset)
69
+ params_with_offset = @params.dup
73
70
  params_with_offset.merge!(after: offset) if offset
74
71
 
75
72
  # Handle different HTTP methods
76
73
  response = fetch_response_by_method(params_with_offset)
77
74
 
78
- if response.code == RATE_LIMIT_STATUS
79
- handle_rate_limit(response, offset, attempt, params_override)
80
- else
81
- handle_response(response)
82
- end
75
+ handle_response(response)
83
76
  end
84
77
 
85
78
  def fetch_response_by_method(params = {})
@@ -90,13 +83,5 @@ module Hubspot
90
83
  self.class.send(@method, @url, body: params.to_json)
91
84
  end
92
85
  end
93
-
94
- def handle_rate_limit(response, offset, attempt, params_override)
95
- raise Hubspot.error_from_response(response) if attempt > MAX_RETRIES
96
-
97
- retry_after = response.headers['Retry-After']&.to_i || RETRY_WAIT_TIME
98
- sleep(retry_after)
99
- fetch_page(offset, attempt + 1, params_override)
100
- end
101
86
  end
102
87
  end
@@ -11,5 +11,9 @@ module Hubspot
11
11
  "#<#{self.class} #{formatted_attrs}>"
12
12
  end
13
13
  # :nocov:
14
+
15
+ def read_only?
16
+ modificationMetadata['readOnlyValue'] == true
17
+ end
14
18
  end
15
19
  end