-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathclient.rb
191 lines (154 loc) · 7.81 KB
/
client.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# frozen_string_literal: true
require 'active_support'
require 'active_support/core_ext/hash/indifferent_access'
require 'active_support/core_ext/module/delegation'
require 'active_support/core_ext/object/blank'
require 'active_support/core_ext/object/json'
require 'active_support/core_ext/object/to_query'
require 'active_support/json'
require 'cocina/models'
require 'faraday'
require 'faraday/retry'
require 'singleton'
require 'zeitwerk'
loader = Zeitwerk::Loader.new
loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
loader.push_dir(File.absolute_path("#{__FILE__}/../../.."))
loader.setup
module Dor
module Services
class Client
include Singleton
DEFAULT_VERSION = 'v1'
TOKEN_HEADER = 'Authorization'
# Base class for Dor::Services::Client exceptions
class Error < StandardError; end
# Error that is raised when the ultimate remote server returns a 404 Not Found for the id in our request (e.g. druid, barcode, catkey, folio_instance_hrid)
class NotFoundResponse < Error; end
# Error that is raised when the remote server returns some unparsable response
class MalformedResponse < Error; end
# Error that wraps Faraday connection exceptions
class ConnectionFailed < Error; end
# Error that is raised when the remote server returns some unexpected response
# this could be any 4xx or 5xx status (except the ones that are direct children of the Error class above)
class UnexpectedResponse < Error
# @param [Faraday::Response] response
# @param [String] object_identifier (nil)
# @param [Hash<String,Object>] errors (nil) the JSON-API errors object
# @param [Hash<String,Object>] graphql_errors (nil) the GraphQL errors object
# rubocop:disable Lint/MissingSuper
def initialize(response:, object_identifier: nil, errors: nil, graphql_errors: nil)
@response = response
@object_identifier = object_identifier
@errors = errors
@graphql_errors = graphql_errors
end
# rubocop:enable Lint/MissingSuper
attr_accessor :errors, :graphql_errors
def to_s
# For GraphQL errors, see https://graphql-ruby.org/errors/execution_errors
return graphql_errors.map { |e| e['message'] }.join(', ') if graphql_errors.present?
return errors.map { |e| "#{e['title']} (#{e['detail']})" }.join(', ') if errors.present?
ResponseErrorFormatter.format(response: @response, object_identifier: @object_identifier)
end
end
# Error that is raised when the remote server returns a 401 Unauthorized
class UnauthorizedResponse < UnexpectedResponse; end
# Error that is raised when the remote server returns a 409 Conflict
class ConflictResponse < UnexpectedResponse; end
# Error that is raised when the remote server returns a 412 Precondition Failed.
# This occurs when you sent an etag with If-Match, but the etag didn't match the latest version
class PreconditionFailedResponse < UnexpectedResponse; end
# Error that is raised when the remote server returns a 400 Bad Request; apps should not retry the request
class BadRequestError < UnexpectedResponse; end
module Types
include Dry.Types()
end
# @param object_identifier [String] the pid for the object
# @raise [ArgumentError] when `object_identifier` is `nil`
# @return [Dor::Services::Client::Object] an instance of the `Client::Object` class
def object(object_identifier)
raise ArgumentError, '`object_identifier` argument cannot be `nil` in call to `#object(object_identifier)' if object_identifier.nil?
# Return memoized object instance if object identifier value is the same
# This allows us to test the client more easily in downstream codebases,
# opening up stubbing without requiring `any_instance_of`
return @object if @object&.object_identifier == object_identifier
@object = Object.new(connection: connection, version: DEFAULT_VERSION, object_identifier: object_identifier)
end
# @return [Dor::Services::Client::AdministrativeTagSearch] an instance of the `Client::AdministrativeTagSearch` class
def administrative_tags
@administrative_tags ||= AdministrativeTagSearch.new(connection: connection, version: DEFAULT_VERSION)
end
# @return [Dor::Services::Client::Objects] an instance of the `Client::Objects` class
def objects
@objects ||= Objects.new(connection: connection, version: DEFAULT_VERSION)
end
# @return [Dor::Services::Client::VirtualObjects] an instance of the `Client::VirtualObjects` class
def virtual_objects
@virtual_objects ||= VirtualObjects.new(connection: connection, version: DEFAULT_VERSION)
end
# @return [Dor::Services::Client::BackgroundJobResults] an instance of the `Client::BackgroundJobResults` class
def background_job_results
@background_job_results ||= BackgroundJobResults.new(connection: connection, version: DEFAULT_VERSION)
end
class << self
# @param [String] url the base url of the endpoint the client should connect to (required)
# @param [String] token a bearer token for HTTP authentication (required)
# @param [Boolean] enable_get_retries retries get requests on errors
# @param [Logger,nil] logger for logging retry attempts
def configure(url:, token:, enable_get_retries: true, logger: nil)
instance.url = url
instance.token = token
instance.enable_get_retries = enable_get_retries
instance.logger = logger
# Force connection to be re-established when `.configure` is called
instance.connection = nil
self
end
delegate :background_job_results, :objects, :object, :virtual_objects, :administrative_tags, to: :instance
end
attr_writer :url, :token, :connection, :enable_get_retries, :logger
private
attr_reader :token, :enable_get_retries, :logger
def url
@url || raise(Error, 'url has not yet been configured')
end
def connection
@connection ||= build_connection(with_retries: enable_get_retries, logger: logger)
end
def build_connection(with_retries: false, logger: nil)
Faraday.new(url) do |builder|
builder.use ErrorFaradayMiddleware
builder.use Faraday::Request::UrlEncoded
# @note when token & token_header are nil, this line is required else
# the Faraday instance will be passed an empty block, which
# causes the adapter not to be set. Thus, everything breaks.
builder.adapter Faraday.default_adapter
# 5 minutes read timeout for very large cocina (eg. many files) object create/update (default if none set is 60 seconds)
builder.options[:timeout] = 300
builder.headers[:user_agent] = user_agent
builder.headers[TOKEN_HEADER] = "Bearer #{token}"
builder.request :retry, retry_options(logger) if with_retries
end
end
def retry_options(logger) # rubocop:disable Metrics/MethodLength
{
max: 4,
interval: 1,
backoff_factor: 2,
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed],
methods: %i[get],
retry_statuses: [503],
# rubocop:disable Lint/UnusedBlockArgument
retry_block: lambda { |env:, options:, retry_count:, exception:, will_retry_in:|
logger&.info("Retry #{retry_count + 1} for #{env.url} due to #{exception.class} (#{exception.message})")
}
# rubocop:enable Lint/UnusedBlockArgument
}
end
def user_agent
"dor-services-client #{Dor::Services::Client::VERSION}"
end
end
end
end