hubspot_v3 0.1.0 → 1.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d55ee9f31364c57b484162bf1e574a5412e9f207072480c51025f4b624e1bc66
4
- data.tar.gz: 6ca13e0d86d8229f188d27b655453271f93e99938d78c36d14231def5bc207b5
3
+ metadata.gz: e0d20f6a8537ba36435a57682fc297817d8172bc6918e94a114ef8d8f0e5f1cc
4
+ data.tar.gz: 7c2d6829f3d59310be90b89b5393318865c8723c129f27e71431f855903ceede
5
5
  SHA512:
6
- metadata.gz: 48b74f1e180b916db063dc5c3f1669d4d4bc3f1c38f64969d6bc3b73035337ea3d1e3b66ba4ab44add9b2e71cd72637645d77b5f22d86e51d195240e42521af0
7
- data.tar.gz: cb09957b5b996fb56de04cce204d12dda956524c0dc4df73b5a3569c795a193648f7c9968594508d9d55dfcf9e09def66691ca4f76b2d36903045295861b773f
6
+ metadata.gz: bca4bf7caba1c559c41cfeba769b2c163d80f160661b5e69064c764f88496431cdd0f0397f8571653619b09355da12b4e40502f2898190e57835641a7366b3b4
7
+ data.tar.gz: 53359b29589e94b29d1dde13a0d6da7c716df63c0020910c32e187a403f0812f9506db43ac58b2bb7c40382cab116a517f1cba181bcb5bbcd37633151803c51d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hubspot_v3 (0.1.0)
4
+ hubspot_v3 (1.0.0)
5
5
  httparty (~> 0.2)
6
6
 
7
7
  GEM
@@ -11,9 +11,9 @@ GEM
11
11
  httparty (0.20.0)
12
12
  mime-types (~> 3.0)
13
13
  multi_xml (>= 0.5.2)
14
- mime-types (3.3.1)
14
+ mime-types (3.4.1)
15
15
  mime-types-data (~> 3.2015)
16
- mime-types-data (3.2021.0901)
16
+ mime-types-data (3.2022.0105)
17
17
  multi_xml (0.6.0)
18
18
  rake (13.0.6)
19
19
  rspec (3.10.0)
@@ -31,6 +31,7 @@ GEM
31
31
  rspec-support (3.10.2)
32
32
 
33
33
  PLATFORMS
34
+ arm64-darwin-21
34
35
  x86_64-linux
35
36
 
36
37
  DEPENDENCIES
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # HubspotV3
2
2
 
3
- Ruby gem wrapper around Hubspot API V3
3
+ Ruby gem wrapper around Hubspot CRM API V3
4
4
 
