blinkboxbooks/swaggerific

View on GitHub
lib/blinkbox/swaggerific/service.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require "yaml"
require "json"
require "faker"
require "genny"
require "logger"
require "digest/sha1"
require_relative "helpers"
require_relative "examplers"

module Blinkbox
  module Swaggerific
    class Service
      include FakeSinatra
      include Helpers
      attr_reader :spec, :hash
      
      @@instances = {}
      @@swagger_store = File.join(__dir__, "../../../public/swag")
      @@tld_level = 2
      @@logger = Logger.new(STDOUT)

      class << self
        def call(env)
          if File.directory?(@@swagger_store)
            # Multi service mode
            idx = (@@tld_level + 1) * -1
            filename_or_subdomain = env['HTTP_HOST'].split(".")[0..idx].join(".")
            return UploaderService.call(env) if ["", "www"].include? filename_or_subdomain
          else
            # Single service mode
            filename_or_subdomain = @@swagger_store
          end

          self.new(filename_or_subdomain).response(env)
        end

        def tld_level=(number)
          raise ArgumentError, "tld_level must be a positive integer" unless number.to_i > 0
          @@tld_level = number
        end

        def swagger_store
          @@swagger_store
        end

        def swagger_store=(store)
          if File.directory?(store)
            @@swagger_store = store
            return
          end

          raise ArgumentError, "swagger_store must be a folder or a swagger file" unless File.exist?(store)
          raise ArgumentError, "The specified file is not a swagger 2.0 file" unless valid_swagger?(ENV['SWAGGERIFIC_SINGLE_SERVICE'])
          @@swagger_store = store
        end

        def valid_swagger?(filename)
          spec = YAML.load(open(filename))
          !(spec.nil? || spec['swagger'].to_f < 2.0)
        rescue
          false
        end

        def logger=(logger)
          @@logger = logger
        end
      end

      def initialize(filename_or_subdomain)
        filename = File.expand_path(filename_or_subdomain.include?("/") ? filename_or_subdomain : File.join(@@swagger_store, "#{filename_or_subdomain}.yaml"))
        logger.debug "Creating Swaggerific instance from #{filename}"
        data = File.read(filename)
        @spec = YAML.load(data)
        @hash = Digest::SHA1.hexdigest(data)[0..8]
      rescue => e
        logger.debug "No docs for #{filename}: #{e.class}"
        body = {
          "error" => "missing_swagger",
          "message" => "No swagger file uploaded with the specified name could be found",
          "details" => {
            "filename" => File.basename(filename)
          }
        }.to_json
        @canned_response = [404, headers, [body]]
      end

      def response(env)
        return @canned_response if @canned_response
        catch :halt do
          matching_paths = matching_paths(env)
          case matching_paths.size
          when 0
            halt(404)
          when 1
            locale = I18n.exists?(:faker, env['HTTP_ACCEPT_LANGUAGE']) ? env['HTTP_ACCEPT_LANGUAGE'] : "en-GB"
            I18n.with_locale(locale) do
              process_path(env, matching_paths.first)
            end
          else
            halt(500, {
              "error" => "route_uncertainty",
              "message" => "Your request matched multiple paths",
              "details" => {
                "matchingPaths" => matching_paths
              }
            }.to_json)
          end
        end
      rescue => e
        logger.fatal(e)
        [500, {}, []]
      end

      private

      def process_path(env, operation: {}, path_params: {}, query_params: {})
        status_code = determine_best_status_code(env, operation['responses'].keys)
        route = operation['responses'][status_code]
        no_route! if route.nil?
        specified_headers = route['headers'] || {}
        check_deprecated!(env, operation, specified_headers)
        check_content_type!(env, operation)
        
        params = Parameters.new(
          operation['parameters'] || {},
          path: path_params,
          env: env,
          query: query_params
        )

        content_type, example = create_example(env, route)
        # Attempt to substitue params into the example, but fallback on the original
        example = example % params.all rescue example

        specified_headers.merge!("Content-Type" => content_type)
        halt(status_code, example, specified_headers)
      end

      def create_example(env, route)
        (route['schema']['definitions'] ||= {}).merge!(@spec['definitions']) if @spec['definitions']
        sources = [
          ExamplesExampler.from_examples(route['examples']),
          SchemaExampler.from_schema(route['schema'])
        ].compact

        sources.reverse! if env['HTTP_X_SWAGGERIFIC_RESPOND_FROM'] == "schema"

        generatable_types = sources.map { |ex| ex.generatable_types }.flatten.uniq
        content_type = best_mime_type(generatable_types, env['HTTP_ACCEPT'])

        example = nil
        sources.each do |s|
          example ||= s.example(content_type)
          break if !example.nil?
        end
        
        halt(501, {
          "error" => "no_example",
          "message" => "The Swagger docs don't specify a suitable example for this route",
          "details" => {
            "routeDescription" => route['description'] || ""
          }
        }.to_json) if example.nil?

        [content_type, example]
      end

      def example_from_examples()

      end

      def example_from_schema()

      end

      def check_deprecated!(env, operation, specified_headers)
        if operation["deprecated"]
          specified_headers["X-Swaggerific-Deprecated-Endpoint"] = "true"
          halt(405,
            {
              "error" => "deprecated_endpoint",
              "message" => "This endpoint has been flagged as deprecated. Please set the X-Swaggerific-Deprecated-Endpoints header to `allow` if you still want to use it, but avoid doing so if possible."
            }.to_json,
            specified_headers
          ) unless env["HTTP_X_SWAGGERIFIC_DEPRECATED_ENDPOINTS"] == "allow"
        end
      end

      def check_content_type!(env, operation)
        return if operation['consumes'].nil?
        acceptable_type = best_mime_type([env['CONTENT_TYPE'].split(';').first], operation['consumes'])
        if acceptable_type.nil?
          halt(415, {
            "error" => "unnacceptable_content_type",
            "message" => "The Content-Type given in the request cannot be dealt with by this endpoint",
            "details" => {
              "delivered" => env['CONTENT_TYPE'].split(';').first,
              "required" => operation['consumes']
            }
          }.to_json)
        end
      end

      def no_route!
        halt(404, {
          "error" => "no_route",
          "message" => "This route is not defined in the Swagger doc"
        }.to_json)
      end

      def determine_best_status_code(env, availble_status_codes)
        requested = env['HTTP_X_SWAGGERIFIC_RESPOND_WITH']
        halt(400, {
          "error" => "invalid_status_code_request",
          "message" => "The X-Swaggerific-Respond-With header must be an http status code"
        }.to_json) unless requested.nil? || requested =~ /^([1-5]\d\d)?$/
        matcher = requested || /^2\d\d$/
        chosen = availble_status_codes.select { |code| code.to_s.match(matcher) }.first
        halt(501, {
          "error" => "no_route",
          "message" => "The Swagger docs don't specify a response with a #{requested || "2xx"} status code"
        }.to_json) if chosen.nil?
        chosen
      end

      def matching_paths(env)
        path = env['PATH_INFO']
        method = env['REQUEST_METHOD'].downcase
        given_query_params = Rack::Utils.parse_nested_query(env['QUERY_STRING'] || "")
        # TODO: Only match routes which have the correct "consumes" value
        matching_paths = (@spec['paths'] || {}).keys.map { |spec_full_path|
          spec_path, spec_query_string = spec_full_path.split("?", 2)
          from_path = match_string(spec_path, path)
          next if from_path.nil?
          operation = @spec['paths'][spec_full_path][method]
          next if operation.nil?
          from_query = (operation["parameters"] || []).inject({}) do |accumulator, param|
            if param["in"] == "query"
              value = given_query_params[param["name"]] || param["default"]
              accumulator.merge!(param["name"] => value) unless value.nil?
            end
            accumulator
          end
          required_get_params = (operation["parameters"] || []).map { |param|
            param["name"] if param["in"] == "query" && param["required"] == true
          }.compact
          next unless (required_get_params - from_query.keys).empty?
          {
            operation: operation,
            path_params: from_path,
            query_params: from_query
          }
        }.compact
      end

      def match_string(bracketed_string, match_string, uri_decode: true)
        re = bracketed_string.gsub(/\{([^}]+)\}/, "(?<\\1>.+?)")
        matches = Regexp.new("^#{re}$").match(match_string)
        return nil if matches.nil?
        captures = matches.captures
        captures.map! { |capture| URI.unescape(capture) } if uri_decode
        Hash[matches.names.zip(captures)]
      end

      def logger
        @@logger
      end
    end
  end
end