module ZendeskAPI # Represents an association between two resources class Association # @return [Hash] Options passed into the association attr_reader :options # Options to pass in # * class - Required # * parent - Parent instance # * path - Optional path instead of resource name def initialize(options = {}) @options = Hashie::Mash.new(options) end # Generate a path to the resource. # id and _id attributes will be deleted from passed in options hash if they are used in the built path. # Arguments that can be passed in: # An instance, any resource instance # Hash Options: # * with_parent - Include the parent path (false by default) # * with_id - Include the instance id, if possible (true) def generate_path(*args) options = Hashie::Mash.new(:with_id => true) if args.last.is_a?(Hash) original_options = args.pop options.merge!(original_options) end instance = args.first namespace = @options[:class].to_s.split("::") namespace.delete("ZendeskAPI") has_parent = namespace.size > 1 || (options[:with_parent] && @options.parent) if has_parent parent_class = @options.parent ? @options.parent.class : ZendeskAPI.get_class(namespace[0]) parent_namespace = build_parent_namespace(parent_class, instance, options, original_options) namespace[1..1] = parent_namespace if parent_namespace namespace[0] = parent_class.resource_name else namespace[0] = @options.path || @options[:class].resource_name end if id = extract_id(instance, options, original_options) namespace << id end namespace.join("/") end private def build_parent_namespace(parent_class, instance, options, original_options) return unless association_on_parent = parent_class.associations.detect {|a| a[:class] == @options[:class] } [ extract_parent_id(parent_class, instance, options, original_options), @options.path || association_on_parent[:name].to_s ] end def extract_parent_id(parent_class, instance, options, original_options) parent_id_column = "#{parent_class.singular_resource_name}_id" if @options.parent @options.parent.id elsif instance instance.send(parent_id_column) elsif options[parent_id_column] original_options.delete(parent_id_column) || original_options.delete(parent_id_column.to_sym) else raise ArgumentError.new("#{@options[:class].resource_name} requires #{parent_id_column} or parent") end end def extract_id(instance, options, original_options) if options[:with_id] && !@options[:class].ancestors.include?(SingularResource) if instance && instance.id instance.id elsif options[:id] original_options.delete(:id) || original_options.delete("id") end end end end # This module holds association method for resources. # Associations can be loaded in three ways: # * Commonly used resources are automatically side-loaded server side and sent along with their parent object. # * Associated resource ids are sent and are then loaded one-by-one into the parent collection. # * The association is represented with Rails' nested association urls (such as tickets/:id/groups) and are loaded that way. module Associations def self.included(base) base.send(:extend, ClassMethods) end def wrap_resource(resource, klass, class_level_association) instance_association = Association.new(class_level_association.merge(:parent => self)) case resource when Hash klass.new(@client, resource.merge(:association => instance_association)) when String, Fixnum klass.new(@client, :id => resource, :association => instance_association) else resource.association = instance_association resource end end module ClassMethods include Rescue def associations @assocations ||= [] end # Represents a parent-to-child association between resources. Options to pass in are: class, path. # @param [Symbol] resource_name The underlying resource name # @param [Hash] opts The options to pass to the method definition. def has(resource_name, class_level_options = {}) klass = get_class(class_level_options.delete(:class)) || get_class(resource_name) class_level_association = { :class => klass, :name => resource_name, :inline => class_level_options.delete(:inline), :path => class_level_options.delete(:path) } associations << class_level_association id_column = "#{resource_name}_id" define_method "#{resource_name}_used?" do !!instance_variable_get("@#{resource_name}") end define_method resource_name do |*args| instance_options = args.last.is_a?(Hash) ? args.pop : {} # return if cached cached = instance_variable_get("@#{resource_name}") return cached if cached && !instance_options[:reload] # find and cache association instance_association = Association.new(class_level_association.merge(:parent => self)) resource = if klass.respond_to?(:find) && resource_id = method_missing(id_column) klass.find(@client, :id => resource_id, :association => instance_association) elsif found = method_missing(resource_name.to_sym) wrap_resource(found, klass, class_level_association) elsif klass.ancestors.include?(DataResource) rescue_client_error do response = @client.connection.get(instance_association.generate_path(:with_parent => true)) klass.new(@client, response.body[klass.singular_resource_name].merge(:association => instance_association)) end end send("#{id_column}=", resource.id) if resource && has_key?(id_column) instance_variable_set("@#{resource_name}", resource) end define_method "#{resource_name}=" do |resource| resource = wrap_resource(resource, klass, class_level_association) send("#{id_column}=", resource.id) if has_key?(id_column) instance_variable_set("@#{resource_name}", resource) end end # Represents a parent-to-children association between resources. Options to pass in are: class, path. # @param [Symbol] resource The underlying resource name # @param [Hash] opts The options to pass to the method definition. def has_many(resource_name, class_level_opts = {}) klass = get_class(class_level_opts.delete(:class)) || get_class(resource_name.to_s.singular) class_level_association = { :class => klass, :name => resource_name, :inline => class_level_opts.delete(:inline), :path => class_level_opts.delete(:path) } associations << class_level_association id_column = "#{resource_name}_ids" define_method "#{resource_name}_used?" do !!instance_variable_get("@#{resource_name}") end define_method resource_name do |*args| instance_opts = args.last.is_a?(Hash) ? args.pop : {} # return if cached cached = instance_variable_get("@#{resource_name}") return cached if cached && !instance_opts[:reload] # find and cache association instance_association = Association.new(class_level_association.merge(:parent => self)) singular_resource_name = resource_name.to_s.singular resources = if (ids = method_missing("#{singular_resource_name}_ids")) && ids.any? ids.map do |id| klass.find(@client, :id => id, :association => instance_association) end.compact elsif (resources = method_missing(resource_name.to_sym)) && resources.any? resources.map do |res| klass.new(@client, res.merge(:association => instance_association)) end else ZendeskAPI::Collection.new(@client, klass, instance_opts.merge(:association => instance_association)) end send("#{id_column}=", resources.map(&:id)) if resource && has_key?(id_column) instance_variable_set("@#{resource_name}", resources) end define_method "#{resource_name}=" do |resources| if resources.is_a?(Array) resources.map! { |attr| wrap_resource(attr, klass, class_level_association) } send(resource_name).replace(resources) else resources.association = instance_association instance_variable_set("@#{resource_name}", resources) end send("#{id_column}=", resources.map(&:id)) if resources && has_key?(id_column) resource end end # Allows using has and has_many without having class defined yet # Guesses at Resource, if it's anything else and the class is later # reopened under a different superclass, an error will be thrown def get_class(resource) return false if resource.nil? res = resource.to_s.modulize begin const_get(res) rescue NameError, ArgumentError # ruby raises NameError, rails raises ArgumentError ZendeskAPI.get_class(resource) end end end end class << self # Make sure Rails' overwriting of const_missing doesn't cause trouble def const_missing(*args) Object.const_missing(*args) end # Allows using has and has_many without having class defined yet # Guesses at Resource, if it's anything else and the class is later # reopened under a different superclass, an error will be thrown def get_class(resource) return false if resource.nil? res = resource.to_s.modulize.split("::") begin res[1..-1].inject(ZendeskAPI.const_get(res[0])) do |iter, k| begin iter.const_get(k) rescue iter.const_set(k, Class.new(Resource)) end end rescue NameError ZendeskAPI.const_set(res[0], Class.new(Resource)) end end end end