lib/hubspot/resource.rb in ruby_hubspot_api-0.2.1.1 vs lib/hubspot/resource.rb in ruby_hubspot_api-0.2.2

- old
+ new

@@ -4,94 +4,229 @@ require_relative './paged_collection' require_relative './paged_batch' module Hubspot # rubocop:disable Metrics/ClassLength - # Hubspot::Resource class + + # HubSpot Resource Base Class + # This class provides common functionality for interacting with + # HubSpot API resources such as Contacts, Companies, etc + # + # It supports common operations like finding, creating, updating, + # and deleting resources, as well as batch operations. + # + # This class is meant to be inherited by specific resources + # like `Hubspot::Contact`. + # + # Example Usage: + # Hubspot::Contact.find(1) + # contact.name # 'Luke' + # + # company = Hubspot::Company.create(name: "Acme Corp") + # company.id.nil? # false + # class Resource < ApiClient METADATA_FIELDS = %w[createdate hs_object_id lastmodifieddate].freeze - # Allow read/write access to properties and metadata - attr_accessor :id, :properties, :changes, :metadata + # Allow read/write access to id, properties, changes and metadata + # the id of the object in hubspot + attr_accessor :id + + # the properties as if read from the api + attr_accessor :properties + + # track any changes made to properties before saving etc + attr_accessor :changes + + # any other data sent from the api about the resource + attr_accessor :metadata + class << self # Find a resource by ID and return an instance of the class - def find(id) - response = get("/crm/v3/objects/#{resource_name}/#{id}") + # + # id - [Integer] The ID (or hs_object_id) of the resource to fetch. + # + # Example: + # contact = Hubspot::Contact.find(1) + # + # Returns An instance of the resource. + def find(id, properties = nil) + all_properties = build_property_list(properties) + if all_properties.is_a?(Array) && !all_properties.empty? + params = { query: { properties: all_properties } } + end + response = get("#{api_root}/#{resource_name}/#{id}", params || {}) instantiate_from_response(response) end + # Finds a resource by a given property and value. + # + # property - The property to search by (e.g., "email"). + # value - The value of the property to match. + # properties - Optional list of properties to return. + # + # Example: + # properties = %w[firstname lastname email last_contacted] + # contact = Hubspot::Contact.find_by("email", "john@example.com", properties) + # + # Returns An instance of the resource. def find_by(property, value, properties = nil) params = { idProperty: property } - params[:properties] = properties if properties.is_a?(Array) - response = get("/crm/v3/objects/#{resource_name}/#{value}", query: params) + + all_properties = build_property_list(properties) + params[:properties] = all_properties unless all_properties.empty? + + response = get("#{api_root}/#{resource_name}/#{value}", query: params) instantiate_from_response(response) end - # Create a new resource + # Creates a new resource with the given parameters. + # + # params - The properties to create the resource with. + # + # Example: + # contact = Hubspot::Contact.create(name: "John Doe", email: "john@example.com") + # + # Returns [Resource] The newly created resource. def create(params) - response = post("/crm/v3/objects/#{resource_name}", body: { properties: params }.to_json) + response = post("#{api_root}/#{resource_name}", body: { properties: params }.to_json) instantiate_from_response(response) end + # Updates an existing resource by ID. + # + # id - The ID of the resource to update. + # params - The properties to update. + # + # Example: + # contact.update(1, name: "Jane Doe") + # + # Returns True if the update was successful def update(id, params) - response = patch("/crm/v3/objects/#{resource_name}/#{id}", body: { properties: params }.to_json) - raise Hubspot.error_from_response(response) unless response.success? + response = patch("#{api_root}/#{resource_name}/#{id}", + body: { properties: params }.to_json) + handle_response(response) true end + # Deletes a resource by ID. + # + # id - The ID of the resource to delete. + # + # Example: + # Hubspot::Contact.archive(1) + # + # Returns True if the deletion was successful def archive(id) - response = delete("/crm/v3/objects/#{resource_name}/#{id}") - raise Hubspot.error_from_response(response) unless response.success? + response = delete("#{api_root}/#{resource_name}/#{id}") + handle_response(response) true end + # Lists all resources with optional filters and pagination. + # + # params - Optional parameters to filter or paginate the results. + # + # Example: + # contacts = Hubspot::Contact.list(limit: 100) + # + # Returns [PagedCollection] A collection of resources. def list(params = {}) + all_properties = build_property_list(params[:properties]) + + if all_properties.is_a?(Array) && !all_properties.empty? + params[:properties] = all_properties.join(',') + end + PagedCollection.new( - url: "/crm/v3/objects/#{resource_name}", + url: list_page_uri, params: params, resource_class: self ) end - def batch_read(object_ids = [], id_property: 'id') - params = id_property == 'id' ? {} : { idProperty: id_property } + # Performs a batch read operation to retrieve multiple resources by their IDs. + # + # object_ids - A list of resource IDs to fetch. + # + # id_property - The property to use for identifying resources (default: 'id'). + # + # + # Example: + # Hubspot::Contact.batch_read([1, 2, 3]) + # + # Returns [PagedBatch] A paged batch of resources + def batch_read(object_ids = [], properties: [], id_property: 'id') + params = {} + params[:idProperty] = id_property unless id_property == 'id' + params[:properties] = properties unless properties.blank? PagedBatch.new( - url: "/crm/v3/objects/#{resource_name}/batch/read", - params: params, + url: "#{api_root}/#{resource_name}/batch/read", + params: params.empty? ? nil : params, object_ids: object_ids, resource_class: self ) end + # Performs a batch read operation to retrieve multiple resources by their IDs + # until there are none left + # + # object_ids - A list of resource IDs to fetch. [Array<Integer>] + # id_property - The property to use for identifying resources (default: 'id'). + # + # Example: + # Hubspot::Contact.batch_read_all(hubspot_contact_ids) + # + # Returns [Hubspot::Batch] A batch of resources that can be operated on further def batch_read_all(object_ids = [], id_property: 'id') Hubspot::Batch.read(self, object_ids, id_property: id_property) end - # Get the complete list of fields (properties) for the object + # Retrieve the complete list of properties for this resource class + # + # Returns [Array<Hubspot::Property>] An array of hubspot properties def properties @properties ||= begin response = get("/crm/v3/properties/#{resource_name}") handle_response(response)['results'].map { |hash| Property.new(hash) } end end + # Retrieve the complete list of user defined properties for this resource class + # + # Returns [Array<Hubspot::Property>] An array of hubspot properties def custom_properties properties.reject { |property| property['hubspotDefined'] } end + # Retrieve the complete list of updatable properties for this resource class + # + # Returns [Array<Hubspot::Property>] An array of updateable hubspot properties def updatable_properties properties.reject(&:read_only?) end + # Retrieve the complete list of read-only properties for this resource class + # + # Returns [Array<Hubspot::Property>] An array of read-only hubspot properties def read_only_properties - properties.select(&:read_only?) + properties.select(&:read_only) end + # Retrieve information about a specific property + # + # Example: + # property = Hubspot::Contact.property('industry_sector') + # values_for_select = property.options.each_with_object({}) do |prop, hash| + # hash[prop['value']] = prop['label'] + # end + # + # Returns [Hubspot::Property] A hubspot property def property(property_name) properties.detect { |prop| prop.name == property_name } end # Simplified search interface @@ -104,10 +239,52 @@ '_neq' => 'NEQ', '_in' => 'IN' }.freeze # rubocop:disable Metrics/MethodLength + + # Search for resources using a flexible query format and optional properties. + # + # This method allows searching for resources by passing a query in the form of a string + # (for full-text search) or a hash with special suffixes on the keys to + # define different comparison operators. + # + # You can also specify which properties to return and the number of results per page. + # + # Available suffixes for query keys (when using a hash): + # - `_contains`: Matches values that contain the given string. + # - `_gt`: Greater than comparison. + # - `_lt`: Less than comparison. + # - `_gte`: Greater than or equal to comparison. + # - `_lte`: Less than or equal to comparison. + # - `_neq`: Not equal to comparison. + # - `_in`: Matches any of the values in the given array. + # + # If no suffix is provided, the default comparison is equality (`EQ`). + # + # query - [String, Hash] The query for searching. This can be either: + # - A String: for full-text search. + # - A Hash: where each key represents a property and may have suffixes for the comparison + # (e.g., `{ email_contains: 'example.org', age_gt: 30 }`). + # properties - An optional array of property names to return in the search results. + # If not specified or empty, HubSpot will return the default set of properties. + # page_size - The number of results to return per page + # (default is 10 for contacts and 100 for everything else). + # + # Example Usage: + # # Full-text search for 'example.org': + # props = %w[email firstname lastname] + # contacts = Hubspot::Contact.search(query: "example.org", properties: props, page_size: 50) + # + # # Search for contacts whose email contains 'example.org' and are older than 30: + # contacts = Hubspot::Contact.search( + # query: { email_contains: 'example.org', age_gt: 30 }, + # properties: ["email", "firstname", "lastname"], + # page_size: 50 + # ) + # + # Returns [PagedCollection] A paged collection of results that can be iterated over. def search(query:, properties: [], page_size: 100) search_body = {} # Add properties if specified search_body[:properties] = properties unless properties.empty? @@ -125,31 +302,49 @@ # Add the page size (passed as limit to the API) search_body[:limit] = page_size # Perform the search and return a PagedCollection PagedCollection.new( - url: "/crm/v3/objects/#{resource_name}/search", + url: "#{api_root}/#{resource_name}/search", params: search_body, resource_class: self, method: :post ) end # rubocop:enable Metrics/MethodLength + # The root of the api call. Mostly this will be "crm" + # but you can override this to account for a different + # object hierarchy + # Define the resource name based on the class def resource_name name = self.name.split('::').last.downcase if name.end_with?('y') name.gsub(/y$/, 'ies') # Company -> companies else "#{name}s" # Contact -> contacts, Deal -> deals end end + # List of properties that will always be retrieved + # should be overridden in specific resource class + def required_properties + [] + end + private + def api_root + '/crm/v3/objects' + end + + def list_page_uri + "#{api_root}/#{resource_name}" + end + # Instantiate a single resource object from the response def instantiate_from_response(response) data = handle_response(response) new(data) # Passing full response data to initialize end @@ -180,13 +375,40 @@ end # Default to 'EQ' operator if no suffix is found { propertyName: key.to_s, operator: 'EQ' } end + + # Internal make a list of properties to request from the API + # will be merged with any required_properties defined on the class + def build_property_list(properties) + properties = [] unless properties.is_a?(Array) + raise 'Must be an array' unless required_properties.is_a?(Array) + + properties.concat(required_properties).uniq + end end - # rubocop:disable Ling/MissingSuper + # rubocop:disable Lint/MissingSuper + + # Public: Initialize a resouce + # + # data - [2D Hash, nested Hash] data to initialise the resourse This can be either: + # - A Simple 2D Hash, key value pairs of property => value (for the create option) + # - A structured hash consisting of { id: <hs_object_id>, properties: {}, ... } + # This is the same structure as per the API, and can be rebuilt if you store the id + # of the object against your own data + # + # Example: + # attrs = { firstname: 'Luke', lastname: 'Skywalker', email: 'luke@jedi.org' } + # contact = Hubspot::Contact.new(attrs) + # contact.persisted? # false + # contact.save # creates the record in Hubspot + # contact.persisted? # true + # puts "Contact saved with hubspot id #{contact.id}" + # + # existing_contact = Hubspot::Contact.new(id: hubspot_id, properties: contact.to_hubspot) def initialize(data = {}) data.transform_keys!(&:to_s) @id = extract_id(data) @properties = {} @metadata = {} @@ -194,17 +416,26 @@ initialize_from_api(data) else initialize_new_object(data) end end - # rubocop:enable Ling/MissingSuper + # rubocop:enable Lint/MissingSuper + # Determine the state of the object + # + # Returns Boolean def changes? !@changes.empty? end - # Instance methods for update (or save) + # Create or Update the resource. + # If the resource was already persisted (e.g. it was retrieved from the API) + # it will be updated using values from @changes + # + # If the resource is new (no id) it will be created + # + # Returns Boolean def save if persisted? self.class.update(@id, @changes).tap do |result| return false unless result @@ -214,36 +445,76 @@ else create_new end end + # If the resource exists in Hubspot + # + # Returns Boolean def persisted? @id ? true : false end - # Update the resource - def update(params) + # Public - Update the resource and persist to the api + # + # attributes - hash of properties to update in key value pairs + # + # Example: + # contact = Hubspot::Contact.find(hubspot_contact_id) + # contact.update(status: 'gold customer', last_contacted_at: Time.now.utc.iso8601) + # + # Returns Boolean + def update(attributes) raise 'Not able to update as not persisted' unless persisted? - params.each do |key, value| - send("#{key}=", value) # This will trigger the @changes tracking via method_missing - end + update_attributes(attributes) save end + # Public - Update resource attributes + # + # Does not persist to the api but processes each attribute correctly + # + # Example: + # contact = Hubspot::Contact.find(hubspot_contact_id) + # contact.changes? # false + # contact.update_attributes(education: 'Graduate', university: 'Life') + # contact.education # Graduate + # contact.changes? # true + # contact.changes # { "education" => "Graduate", "university" => "Life" } + # + # Returns Hash of changes + def update_attributes(attributes) + raise ArgumentError, 'must be a hash' unless attributes.is_a?(Hash) + + attributes.each do |key, value| + send("#{key}=", value) # This will trigger the @changes tracking via method_missing + end + end + + # Archive the object in Hubspot + # + # Example: + # company = Hubspot::Company.find(hubspot_company_id) + # company.delete + # def delete self.class.archive(id) end alias archive delete def resource_name self.class.resource_name end # rubocop:disable Metrics/MethodLength - # Handle dynamic getter and setter methods with method_missing + + # getter: Check the properties and changes hashes to see if the method + # being called is a key, and return the corresponding value + # setter: If the method ends in "=" persist the value in the changes hash + # (when it is different from the corresponding value in properties if set) def method_missing(method, *args) method_name = method.to_s # Handle setters if method_name.end_with?('=') @@ -265,13 +536,14 @@ end # Fallback if the method or attribute is not found super end + # rubocop:enable Metrics/MethodLength - # Ensure respond_to_missing? is properly overridden + # Ensure respond_to_missing? handles existing keys in the properties anc changes hashes def respond_to_missing?(method_name, include_private = false) property_name = method_name.to_s.chomp('=') @properties.key?(property_name) || @changes.key?(property_name) || super end @@ -282,21 +554,27 @@ data['id'] ? data['id'].to_i : nil end # Initialize from API response, separating metadata from properties def initialize_from_api(data) - @metadata = extract_metadata(data) - properties_data = data['properties'] || {} + if data['properties'] + @metadata = data.reject { |key, _v| key == 'properties' } + handle_properties(data['properties']) + else + handle_properties(data) + end + @changes = {} + end + + def handle_properties(properties_data) properties_data.each do |key, value| if METADATA_FIELDS.include?(key) @metadata[key] = value else @properties[key] = value end end - - @changes = {} end # Initialize a new object (no API response) def initialize_new_object(data) @properties = {}