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