sul-dlss/globus_client

View on GitHub
lib/globus_client.rb

Summary

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

require 'active_support/core_ext/module/delegation'
require 'active_support/core_ext/object/blank'
require 'faraday'
require 'faraday/retry'
require 'singleton'
require 'zeitwerk'

# Load the gem's internal dependencies: use Zeitwerk instead of needing to manually require classes
Zeitwerk::Loader.for_gem.setup

# Client for interacting with the Globus API
class GlobusClient # rubocop:disable Metrics/ClassLength
  include Singleton

  class << self
    # @param client_id [String] the client identifier registered with Globus
    # @param client_secret [String] the client secret to authenticate with Globus
    # @param uploads_directory [String] where to upload files
    # @param transfer_endpoint_id [String] the transfer API endpoint ID supplied by Globus
    # @param transfer_url [String] the transfer API URL
    # @param auth_url [String] the authentication API URL
    # rubocop:disable Metrics/ParameterLists
    def configure(client_id:, client_secret:, uploads_directory:, transfer_endpoint_id:,
                  transfer_url: default_transfer_url, auth_url: default_auth_url)
      instance.config = Config.new(
        # 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`
        token: 'a temporary dummy token to avoid hitting the API before it is needed',
        client_id:,
        client_secret:,
        uploads_directory:,
        transfer_endpoint_id:,
        transfer_url:,
        auth_url:
      )

      self
    end
    # rubocop:enable Metrics/ParameterLists

    delegate :config, :disallow_writes, :delete_access_rule, :file_count, :list_files, :mkdir, :total_size,
             :user_valid?, :get_filenames, :has_files?, :delete, :get, :post, :put, to: :instance

    def default_transfer_url
      'https://transfer.api.globusonline.org'
    end

    def default_auth_url
      'https://auth.globus.org'
    end
  end

  attr_accessor :config

  # Send an authenticated GET request
  # @param base_url [String] the base URL of the Globus API
  # @param path [String] the path to the Globus API request
  # @param params [Hash] params to get to the API
  def get(base_url:, path:, params: {}, content_type: nil)
    response = with_token_refresh_when_unauthorized do
      connection(base_url).get(path, params) do |request|
        request.headers['Authorization'] = "Bearer #{config.token}"
        request.headers['Content-Type'] = content_type if content_type
      end
    end

    UnexpectedResponse.call(response) unless response.success?

    return nil if response.body.blank?

    JSON.parse(response.body)
  end

  # Send an authenticated POST request
  # @param base_url [String] the base URL of the Globus API
  # @param path [String] the path to the Globus API request
  # @param body [String] the body of the Globus API request
  # @param expected_response [#call] an expected response handler to allow short-circuiting the unexpected response
  def post(base_url:, path:, body:, expected_response: ->(_resp) { false })
    response = with_token_refresh_when_unauthorized do
      connection(base_url).post(path) do |request|
        request.headers['Authorization'] = "Bearer #{config.token}"
        request.headers['Content-Type'] = 'application/json'
        request.body = body.to_json
      end
    end

    UnexpectedResponse.call(response) unless response.success? || expected_response.call(response)

    return nil if response.body.blank?

    JSON.parse(response.body)
  end

  # Send an authenticated PUT request
  # @param base_url [String] the base URL of the Globus API
  # @param path [String] the path to the Globus API request
  # @param body [String] the body of the Globus API request
  def put(base_url:, path:, body:)
    response = with_token_refresh_when_unauthorized do
      connection(base_url).put(path) do |request|
        request.headers['Authorization'] = "Bearer #{config.token}"
        request.headers['Content-Type'] = 'application/json'
        request.body = body.to_json
      end
    end

    UnexpectedResponse.call(response) unless response.success?

    return nil if response.body.blank?

    JSON.parse(response.body)
  end

  # Send an authenticated DELETE request
  # @param base_url [String] the base URL of the Globus API
  # @param path [String] the path to the Globus API request
  def delete(base_url:, path:)
    response = with_token_refresh_when_unauthorized do
      connection(base_url).delete(path) do |request|
        request.headers['Authorization'] = "Bearer #{config.token}"
      end
    end

    UnexpectedResponse.call(response) unless response.success?

    return nil if response.body.blank?

    JSON.parse(response.body)
  end

  def mkdir(...)
    Endpoint.new(...).tap do |endpoint|
      endpoint.mkdir
      endpoint.allow_writes
    end
  end

  def disallow_writes(...)
    Endpoint
      .new(...)
      .disallow_writes
  end

  def delete_access_rule(...)
    Endpoint
      .new(...)
      .delete_access_rule
  end

  # NOTE: Can't use the `...` (argument forwarding) operator here because we
  #       want to route the keyword args to `Endpoint#new` and the block arg to
  #       `Endpoint#list_files`
  def list_files(**keywords, &)
    Endpoint
      .new(**keywords)
      .list_files(&)
  end

  def file_count(...)
    Endpoint
      .new(...)
      .list_files { |files| return files.count }
  end

  def total_size(...)
    Endpoint
      .new(...)
      .list_files { |files| return files.sum(&:size) }
  end

  def get_filenames(...)
    Endpoint
      .new(...)
      .list_files { |files| return files.map(&:name) }
  end

  def has_files?(...)
    Endpoint
      .new(...)
      .has_files?
  end

  def user_valid?(...)
    Identity
      .new
      .valid?(...)
  end

  private

  Config = Struct.new(:client_id, :auth_url, :client_secret, :transfer_endpoint_id, :transfer_url, :uploads_directory, :token, keyword_init: true)

  def connection(base_url)
    Faraday.new(url: base_url) do |conn|
      conn.request :retry, {
        max: 10,
        interval: 0.05,
        interval_randomness: 0.5,
        backoff_factor: 2,
        exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
      }
    end
  end

  # Wraps API operations to request new access token if expired.
  # @yieldreturn response [Faraday::Response] the response to inspect
  #
  # @note You likely want to make sure you're wrapping a _single_ HTTP request in this
  # method, because 1) all calls in the block will be retried from the top if there's
  # an authN failure detected, and 2) only the response returned by the block will be
  # inspected for authN failure.
  # Related: consider that the client instance and its token will live across many
  # invocations of the GlobusClient methods once the client is configured by a consuming application,
  # since this class is a Singleton.  Thus, a token may expire between any two calls (i.e. it
  # isn't necessary for a set of operations to collectively take longer than the token lifetime for
  # expiry to fall in the middle of that related set of HTTP calls).
  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 = Authenticator.token
      response = yield
    end

    response
  end
end