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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +47 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +36 -1
- data/Gemfile.lock +19 -10
- data/README.md +316 -24
- data/lib/hubspot/api_client.rb +40 -8
- data/lib/hubspot/batch.rb +252 -0
- data/lib/hubspot/config.rb +2 -1
- data/lib/hubspot/paged_batch.rb +56 -0
- data/lib/hubspot/paged_collection.rb +3 -18
- data/lib/hubspot/property.rb +4 -0
- data/lib/hubspot/resource.rb +248 -15
- data/lib/hubspot/version.rb +1 -1
- data/lib/hubspot.rb +14 -0
- data/lib/ruby_hubspot_api.rb +3 -0
- data/lib/support/patches.rb +11 -0
- data/ruby_hubspot_api.gemspec +4 -2
- metadata +41 -10
- data/.ruby-version +0 -1
data/lib/hubspot/api_client.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/hubspot/config.rb
CHANGED
@@ -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
|
72
|
-
params_with_offset =
|
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
|
-
|
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
|