ruby_hubspot_api 0.1.2.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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