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.
@@ -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