sul-dlss/sdr-client

View on GitHub
lib/sdr_client/redesigned_client.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

require 'digest'
require 'logger'
require 'shellwords'
require 'singleton'
require 'timeout'

module SdrClient
  # The SDR client reimagined, built using patterns successfully used in other client gems we maintain
  class RedesignedClient
    include Singleton

    class << self
      # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
      def configure(url:, email: nil, password: nil, token_refresher: nil, token: default_token,
                    request_options: default_request_options, logger: default_logger)
        if email.blank? && password.blank? && !token_refresher.respond_to?(:call)
          raise ArgumentError, 'email and password cannot be blank without a custom token refresher callable'
        end

        instance.config = Config.new(
          token: token,
          url: url,
          email: email,
          password: password,
          request_options: request_options,
          logger: logger,
          token_refresher: token_refresher
        )

        instance
      end
      # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists

      # For the initial token, use a dummy value to avoid hitting any APIs
      # during configuration, allowing `with_token_refresh_when_unauthorized` to handle
      # auto-magic token refreshing. Why not immediately get a valid token? Our apps
      # commonly invoke client `.configure` methods in the initializer in all
      # application environments, even those that are never expected to
      # connect to production APIs, such as local development machines.
      #
      # NOTE: `nil` and blank string cannot be used as dummy values here as
      # they lead to a malformed request to be sent, which triggers an
      # exception not rescued by `with_token_refresh_when_unauthorized`
      def default_token
        'a temporary dummy token to avoid hitting the API before it is needed'
      end

      def default_logger
        Logger.new($stdout)
      end

      def default_request_options
        {
          read_timeout: default_timeout,
          timeout: default_timeout
        }
      end

      # NOTE: This is the number of seconds it roughly takes for H2 to
      #       successfully shunt ~10GB files over to SDR API
      def default_timeout
        900
      end

      delegate :config, :connection, :deposit_model, :job_status, :find, :update_model, :build_and_deposit,
               to: :instance
    end

    attr_accessor :config

    def deposit_model(...)
      Deposit.deposit_model(...)
    end

    def job_status(...)
      JobStatus.new(...)
    end

    def find(...)
      Find.run(...)
    end

    def update_model(...)
      UpdateResource.run(...)
    end

    def build_and_deposit(...)
      Metadata.deposit(...)
    end

    # Send an authenticated GET request
    # @param path [String] the path to the SDR API request
    def get(path:)
      response = with_token_refresh_when_unauthorized do
        connection.get(path)
      end

      UnexpectedResponse.call(response) unless response.success?

      return nil if response.body.blank?

      JSON.parse(response.body).with_indifferent_access
    end

    # Send an authenticated POST request
    # @param path [String] the path to the SDR API request
    # @param body [String] the body of the SDR API request
    # @param headers [Hash] extra headers to add to the SDR API request
    # @param expected_status [Integer] override if all 2xx statuses aren't success conditions
    def post(path:, body:, headers: {}, expected_status: nil) # rubocop:disable Metrics/MethodLength
      response = with_token_refresh_when_unauthorized do
        connection.post(path) do |request|
          request.body = body
          request.headers = default_headers.merge(headers)
        end
      end

      if expected_status
        UnexpectedResponse.call(response) if response.status != expected_status
      elsif !response.success?
        UnexpectedResponse.call(response)
      end

      return nil if response.body.blank?

      JSON.parse(response.body).with_indifferent_access
    end

    # Send an authenticated PUT request
    # @param path [String] the path to the SDR API request
    # @param body [String] the body of the SDR API request
    # @param headers [Hash] extra headers to add to the SDR API request
    # @param params [Hash] query parameters to add to the SDR API request
    # @param expected_status [Integer] override if all 2xx statuses aren't success conditions
    def put(path:, body:, headers: {}, params: {}, expected_status: nil) # rubocop:disable Metrics/MethodLength
      response = with_token_refresh_when_unauthorized do
        connection.put(path) do |request|
          request.body = body
          request.headers = default_headers.merge(headers)
          request.params = params if params.present?
        end
      end

      if expected_status
        UnexpectedResponse.call(response) if response.status != expected_status
      elsif !response.success?
        UnexpectedResponse.call(response)
      end

      return nil if response.body.blank?

      JSON.parse(response.body).with_indifferent_access
    end

    private

    Config = Struct.new(:url, :email, :password, :token, :logger,
                        :request_options, :token_refresher, keyword_init: true)

    def connection
      Faraday.new(
        url: SdrClient::RedesignedClient.config.url,
        headers: default_headers,
        request: SdrClient::RedesignedClient.config.request_options
      ) do |conn|
        conn.adapter :net_http
      end
    end

    def default_headers
      {
        accept: 'application/json',
        content_type: 'application/json',
        Authorization: "Bearer #{config.token}"
      }
    end

    def with_token_refresh_when_unauthorized
      response = yield

      # if unauthorized, token has likely expired. try to get a new token and then retry the same request(s).
      if response.status == 401
        config.token = config.token_refresher ? config.token_refresher.call : Authenticator.token
        response = yield
      end

      response
    end
  end
end