squareteam/yodatra

View on GitHub
lib/yodatra/models_controller.rb

Summary

Maintainability
C
7 hrs
Test Coverage
module Yodatra
  # This is a generic model controller that expose a REST API for your models.
  # The responses are encoded in JSON.
  #
  # Simply create your controller that inherits from this class, keeping the naming convention.
  #
  # For example, given a `User` model, creating a `class UsersController < Yodatra::ModelsController`, it will expose these routes:
  #
  #  GET /users
  # > retrieves all users _(attributes exposed are limited by the <b>read_scope</b> method defined in the <b>UsersController</b>)_
  #
  #  GET /users/:id
  # > retrieves a user _(attributes exposed are limited by the `read_scope`` method defined in the `UsersController`)_
  #
  #  POST /users
  # > creates a user _(attributes assignable are limited by the `user_params` method defined in the `UsersController`)_
  #
  #  PUT /users/:id
  # > updates a user _(attributes assignable are limited by the `user_params` method defined in the `UsersController`)_
  #
  #  DELETE /users/:id
  # > deletes a user
  #
  # If your model is referenced by another model, nested routes are also created for you. And you don't need to worry about the references/joins, they are done automaticly!
  # For example, imagine a `Team` model that has many `User`s, the following routes will be exposed:
  #
  #  GET /team/:team_id/users
  #
  #  GET /team/:team_id/users/:id
  #
  #  POST /team/:team_id/users
  #
  #  PUT /team/:team_id/users/:id
  #
  #  DESTROY /team/:team_id/users/:id
  #
  # === Note:
  # You can disable any of these actions by using the __::disable__ class method
  # and providing the list of actions you want to disable
  #  disable :read, :read_all, :create, :update, :delete, :nested_read_all, :nested_delete
  #
  # === Extra:
  # You can enable a special "search" action by using the __::enable_search_on__ class method
  #  enable_search_on :name
  class ModelsController < Sinatra::Base

    # Generic route to target ONE resource
    ONE_ROUTE =
        %r{\A/([\w]+?)/([0-9]+)(?:/([\w]+?)/([0-9]+)){0,1}\Z}

    # Generic route to target ALL resources
    ALL_ROUTE =
        %r{\A/([\w]+?)(?:/([0-9]+)/([\w]+?)){0,1}\Z}

    # Search route
    SEARCH_ROUTE =
      %r{\A/([\w]+?)(?:/([0-9]+)/([\w]+?)){0,1}/search\Z}

    before do
      content_type 'application/json'
    end

    READ_ALL = :read_all
    get ALL_ROUTE do
      retrieve_resources READ_ALL do |resource|
        resource.all.as_json(read_scope)
      end
    end

    READ_ONE = :read
    get ONE_ROUTE do
      retrieve_resources READ_ONE do |resource|
        resource.as_json(read_scope)
      end
    end

    CREATE_ONE = :create
    post ALL_ROUTE do
      retrieve_resources CREATE_ONE do |resource|
        if resource.nil?
          not_allowed
        else
          hash = self.send("#{model_name.underscore}_params".to_sym)
          @one = resource.create hash

          if @one.id.nil?
            status 400
            @one.errors.full_messages
          else
            @one.as_json(read_scope)
          end
        end
      end
    end

    UPDATE_ONE = :update
    put ONE_ROUTE do
      retrieve_resources UPDATE_ONE do |resource|
        hash = self.send("#{model_name.underscore}_params".to_sym)
        if resource.update_attributes(hash)
          resource.as_json(read_scope)
        else
          status 400
          resource.errors.full_messages
        end
      end
    end

    DELETE_ONE = :delete
    delete ONE_ROUTE do
      retrieve_resources DELETE_ONE do |resource|
        if resource.destroy
          resource.as_json(read_scope)
        else
          status 400
          resource.errors.full_messages
        end
      end
    end

    class << self
      def model_name
        self.name.split('::').last.gsub(/sController/, '')
      end

      def model
        model_name.constantize
      end

      # This helper gives the ability to disable default root by specifying
      # a list of routes to disable.
      # @param *opts list of routes to disable (e.g. :create, :destroy)
      def disable(*opts)
        opts.each do |key|
          method = "#{key}_disabled?".to_sym
          undef_method method if method_defined? method
          define_method method, Proc.new {|| true}
        end
      end

      # This class method enables the search routes `/resoures/search?q=search+terms` for the model.
      # The search will be performed on all the attributes given in parameter of this method.
      # E.g. if you enabled the search on `:name` and `:email` attrs
      # a GET /resources/search?q=john+doe
      # will return all `Resource` instance where the name or the email matches either "john" or "doe"
      def enable_search_on(*attributes)
        self.instance_eval do
          get SEARCH_ROUTE do
            retrieve_resources '' do |resource|

              pass if !involved? || params[:q].blank? || params[:q].size > 100

              terms = params[:q].split(/[\+ ]/)
              search_terms = []

              # Seperate terms to match
              terms.each do |term|
                attributes.each do |attr|
                  search_terms << resource.arel_table[attr.to_sym].matches("%#{term}%")
                end
              end

              resource.where(search_terms.reduce(:or)).limit(100).
                flatten.as_json(read_scope)
            end
          end
        end
      end
    end

    private

    # Defines a nested route or not and retrieves the correct resource (or resources)
    # @param disables is the name to check if it was disabled
    # @param &block to be yield with the retrieved resource
    # @returns resource in json format
    def retrieve_resources(action)
      pass unless involved?
      no_route if disabled? action

      model = model_name.constantize
      nested = nested_resources if nested?

      if model.nil? || nested.nil? && nested?
        raise ActiveRecord::RecordNotFound
      else
        resource = nested? ? nested : model

        # Check access to the resource
        method = "limit_#{action}_for".to_sym
        resource = self.class.send(method, resource, current_user) if self.class.respond_to?(method) && !current_user.nil?

        # ONE resource else COLLECTION
        one_id = nested? ? params[:captures].fourth : params[:captures].second if params[:captures].length == 4
        resource = resource.find one_id unless one_id.nil?
        yield(resource).to_json
      end
    rescue ActiveRecord::RecordNotFound
      record_not_found.to_json
    end

    def nested?
      params[:captures].length >= 3 && params[:captures].first(3).none?(&:nil?)
    end

    def nested_resources
      resources = nil
      begin
        parent_model = params[:captures].first.classify.constantize
        parent_controller = "#{parent_model}sController".constantize
      rescue NameError
        parent_model = nil
        parent_controller = nil
      end

      unless parent_model.nil?
        parent_model = parent_controller.limit_read_for parent_model, current_user if parent_controller.respond_to?(:limit_read_for) && !current_user.nil?
        parent = parent_model.find params[:captures].second
        if parent.respond_to? :reflections
          # This is AR 4.0.x compatibility
          has_association = !parent.reflections[involved.to_sym].nil?
        else
          # This is AR 4.1.x compatibility
          has_association = !parent._reflections[involved.to_sym].nil?
        end
        resources = parent.send(involved.to_sym) if has_association
      end
      resources
    rescue ActiveRecord::RecordNotFound
      nil
    end

    def involved
      involved = params[:splat] && params[:splat].first
      params[:captures].each_index { |i|
        involved ||= params[:captures].last(i+1).first if !params[:captures].last(i+1).first.nil? && params[:captures].last(i+1).first.match(/[\d]+/).nil?
      } unless params[:captures].nil?
      involved
    end

    def involved?
      !involved.match(/\A#{model_name.underscore}[s]?\Z/).nil?
    end

    # read_scope defaults to all attrs of the model
    def read_scope
      {}
    end

    # create/update scope defaults to all data given in the POST/PUT
    def method_missing(name, *args)
      if name.to_s == "#{model_name.underscore}_params"
        return params.reject{|k,v| %w(splat captures id updated_at created_at).include? k}
      end
    end

    def model_name
      self.class.model_name
    end

    def model
      self.class.model
    end

    def disabled? key
      method = ((nested? ? 'nested_' : '')+"#{key}_disabled?").to_sym
      self.class.method_defined?(method) && self.send(method)
    end

    def no_route
      pass
    end

    def record_not_found
      status 404
      ['record not found']
    end

    def not_allowed
      status 401
      ['not allowed']
    end
  end
end