sul-dlss/folio_client

View on GitHub
lib/folio_client.rb

Summary

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

require 'active_support/core_ext/module/delegation'
require 'active_support/core_ext/object/blank'
require 'faraday'
require 'faraday-cookie_jar'
require 'marc'
require 'ostruct'
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 Folio API
# rubocop:disable Metrics/ClassLength
class FolioClient
  include Singleton

  # Base class for all FolioClient errors
  class Error < StandardError; end

  # Error raised by the Folio Auth API returns a 401 Unauthorized
  class UnauthorizedError < Error; end

  # Error raised when the Folio API returns a 404 NotFound, or returns 0 results when one was expected
  class ResourceNotFound < Error; end

  # Error raised when e.g. exactly one result was expected, but more than one was returned
  class MultipleResourcesFound < Error; end

  # Error raised when the Folio API returns a 403 Forbidden
  class ForbiddenError < Error; end

  # Error raised when the Folio API returns a 500
  class ServiceUnavailable < Error; end

  # Error raised when the Folio API returns a 422 Unprocessable Entity
  class ValidationError < Error; end

  # Error raised when the Folio API returns a 409 Conflict
  class ConflictError < Error; end

  DEFAULT_HEADERS = {
    accept: 'application/json, text/plain',
    content_type: 'application/json'
  }.freeze

  class << self
    # @param url [String] the folio API URL
    # @param login_params [Hash] the folio client login params (username:, password:)
    # @param okapi_headers [Hash] the okapi specific headers to add (X-Okapi-Tenant:, User-Agent:)
    # @param legacy_auth [Boolean] true to use legacy /login rather than Poppy /login-with-expiry endpoint
    # @return [FolioClient] the configured Singleton class
    def configure(url:, login_params:, okapi_headers:, timeout: default_timeout, legacy_auth: true)
      # rubocop:disable Style/OpenStructUse
      instance.config = OpenStruct.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',
        url: url,
        login_params: login_params,
        okapi_headers: okapi_headers,
        timeout: timeout,
        legacy_auth: legacy_auth # default true until we have new token endpoint enabled in Poppy
      )
      # rubocop:enable Style/OpenStructUse

      self
    end

    delegate :config, :connection, :cookie_jar, :data_import, :default_timeout,
             :edit_marc_json, :fetch_external_id, :fetch_hrid,
             :fetch_instance_info, :fetch_marc_hash, :fetch_marc_xml,
             :force_token_refresh!, :get, :has_instance_status?,
             :http_get_headers, :http_post_and_put_headers, :interface_details,
             :job_profiles, :organization_interfaces, :organizations, :users,
             :user_details, :post, :put, to: :instance
  end

  attr_accessor :config

  # Send an authenticated get request
  # @param path [String] the path to the Folio API request
  # @param params [Hash] params to get to the API
  def get(path, params = {})
    response = with_token_refresh_when_unauthorized do
      connection.get(path, params, { 'x-okapi-token': config.token })
    end

    UnexpectedResponse.call(response) unless response.success?

    return nil if response.body.blank?

    JSON.parse(response.body)
  end

  # Send an authenticated post request
  # If the body is JSON, it will be automatically serialized
  # @param path [String] the path to the Folio API request
  # @param body [Object] body to post to the API as JSON
  # rubocop:disable Metrics/MethodLength
  def post(path, body = nil, content_type: 'application/json')
    req_body = content_type == 'application/json' ? body&.to_json : body
    response = with_token_refresh_when_unauthorized do
      req_headers = {
        'x-okapi-token': config.token,
        'content-type': content_type
      }
      connection.post(path, req_body, req_headers)
    end

    UnexpectedResponse.call(response) unless response.success?

    return nil if response.body.blank?

    JSON.parse(response.body)
  end
  # rubocop:enable Metrics/MethodLength

  # Send an authenticated put request
  # If the body is JSON, it will be automatically serialized
  # @param path [String] the path to the Folio API request
  # @param body [Object] body to put to the API as JSON
  # rubocop:disable Metrics/MethodLength
  def put(path, body = nil, content_type: 'application/json')
    req_body = content_type == 'application/json' ? body&.to_json : body
    response = with_token_refresh_when_unauthorized do
      req_headers = {
        'x-okapi-token': config.token,
        'content-type': content_type
      }
      connection.put(path, req_body, req_headers)
    end

    UnexpectedResponse.call(response) unless response.success?

    return nil if response.body.blank?

    JSON.parse(response.body)
  end
  # rubocop:enable Metrics/MethodLength

  # the base connection to the Folio API
  def connection
    @connection ||= Faraday.new(
      url: config.url,
      headers: DEFAULT_HEADERS.merge(config.okapi_headers || {}),
      request: { timeout: config.timeout }
    ) do |faraday|
      faraday.use :cookie_jar, jar: cookie_jar
      faraday.adapter Faraday.default_adapter
    end
  end

  def cookie_jar
    @cookie_jar ||= HTTP::CookieJar.new
  end

  # Public methods available on the FolioClient below

  # @see Inventory#fetch_hrid
  def fetch_hrid(...)
    Inventory
      .new
      .fetch_hrid(...)
  end

  # @see Inventory#fetch_external_id
  def fetch_external_id(...)
    Inventory
      .new
      .fetch_external_id(...)
  end

  # @see Inventory#fetch_instance_info
  def fetch_instance_info(...)
    Inventory
      .new
      .fetch_instance_info(...)
  end

  # @see SourceStorage#fetch_marc_hash
  def fetch_marc_hash(...)
    SourceStorage
      .new
      .fetch_marc_hash(...)
  end

  # @see SourceStorage#fetch_marc_xml
  def fetch_marc_xml(...)
    SourceStorage
      .new
      .fetch_marc_xml(...)
  end

  # @see Inventory#has_instance_status?
  def has_instance_status?(...) # rubocop:disable Naming/PredicateName
    Inventory
      .new
      .has_instance_status?(...)
  end

  # @ see DataImport#import
  def data_import(...)
    DataImport
      .new
      .import(...)
  end

  # @ see DataImport#job_profiles
  def job_profiles(...)
    DataImport
      .new
      .job_profiles(...)
  end

  # @see RecordsEditor#edit_marc_json
  def edit_marc_json(...)
    RecordsEditor
      .new
      .edit_marc_json(...)
  end

  # @see Organizations#fetch_list
  def organizations(...)
    Organizations
      .new
      .fetch_list(...)
  end

  # @see Organizations#fetch_interface_list
  def organization_interfaces(...)
    Organizations
      .new
      .fetch_interface_list(...)
  end

  # @see Organizations#fetch_interface_details
  def interface_details(...)
    Organizations
      .new
      .fetch_interface_details(...)
  end

  # @see Users#fetch_list
  def users(...)
    Users
      .new
      .fetch_list(...)
  end

  # @see Users#fetch_user_details
  def user_details(...)
    Users
      .new
      .fetch_user_details(...)
  end

  def default_timeout
    120
  end

  def force_token_refresh!
    config.token = Authenticator.token
  end

  private

  # 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 FolioClient 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 || response.status == 403
      force_token_refresh!
      response = yield
    end

    response
  end
end
# rubocop:enable Metrics/ClassLength