ruby_hubspot_api 0.1.2.1 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b77e20469ffc97b7330e7a163ee5ed4d43b1bef4a17ee7a1eb38663157d3faab
4
- data.tar.gz: 52088790ea4ccea816e0b2efc0846331dcffbc29fd0d01c80e16baa9dbafb331
3
+ metadata.gz: 67b58c873d1e9277df15b213a804afe47189c888aab565963950bf9992050e07
4
+ data.tar.gz: 62028c4b6b0a2ac9a9a29e88cf4762cbbdb909942415d1bc2bf02e3838b405eb
5
5
  SHA512:
6
- metadata.gz: eab5e7e4ab612428b97b31b81c06c25562f6dd0ef89eba5f47628bb1dbc79dd84fa893c868f6515f0f2d2b435016e8ed50c897e7aa97ac66355b136c1778aea3
7
- data.tar.gz: '08dbe5b3847885d286e7f26142e8d2925583195d1c624158d165e023cc2fc3b7cb4482eab4db459c3ecf50aae226457a3487e3fbe8242eb8c59c346c1e0132bb'
6
+ metadata.gz: 98ec373c58ef3aa92eb963d2ab567bd41204bd1c80552e0abacf4cadc1a6860d5b227dde78f5fbfe8b6d3cdff65536390116f468492c6f639e87c1a83f4e60ff
7
+ data.tar.gz: 5bea871875782b5fd35ff62de85c11da16f7e763a88972ac3d6cbca3e6b700eca589eed7933058434d9c6574079b71fd5e2d0aabc8fcc3be288dd27d5a9ae405
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
+ ## v.0.2.0
1
2
 
2
- ## v0.1.1
3
+ - Get the development dependencies right!
4
+ - Bump the version again
5
+ - describe find_by method
6
+ - update lock
7
+ - batch :sparkle: upsert spec
8
+ - Borrowing Object#blank? method cos it actually really helps...
9
+ - batch implemntation
10
+ - logger.debug the post body and response body
11
+ - Adds a changes? Method on resource
12
+ - Adds instance method resource_name on resource
13
+ - Ensure keys are stringified
14
+ - Add all end points to the batch spec
15
+ - Adds create and archive methods to batches
16
+ - Tidy up resource code
17
+ - Cover the previously nocov'd code
18
+ - Add api client logging spec
19
+ - add configurable timeouts to requests
20
+ - Move rate limit handling to the client
21
+ - Simplify mocked responses in batch spec
22
+ - Adds PagedBatch as pager for batch/read request
23
+ - Update the Readme to add Batch operations
24
+ - #5 batch_updating
25
+
26
+ ## v0.1.2
3
27
 
4
28
  - initial setup
5
29
  - Setup the configuration block
@@ -46,6 +70,17 @@
46
70
  - adds the version numbers to the gemspec
47
71
  - Fix dependencies
48
72
  - bump version
73
+ - Fix the Readme
74
+ - Sure the search param is values where passing an array
75
+ - update changelog and Gemfile.lock
76
+ - bump version
77
+
78
+ ## v0.1.1
79
+
80
+ - Fix the Readme
81
+ - Sure the search param is values where passing an array
82
+ - update changelog and Gemfile.lock
83
+ - bump version
49
84
 
50
85
  ## v0.1.0
51
86
 
data/Gemfile.lock CHANGED
@@ -1,16 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ruby_hubspot_api (0.1.1)
5
- bundler (>= 2.0)
6
- dotenv (>= 2.0)
4
+ ruby_hubspot_api (0.1.2.1)
7
5
  httparty (>= 0.1, < 1.0)
8
- pry (>= 0.1)
9
- pry-byebug (>= 3.0)
10
- rspec (>= 3.0)
11
- simplecov (>= 0.22, < 1.0)
12
- vcr (>= 6.0)
13
- webmock (>= 3.0)
14
6
 
15
7
  GEM
16
8
  remote: https://rubygems.org/
@@ -71,8 +63,16 @@ PLATFORMS
71
63
  x86_64-darwin-18
72
64
 
73
65
  DEPENDENCIES
66
+ bundler (>= 2.0)
67
+ dotenv (>= 2.0)
68
+ pry (>= 0.1)
69
+ pry-byebug (>= 3.0)
74
70
  rake (>= 11.0, < 14.0)
71
+ rspec (>= 3.0)
75
72
  ruby_hubspot_api!
