lib/globus_client.rb
# 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