ruby_hubspot_api 0.1.2.1 → 0.2.1.pre.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.
@@ -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