5
5
  Currently this gem focuses on **Batch** update/create/search of Contacts. More info in [source code](https://github.com/Pobble/hubspot_v3/blob/master/lib/hubspot_v3.rb)
6
6
 
@@ -13,7 +13,8 @@ killing those limits quite quickly.
13
13
 
14
14
  ## Other solutions out there
15
15
 
16
- Gem currently covers only features that are needed for our use cases, however this repo/gem is open for any Pull Requests with additional features.
16
+ Gem currently covers only features that are needed for our use cases (CRM Contacts & Companies),
17
+ however this repo/gem is open for any Pull Requests with additional features.
17
18
 
18
19
  If you need other features and wish not to contribute to this gem there are 2 existing Hubspot gems out there:
19
20
 
@@ -27,19 +28,29 @@ It's possible to use our gem along with any of these two gems.
27
28
  Add this line to your application's Gemfile:
28
29
 
29
30
  ```ruby
30
- gem 'hubspot_v3', github: 'Pobble/hubspot_v3'
31
+ gem 'hubspot_v3', '~> 1.0'
31
32
  ```
32
33
 
34
+ [![Gem Version](https://badge.fury.io/rb/hubspot_v3.svg)](https://badge.fury.io/rb/hubspot_v3)
35
+
33
36
  And then execute:
34
37
 
35
38
  $ bundle install
36
39
 
37
40
  ## Usage
38
41
 
39
- ### set API key
42
+ ### set App Token (API key)
43
+
44
+ > **NOTE**: Starting November 30, 2022, HubSpot API keys will no longer be able to be used as an
45
+ > authentication method to access HubSpot APIs [source](https://developers.hubspot.com/changelog/upcoming-api-key-sunset)
46
+
47
+ This means you cannot use Hubspot API KEY (a.k.a hapikey) to authenticate but rather create Hubspot Private App and use it's auth token
48
+ ([how to setup Hubspot private app](https://developers.hubspot.com/docs/api/private-apps#make-api-calls-with-your-app-s-access-token))
49
+
40
50
 
41
51
  ```
42
- HubspotV3.config.apikey = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
52
+ # Hubspot private app token (It's not the same as API KEY)
53
+ HubspotV3.config.token = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
43
54
  ```
44
55
 
45
56
  ### Contacts - Search
@@ -184,6 +195,121 @@ bodyhash = {
184
195
  HubspotV3.contacts_update(bodyhash)
185
196
  ```
186
197
 
198
+ ### Companies - Search
199
+
200
+ ```
201
+ bodyhash = {
202
+ "filterGroups":[
203
+ {
204
+ "filters": [
205
+ {
206
+ "propertyName": "name",
207
+ "operator": "EQ",
208
+ "value": "ACME Company"
209
+ }
210
+ ]
211
+ }
212
+ ]
213
+ }
214
+ HubspotV3.companies_search(bodyhash)
215
+ ```
216
+
217
+ ### Companies - Search by id
218
+
219
+ ```
220
+ HubspotV3.companies_search_by_ids(['9582682125'])
221
+ #=> [
222
+ # {
223
+ # "id"=>"9582682125",
224
+ # "properties"=> {
225
+ # "createdate"=>"2022-09-13T15:06:03.116Z",
226
+ # "domain"=>nil,
227
+ # "hs_lastmodifieddate"=>"2022-09-13T15:20:48.331Z",
228
+ # "hs_object_id"=>"9582682125",
229
+ # "name"=>"ACME Company"},
230
+ # "createdAt"=>"2022-09-13T15:06:03.116Z",
231
+ # "updatedAt"=>"2022-09-13T15:20:48.331Z",
232
+ # "archived"=>false
233
+ # }
234
+ #]
235
+
236
+ HubspotV3.companies_search_by_ids(['66666'])
237
+ #=> []
238
+
239
+ ```
240
+
241
+
242
+ * Full list of search filters and operators can be found in [official hubspot docs](https://developers.hubspot.com/docs/api/crm/companies)
243
+
244
+ ### Companies - Batch Create
245
+
246
+ ```
247
+ bodyhash = {
248
+ "inputs": [
249
+ {
250
+ "properties": {
251
+ "name": "ACME Corporation"
252
+ }
253
+ },
254
+ {
255
+ "city": "Cambridge",
256
+ "domain": "biglytics.net",
257
+ "industry": "Technology",
258
+ "name": "Biglytics",
259
+ "phone": "(877) 929-0687",
260
+ "state": "Massachusetts"
261
+ }
262
+ ]
263
+ }
264
+
265
+ begin
266
+ HubspotV3.companies_create(bodyhash)
267
+ rescue HubspotV3::RequestFailedError => e
268
+ puts e.message
269
+ # => 409 - some error reason (I never encounterd an error when creating company)
270
+
271
+ httparty_response_object = e.httparty_response
272
+ # => #<HTTParty::Response:0x1d920 parsed_response={"status"=>"error"...
273
+ end
274
+ ```
275
+
276
+ return value:
277
+
278
+ ```
279
+ [
280
+ {
281
+ "id"=>"9674616673",
282
+ "properties"=> {
283
+ ...
284
+ }
285
+ ...
286
+ },
287
+ {
288
+ "id"=>"9674616674",
289
+ "properties"=> {
290
+ ...
291
+ }
292
+ ...
293
+ }
294
+ ]
295
+ ```
296
+
297
+ ### Companies - Batch Update
298
+
299
+ ```
300
+ bodyhash = {
301
+ "inputs": [
302
+ {
303
+ "id": "9582682125",
304
+ "properties": {
305
+ "name": "ACME Company"
306
+ }
307
+ }
308
+ ]
309
+ }
310
+ HubspotV3.companies_update(bodyhash)
311
+ ```
312
+
187
313
  ## Test your app
188
314
 
189
315
  You can use http interceptor like [webmock](https://github.com/bblimke/webmock), [vcr](https://github.com/vcr/vcr).
@@ -197,13 +323,15 @@ require 'hubspot_v3/mock_contract'
197
323
  HubspotV3::MockContract.contacts_search_by_emails(["hello@pobble.com", "notfound@pobble.com", "info@pobble.com"])
198
324
  # [
199
325
  # {
200
- # "id" => 1589, "properties" => {
326
+ # "id" => 1589,
327
+ # "properties" => {
201
328
  # "email"=>"hello@pobble.com",
202
329
  # ...
203
330
  # }
204
331
  # },
205
- {
206
- # "id" => 1485, "properties" => {
332
+ # {
333
+ # "id" => 1485,
334
+ # "properties" => {
207
335
  # "email"=>"info@pobble.com",
208
336
  # ...
209
337
  # }
@@ -214,11 +342,32 @@ HubspotV3::MockContract.contacts_search_by_emails_mapped(["hello@pobble.com", "n
214
342
  # => ["hello@pobble.com", "info@pobble.com"]
215
343
  ```
216
344
 
217
- `id` field of test Contact contracts is calculated as `'info@pobble.com'.bytes.sum == 1485`, create contacts will be `'info@pobble.com'.bytes.sum + 1_000_000 == 1001485`
345
+ `id` field of test Contact contracts is calculated as `'info@pobble.com'.bytes.sum == 1485` ([source](https://github.com/Pobble/hubspot_v3/blob/master/lib/hubspot_v3/mock_contract.rb#L181)), create contacts will be `'info@pobble.com'.bytes.sum + 1_000_000 == 1001485` ([source](https://github.com/Pobble/hubspot_v3/blob/master/lib/hubspot_v3/mock_contract.rb#L29))
218
346
 
219
347
  > More info on how to use [Contract tests](https://blog.eq8.eu/article/explicit-contracts-for-rails-http-api-usecase.html)
220
348
 
349
+ ## Troubleshooting
350
+
351
+ #### Error - Cannot deserialize value of type
352
+ ```
353
+ `post': 400 - Invalid input JSON on line 1, column 1: Cannot deserialize value of type `com.hubspot.apiutils.core.models.batch.BatchInput$Json<com.hubspot.inbounddb.publicobject.core.v2.SimplePublicObjectBatchInput>` from Array value (token `JsonToken.START_ARRAY`) (HubspotV3::RequestFailedError)
354
+ ```
355
+
356
+ You probably forgot to wrap your batch call body hash in `inputs`.
357
+
358
+ E.g.:
359
+
360
+ instead of
361
+
362
+ ```
363
+ HubspotV3.companies_update([{"id"=>"1234", "properties"=> {"city" => "Cambridge"}}])
364
+ ```
365
+
366
+ you need to do:
221
367
 
368
+ ```
369
+ HubspotV3.companies_update("inputs" => [{"id"=>"1234", "properties"=> {"city" => "Cambridge"}}])
370
+ ```
222
371
 
223
372
  ## Development
224
373
 
data/hubspot_v3.gemspec CHANGED
@@ -6,10 +6,10 @@ Gem::Specification.new do |spec|
6
6
  spec.name = "hubspot_v3"
7
7
  spec.version = HubspotV3::VERSION
8
8
  spec.authors = ["Tomas Valent"]
9
- spec.email = ["equivalent@eq8.eu"]
9
+ spec.email = ["tomas.valent@gmail.com"]
10
10
 
11
- spec.summary = "Hubspot API v3 Ruby gem"
12
- spec.description = "Ruby wrapper around Hubspot API v3 with simple implementation and batch endpoints support"
11
+ spec.summary = "Hubspot CRM API (v3) Ruby gem"
12
+ spec.description = "Ruby wrapper around Hubspot CRM API v3 with simple implementation around batch endpoints and private apps token support"
13
13
  spec.homepage = "https://github.com/Pobble/hubspot_v3"
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
@@ -1,9 +1,15 @@
1
1
  module HubspotV3
2
2
  class Config
3
- attr_writer :apikey, :contract
3
+ attr_writer :token, :contract
4
4
 
5
- def apikey
6
- @apikey || raise('Hubspot API key is not set. Set it with HubspotV3.config.apikey="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"')
5
+ def token
6
+ @token || raise('Hubspot API key is not set. Set it with HubspotV3.config.token="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"')
7
+ end
8
+
9
+ def apikey=(*)
10
+ raise 'Hubspot API keys are deprecated. Don\'t use HubspotV3.config.apikey="hubspotApiKeyNoLongerWorks".' +
11
+ ' Make sure you set up Hubspot private app and set the token for this gem' +
12
+ ' with HubspotV3.config.token="xxMyHubspotPrivateAppTokenxx"'
7
13
  end
8
14
  end
9
15
 
@@ -83,8 +83,103 @@ module HubspotV3
83
83
  HubspotV3::Helpers.map_search_by_email(contacts_search_by_emails(emails_ary))
84
84
  end
85
85
 
86
- def _calculate_id(email)
87
- email.bytes.sum # sum of asci values of the email string
86
+ def contacts_search(bodyhash)
87
+ puts _general_batch_search_should_be_overridden_msg('contacts_search')
88
+ raise "HubspotV3::MockContract.contacts_search should be stubbed or overridden"
89
+ end
90
+
91
+ def companies_create(bodyhash)
92
+ inputs = _fetch_inputs(bodyhash)
93
+ inputs.map do |input|
94
+ properties = _fetch_input_properties(input)
95
+
96
+ #note: Hubspot API doesn't really require name, but for sake of this
97
+ # contract functionality we need properties.name
98
+ name = properties.fetch('name') { raise KeyError.new("Item in Inputs hash must contain key 'properties.name' - hash['inputs'][0]['properties']['name']") }
99
+
100
+ id = _calculate_id(name) + 1_000_000
101
+
102
+ default_properties = {
103
+ "createdate"=>"2022-09-13T15:06:03.116Z",
104
+ "hs_lastmodifieddate"=>"2022-09-13T15:06:03.116Z",
105
+ "hs_object_id"=>id.to_s,
106
+ "hs_pipeline"=>"companies-lifecycle-pipeline",
107
+ "lifecyclestage"=>"lead",
108
+ "name"=>name
109
+ }
110
+
111
+ properties = default_properties.merge(properties)
112
+
113
+ {
114
+ "id"=>id.to_s,
115
+ "properties"=> properties,
116
+ "createdAt"=>"2022-09-13T15:06:03.116Z",
117
+ "updatedAt"=>"2022-09-13T15:06:03.116Z",
118
+ "archived"=>false
119
+ }
120
+ end
121
+ end
122
+
123
+ def companies_update(bodyhash)
124
+ inputs = _fetch_inputs(bodyhash)
125
+
126
+ inputs.map do |input|
127
+ id = _fetch_input_id(input)
128
+ properties = _fetch_input_properties(input)
129
+
130
+ default_properties = {
131
+ "hs_lastmodifieddate"=>"2022-09-13T15:06:33.116Z",
132
+ "createdate"=>"2022-09-13T15:06:03.116Z",
133
+ "hs_object_id"=>id.to_s,
134
+ "hs_pipeline"=>"companies-lifecycle-pipeline",
135
+ "lifecyclestage"=>"lead"
136
+ }
137
+ properties = default_properties.merge(properties)
138
+
139
+ {
140
+ "id"=>id.to_s,
141
+ "properties"=>properties,
142
+ "createdAt"=>"2022-09-13T15:06:03.116Z",
143
+ "updatedAt"=>"2022-09-13T15:06:33.116Z",
144
+ "archived"=>false
145
+ }
146
+ end
147
+ end
148
+
149
+ def companies_search_by_ids(ids)
150
+ raise 'argument must be an Array' unless ids.is_a?(Array)
151
+ raise 'Array must include only String ids' if ids.select { |x| ! x.is_a?(String) }.any?
152
+
153
+ resp = ids.map do |id|
154
+ if id.match(/666666/)
155
+ # this represents not found records
156
+ nil
157
+ else
158
+ {
159
+ "id"=>id,
160
+ "properties"=>{
161
+ "createdate"=>"2022-09-13T15:06:03.116Z",
162
+ "domain"=>nil,
163
+ "hs_lastmodifieddate"=>"2022-09-13T15:20:48.331Z",
164
+ "hs_object_id"=>id,
165
+ "name"=>"ACME Company #{id}"},
166
+ "createdAt"=>"2022-09-13T15:06:03.116Z",
167
+ "updatedAt"=>"2022-09-13T15:20:48.331Z",
168
+ "archived"=>false
169
+ }
170
+ end
171
+ end
172
+
173
+ resp.compact
174
+ end
175
+
176
+ def companies_search(*)
177
+ puts _general_batch_search_should_be_overridden_msg('companies_search')
178
+ raise "HubspotV3::MockContract.companies_search should be stubbed or overridden"
179
+ end
180
+
181
+ def _calculate_id(whatever)
182
+ whatever.bytes.sum # sum of asci values of the email string
88
183
  end
89
184
 
90
185
  def _sanitize_email_as_hubspot_would(email)
@@ -103,5 +198,13 @@ module HubspotV3
103
198
  def _fetch_input_id(input)
104
199
  input.fetch('id') { raise KeyError.new("Item in Inputs hash must contain key 'id' - hash['inputs'][0]['id']") }
105
200
  end
201
+
202
+ def _general_batch_search_should_be_overridden_msg(name)
203
+ "General search queries are not covered by test contract and should be customly" +
204
+ "\noverridden based on your usecase. E.g.:" +
205
+ "\n expect(HubspotV3::MockContract)" +
206
+ "\n .to receive(:#{name})" +
207
+ "\n .and_return([{'id'=>'12345', 'properties'=>{ }])\n\n"
208
+ end
106
209
  end
107
210
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HubspotV3
4
- VERSION = "0.1.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/hubspot_v3.rb CHANGED
@@ -15,10 +15,15 @@ module HubspotV3
15
15
  end
16
16
  end
17
17
 
18
+ HubspotRequestLimitReached = Class.new(RequestFailedError)
19
+
18
20
  API_URL='https://api.hubapi.com'
19
21
  CONTACTS_SEARCH='/crm/v3/objects/contacts/search'
20
22
  CONTACTS_CREATE='/crm/v3/objects/contacts/batch/create'
21
23
  CONTACTS_UPDATE='/crm/v3/objects/contacts/batch/update'
24
+ COMPANIES_SEARCH='/crm/v3/objects/companies/search'
25
+ COMPANIES_CREATE='/crm/v3/objects/companies/batch/create'
26
+ COMPANIES_UPDATE='/crm/v3/objects/companies/batch/update'
22
27
 
23
28
  def self.contacts_create(bodyhash)
24
29
  post(CONTACTS_CREATE, bodyhash)
@@ -53,20 +58,79 @@ module HubspotV3
53
58
  HubspotV3::Helpers.map_search_by_email(contacts_search_by_emails(emails_ary))
54
59
  end
55
60
 
61
+ def self.companies_create(bodyhash)
62
+ post(COMPANIES_CREATE, bodyhash)
63
+ end
64
+
65
+ def self.companies_update(bodyhash)
66
+ post(COMPANIES_UPDATE, bodyhash)
67
+ end
68
+
69
+ def self.companies_search(bodyhash)
70
+ post(COMPANIES_SEARCH, bodyhash)
71
+ end
72
+
73
+ def self.companies_search_by_ids(hubspot_object_ids_ary)
74
+ filters_group_ary = hubspot_object_ids_ary.map do |e|
75
+ {
76
+ "filters": [
77
+ {
78
+ "propertyName": "hs_object_id",
79
+ "operator": "EQ",
80
+ "value": e
81
+ }
82
+ ]
83
+ }
84
+ end
85
+
86
+ bodyhash = { "filterGroups": filters_group_ary }
87
+ companies_search(bodyhash)
88
+ end
89
+
56
90
  def self.url(path)
57
- "#{API_URL}#{path}?hapikey=#{config.apikey}"
91
+ "#{API_URL}#{path}"
58
92
  end
59
93
 
60
94
  def self.post(path, bodyhash)
61
- res = HTTParty.post(url(path), {
95
+ httparty_response = HTTParty.post(url(path), {
62
96
  body: bodyhash.to_json,
63
- headers: {'Content-Type' => 'application/json'}
97
+ headers: headers
64
98
  })
65
- case res.code
99
+ case httparty_response.code
66
100
  when 200, 201
67
- res.parsed_response['results']
101
+ httparty_response.parsed_response['results']
102
+ when 429
103
+ # Hubspot error 429 - You have reached your secondly limit.
104
+ raise _hubspot_request_limit_reached_error(httparty_response)
105
+ when 500
106
+ if httparty_response.parsed_response["category"] == "RATE_LIMITS"
107
+ # e.g.: {"status":"error","message":"You have reached your secondly limit.","category":"RATE_LIMITS"}
108
+ # Yes, Hubspot will sometimes give 429 or 500 when limit reached
109
+ raise _hubspot_request_limit_reached_error(httparty_response)
110
+ else
111
+ raise _hubspot_request_failed_error(httparty_response)
112
+ end
68
113
  else
69
- raise HubspotV3::RequestFailedError.new("#{res.code} - #{res.parsed_response['message']}", res)
114
+ raise _hubspot_request_failed_error(httparty_response)
70
115
  end
71
116
  end
117
+
118
+ def self.headers
119
+ {
120
+ 'Content-Type' => 'application/json',
121
+ 'Authorization': "Bearer #{config.token}"
122
+ }
123
+ end
124
+
125
+ def self._hubspot_request_limit_reached_error(httparty_response)
126
+ code = httparty_response.code
127
+ message = httparty_response.parsed_response['message']
128
+ HubspotV3::HubspotRequestLimitReached.new("#{code} - #{message}", httparty_response)
129
+ end
130
+
131
+ def self._hubspot_request_failed_error(httparty_response)
132
+ code = httparty_response.code
133
+ message = httparty_response.parsed_response['message']
134
+ HubspotV3::RequestFailedError.new("#{code} - #{message}", httparty_response)
135
+ end
72
136
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hubspot_v3
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tomas Valent
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-09-27 00:00:00.000000000 Z
11
+ date: 2022-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -24,10 +24,10 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.2'
27
- description: Ruby wrapper around Hubspot API v3 with simple implementation and batch
28
- endpoints support
27
+ description: Ruby wrapper around Hubspot CRM API v3 with simple implementation around
28
+ batch endpoints and private apps token support
29
29
  email:
30
- - equivalent@eq8.eu
30
+ - tomas.valent@gmail.com
31
31
  executables: []
32
32
  extensions: []
33
33
  extra_rdoc_files: []
@@ -72,5 +72,5 @@ requirements: []
72
72
  rubygems_version: 3.3.7
73
73
  signing_key:
74
74
  specification_version: 4
75
- summary: Hubspot API v3 Ruby gem
75
+ summary: Hubspot CRM API (v3) Ruby gem
76
76
  test_files: []