ruby_hubspot_api 0.1.2.1 → 0.2.1.pre.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # HubSpot API Client
4
+ # Handles all HTTP interactions with the HubSpot API. It manages GET, POST, PATCH, and DELETE requests.
3
5
  module Hubspot
4
6
  # All interations with the Hubspot API happen here...
5
7
  class ApiClient
8
+ MAX_RETRIES = 3
9
+ RETRY_WAIT_TIME = 1 # seconds
10
+
6
11
  include HTTParty
7
12
  base_uri 'https://api.hubapi.com'
8
13
 
@@ -32,7 +37,7 @@ module Hubspot
32
37
  ensure_configuration!
33
38
  start_time = Time.now
34
39
  response = super(url, options)
35
- log_request(:post, url, response, start_time)
40
+ log_request(:post, url, response, start_time, options)
36
41
  response
37
42
  end
38
43
 
@@ -40,7 +45,7 @@ module Hubspot
40
45
  ensure_configuration!
41
46
  start_time = Time.now
42
47
  response = super(url, options)
43
- log_request(:patch, url, response, start_time)
48
+ log_request(:patch, url, response, start_time, options)
44
49
  response
45
50
  end
46
51
 
@@ -52,23 +57,52 @@ module Hubspot
52
57
  response
53
58
  end
54
59
 
55
- def log_request(http_method, url, response, start_time)
60
+ def log_request(http_method, url, response, start_time, extra = nil)
56
61
  d = Time.now - start_time
57
62
  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?
63
+ return unless Hubspot.logger.debug?
64
+
65
+ Hubspot.logger.debug("Request body: #{extra}") if extra
66
+ Hubspot.logger.debug("Response body: #{response.body}")
59
67
  end
60
68
 
61
- def handle_response(response)
62
- if response.success?
69
+ def handle_response(response, retries = 0)
70
+ case response.code
71
+ when 200..299
63
72
  response.parsed_response
73
+ when 429
74
+ handle_rate_limit(response, retries)
64
75
  else
65
- Hubspot.logger.error("API Error: #{response.code} - #{response.body}")
66
- raise Hubspot.error_from_response(response)
76
+ log_and_raise_error(response)
67
77
  end
68
78
  end
69
79
 
70
80
  private
71
81
 
82
+ def handle_rate_limit(response, retries)
83
+ if retries < MAX_RETRIES
84
+ retry_after = response.headers['Retry-After']&.to_i || RETRY_WAIT_TIME
85
+ Hubspot.logger.warn("Rate limit hit. Retrying in #{retry_after} seconds...")
86
+ sleep(retry_after)
87
+ retry_request(response.request, retries + 1)
88
+ else
89
+ Hubspot.logger.error('Exceeded maximum retries for rate-limited request.')
90
+ raise Hubspot.error_from_response(response)
91
+ end
92
+ end
93
+
94
+ def retry_request(request, retries)
95
+ # Re-issues the original request using the retry logic
96
+ http_method = request.http_method::METHOD.downcase # Use the METHOD constant to get the method string
97
+ response = HTTParty.send(http_method, request.uri, request.options)
98
+ handle_response(response, retries)
99
+ end
100
+
101
+ def log_and_raise_error(response)
102
+ Hubspot.logger.error("API Error: #{response.code} - #{response.body}")
103
+ raise Hubspot.error_from_response(response)
104
+ end
105
+
72
106
  def ensure_configuration!
73
107
  raise NotConfiguredError, 'Hubspot API not configured' unless Hubspot.configured?
74
108
  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
@@ -14,6 +15,11 @@ module Hubspot
14
15
  apply_log_level
15
16
  end
16
17
 
18
+ # Apply the log level to the logger
19
+ def apply_log_level
20
+ @logger.level = @log_level
21
+ end
22
+
17
23
  private
18
24
 
19
25
  # Initialize the default logger
@@ -42,11 +48,6 @@ module Hubspot
42
48
  end
43
49
  # rubocop:enable Metrics/MethodLength
44
50
 
45
- # Apply the log level to the logger
46
- def apply_log_level
47
- @logger.level = @log_level
48
- end
49
-
50
51
  # Set the default log level based on environment
51
52
  def default_log_level
52
53
  if defined?(Rails) && Rails.env.test?
@@ -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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative './api_client'
4
+ require_relative './paged_collection'
5
+ require_relative './paged_batch'
4
6
 
5
7
  module Hubspot
6
8
  # rubocop:disable Metrics/ClassLength
@@ -53,6 +55,21 @@ module Hubspot
53
55
  )
54
56
  end
55
57
 
58
+ def batch_read(object_ids = [], id_property: 'id')
59
+ params = id_property == 'id' ? {} : { idProperty: id_property }
60
+
61
+ PagedBatch.new(
62
+ url: "/crm/v3/objects/#{resource_name}/batch/read",
63
+ params: params,
64
+ object_ids: object_ids,
65
+ resource_class: self
66
+ )
67
+ end
68
+
69
+ def batch_read_all(object_ids = [], id_property: 'id')
70
+ Hubspot::Batch.read(self, object_ids, id_property: id_property)
71
+ end
72
+
56
73
  # Get the complete list of fields (properties) for the object
57
74
  def properties
58
75
  @properties ||= begin
@@ -65,6 +82,14 @@ module Hubspot
65
82
  properties.reject { |property| property['hubspotDefined'] }
66
83
  end
67
84
 
85
+ def updatable_properties
86
+ properties.reject(&:read_only?)
87
+ end
88
+
89
+ def read_only_properties
90
+ properties.select(&:read_only?)
91
+ end
92
+
68
93
  def property(property_name)
69
94
  properties.detect { |prop| prop.name == property_name }
70
95
  end
@@ -111,8 +136,6 @@ module Hubspot
111
136
 
112
137
  # rubocop:enable Metrics/MethodLength
113
138
 
114
- private
115
-
116
139
  # Define the resource name based on the class
117
140
  def resource_name
118
141
  name = self.name.split('::').last.downcase
@@ -123,6 +146,8 @@ module Hubspot
123
146
  end
124
147
  end
125
148
 
149
+ private
150
+
126
151
  # Instantiate a single resource object from the response
127
152
  def instantiate_from_response(response)
128
153
  data = handle_response(response)
@@ -161,10 +186,10 @@ module Hubspot
161
186
 
162
187
  # rubocop:disable Ling/MissingSuper
163
188
  def initialize(data = {})
189
+ data.transform_keys!(&:to_s)
164
190
  @id = extract_id(data)
165
191
  @properties = {}
166
192
  @metadata = {}
167
-
168
193
  if @id
169
194
  initialize_from_api(data)
170
195
  else
@@ -173,6 +198,10 @@ module Hubspot
173
198
  end
174
199
  # rubocop:enable Ling/MissingSuper
175
200
 
201
+ def changes?
202
+ !@changes.empty?
203
+ end
204
+
176
205
  # Instance methods for update (or save)
177
206
  def save
178
207
  if persisted?
@@ -207,6 +236,10 @@ module Hubspot
207
236
  end
208
237
  alias archive delete
209
238
 
239
+ def resource_name
240
+ self.class.resource_name
241
+ end
242
+
210
243
  # rubocop:disable Metrics/MethodLength
211
244
  # Handle dynamic getter and setter methods with method_missing
212
245
  def method_missing(method, *args)
@@ -232,19 +265,15 @@ module Hubspot
232
265
  end
233
266
 
234
267
  # Fallback if the method or attribute is not found
235
- # :nocov:
236
268
  super
237
- # :nocov:
238
269
  end
239
270
  # rubocop:enable Metrics/MethodLength
240
271
 
241
272
  # Ensure respond_to_missing? is properly overridden
242
- # :nocov:
243
273
  def respond_to_missing?(method_name, include_private = false)
244
274
  property_name = method_name.to_s.chomp('=')
245
275
  @properties.key?(property_name) || @changes.key?(property_name) || super
246
276
  end
247
- # :nocov:
248
277
 
249
278
  private
250
279
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hubspot
4
- VERSION = '0.1.2.1'
4
+ VERSION = '0.2.1-1'
5
5
  end
data/lib/hubspot.rb CHANGED
@@ -19,6 +19,8 @@ module Hubspot
19
19
  def configure
20
20
  yield(config) if block_given?
21
21
  set_client_headers if config.access_token
22
+ set_request_timeouts
23
+ config.apply_log_level
22
24
  end
23
25
 
24
26
  def configured?
@@ -31,5 +33,18 @@ module Hubspot
31
33
  def set_client_headers
32
34
  Hubspot::ApiClient.headers 'Authorization' => "Bearer #{config.access_token}"
33
35
  end
36
+
37
+ def set_request_timeouts
38
+ config.timeout && Hubspot::ApiClient.default_timeout(config.timeout)
39
+ timeouts = %i[open_timeout read_timeout]
40
+ timeouts << :write_timeout if RUBY_VERSION >= '2.6'
41
+
42
+ timeouts.each do |t|
43
+ timeout = config.send(t)
44
+ next unless timeout
45
+
46
+ Hubspot::ApiClient.send(t, timeout)
47
+ end
48
+ end
34
49
  end
35
50
  end
@@ -20,4 +20,7 @@ require_relative 'hubspot/company'
20
20
  require_relative 'hubspot/user'
21
21
 
22
22
  # Load other components
23
+ require_relative 'hubspot/batch'
23
24
  require_relative 'hubspot/paged_collection'
25
+
26
+ require_relative 'support/patches'
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # simplest monkey patch, honest ;)
4
+ class Object
5
+ # Only define blank? if it's not already defined
6
+ unless method_defined?(:blank?)
7
+ def blank?
8
+ respond_to?(:empty?) ? empty? : !self
9
+ end
10
+ end
11
+ end
12
+
13
+ # At some point this will seem like a bad idea ;)
14
+
15
+ # :nocov:
16
+ if RUBY_VERSION < '2.5.0'
17
+ class Hash
18
+ # Non-mutating version (returns a new hash with transformed keys)
19
+ def transform_keys
20
+ return enum_for(:transform_keys) unless block_given?
21
+ result = {}
22
+ each_key do |key|
23
+ result[yield(key)] = self[key]
24
+ end
25
+ result
26
+ end
27
+
28
+ # Mutating version (modifies the hash in place)
29
+ def transform_keys!
30
+ return enum_for(:transform_keys!) unless block_given?
31
+ keys.each do |key|
32
+ self[yield(key)] = delete(key)
33
+ end
34
+ self
35
+ end
36
+ end
37
+ end
38
+ # :nocov:end