73
+ simplecov (>= 0.22, < 1.0)
74
+ vcr (>= 6.0)
75
+ webmock (>= 3.0)
76
76
 
77
77
  BUNDLED WITH
78
78
  2.3.26
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This gem was largely inspired by [hubspot-api-ruby](https://github.com/captaincontrat/hubspot-api-ruby) which, in turn, was inspired by the [hubspot-ruby](https://github.com/HubspotCommunity/hubspot-ruby) community gem. I wanted to use version 3 of the api and simplify some parts of the interface
4
4
 
5
- The Ruby HubSpot API gem is a starting point for building an ORM-like interface to HubSpot's API.
5
+ The Ruby HubSpot API gem is a starting point for building an ORM-like interface to HubSpot's API.
6
6
 
7
7
  ## Installation
8
8
 
@@ -40,13 +40,13 @@ This configuration ensures that your API requests are authenticated using your H
40
40
 
41
41
  ## Working with Objects
42
42
 
43
- This gem allows you to interact with HubSpot objects such as contacts and companies. You can perform operations on individual instances (e.g., creating or updating records) as well as on collections (e.g., listing or searching).
43
+ _(N.B when referring to the resources in the api we will refer to them as Objects as per the Hubspot nomenclature)_
44
44
 
45
- ### Instance Methods
45
+ This gem allows you to interact with HubSpot objects such as contacts and companies. You can perform operations on individual instances (e.g., creating or updating records) as well as on collections (e.g., listing or searching).
46
46
 
47
- #### Creating and Saving an Object
47
+ ### Creating and Saving an Object
48
48
 
49
- You can instantiate a new resource (such as a contact) by passing a hash of properties when creating the object. After setting the properties, calling `save` will persist the object to the HubSpot API.
49
+ Create and instance of the resource passing a hash of properties. Calling `save` on the instance will persist the object to the HubSpot API, as well as set the id property (which you can then store in your own database for example)
50
50
 
51
51
  Example:
52
52
 
@@ -60,44 +60,96 @@ new_contact.save
60
60
  puts "New contact ID: #{new_contact.id}"
61
61
  ```
62
62
 
63
- #### Updating an Existing Object
63
+ ### Retreiving an Object
64
+
65
+ If you know the id of the object you can fetch it from the api using the find method
66
+
67
+ ```ruby
68
+ contact = Hubspot::Contact.find(1)
69
+ puts "Contact: #{contact.firstname} #{contact.lastname}"
70
+ ```
71
+
72
+ You can also retrieve a single object by using the `find_by` method. Simply specify the property and the value you want to search on:
73
+
74
+ ```ruby
75
+ # find by email
76
+ contact = Hubspot::Contact.find_by('email', 'john.doe@example.org')
77
+ puts "Contact: #{contact.firstname} #{contact.lastname}"
64
78
 
65
- To update an existing object, you can either modify the object and call `save`, or use the `update` method specifying the properties you want to update
79
+ #find by internal id (custom field)
80
+ contact = Hubspot::Contact.find_by('member_id', 123)
81
+ puts "Contact: #{contact.firstname} #{contact.lastname}"
82
+ ```
83
+
84
+ ### Updating an Existing Object
85
+
86
+ To update an existing object, you can either modify the object and call `save`, or use the `update` method specifying the properties you want to update. You can test whether or not the object will need to upload changes to the api by using the changes? method
66
87
 
67
88
  Example using `save`:
68
89
 
69
90
  ```ruby
70
91
  contact = Hubspot::Contact.find(1)
92
+ contact.changes? # false
93
+
71
94
  contact.lastname = 'DoeUpdated'
95
+ contact.changes? # true
96
+
97
+ # save the updates to Hubspot
72
98
  contact.save # true
99
+ contact.changes? # false
73
100
  ```
74
101
 
75
102
  Example using `update`:
76
103
 
77
104
  ```ruby
78
105
  contact = Hubspot::Contact.find(1)
106
+ # save the updates to Hubspot
79
107
  contact.update(lastname: 'DoeUpdated') # true
80
108
  ```
81
109
 
82
- ### Class Methods
110
+ If you are able to construct an Object with data stored locally you can save the inital `find` api call, but you will need to construct the persisted object specifying the id and a properties hash (as if it came from the api!)
111
+
112
+ Example:
83
113
 
84
- #### Listing Objects
114
+ ```ruby
115
+ local_contact = Contact.find(contact_id)
85
116
 
86
- You can list all objects (such as contacts) using the `list` method, which returns a `PagedCollection`. This collection handles paginated results and responds to methods like `each_page`, `each`, and `all`. You can also pass the `page_size` parameter to control the number of records returned per page.
117
+ hubspot_properties = {
118
+ firstname: local_contact.first_name,
119
+ lastname: local_contact.last_name,
120
+ email: local_contact.email,
121
+ # ... more properties...
122
+ }
123
+
124
+ hubspot_contact = Hubspot::Contact.new(id: contact.hs_object_id, properties: hubspot_properties )
125
+ hubspot_contact.changes? # false
126
+
127
+ # update a custom field "last_contacted"
128
+ # (in the hubspot_contact instance this will be stored in the changes property)
129
+ hubspot_contact.last_contacted = Time.now.utc.iso8601
130
+ hubspot_contact.changes? # true
131
+
132
+ # persist the change to hubspot
133
+ hubspot_contact.save #true
134
+ ```
135
+
136
+ ### Listing Objects
137
+
138
+ You can list all objects (such as contacts) using the `list` method, which returns a `PagedCollection`. This collection handles paginated results and is Enumerable, so responds to methods like `each_page`, `each`, and `all`. You can also pass the `page_size` parameter to control the number of records returned per page.
87
139
 
88
140
  Example:
89
141
 
90
142
  ```ruby
91
143
  contacts = Hubspot::Contact.list(page_size: 10)
92
144
 
93
- # Using each_page to iterate over pages
145
+ # Using each_page to iterate over pages, there will be up to 10 contacts per page
94
146
  contacts.each_page do |page|
95
147
  page.each do |contact|
96
148
  puts "Contact: #{contact.firstname} #{contact.lastname}"
97
149
  end
98
150
  end
99
151
 
100
- # Or iterate over all contacts without worrying about pagination
152
+ # Or iterate over all contacts and the pagination will be handled transparently (page_size still applies)
101
153
  contacts.each do |contact|
102
154
  puts "Contact: #{contact.firstname} #{contact.lastname}"
103
155
  end
@@ -106,23 +158,42 @@ end
106
158
  all_contacts = contacts.all
107
159
  ```
108
160
 
109
- #### Retrieving the first n items
161
+ #### Retrieving the first n items:
110
162
 
111
- You can use the `first` method to retrieve the first item or a specified number of items:
163
+ As mentioned the list method returns a PagedCollection. You can call `first` on the result to retrieve the first item or a specified number of items:
112
164
 
113
165
  ```ruby
114
- contacts = Hubspot::Contact.list(page_size: 10)
166
+ contacts_collection = Hubspot::Contact.list
115
167
 
116
168
  # Retrieve the first contact
117
- first_contact = contacts.first
169
+ first_contact = contacts_collection.first
118
170
 
119
171
  # Retrieve the first 5 contacts
120
- first_five_contacts = contacts.first(5)
172
+ first_five_contacts = contacts_collection.first(5)
121
173
  ```
122
174
 
123
175
  This will automatically set the limits and handle paging for the most efficient API calls while honouring the maximum page count for hubspot resources
124
176
 
125
- #### Searching
177
+ #### Specifying properties
178
+
179
+ By default Hubspot will only send back the [default hubspot properties](https://knowledge.hubspot.com/properties/hubspots-default-contact-properties)
180
+
181
+ You can pass an array of properties to be return as follows:
182
+
183
+ Example:
184
+
185
+ ```ruby
186
+ # Search for contacts with email containing "hubspot.com" and only return specific properties
187
+ contacts = Hubspot::Contact.list(
188
+ properties: ['firstname', 'lastname', 'email', 'mobile', 'custom_property_1']
189
+ )
190
+
191
+ contacts.each do |contact|
192
+ puts "Name: #{contact.firstname} #{contact.lastname}, Email: #{contact.email}, Mobile: #{contact.mobile} CustomerRef: #{contact.custom_property_1}"
193
+ end
194
+ ```
195
+
196
+ ### Searching
126
197
 
127
198
  You can search for objects by passing query parameters to the `search` method. HubSpot supports several operators such as `eq`, `gte`, `lt`, and `IN` for filtering.
128
199
 
@@ -132,15 +203,21 @@ Example:
132
203
  # Search for contacts with email containing "hubspot.com"
133
204
  contacts = Hubspot::Contact.search(query: { email_contains: 'hubspot.com' })
134
205
 
206
+ puts "Searching for Hubspot staff in the contacts CRM"
207
+ puts ""
208
+
135
209
  contacts.each do |contact|
136
- puts "Found: #{contact.email}"
210
+ puts " Found: #{contact.firstname} #{contact.lastname} (#{contact.email})"
137
211
  end
138
212
 
139
213
  # Search for companies with number of employees greater than or equal to 100
140
214
  companies = Hubspot::Company.search(query: { number_of_employees_gte: 100 })
141
215
 
216
+ puts "Searching for medium to large companies"
217
+ puts ""
218
+
142
219
  companies.each do |company|
143
- puts "Found: #{company.name}, Employees: #{company.number_of_employees}"
220
+ puts " Found: #{company.name} (#{company.number_of_employees} employees)"
144
221
  end
145
222
 
146
223
  # Search for contacts with email in a specific list (IN operator)
@@ -152,15 +229,18 @@ end
152
229
  ```
153
230
 
154
231
  ### Available Search Operators:
155
- - **eq**: Equal to.
232
+ - **contains**: contains <string>
156
233
  - **neq**: Not equal to.
234
+ - **gt**: Greater than.
157
235
  - **gte**: Greater than or equal to.
236
+ - **lt**: Less than.
158
237
  - **lte**: Less than or equal to.
159
238
  - **IN**: Matches any of the values in an array.
160
239
 
161
240
  #### Specifying Properties in Search
162
241
 
163
- When performing a search, you can also specify which properties to return. If you specify any properties, you will only get those properties back, and the default HubSpot properties will not be included automatically.
242
+ When performing a search, you can also specify which properties to return.
243
+ *NB* If you specify any properties, you will only get those properties back, and the default HubSpot properties will not be included automatically.
164
244
 
165
245
  Example:
166
246
 
@@ -172,10 +252,159 @@ contacts = Hubspot::Contact.search(
172
252
  )
173
253
 
174
254
  contacts.each do |contact|
175
- puts "Name: #{contact.firstname} #{contact.lastname}, Email: #{contact.email}, Mobile: #{contact.mobile}"
255
+ puts "Name: #{contact.firstname} #{contact.lastname}, Email: #{contact.email}, Mobile: #{contact.mobile} CustomerRef: #{contact.custom_property_1}"
256
+ end
257
+ ```
258
+
259
+ ## Working with batches
260
+
261
+ ### Hubspot::Batch
262
+
263
+ The `Hubspot::Batch` class allows you to perform batch operations on HubSpot resources, such as contacts or companies. This includes batch `create`, `read`, `update`, `upsert`, and `archive` operations. Below are examples of how to use these methods.
264
+
265
+ #### Batch Create
266
+
267
+ To create new resources in bulk, you can use the `create` method.
268
+
269
+ In this example, `batch.create` triggers the creation of new contacts. After creation, the batch response will include the new IDs assigned to each object by HubSpot which will be assigned to the resources in the batch
270
+
271
+ ```ruby
272
+ contacts = [
273
+ Hubspot::Contact.new(email: 'new.john@example.com', firstname: 'John', lastname: 'Doe'),
274
+ Hubspot::Contact.new(email: 'new.jane@example.com', firstname: 'Jane', lastname: 'Doe')
275
+ ]
276
+
277
+ batch = Hubspot::Batch.new(contacts)
278
+ batch.create
279
+
280
+ batch.resources.each do |contact|
281
+ hubspot_id = contact.id
282
+ # store hubspot_id against a contact....
176
283
  end
177
284
  ```
178
285
 
286
+
287
+ #### Batch Read
288
+
289
+ To read a batch of Objects by their internal hubspot id or by another uniq property you can use the `read` method. You will need to pass the class of the resource, an array of ids and optionally an id_property.
290
+
291
+ For simplicity you can also use the `batch_read` method of the corresponding class (e.g. Hubspot::Contacts, Hubspot::Company etc) passing an array of ids and optionally an id_property (defaults to 'id'). This method will return a Hubspot::Batch and the results will be in "resources"
292
+
293
+ Example using `read` along with the Hubspot id of several companies...
294
+
295
+ ```ruby
296
+ # Grab an array of hubspot company ids to read from the api...
297
+ company_ids = my_companies.collect(&:hubspot_id).compact
298
+
299
+ # this will grab all the results from the api handling paging automagically
300
+ batch = Hubspot::Batch.read(Hubspot::Company, company_ids)
301
+ companies = batch.resources
302
+
303
+ # Or using the domain field
304
+ # Grab an array of company domains
305
+ company_domains = my_companies.collect(&:domain_name).compact
306
+
307
+ # calls /crm/v3/objects/companies/batch/read
308
+ batch = Hubspot::Batch.read(Hubspot::Company, company_domains, id_property: 'domain')
309
+ companies = batch.resources
310
+ ```
311
+
312
+ Example of reading contacts by email and the helper method `batch_read`
313
+ By using this method you can page through the results as needed or collect them
314
+
315
+ ```ruby
316
+ email_addresses = my_selected_contacts.collect(&:email).compact
317
+
318
+ batch = Hubspot::Contact.batch_read(email_addresses, id_property: 'email')
319
+
320
+ batch.each_page do |contacts|
321
+ contacts.each do |contact|
322
+ # persist some data locally
323
+ update_local_contact_from_hubspot(contact)
324
+ end
325
+ # stop the api calls if a condition is met
326
+ # break if <condition>
327
+ end
328
+ ```
329
+
330
+ Finally there is another helper method on re
331
+
332
+ #### Batch Update
333
+
334
+ For updating existing resources in bulk, you can use the `update` method. If you want to locally create an object without calling the API you specify the id and pass any properties in the 'properties' hash (this is how the objects are returned from Hubspot)
335
+
336
+ ```ruby
337
+ contacts = [
338
+ Hubspot::Contact.new(id: 1, properties: { firstname: 'John', lastname: 'Doe' }),
339
+ Hubspot::Contact.new(id: 2, properties: { firstname: 'Jane', lastname: 'Doe' })
340
+ ]
341
+
342
+ # make a changes to each contact
343
+ contacts.each { |contact| contact.last_contacted = Time.now.utc.iso8601 }
344
+
345
+ batch = Hubspot::Batch.new(contacts)
346
+ batch.update
347
+ ```
348
+
349
+ Example using a batch
350
+ ```ruby
351
+ contact_ids = my_contacts.collect(&:hubspot_id).compact
352
+ batch = Hubspot::contacts.batch_read(contact_ids)
353
+
354
+ batch.resources.each do |hubspot_contact|
355
+ my_contact = my_contacts.find { |c| c.hubspot_id == hubspot_contact.id }
356
+ # some logic or method to set any new/changed properties on hubspot_contact
357
+ update_hubspot_contact_from_local(hubspot_contact, my_contact)
358
+ end
359
+
360
+ # now we have a batch with changed resources we can update the batch
361
+ batch.update # true
362
+ ```
363
+
364
+ #### Batch Upsert
365
+
366
+ The `upsert` method allows you to insert new records or update existing ones. You’ll need to specify an `id_property` (like `email`) to uniquely identify records
367
+
368
+ ```ruby
369
+ contacts = [
370
+ Hubspot::Contact.new(email: 'new.john@example.com', firstname: 'John', lastname: 'Doe'),
371
+ Hubspot::Contact.new(email: 'new.jane@example.com', firstname: 'Jane', lastname: 'Doe')
372
+ ]
373
+
374
+ batch = Hubspot::Batch.new(contacts, id_property: 'email')
375
+ batch.upsert
376
+ ```
377
+
378
+ In this example, if a contact with the given email already exists in HubSpot, it will be updated. If it doesn't, a new contact will be created.
379
+
380
+ #### Batch Archive
381
+
382
+ To archive objects in bulk, you can use the `archive` method. This removes the objects from HubSpot.
383
+
384
+ ```ruby
385
+ contacts = Hubspot::Contact.search(query: { email_contains: 'hubspot.com' }).all
386
+
387
+ batch = Hubspot::Batch.new(contacts)
388
+ batch.archive
389
+ ```
390
+
391
+ The `archive` method sends a batch request to HubSpot to archive the objects. If any of the objects fail to be archived, you can check for partial success using the `partial_success?` method.
392
+
393
+ #### Error Handling and Success Checks
394
+
395
+ You can check whether the batch operation was entirely successful, partially successful, or if any failures occurred:
396
+
397
+ ```ruby
398
+ if batch.all_successful?
399
+ puts "All resources were successfully processed."
400
+ elsif batch.partial_success?
401
+ puts "Some resources were successfully processed, but others failed."
402
+ else
403
+ puts "The batch operation failed."
404
+ end
405
+ ```
406
+
407
+
179
408
  ## Contributing
180
409
 
181
410
  There is much to do (including writing a TODO list, or at least adding issues in github!) but this should provide a solid start
@@ -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
@@ -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
@@ -111,8 +128,6 @@ module Hubspot
111
128
 
112
129
  # rubocop:enable Metrics/MethodLength
113
130
 
114
- private
115
-
116
131
  # Define the resource name based on the class
117
132
  def resource_name
118
133
  name = self.name.split('::').last.downcase
@@ -123,6 +138,8 @@ module Hubspot
123
138
  end
124
139
  end
125
140
 
141
+ private
142
+
126
143
  # Instantiate a single resource object from the response
127
144
  def instantiate_from_response(response)
128
145
  data = handle_response(response)
@@ -161,10 +178,10 @@ module Hubspot
161
178
 
162
179
  # rubocop:disable Ling/MissingSuper
163
180
  def initialize(data = {})
181
+ data.transform_keys!(&:to_s)
164
182
  @id = extract_id(data)
165
183
  @properties = {}
166
184
  @metadata = {}
167
-
168
185
  if @id
169
186
  initialize_from_api(data)
170
187
  else
@@ -173,6 +190,10 @@ module Hubspot
173
190
  end
174
191
  # rubocop:enable Ling/MissingSuper
175
192
 
193
+ def changes?
194
+ !@changes.empty?
195
+ end
196
+
176
197
  # Instance methods for update (or save)
177
198
  def save
178
199
  if persisted?
@@ -207,6 +228,10 @@ module Hubspot
207
228
  end
208
229
  alias archive delete
209
230
 
231
+ def resource_name
232
+ self.class.resource_name
233
+ end
234
+
210
235
  # rubocop:disable Metrics/MethodLength
211
236
  # Handle dynamic getter and setter methods with method_missing
212
237
  def method_missing(method, *args)
@@ -232,19 +257,15 @@ module Hubspot
232
257
  end
233
258
 
234
259
  # Fallback if the method or attribute is not found
235
- # :nocov:
236
260
  super
237
- # :nocov:
238
261
  end
239
262
  # rubocop:enable Metrics/MethodLength
240
263
 
241
264
  # Ensure respond_to_missing? is properly overridden
242
- # :nocov:
243
265
  def respond_to_missing?(method_name, include_private = false)
244
266
  property_name = method_name.to_s.chomp('=')
245
267
  @properties.key?(property_name) || @changes.key?(property_name) || super
246
268
  end
247
- # :nocov:
248
269
 
249
270
  private
250
271
 
@@ -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'
5
5
  end
data/lib/hubspot.rb CHANGED
@@ -19,6 +19,7 @@ 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
22
23
  end
23
24
 
24
25
  def configured?
@@ -31,5 +32,18 @@ module Hubspot
31
32
  def set_client_headers
32
33
  Hubspot::ApiClient.headers 'Authorization' => "Bearer #{config.access_token}"
33
34
  end
35
+
36
+ def set_request_timeouts
37
+ config.timeout && Hubspot::ApiClient.default_timeout(config.timeout)
38
+ timeouts = %i[open_timeout read_timeout]
39
+ timeouts << :write_timeout if RUBY_VERSION >= '2.6'
40
+
41
+ timeouts.each do |t|
42
+ timeout = config.send(t)
43
+ next unless timeout
44
+
45
+ Hubspot::ApiClient.send(t, timeout)
46
+ end
47
+ end
34
48
  end
35
49
  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,11 @@
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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_hubspot_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2.1
4
+ version: '0.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Brook
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-25 00:00:00.000000000 Z
11
+ date: 2024-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -190,16 +190,19 @@ files:
190
190
  - bin/setup
191
191
  - lib/hubspot.rb
192
192
  - lib/hubspot/api_client.rb
193
+ - lib/hubspot/batch.rb
193
194
  - lib/hubspot/company.rb
194
195
  - lib/hubspot/config.rb
195
196
  - lib/hubspot/contact.rb
196
197
  - lib/hubspot/exceptions.rb
198
+ - lib/hubspot/paged_batch.rb
197
199
  - lib/hubspot/paged_collection.rb
198
200
  - lib/hubspot/property.rb
199
201
  - lib/hubspot/resource.rb
200
202
  - lib/hubspot/user.rb
201
203
  - lib/hubspot/version.rb
202
204
  - lib/ruby_hubspot_api.rb
205
+ - lib/support/patches.rb
203
206
  - ruby_hubspot_api.gemspec
204
207
  homepage: https://github.com/sensadrome/ruby_hubspot_api
205
208
  licenses: