ruby_hubspot_api 0.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 +7 -0
- data/.env.sample +40 -0
- data/.gitignore +8 -0
- data/.rspec +3 -0
- data/.rubocop.yml +6 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +43 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +78 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +8 -0
- data/bin/console +16 -0
- data/bin/setup +8 -0
- data/lib/hubspot/api_client.rb +77 -0
- data/lib/hubspot/company.rb +6 -0
- data/lib/hubspot/config.rb +59 -0
- data/lib/hubspot/contact.rb +6 -0
- data/lib/hubspot/exceptions.rb +36 -0
- data/lib/hubspot/paged_collection.rb +102 -0
- data/lib/hubspot/property.rb +15 -0
- data/lib/hubspot/resource.rb +289 -0
- data/lib/hubspot/user.rb +8 -0
- data/lib/hubspot/version.rb +5 -0
- data/lib/hubspot.rb +35 -0
- data/lib/ruby_hubspot_api.rb +23 -0
- data/ruby_hubspot_api.gemspec +55 -0
- metadata +271 -0
@@ -0,0 +1,102 @@
|
|
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 PagedCollection < ApiClient
|
9
|
+
include Enumerable
|
10
|
+
|
11
|
+
RATE_LIMIT_STATUS = 429
|
12
|
+
MAX_RETRIES = 3
|
13
|
+
RETRY_WAIT_TIME = 3
|
14
|
+
MAX_LIMIT = 100 # HubSpot max items per page
|
15
|
+
|
16
|
+
# rubocop:disable Lint/MissingSuper
|
17
|
+
def initialize(url:, params: {}, resource_class: nil, method: :get)
|
18
|
+
@url = url
|
19
|
+
@params = params
|
20
|
+
@resource_class = resource_class
|
21
|
+
@method = method.to_sym
|
22
|
+
end
|
23
|
+
# rubocop:enable Lint/MissingSuper
|
24
|
+
|
25
|
+
def each_page
|
26
|
+
offset = nil
|
27
|
+
loop do
|
28
|
+
response = fetch_page(offset)
|
29
|
+
results = response['results'] || []
|
30
|
+
mapped_results = @resource_class ? results.map { |result| @resource_class.new(result) } : results
|
31
|
+
yield mapped_results unless mapped_results.empty?
|
32
|
+
offset = response.dig('paging', 'next', 'after')
|
33
|
+
break unless offset
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def all
|
38
|
+
results = []
|
39
|
+
each_page do |page|
|
40
|
+
results.concat(page)
|
41
|
+
end
|
42
|
+
results
|
43
|
+
end
|
44
|
+
|
45
|
+
# Override Enumerable's first method so as not to have to call each (via all)
|
46
|
+
def first(limit = 1)
|
47
|
+
resources = []
|
48
|
+
remaining = limit
|
49
|
+
|
50
|
+
# Modify @params directly to set the limit
|
51
|
+
@params[:limit] = [remaining, MAX_LIMIT].min
|
52
|
+
|
53
|
+
# loop through pages in case limit is more than the max limit
|
54
|
+
each_page do |page|
|
55
|
+
resources.concat(page)
|
56
|
+
remaining -= page.size
|
57
|
+
break if remaining <= 0
|
58
|
+
end
|
59
|
+
|
60
|
+
limit == 1 ? resources.first : resources.first(limit)
|
61
|
+
end
|
62
|
+
|
63
|
+
def each(&block)
|
64
|
+
each_page do |page|
|
65
|
+
page.each(&block)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def fetch_page(offset, attempt = 1, params_override = @params)
|
72
|
+
params_with_offset = params_override.dup
|
73
|
+
params_with_offset.merge!(after: offset) if offset
|
74
|
+
|
75
|
+
# Handle different HTTP methods
|
76
|
+
response = fetch_response_by_method(params_with_offset)
|
77
|
+
|
78
|
+
if response.code == RATE_LIMIT_STATUS
|
79
|
+
handle_rate_limit(response, offset, attempt, params_override)
|
80
|
+
else
|
81
|
+
handle_response(response)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def fetch_response_by_method(params = {})
|
86
|
+
case @method
|
87
|
+
when :get
|
88
|
+
self.class.send(@method, @url, query: params)
|
89
|
+
else
|
90
|
+
self.class.send(@method, @url, body: params.to_json)
|
91
|
+
end
|
92
|
+
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
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hubspot
|
4
|
+
# Properties from the object schema
|
5
|
+
class Property < OpenStruct
|
6
|
+
# :nocov:
|
7
|
+
def inspect
|
8
|
+
included_keys = %i[name type fieldType hubspotDefined]
|
9
|
+
filtered_hash = to_h.slice(*included_keys)
|
10
|
+
formatted_attrs = filtered_hash.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
|
11
|
+
"#<#{self.class} #{formatted_attrs}>"
|
12
|
+
end
|
13
|
+
# :nocov:
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './api_client'
|
4
|
+
|
5
|
+
module Hubspot
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
7
|
+
# Hubspot::Resource class
|
8
|
+
class Resource < ApiClient
|
9
|
+
METADATA_FIELDS = %w[createdate hs_object_id lastmodifieddate].freeze
|
10
|
+
|
11
|
+
# Allow read/write access to properties and metadata
|
12
|
+
attr_accessor :id, :properties, :changes, :metadata
|
13
|
+
|
14
|
+
class << self
|
15
|
+
# Find a resource by ID and return an instance of the class
|
16
|
+
def find(id)
|
17
|
+
response = get("/crm/v3/objects/#{resource_name}/#{id}")
|
18
|
+
instantiate_from_response(response)
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_by(property, value, properties = nil)
|
22
|
+
params = { idProperty: property }
|
23
|
+
params[:properties] = properties if properties.is_a?(Array)
|
24
|
+
response = get("/crm/v3/objects/#{resource_name}/#{value}", query: params)
|
25
|
+
instantiate_from_response(response)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Create a new resource
|
29
|
+
def create(params)
|
30
|
+
response = post("/crm/v3/objects/#{resource_name}", body: { properties: params }.to_json)
|
31
|
+
instantiate_from_response(response)
|
32
|
+
end
|
33
|
+
|
34
|
+
def update(id, params)
|
35
|
+
response = patch("/crm/v3/objects/#{resource_name}/#{id}", body: { properties: params }.to_json)
|
36
|
+
raise Hubspot.error_from_response(response) unless response.success?
|
37
|
+
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
def archive(id)
|
42
|
+
response = delete("/crm/v3/objects/#{resource_name}/#{id}")
|
43
|
+
raise Hubspot.error_from_response(response) unless response.success?
|
44
|
+
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
def list(params = {})
|
49
|
+
PagedCollection.new(
|
50
|
+
url: "/crm/v3/objects/#{resource_name}",
|
51
|
+
params: params,
|
52
|
+
resource_class: self
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Get the complete list of fields (properties) for the object
|
57
|
+
def properties
|
58
|
+
@properties ||= begin
|
59
|
+
response = get("/crm/v3/properties/#{resource_name}")
|
60
|
+
handle_response(response)['results'].map { |hash| Property.new(hash) }
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def custom_properties
|
65
|
+
properties.reject { |property| property['hubspotDefined'] }
|
66
|
+
end
|
67
|
+
|
68
|
+
def property(property_name)
|
69
|
+
properties.detect { |prop| prop.name == property_name }
|
70
|
+
end
|
71
|
+
|
72
|
+
# Simplified search interface
|
73
|
+
OPERATOR_MAP = {
|
74
|
+
'_contains' => 'CONTAINS_TOKEN',
|
75
|
+
'_gt' => 'GT',
|
76
|
+
'_lt' => 'LT',
|
77
|
+
'_gte' => 'GTE',
|
78
|
+
'_lte' => 'LTE',
|
79
|
+
'_neq' => 'NEQ',
|
80
|
+
'_in' => 'IN'
|
81
|
+
}.freeze
|
82
|
+
|
83
|
+
# rubocop:disable Metrics/MethodLength
|
84
|
+
def search(query:, properties: [], page_size: 100)
|
85
|
+
search_body = {}
|
86
|
+
|
87
|
+
# Add properties if specified
|
88
|
+
search_body[:properties] = properties unless properties.empty?
|
89
|
+
|
90
|
+
# Handle the query using case-when for RuboCop compliance
|
91
|
+
case query
|
92
|
+
when String
|
93
|
+
search_body[:query] = query
|
94
|
+
when Hash
|
95
|
+
search_body[:filterGroups] = build_filter_groups(query)
|
96
|
+
else
|
97
|
+
raise ArgumentError, 'query must be either a string or a hash'
|
98
|
+
end
|
99
|
+
|
100
|
+
# Add the page size (passed as limit to the API)
|
101
|
+
search_body[:limit] = page_size
|
102
|
+
|
103
|
+
# Perform the search and return a PagedCollection
|
104
|
+
PagedCollection.new(
|
105
|
+
url: "/crm/v3/objects/#{resource_name}/search",
|
106
|
+
params: search_body,
|
107
|
+
resource_class: self,
|
108
|
+
method: :post
|
109
|
+
)
|
110
|
+
end
|
111
|
+
|
112
|
+
# rubocop:enable Metrics/MethodLength
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
# Define the resource name based on the class
|
117
|
+
def resource_name
|
118
|
+
name = self.name.split('::').last.downcase
|
119
|
+
if name.end_with?('y')
|
120
|
+
name.gsub(/y$/, 'ies') # Company -> companies
|
121
|
+
else
|
122
|
+
"#{name}s" # Contact -> contacts, Deal -> deals
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Instantiate a single resource object from the response
|
127
|
+
def instantiate_from_response(response)
|
128
|
+
data = handle_response(response)
|
129
|
+
new(data) # Passing full response data to initialize
|
130
|
+
end
|
131
|
+
|
132
|
+
# Convert simple filters to HubSpot's filterGroups format
|
133
|
+
def build_filter_groups(filters)
|
134
|
+
filter_groups = [{ filters: [] }]
|
135
|
+
|
136
|
+
filters.each do |key, value|
|
137
|
+
property_name, operator = extract_property_and_operator(key)
|
138
|
+
filter_groups.first[:filters] << {
|
139
|
+
propertyName: property_name,
|
140
|
+
operator: operator,
|
141
|
+
value: value
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
filter_groups
|
146
|
+
end
|
147
|
+
|
148
|
+
# Extract property name and operator from the key
|
149
|
+
def extract_property_and_operator(key)
|
150
|
+
OPERATOR_MAP.each do |suffix, hubspot_operator|
|
151
|
+
return [key.to_s.sub(suffix, ''), hubspot_operator] if key.to_s.end_with?(suffix)
|
152
|
+
end
|
153
|
+
|
154
|
+
# Default to 'EQ' operator if no suffix is found
|
155
|
+
[key.to_s, 'EQ']
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# rubocop:disable Ling/MissingSuper
|
160
|
+
def initialize(data = {})
|
161
|
+
@id = extract_id(data)
|
162
|
+
@properties = {}
|
163
|
+
@metadata = {}
|
164
|
+
|
165
|
+
if @id
|
166
|
+
initialize_from_api(data)
|
167
|
+
else
|
168
|
+
initialize_new_object(data)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
# rubocop:enable Ling/MissingSuper
|
172
|
+
|
173
|
+
# Instance methods for update (or save)
|
174
|
+
def save
|
175
|
+
if persisted?
|
176
|
+
self.class.update(@id, @changes).tap do |result|
|
177
|
+
return false unless result
|
178
|
+
|
179
|
+
@properties.merge!(@changes)
|
180
|
+
@changes = {}
|
181
|
+
end
|
182
|
+
else
|
183
|
+
create_new
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def persisted?
|
188
|
+
@id ? true : false
|
189
|
+
end
|
190
|
+
|
191
|
+
# Update the resource
|
192
|
+
def update(params)
|
193
|
+
raise 'Not able to update as not persisted' unless persisted?
|
194
|
+
|
195
|
+
params.each do |key, value|
|
196
|
+
send("#{key}=", value) # This will trigger the @changes tracking via method_missing
|
197
|
+
end
|
198
|
+
|
199
|
+
save
|
200
|
+
end
|
201
|
+
|
202
|
+
def delete
|
203
|
+
self.class.archive(id)
|
204
|
+
end
|
205
|
+
alias archive delete
|
206
|
+
|
207
|
+
# rubocop:disable Metrics/MethodLength
|
208
|
+
# Handle dynamic getter and setter methods with method_missing
|
209
|
+
def method_missing(method, *args)
|
210
|
+
method_name = method.to_s
|
211
|
+
|
212
|
+
# Handle setters
|
213
|
+
if method_name.end_with?('=')
|
214
|
+
attribute = method_name.chomp('=')
|
215
|
+
new_value = args.first
|
216
|
+
|
217
|
+
# Track changes only if the value has actually changed
|
218
|
+
if @properties[attribute] != new_value
|
219
|
+
@changes[attribute] = new_value
|
220
|
+
else
|
221
|
+
@changes.delete(attribute) # Remove from changes if it reverts to the original value
|
222
|
+
end
|
223
|
+
|
224
|
+
return new_value
|
225
|
+
# Handle getters
|
226
|
+
else
|
227
|
+
return @changes[method_name] if @changes.key?(method_name)
|
228
|
+
return @properties[method_name] if @properties.key?(method_name)
|
229
|
+
end
|
230
|
+
|
231
|
+
# Fallback if the method or attribute is not found
|
232
|
+
# :nocov:
|
233
|
+
super
|
234
|
+
# :nocov:
|
235
|
+
end
|
236
|
+
# rubocop:enable Metrics/MethodLength
|
237
|
+
|
238
|
+
# Ensure respond_to_missing? is properly overridden
|
239
|
+
# :nocov:
|
240
|
+
def respond_to_missing?(method_name, include_private = false)
|
241
|
+
property_name = method_name.to_s.chomp('=')
|
242
|
+
@properties.key?(property_name) || @changes.key?(property_name) || super
|
243
|
+
end
|
244
|
+
# :nocov:
|
245
|
+
|
246
|
+
private
|
247
|
+
|
248
|
+
# Extract ID from data and convert to integer
|
249
|
+
def extract_id(data)
|
250
|
+
data['id'] ? data['id'].to_i : nil
|
251
|
+
end
|
252
|
+
|
253
|
+
# Initialize from API response, separating metadata from properties
|
254
|
+
def initialize_from_api(data)
|
255
|
+
@metadata = extract_metadata(data)
|
256
|
+
properties_data = data['properties'] || {}
|
257
|
+
|
258
|
+
properties_data.each do |key, value|
|
259
|
+
if METADATA_FIELDS.include?(key)
|
260
|
+
@metadata[key] = value
|
261
|
+
else
|
262
|
+
@properties[key] = value
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
@changes = {}
|
267
|
+
end
|
268
|
+
|
269
|
+
# Initialize a new object (no API response)
|
270
|
+
def initialize_new_object(data)
|
271
|
+
@properties = {}
|
272
|
+
@changes = data.transform_keys(&:to_s)
|
273
|
+
@metadata = {}
|
274
|
+
end
|
275
|
+
|
276
|
+
# Extract metadata from data, excluding properties
|
277
|
+
def extract_metadata(data)
|
278
|
+
data.reject { |key, _| key == 'properties' }
|
279
|
+
end
|
280
|
+
|
281
|
+
# Create a new resource
|
282
|
+
def create_new
|
283
|
+
created_resource = self.class.create(@changes)
|
284
|
+
@id = created_resource.id
|
285
|
+
@id ? true : false
|
286
|
+
end
|
287
|
+
end
|
288
|
+
# rubocop:enable Metrics/ClassLength
|
289
|
+
end
|
data/lib/hubspot/user.rb
ADDED
data/lib/hubspot.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'hubspot/config'
|
4
|
+
|
5
|
+
# Main Hubspot module
|
6
|
+
module Hubspot
|
7
|
+
class << self
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
# Delegate logger to config.logger
|
11
|
+
def_delegator :config, :logger
|
12
|
+
|
13
|
+
attr_writer :config
|
14
|
+
|
15
|
+
def config
|
16
|
+
@config ||= Config.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def configure
|
20
|
+
yield(config) if block_given?
|
21
|
+
set_client_headers if config.access_token
|
22
|
+
end
|
23
|
+
|
24
|
+
def configured?
|
25
|
+
return true unless @config.nil?
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# Set Authorization header on Hubspot::ApiClient when access_token is configured
|
31
|
+
def set_client_headers
|
32
|
+
Hubspot::ApiClient.headers 'Authorization' => "Bearer #{config.access_token}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'httparty'
|
4
|
+
require 'logger'
|
5
|
+
|
6
|
+
# Load the main Hubspot module, version and configuration
|
7
|
+
require_relative 'hubspot'
|
8
|
+
require_relative 'hubspot/version'
|
9
|
+
require_relative 'hubspot/config'
|
10
|
+
|
11
|
+
# define the exception classes, then load the main API client
|
12
|
+
require_relative 'hubspot/exceptions'
|
13
|
+
require_relative 'hubspot/api_client'
|
14
|
+
|
15
|
+
# load base class then modules
|
16
|
+
require_relative 'hubspot/resource'
|
17
|
+
require_relative 'hubspot/property'
|
18
|
+
require_relative 'hubspot/contact'
|
19
|
+
require_relative 'hubspot/company'
|
20
|
+
require_relative 'hubspot/user'
|
21
|
+
|
22
|
+
# Load other components
|
23
|
+
require_relative 'hubspot/paged_collection'
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'hubspot/version'
|
6
|
+
|
7
|
+
# rubocop:disable Metrics/BlockLength
|
8
|
+
Gem::Specification.new do |spec|
|
9
|
+
spec.name = 'ruby_hubspot_api'
|
10
|
+
spec.version = Hubspot::VERSION
|
11
|
+
spec.authors = ['Simon Brook']
|
12
|
+
spec.email = ['simon@datanauts.co.uk']
|
13
|
+
|
14
|
+
spec.summary = 'ruby_hubspot_api is an ORM-like wrapper for the Hubspot API'
|
15
|
+
spec.description = 'ruby_hubspot_api is an ORM-like wrapper for v3 of the Hubspot API'
|
16
|
+
spec.homepage = 'https://github.com/sensadrome/ruby_hubspot_api'
|
17
|
+
spec.license = 'MIT'
|
18
|
+
|
19
|
+
spec.required_ruby_version = '>= 2.5'
|
20
|
+
|
21
|
+
# Prevent pushing this gem to RubyGems.org.
|
22
|
+
# To allow pushes either set the 'allowed_push_host'
|
23
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
24
|
+
if spec.respond_to?(:metadata)
|
25
|
+
# spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
|
26
|
+
|
27
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
28
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/CHANGELOG.md"
|
29
|
+
end
|
30
|
+
|
31
|
+
# Specify which files should be added to the gem when it is released.
|
32
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
33
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
34
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
35
|
+
end
|
36
|
+
spec.bindir = 'exe'
|
37
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
38
|
+
spec.require_paths = ['lib']
|
39
|
+
|
40
|
+
# Define development dependencies
|
41
|
+
spec.add_development_dependency 'rake', '>= 11.0', '< 14.0'
|
42
|
+
|
43
|
+
spec.add_dependency 'bundler', '~> 2.3', '< 3.0'
|
44
|
+
spec.add_dependency 'dotenv', '~> 2.8', '< 3.0'
|
45
|
+
spec.add_dependency 'pry', '~> 0.13', '< 1.0'
|
46
|
+
spec.add_dependency 'pry-byebug', '~> 3.9', '< 4.0'
|
47
|
+
spec.add_dependency 'rspec', '~> 3.13', '< 4.0'
|
48
|
+
spec.add_dependency 'simplecov', '~> 0.22', '< 1.0'
|
49
|
+
spec.add_dependency 'vcr', '~> 6.0', '< 7.0'
|
50
|
+
spec.add_dependency 'webmock', '~> 3.23', '< 4.0'
|
51
|
+
|
52
|
+
# Define runtime dependencies
|
53
|
+
spec.add_runtime_dependency 'httparty', '~> 0.21', '< 1.0'
|
54
|
+
end
|
55
|
+
# rubocop:enable Metrics/BlockLength
|