ruby-grape/grape-swagger

View on GitHub
lib/grape-swagger/endpoint.rb

Summary

Maintainability
D
1 day
Test Coverage
# frozen_string_literal: true

require 'active_support'
require 'active_support/core_ext/string/inflections'
require 'grape-swagger/endpoint/params_parser'

module Grape
  class Endpoint
    def content_types_for(target_class)
      content_types = (target_class.content_types || {}).values

      if content_types.empty?
        formats       = [target_class.format, target_class.default_format].compact.uniq
        formats       = Grape::Formatter.formatters(**{}).keys if formats.empty?
        content_types = Grape::ContentTypes::CONTENT_TYPES.select do |content_type, _mime_type|
          formats.include? content_type
        end.values
      end

      content_types.uniq
    end

    # swagger spec2.0 related parts
    #
    # required keys for SwaggerObject
    def swagger_object(target_class, request, options)
      object = {
        info: info_object(options[:info].merge(version: options[:doc_version])),
        swagger: '2.0',
        produces: content_types_for(target_class),
        authorizations: options[:authorizations],
        securityDefinitions: options[:security_definitions],
        security: options[:security],
        host: GrapeSwagger::DocMethods::OptionalObject.build(:host, options, request),
        basePath: GrapeSwagger::DocMethods::OptionalObject.build(:base_path, options, request),
        schemes: options[:schemes].is_a?(String) ? [options[:schemes]] : options[:schemes]
      }

      GrapeSwagger::DocMethods::Extensions.add_extensions_to_root(options, object)
      object.delete_if { |_, value| value.blank? }
    end

    # building info object
    def info_object(infos)
      result = {
        title: infos[:title] || 'API title',
        description: infos[:description],
        termsOfService: infos[:terms_of_service_url],
        contact: contact_object(infos),
        license: license_object(infos),
        version: infos[:version]
      }

      GrapeSwagger::DocMethods::Extensions.add_extensions_to_info(infos, result)

      result.delete_if { |_, value| value.blank? }
    end

    # sub-objects of info object
    # license
    def license_object(infos)
      {
        name: infos.delete(:license),
        url: infos.delete(:license_url)
      }.delete_if { |_, value| value.blank? }
    end

    # contact
    def contact_object(infos)
      {
        name: infos.delete(:contact_name),
        email: infos.delete(:contact_email),
        url: infos.delete(:contact_url)
      }.delete_if { |_, value| value.blank? }
    end

    # building path and definitions objects
    def path_and_definition_objects(namespace_routes, options)
      @paths = {}
      @definitions = {}
      add_definitions_from options[:models]
      namespace_routes.each_value do |routes|
        path_item(routes, options)
      end

      [@paths, @definitions]
    end

    def add_definitions_from(models)
      return if models.nil?

      models.each { |x| expose_params_from_model(x) }
    end

    # path object
    def path_item(routes, options)
      routes.each do |route|
        next if hidden?(route, options)

        @item, path = GrapeSwagger::DocMethods::PathString.build(route, options)
        @entity = route.entity || route.options[:success]

        verb, method_object = method_object(route, options, path)

        if @paths.key?(path.to_s)
          @paths[path.to_s][verb] = method_object
        else
          @paths[path.to_s] = { verb => method_object }
        end

        GrapeSwagger::DocMethods::Extensions.add(@paths[path.to_s], @definitions, route)
      end
    end

    def method_object(route, options, path)
      method = {}
      method[:summary]     = summary_object(route)
      method[:description] = description_object(route)
      method[:produces]    = produces_object(route, options[:produces] || options[:format])
      method[:consumes]    = consumes_object(route, options[:format])
      method[:parameters]  = params_object(route, options, path)
      method[:security]    = security_object(route)
      method[:responses]   = response_object(route, options)
      method[:tags]        = route.options.fetch(:tags, tag_object(route, path))
      method[:operationId] = GrapeSwagger::DocMethods::OperationId.build(route, path)
      method[:deprecated] = deprecated_object(route)
      method.delete_if { |_, value| value.nil? }

      [route.request_method.downcase.to_sym, method]
    end

    def deprecated_object(route)
      route.options[:deprecated] if route.options.key?(:deprecated)
    end

    def security_object(route)
      route.options[:security] if route.options.key?(:security)
    end

    def summary_object(route)
      summary = route.options[:desc] if route.options.key?(:desc)
      summary = route.description if route.description.present? && route.options.key?(:detail)
      summary = route.options[:summary] if route.options.key?(:summary)

      summary
    end

    def description_object(route)
      description = route.description if route.description.present?
      description = route.options[:detail] if route.options.key?(:detail)

      description
    end

    def produces_object(route, format)
      return ['application/octet-stream'] if file_response?(route.attributes.success) &&
                                             !route.attributes.produces.present?

      mime_types = GrapeSwagger::DocMethods::ProducesConsumes.call(format)

      route_mime_types = %i[formats content_types produces].map do |producer|
        possible = route.options[producer]
        GrapeSwagger::DocMethods::ProducesConsumes.call(possible) if possible.present?
      end.flatten.compact.uniq

      route_mime_types.present? ? route_mime_types : mime_types
    end

    SUPPORTS_CONSUMES = %i[post put patch].freeze

    def consumes_object(route, format)
      return unless SUPPORTS_CONSUMES.include?(route.request_method.downcase.to_sym)

      GrapeSwagger::DocMethods::ProducesConsumes.call(route.settings.dig(:description, :consumes) || format)
    end

    def params_object(route, options, path)
      parameters = build_request_params(route, options).each_with_object([]) do |(param, value), memo|
        next if hidden_parameter?(value)

        value = { required: false }.merge(value) if value.is_a?(Hash)
        _, value = default_type([[param, value]]).first if value == ''

        if value.dig(:documentation, :type)
          expose_params(value[:documentation][:type])
        elsif value[:type]
          expose_params(value[:type])
        end
        memo << GrapeSwagger::DocMethods::ParseParams.call(param, value, path, route, @definitions)
      end

      if GrapeSwagger::DocMethods::MoveParams.can_be_moved?(route.request_method, parameters)
        parameters = GrapeSwagger::DocMethods::MoveParams.to_definition(path, parameters, route, @definitions)
      end

      GrapeSwagger::DocMethods::FormatData.to_format(parameters)

      parameters.presence
    end

    def response_object(route, options)
      codes(route).each_with_object({}) do |value, memo|
        value[:message] ||= ''
        memo[value[:code]] = { description: value[:message] }

        memo[value[:code]][:headers] = value[:headers] if value[:headers]

        next build_file_response(memo[value[:code]]) if file_response?(value[:model])

        if memo.key?(200) && route.request_method == 'DELETE' && value[:model].nil?
          memo[204] = memo.delete(200)
          value[:code] = 204
          next
        end

        # Explicitly request no model with { model: '' }
        next if value[:model] == ''

        response_model = value[:model] ? expose_params_from_model(value[:model]) : @item
        next unless @definitions[response_model]
        next if response_model.start_with?('Swagger_doc')

        @definitions[response_model][:description] ||= "#{response_model} model"
        memo[value[:code]][:schema] = build_reference(route, value, response_model, options)
        memo[value[:code]][:examples] = value[:examples] if value[:examples]
      end
    end

    def codes(route)
      http_codes_from_route(route).map do |x|
        x.is_a?(Array) ? { code: x[0], message: x[1], model: x[2], examples: x[3], headers: x[4] } : x
      end
    end

    def success_code?(code)
      status = code.is_a?(Array) ? code.first : code[:code]
      status.between?(200, 299)
    end

    def http_codes_from_route(route)
      if route.http_codes.is_a?(Array) && route.http_codes.any? { |code| success_code?(code) }
        route.http_codes.clone
      else
        success_codes_from_route(route) + (route.http_codes || route.options[:failure] || [])
      end
    end

    def success_codes_from_route(route)
      if @entity.is_a?(Array)
        return @entity.map do |entity|
          success_code_from_entity(route, entity)
        end
      end

      [success_code_from_entity(route, @entity)]
    end

    def tag_object(route, path)
      version = GrapeSwagger::DocMethods::Version.get(route)
      version = Array(version)
      prefix = route.prefix.to_s.split('/').reject(&:empty?)
      Array(
        path.split('{')[0].split('/').reject(&:empty?).delete_if do |i|
          prefix.include?(i) || version.map(&:to_s).include?(i)
        end.first
      ).presence
    end

    private

    def build_reference(route, value, response_model, settings)
      # TODO: proof that the definition exist, if model isn't specified
      reference = { '$ref' => "#/definitions/#{response_model}" }
      return reference unless value[:code] < 300

      reference = { type: 'array', items: reference } if route.options[:is_array]
      build_root(route, reference, response_model, settings)
    end

    def build_root(route, reference, response_model, settings)
      default_root = response_model.underscore
      default_root = default_root.pluralize if route.options[:is_array]
      case route.settings.dig(:swagger, :root)
      when true
        { type: 'object', properties: { default_root => reference } }
      when false
        reference
      when nil
        settings[:add_root] ? { type: 'object', properties: { default_root => reference } } : reference
      else
        { type: 'object', properties: { route.settings.dig(:swagger, :root) => reference } }
      end
    end

    def file_response?(value)
      value.to_s.casecmp('file').zero? ? true : false
    end

    def build_file_response(memo)
      memo['schema'] = { type: 'file' }
    end

    def build_request_params(route, settings)
      required = merge_params(route)
      required = GrapeSwagger::DocMethods::Headers.parse(route) + required unless route.headers.nil?

      default_type(required)

      request_params = GrapeSwagger::Endpoint::ParamsParser.parse_request_params(required, settings, self)

      request_params.empty? ? required : request_params
    end

    def merge_params(route)
      param_keys = route.params.keys
      route.params.delete_if { |key| key.is_a?(String) && param_keys.include?(key.to_sym) }.to_a
    end

    def default_type(params)
      default_param_type = { required: true, type: 'Integer' }
      params.each { |param| param[-1] = param.last == '' ? default_param_type : param.last }
    end

    def expose_params(value)
      if value.is_a?(Class) && GrapeSwagger.model_parsers.find(value)
        expose_params_from_model(value)
      elsif value.is_a?(String)
        begin
          expose_params(Object.const_get(value.gsub(/\[|\]/, ''))) # try to load class from its name
        rescue NameError
          nil
        end
      end
    end

    def expose_params_from_model(model)
      model = model.is_a?(String) ? model.constantize : model
      model_name = model_name(model)

      return model_name if @definitions.key?(model_name)

      @definitions[model_name] = nil

      parser = GrapeSwagger.model_parsers.find(model)
      raise GrapeSwagger::Errors::UnregisteredParser, "No parser registered for #{model_name}." unless parser

      parsed_response = parser.new(model, self).call

      @definitions[model_name] =
        GrapeSwagger::DocMethods::BuildModelDefinition.parse_params_from_model(parsed_response, model, model_name)

      model_name
    end

    def model_name(name)
      GrapeSwagger::DocMethods::DataType.parse_entity_name(name)
    end

    def hidden?(route, options)
      route_hidden = route.settings.try(:[], :swagger).try(:[], :hidden)
      route_hidden = route.options[:hidden] if route.options.key?(:hidden)
      return route_hidden unless route_hidden.is_a?(Proc)

      options[:token_owner] ? route_hidden.call(send(options[:token_owner].to_sym)) : route_hidden.call
    end

    def hidden_parameter?(value)
      return false if value[:required]

      if value.dig(:documentation, :hidden).is_a?(Proc)
        value.dig(:documentation, :hidden).call
      else
        value.dig(:documentation, :hidden)
      end
    end

    def success_code_from_entity(route, entity)
      default_code = GrapeSwagger::DocMethods::StatusCodes.get[route.request_method.downcase.to_sym]
      if entity.is_a?(Hash)
        default_code[:code] = entity[:code] if entity[:code].present?
        default_code[:model] = entity[:model] if entity[:model].present?
        default_code[:message] = entity[:message] || route.description || default_code[:message].sub('{item}', @item)
        default_code[:examples] = entity[:examples] if entity[:examples]
        default_code[:headers] = entity[:headers] if entity[:headers]
      else
        default_code = GrapeSwagger::DocMethods::StatusCodes.get[route.request_method.downcase.to_sym]
        default_code[:model] = entity if entity
        default_code[:message] = route.description || default_code[:message].sub('{item}', @item)
      end

      default_code
    end
  end
end