Apipie/apipie-rails

View on GitHub
lib/apipie/dsl_definition.rb

Summary

Maintainability
D
2 days
Test Coverage
# Apipie DSL functions.

module Apipie

  # DSL is a module that provides #api, #error, #param, #returns.
  module DSL

    module Base
      attr_reader :apipie_resource_descriptions, :api_params

      def _apipie_eval_dsl(*args, &block)
        raise 'The Apipie DLS data need to be cleared before evaluating new block' if @_apipie_dsl_data
        instance_exec(*args, &block)
        return _apipie_dsl_data
      ensure
        _apipie_dsl_data_clear
      end

      private

      def _apipie_dsl_data
        @_apipie_dsl_data ||= _apipie_dsl_data_init
      end

      def _apipie_dsl_data_clear
        @_apipie_dsl_data = nil
      end

      def _apipie_dsl_data_init
        @_apipie_dsl_data =  {
         :api               => false,
         :api_args          => [],
         :api_from_routes   => nil,
         :errors            => [],
         :tag_list          => [],
         :returns           => {},
         :params            => [],
         :headers           => [],
         :resource_id       => nil,
         :short_description => nil,
         :description       => nil,
         :examples          => [],
         :see               => [],
         :formats           => nil,
         :api_versions      => [],
         :meta              => nil,
         :show              => true,
         :deprecated        => false
       }
      end
    end

    module Resource
      #    resource_id "my_own_resource_id"
      def resource_id(resource_id)
        Apipie.set_resource_id(@controller, resource_id)
      end

      def name(name)
        _apipie_dsl_data[:resource_name] = name
      end

      def api_base_url(url)
        _apipie_dsl_data[:api_base_url] = url
      end

      def short(short)
        _apipie_dsl_data[:short_description] = short
      end
      alias short_description short

      def path(path)
        _apipie_dsl_data[:path] = path
      end

      def app_info(app_info)
        _apipie_dsl_data[:app_info] = app_info
      end

      def deprecated(value)
        _apipie_dsl_data[:deprecated] = value
      end

    end

    module Action

      def def_param_group(name, &block)
        Apipie.add_param_group(self, name, &block)
      end

      #
      #   # load paths from routes and don't provide description
      #   api
      #
      def api(method, path, desc = nil, options = {}) #:doc:
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:api] = true
        _apipie_dsl_data[:api_args] << [method, path, desc, options]
      end

      #   # load paths from routes
      #   api! "short description",
      #
      def api!(desc = nil, options = {}) #:doc:
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:api] = true
        _apipie_dsl_data[:api_from_routes] = { :desc => desc, :options =>options }
      end

      # Reference other similar method
      #
      #   api :PUT, '/articles/:id'
      #   see "articles#create"
      #   def update; end
      def see(*args)
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:see] << args
      end

      # Show some example of what does the described
      # method return.
      def example(example) #:doc:
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:examples] << example.strip_heredoc
      end

      # Determine if the method should be included
      # in the documentation
      def show(show)
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:show] = show
      end

      # Describe whole resource
      #
      # Example:
      # api :desc => "Show user profile", :path => "/users/", :version => '1.0 - 3.4.2012'
      # param :id, Integer, :desc => "User ID", :required => true
      # desc <<-EOS
      #   Long description...
      # EOS
      def resource_description(options = {}, &block) #:doc:
        return unless Apipie.active_dsl?
        raise ArgumentError, "Block expected" unless block

        dsl_data = ResourceDescriptionDsl.eval_dsl(self, &block)
        versions = dsl_data[:api_versions]
        Apipie.set_controller_versions(self, versions)
        @apipie_resource_descriptions = versions.map do |version|
          Apipie.define_resource_description(self, version, dsl_data)
        end
      end
    end

    module Common
      def api_versions(*versions)
        _apipie_dsl_data[:api_versions].concat(versions)
      end
      alias api_version api_versions

      # Describe the next method.
      #
      # Example:
      #   desc "print hello world"
      #   def hello_world
      #     puts "hello world"
      #   end
      #
      def desc(description) #:doc:
        return unless Apipie.active_dsl?
        if _apipie_dsl_data[:description]
          raise "Double method description."
        end
        _apipie_dsl_data[:description] = description
      end
      alias description desc
      alias full_description desc

      # describe next method with document in given path
      # in convention, these doc located under "#{Rails.root}/doc"
      # Example:
      # document "hello_world.md"
      # def hello_world
      #   puts "hello world"
      # end
      def document path
        content = File.open(File.join(Rails.root,  Apipie.configuration.doc_path, path)).read
        desc content
      end

      # Describe available request/response formats
      #
      #   formats ['json', 'jsonp', 'xml']
      def formats(formats) #:doc:
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:formats] = formats
      end

      # Describe additional metadata
      #
      #   meta :author => { :name => 'John', :surname => 'Doe' }
      def meta(meta) #:doc:
        _apipie_dsl_data[:meta] = meta
      end


      # Describe possible errors
      #
      # Example:
      #   error :desc => "speaker is sleeping", :code => 500, :meta => [:some, :more, :data]
      #   error 500, "speaker is sleeping"
      #   def hello_world
      #     return 500 if self.speaker.sleeping?
      #     puts "hello world"
      #   end
      #
      def error(code_or_options, desc = nil, options = {}) #:doc:
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:errors] << [code_or_options, desc, options]
      end

      # Add tags to resources and actions group operations together.
      def tags(*args)
        return unless Apipie.active_dsl?
        tags = args.length == 1 ? args.first : args
        _apipie_dsl_data[:tag_list] += tags
      end

      def _apipie_define_validators(description)

        # [re]define method only if validation is turned on
        if description && (Apipie.configuration.validate == true ||
                           Apipie.configuration.validate == :implicitly ||
                           Apipie.configuration.validate == :explicitly)

          _apipie_save_method_params(description.method, description.params)

          unless instance_methods.include?(:apipie_validations)
            define_method(:apipie_validations) do
              method_params = self.class._apipie_get_method_params(action_name)

              if Apipie.configuration.validate_presence?
                Validator::BaseValidator.raise_if_missing_params do |missing|
                  method_params.each_value do |param|
                    # check if required parameters are present
                    missing << param if param.required && !params.key?(param.name)
                  end
                end
              end

              if Apipie.configuration.validate_value?
                method_params.each_value do |param|
                  # params validations
                  param.validate(params[:"#{param.name}"]) if params.key?(param.name)
                end
              end

              # Only allow params passed in that are defined keys in the api
              # Auto skip the default params (format, controller, action)
              if Apipie.configuration.validate_key?
                params.reject{|k,_| %w[format controller action].include?(k.to_s) }.each_pair do |param, _|
                  # params allowed
                  if method_params.none? {|_,p| p.name.to_s == param.to_s}
                    self.class._apipie_handle_validate_key_error params, param
                  end
                end
              end

              return unless Apipie.configuration.process_value?
              @api_params ||= {}
              method_params.each_value do |param|
                # params processing
                @api_params[param.as] = param.process_value(params[:"#{param.name}"]) if params.key?(param.name)
              end
            end
          end

          if Apipie.configuration.validate == :implicitly || Apipie.configuration.validate == true
            old_method = instance_method(description.method)

            define_method(description.method) do |*args|
              apipie_validations

              # run the original method code
              old_method.bind(self).call(*args)
            end
          end
        end
      end

      def _apipie_handle_validate_key_error params, param
        case Apipie.configuration.action_on_non_validated_keys
        when :raise
          raise UnknownParam, param
        when :skip
          params.delete(param)
          Rails.logger.warn(UnknownParam.new(param).to_s)
        end
      end

      def _apipie_save_method_params(method, params)
        @method_params ||= {}
        @method_params[method] = params
      end

      def _apipie_get_method_params(method)
        @method_params[method]
      end

      # Describe request header.
      #  Headers can't be validated with config.validate_presence = true
      #
      # Example:
      #   header 'ClientId', "client-id"
      #   def show
      #     render :text => headers['HTTP_CLIENT_ID']
      #   end
      #
      def header(header_name, description, options = {}) #:doc
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:headers] << {
          name: header_name,
          description: description,
          options: options
        }
      end
    end


    # this describes the params, it's in separate module because it's
    # used in Validators as well
    module Param
      # Describe method's parameter
      #
      # Example:
      #   param :greeting, String, :desc => "arbitrary text", :required => true
      #   def hello_world(greeting)
      #     puts greeting
      #   end
      #
      def param(param_name, validator, desc_or_options = nil, options = {}, &block) #:doc:
        return unless Apipie.active_dsl?
        _apipie_dsl_data[:params] << [param_name,
                                      validator,
                                      desc_or_options,
                                      options.merge(:param_group => @_current_param_group),
                                      block]
      end

      def property(param_name, validator, desc_or_options = nil, options = {}, &block) #:doc:
        return unless Apipie.active_dsl?
        options[:only_in] ||= :response
        options[:required] = true if options[:required].nil?
        param(param_name, validator, desc_or_options, options, &block)
      end

      # Reuses param group for this method. The definition is looked up
      # in scope of this controller. If the group was defined in
      # different controller, the second param can be used to specify it.
      # when using action_aware params, you can specify :as =>
      # :create or :update to explicitly say how it should behave
      def param_group(name, scope_or_options = nil, options = {})
        if scope_or_options.is_a? Hash
          options.merge!(scope_or_options)
          scope = options[:scope]
        else
          scope = scope_or_options
        end
        scope ||= _default_param_group_scope

        @_current_param_group = {
          :scope => scope,
          :name => name,
          :options => options,
          :from_concern => scope.apipie_concern?
        }
        self.instance_exec(&Apipie.get_param_group(scope, name))
      ensure
        @_current_param_group = nil
      end

      # Describe possible responses
      #
      # Example:
      #     def_param_group :user do
      #       param :user, Hash do
      #         param :name, String
      #       end
      #     end
      #
      #   returns :user, "the speaker"
      #   returns "the speaker" do
      #        param_group: :user
      #   end
      #   returns :param_group => :user, "the speaker"
      #   returns :user, :code => 201, :desc => "the created speaker record"
      #   returns :array_of => :user, "many speakers"
      #   def hello_world
      #     render json: {user: {name: "Alfred"}}
      #   end
      #
      def returns(pgroup_or_options, desc_or_options = nil, options = {}, &block) #:doc:
        return unless Apipie.active_dsl?


        if desc_or_options.is_a? Hash
          options.merge!(desc_or_options)
        elsif !desc_or_options.nil?
          options[:desc] = desc_or_options
        end

        if pgroup_or_options.is_a? Hash
          options.merge!(pgroup_or_options)
        else
          options[:param_group] = pgroup_or_options
        end

        code = options[:code] || 200
        scope = options[:scope] || _default_param_group_scope
        descriptor = options[:param_group] || options[:array_of]

        if block.nil?
          if descriptor.is_a? ResponseDescriptionAdapter
            adapter = descriptor
          elsif descriptor.respond_to? :describe_own_properties
            adapter = ResponseDescriptionAdapter.from_self_describing_class(descriptor)
          else
            begin
              block = Apipie.get_param_group(scope, descriptor) if descriptor
            rescue
              raise "No param_group or self-describing class named #{descriptor}"
            end
          end
        elsif descriptor
          raise "cannot specify both block and param_group"
        end

        _apipie_dsl_data[:returns][code] = [options, scope, block, adapter]
      end

      # where the group definition should be looked up when no scope
      # given. This is expected to return a controller.
      def _default_param_group_scope
        self
      end
    end

    module Controller
      include Apipie::DSL::Base
      include Apipie::DSL::Common
      include Apipie::DSL::Action
      include Apipie::DSL::Param

      # defines the substitutions to be made in the API paths defined
      # in concerns included. For example:
      #
      # There is this method defined in concern:
      #
      #    api GET ':controller_path/:id'
      #    def show
      #      # ...
      #    end
      #
      # If you include the concern into some controller, you can
      # specify the value for :controller_path like this:
      #
      #      apipie_concern_subst(:controller_path => '/users')
      #      include ::Concerns::SampleController
      #
      # The resulting path will be '/users/:id'.
      #
      # It has to be specified before the concern is included.
      #
      # If not specified, the default predefined substitutions are
      #
      #    {:conroller_path => controller.controller_path,
      #     :resource_id  => `resource_id_from_apipie` }
      def apipie_concern_subst(subst_hash)
        _apipie_concern_subst.merge!(subst_hash)
      end

      # Allows to update existing params after definition was made (usually needed
      # when extending the API form plugins).
      #
      #     UsersController.apipie_update_params([:create, :update]) do
      #       param :user, Hash do
      #         param :oauth, String
      #       end
      #      end
      #
      # The block is evaluated in scope of the controller. Ohe can pass some additional
      # objects via additional arguments like this:
      #
      #     UsersController.apipie_update_params([:create, :update], [:name, :secret]) do |param_names|
      #       param :user, Hash do
      #         param_names.each { |p| param p, String }
      #       end
      #      end
      def _apipie_update_params(method_desc, dsl_data)
        params_ordered = dsl_data[:params].map do |args|
          Apipie::ParamDescription.from_dsl_data(method_desc, args)
        end
        ParamDescription.merge(method_desc.params_ordered_self, params_ordered)
      end

      def _apipie_update_meta(method_desc, dsl_data)
        return unless dsl_data[:meta].is_a?(Hash)

        method_desc.metadata ||= {}
        method_desc.metadata.merge!(dsl_data[:meta])
      end

      def apipie_update_methods(methods, *args, &block)
        methods.each do |method|
          method_desc = Apipie.get_method_description(self, method)
          unless method_desc
            raise "Could not find method description for #{self}##{method}. Was the method defined?"
          end
          dsl_data = _apipie_eval_dsl(*args, &block)
          _apipie_update_params(method_desc, dsl_data)
          _apipie_update_meta(method_desc, dsl_data)
        end
      end
      # backwards compatibility
      alias apipie_update_params apipie_update_methods

      def _apipie_concern_subst
        @_apipie_concern_subst ||= {
          controller_path: self.controller_path,
          resource_id: Apipie.get_resource_id(self)
        }
      end

      def _apipie_perform_concern_subst(string)
        _apipie_concern_subst.reduce(string) do |ret, (key, val)|
          ret.gsub(":#{key}", val)
        end
      end

      def apipie_concern?
        false
      end

      # create method api and redefine newly added method
      def method_added(method_name) #:doc:
        super
        return if !Apipie.active_dsl? || !_apipie_dsl_data[:api]

        return if _apipie_dsl_data[:api_args].blank? && _apipie_dsl_data[:api_from_routes].blank?

        # remove method description if exists and create new one
        Apipie.remove_method_description(self, _apipie_dsl_data[:api_versions], method_name)
        description = Apipie.define_method_description(self, method_name, _apipie_dsl_data)

        _apipie_dsl_data_clear
        _apipie_define_validators(description)
      ensure
        _apipie_dsl_data_clear
      end
    end

    module Concern
      include Apipie::DSL::Base
      include Apipie::DSL::Common
      include Apipie::DSL::Action
      include Apipie::DSL::Param

      # the concern was included into a controller
      def included(controller)
        super
        _apipie_concern_data.each do |method_name, _apipie_dsl_data|
          # remove method description if exists and create new one
          description = Apipie.define_method_description(controller, method_name, _apipie_dsl_data)
          controller._apipie_define_validators(description)
        end
        _apipie_concern_update_api_blocks.each do |(methods, block)|
          controller.apipie_update_methods(methods, &block)
        end
      end

      def _apipie_concern_data
        @_apipie_concern_data ||= []
      end

      def _apipie_concern_update_api_blocks
        @_apipie_concern_update_api_blocks ||= []
      end

      def apipie_concern?
        true
      end

      # create method api and redefine newly added method
      def method_added(method_name) #:doc:
        super

        return if ! Apipie.active_dsl? || !_apipie_dsl_data[:api]

        _apipie_concern_data << [method_name, _apipie_dsl_data.merge(:from_concern => true)]
      ensure
        _apipie_dsl_data_clear
      end

      def update_api(*methods, &block)
        _apipie_concern_update_api_blocks << [methods, block]
      end

    end

    class ResourceDescriptionDsl
      include Apipie::DSL::Base
      include Apipie::DSL::Common
      include Apipie::DSL::Resource
      include Apipie::DSL::Param

      def initialize(controller)
        @controller = controller
      end

      # evaluates resource description DSL and returns results
      def self.eval_dsl(controller, &block)
        dsl_data  = self.new(controller)._apipie_eval_dsl(&block)
        if dsl_data[:api_versions].empty?
          dsl_data[:api_versions] = Apipie.controller_versions(controller)
        end
        dsl_data
      end
    end

  end # module DSL
end # module Apipie