ruby_hubspot_api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hubspot
4
+ class User < Resource
5
+ end
6
+
7
+ Owner = User
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hubspot
4
+ VERSION = '0.1.0'
5
+ end
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