module Serviceable::ClassMethods

Public Instance Methods

acts_as_service(object,defaults={}) click to toggle source

Serviceable Usage

Controller: class PostsController

acts_as_service :post

end

# File lib/serviceable.rb, line 25
def acts_as_service(object,defaults={})

  before_action :assign_new_instance, only: :create
  before_action :did_assign_new_instance, only: :create
  before_action :assign_existing_instance, only: [ :show, :update, :destroy ]
  before_action :did_assign_existing_instance, only: [ :show, :update ]
  before_action :assign_collection, only: [ :index, :count ]
  before_action :did_assign_collection, only: [ :index, :count ]
  
  define_method("index") do
    respond_to do |format|
      format.json { render json: @collection.to_json(merge_options(defaults[:index])) }
      format.xml  { render xml: @collection.to_xml(merge_options(defaults[:index])) }
    end
  end
  
  define_method("count") do
    respond_to do |format|
      format.json { render json: @collection.count }
      format.xml { render xml: @collection.count }
    end
  end

  define_method("create") do
    respond_to do |format|
      if @instance.save
        format.json { render json: @instance, status: :created }
        format.xml  { render xml: @instance, status: :created }
      else
        format.json { render json: { errors: @instance.errors.full_messages }, status: :unprocessable_entity }
        format.xml  { render xml: { errors: @instance.errors.full_messages }, status: :unprocessable_entity }
      end
    end
  end

  define_method("show") do
    respond_to do |format|
      format.json { render json: @instance.to_json(merge_options(defaults[:show])) }
      format.xml  { render xml: @instance.to_xml(merge_options(defaults[:show])) }
    end
  end

  define_method("update") do
    respond_to do |format|
      if @instance.update_attributes(params[object])
        format.json { head :ok }
        format.xml  { head :ok }
      else
        format.json { render json: { errors: @instance.errors.full_messages }, status: :unprocessable_entity }
        format.xml  { render xml: { errors: @instance.errors.full_messages }, status: :unprocessable_entity }
      end
    end
  end

  define_method("destroy") do
    @instance.destroy

    respond_to do |format|
      format.json { head :no_content }
      format.xml  { head :no_content }
    end
  end
  
  define_method("describe") do
    details = {
      allowed_includes: force_array(defaults[:allowed_includes]),
      allowed_methods: force_array(defaults[:allowed_methods])
    }
    respond_to do |format|
      format.json { render json: details.to_json, status: :ok }
      format.xml { render xml: details.to_xml, status: :ok }
    end
  end

  # query string params can be given in the following formats:
  # only=field1,field2
  # except=field1,field2
  # include=assoc1
  # methods=my_helper
  #
  # if an included association is present, only and except params can be nested
  # include[user][except]=encrypted_password
  # include[user][only][]=first_name&include[user][only][]=last_name
  # include[user][only]=first_name,last_name
  #
  # NOTE: includes and methods are not supported for nested associations
  #
  # options specified by the developer are considered mandatory and can not be
  # overridden by the client
  #
  # client may only use includes and methods that are explicitly enabled by
  # the developer
  define_method("merge_options") do |options={}|
    merged_options = {}
    for key in [:only, :except]
      opts = {key => params[key]} if params[key]
      merged_options = merged_options.merge(opts) if opts
    end
    requested_includes = hash_for(params[:includes])
    allowed_includes = hash_for(defaults[:allowed_includes])
    requested_includes = deep_sym(requested_includes)
    allowed_includes = deep_sym(allowed_includes)
    whitelisted_includes = {}
    requested_includes.keys.each do |k|
      if allowed_includes.keys.include?(k)
        values = requested_includes[k]
        opts = {}
        opts[:only] = values[:only] if values[:only]
        opts[:except] = values[:except] if values[:except]
        whitelisted_includes[k] = opts
      end
    end
    if options && options[:include]
      if options[:include].kind_of?(Hash) 
        mandatory_includes = options[:include]
      elsif options[:include].kind_of?(Array)
        mandatory_includes = Hash[options[:include].map {|e| [e,{}]}]
      else
        mandatory_includes = {options[:include] => {}}
      end
      whitelisted_includes = whitelisted_includes.merge(mandatory_includes)
    end
    merged_options = merged_options.merge({include: whitelisted_includes}) if whitelisted_includes.keys.any?

    requested_methods = array_for(params[:methods])
    allowed_methods = array_for(defaults[:allowed_methods])
    requested_methods = requested_methods.map(&:to_s).map(&:to_sym)
    allowed_methods = allowed_methods.map(&:to_s).map(&:to_sym)
    whitelisted_methods = requested_methods & allowed_methods
    if options && options[:methods]
      mandatory_methods = array_for(options[:methods])
      whitelisted_methods = whitelisted_methods + mandatory_methods
    end
    merged_options = merged_options.merge({methods: whitelisted_methods}) if whitelisted_methods.any?
    merged_options = deep_split(merged_options.compact)
    return merged_options
  end
  
  define_method("assign_existing_instance") do
    @instance = object.to_s.camelize.constantize.all
    if params[:include].kind_of?(Hash)
      @instance = @instance.includes(params[:include].keys)
    end
    if params[:include].kind_of?(String)
      @instance = @instance.includes(params[:include].split(",").map(&:to_sym))
    end
    @instance = @instance.find(params[:id])
  end
  
  define_method("did_assign_existing_instance") do
    # do nothing
  end
  
  define_method("assign_new_instance") do
    @instance = object.to_s.camelize.constantize.new(object_params)
  end
  
  define_method("did_assign_new_instance") do
    # do nothing
  end
  
  define_method("object_params") do
    params.require(object).permit!
  end
  
  # query string params can be used to filter collections
  #
  # filters apply on associated collections using the following conventions:
  # where[user][category]=Expert
  # where[user][created_at][gt]=20130807T12:34:56.789Z
  #
  # filters can be constructed with AND and OR behavior
  # where[tags][id][in]=123,234,345  (OR)
  # where[tags][id]=123&where[tags][id]=234  (AND)
  define_method("assign_collection") do
    @collection = object.to_s.camelize.constantize.all
    if params[:include].kind_of?(Hash)
      for assoc in params[:include].keys
        @collection = @collection.includes(assoc.to_sym)
      end
    end
    if params[:include].kind_of?(String)
      @collection = @collection.includes(params[:include].split(",").map(&:to_sym))
    end
    for assoc in (params[:where].keys rescue [])
      attrs = params[:where][assoc]
      if attrs.kind_of?(Hash)
        for target_column in attrs.keys
          if attrs[target_column].kind_of?(String)
            if is_boolean_column?(target_column)
              value = true if ['t','true','1','y','yes'].include?(attrs[target_column].to_s)
              value = false if ['f','false','0','n','no'].include?(attrs[target_column].to_s)
            else
              value = attrs[target_column]
            end
            @collection = @collection.where(assoc => { target_column => value })
          elsif attrs[target_column].kind_of?(Hash)
            for op in attrs[target_column].keys.map(&:to_sym)
              value = is_time_column?(target_column) ? Time.parse(attrs[target_column][op]) : attrs[target_column][op]
              unless assoc.to_sym==object.to_s.pluralize.to_sym
                @collection = @collection.includes(assoc)
              end
              if op==:gt
                @collection = @collection.where("#{assoc}.#{target_column} > ?",value)
              elsif op==:lt
                @collection = @collection.where("#{assoc}.#{target_column} < ?",value)
              elsif op==:in
                @collection = @collection.where("#{assoc}.#{target_column} IN (?)",value.split(','))
              end
            end
          end  
        end
      else
        @collection = @collection.includes(assoc).where(assoc => attrs)
      end
    end
  end
  
  define_method("did_assign_collection") do
    # do nothing
  end
  
  define_method("array_for") do |obj|
    if obj.kind_of?(Hash)
      arr = obj.keys
    elsif obj.kind_of?(Array)
      arr = obj
    elsif obj.kind_of?(String)
      arr = obj.split(',')
    else
      arr = Array(obj)
    end
    arr.compact.uniq rescue []
  end
  
  # designed to traverse an entire hash, replacing delimited strings with arrays of symbols
  define_method("deep_split") do |hash={},pivot=','|
    Hash[hash.reject {|k,v| k.nil? || v.nil?}.map {|k,v| [k.to_sym,v.kind_of?(String) ? v.split(pivot).compact.map(&:to_sym) : (v.kind_of?(Hash) ? deep_split(v,pivot) : v)]}]
  end
  
  define_method("deep_sym") do |hash={}|
    Hash[hash.reject {|k,v| k.nil? || v.nil?}.map {|k,v| [k.to_sym,v.kind_of?(String) ? v.to_sym : (v.kind_of?(Hash) ? deep_sym(v) : (v.kind_of?(Array) ? v.compact.map(&:to_sym) : v))]}]
  end
  
  define_method("force_array") do |obj|
    obj.kind_of?(Array) ? obj : (obj.kind_of?(Hash) ? obj.keys : (obj==nil ? [] : [obj]))
  end
  
  define_method("hash_for") do |obj|
    if obj.kind_of?(Hash)
      hash = obj
    elsif obj.kind_of?(Array)
      hash = Hash[obj.map {|e| [e,{}]}]
    elsif obj.kind_of?(String)
      hash = Hash[obj.split(',').map {|e| [e,{}]}]
    else
      hash = {}
    end
    hash
  end
  
  define_method("required_fields") do
    object.to_s.capitalize.constantize.accessible_attributes.select {|e| is_required_column?(e)}
  end
  
  define_method("is_time_column?") do |column|
    object.to_s.capitalize.constantize.columns.select {|e| e.name==column.to_s}.first.type == :timestamp rescue false
  end
  
  define_method("is_boolean_column?") do |column|
    object.to_s.capitalize.constantize.columns.select {|e| e.name==column.to_s}.first.type == :boolean rescue false
  end
  
  define_method("is_required_column?") do |column|
    object.to_s.capitalize.constantize.validators_on(column).map(&:class).include?(ActiveModel::Validations::PresenceValidator)
  end
end