sanger/sequencescape

View on GitHub
app/api/core/service.rb

Summary

Maintainability
A
0 mins
Test Coverage
B
88%
# frozen_string_literal: true
require 'sinatra/base'
module Core
  class Service < Sinatra::Base
    API_VERSION = 1

    class Error < StandardError
      module Behaviour
        def self.included(base)
          base.class_eval do
            class_attribute :api_error_code
            class_attribute :api_error_message
            alias_method :api_error_message, :message
            self.api_error_code = 500
          end
        end

        def api_error(response)
          response.general_error(self.class.api_error_code, [self.class.api_error_message || api_error_message])
        end
      end

      include Behaviour
    end

    class UnsupportedAction < Error
      self.api_error_code = 501
      self.api_error_message = 'requested action is not supported on this resource'
    end

    class DeprecatedAction < Error
      self.api_error_code = 410
      self.api_error_message = 'requested action is no longer supported'
    end

    class MethodNotAllowed < Error
      def initialize(allowed_http_verbs)
        super('HTTP verb was not allowed!')
        @allowed = Array(allowed_http_verbs).map(&:to_s).map(&:upcase).join(',')
      end

      self.api_error_code = 405
      self.api_error_message = 'unsupported action'

      def api_error(response)
        response.headers('Allow' => @allowed)
        super
      end
    end

    # Report the performance and status of any request
    def report(handler)
      Rails.logger.info("API[start]: #{handler}: #{request.fullpath}")
      yield
    ensure
      Rails.logger.info("API[handled]: #{handler}: #{request.fullpath}")
    end
    private :report

    # Disable the Sinatra rubbish that happens in the development environment because we want
    # Rails to do all of the handling if we don't
    set(:environment, Rails.env)

    # This ensures that our Sinatra applications behave properly within the Rails environment.
    # Without this you'll find that only one of the services actually behaves properly, the others
    # will all fail with 404 errors.
    def handle_not_found!(_boom)
      @response.status = 404
      @response.headers['X-Cascade'] = 'pass'
      @response.body = nil
    end

    # Configure a handler for the cucumber environment that logs the error to the console so that
    # we can see it.
    configure :cucumber do
      error(Exception) do
        $stderr.puts exception_thrown.message
        $stderr.puts exception_thrown.backtrace.join("\n")
        raise exception_thrown
      end
    end

    def redirect_to(url, body)
      status(301) # Moved permanently
      headers('Location' => url)
      body(body)
    end

    def redirect_to_multiple_locations(body)
      status(300) # Multiple content
      body(body)
    end

    def self.api_version_path
      @version = "api/#{API_VERSION}"
    end

    def api_path(*sub_path)
      "#{request.scheme}://#{request.host_with_port}/#{self.class.api_version_path}/#{sub_path.compact.join('/')}"
    end

    def self.before_all_actions(&block)
      before('/*', &block)
    end

    def self.after_all_actions(&block)
      after('/*', &block)
    end

    attr_reader :command

    register Core::Benchmarking
    register Core::Service::ErrorHandling
    register Core::Service::Authentication
    register Core::Service::ContentFiltering

    class Request
      extend Core::Initializable
      include Core::References
      include Core::Benchmarking
      include Core::Service::EndpointHandling

      initialized_attr_reader :service, :target, :path, :io, :json, :file, :filename
      attr_writer :io, :file, :filename
      attr_reader :ability, :identifier, :started_at

      delegate :user, to: :service

      def initialize(identifier, *args, &block)
        @identifier, @started_at = identifier, Time.zone.now
        super(*args, &block)
        @ability = Core::Abilities.create(self)
      end

      def authorisation_code
        @service.request.env['HTTP_X_SEQUENCESCAPE_CLIENT_ID']
      end

      def authentication_code
        # The WTSISignOn service has been retired. However previously the code
        # supported supplying the API key in this cookie, so this has been left
        # for compatibility purposes
        @service.request.cookies['api_key'] || @service.request.cookies['WTSISignOn']
      end

      def response(&block)
        ::Core::Service::Response.new(self, &block)
      end

      # Safe way to push a particular value on to the request target stack.  Ensures that the
      # original value is reset when the block is exitted.
      def push(value)
        target_before, @target = @target, value
        yield
      ensure
        @target = target_before
      end

      def attributes(_object = nil)
        io.map_parameters_to_attributes(json, nil)
      end

      def create!(instance_attributes = attributes)
        ActiveRecord::Base.transaction do
          record = target.create!(instance_attributes)
          ::Core::Io::Registry
            .instance
            .lookup_for_object(record)
            .eager_loading_for(record.class)
            .include_uuid
            .find(record.id)
        end
      end

      def update!(instance_attributes = attributes)
        ActiveRecord::Base.transaction { target.tap { |o| o.update!(instance_attributes) } }
      end
    end

    include Core::Endpoint::BasicHandler::EndpointLookup

    # A response from an endpoint handler is made of a pair of values.  One is the object that
    # is to be sent back to the client in JSON.  The other is the endpoint handler that dealt
    # with the request and that provides the actions that are available for said object.  So
    # the JSON that is actually returned is a merge of the object JSON and the actions.
    class Response
      extend Core::Initializable
      include Core::References
      include Core::Benchmarking

      class Initializer
        delegate :status, :headers, :api_path, to: '@owner.request.service'

        # Causes a response that will redirect the client to the specified UUID path.
        def redirect_to(uuid)
          status(301)
          headers('Location' => api_path(uuid))
        end

        # If you want to return multiple records as a kind of "redirect" then this is the
        # method you want to use!
        def multiple_choices
          status(300)
        end
      end

      attr_reader :request

      initialized_attr_reader :handled_by, :object

      delegate :io, :identifier, :started_at, to: :request

      delegate :status, to: 'request.service'
      initialized_delegate :status

      delegate :endpoint_for_object, to: 'request.service'
      private :endpoint_for_object

      def initialize(request, &block)
        @request, @io, @include_actions = request, nil, true
        status(200)
        super(&block)
      end

      #--
      # Note that this method disables garbage collection, which should improve the performance of writing
      # out the JSON to the client.  The garbage collection is then re-enabled in close.
      #++
      # rubocop:todo Metrics/MethodLength
      def each(&block) # rubocop:todo Metrics/AbcSize
        Rails.logger.info('API[streaming]: starting JSON streaming')
        start = Time.zone.now

        ::Core::Io::Buffer.new(block) do |buffer|
          ::Core::Io::Json::Stream
            .new(buffer)
            .open do |stream|
              ::Core::Io::Registry
                .instance
                .lookup_for_object(object)
                .as_json(response: self, target: object, stream: stream, object: object, handled_by: handled_by)
            end
        end

        Rails.logger.info("API[streaming]: finished JSON streaming in #{Time.zone.now - start}s")
      end

      # rubocop:enable Metrics/MethodLength

      def close
        identifier, started_at = self.identifier, self.started_at # Save for later as next line discards our request!
        discard_all_references
      ensure
        Rails.logger.info("API[finished]: #{identifier} in #{Time.zone.now - started_at}s")
      end

      def discard_all_references
        request.send(:discard_all_references)
        super
        # NOTE: Previously we released our connection here, which prevented rails from
        # properly sweeping the query cache.
      end
      private :discard_all_references
    end
  end
end