folio_client 0.21.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8c16843f867a9647afa4265ac90ce5d853e3e4ed4bd543dd209913302e8fd1e
4
- data.tar.gz: e9e6f447bd7a23b75e2b38ccfbb641d26880ab9c6db815a13f47bae0a6ef6788
3
+ metadata.gz: c7ff536f92f06403a2d47dd9ede2dab90d0f20d8b6ad52f9c2bd047dd7eb0650
4
+ data.tar.gz: 3ba0ff3343cbf79bfb383254d18f8cad2bd0c74b0251e4c76bf69b26d6d75ed4
5
5
  SHA512:
6
- metadata.gz: caa83ad6b9dd3d56312791ca3d5917b86895c456067b8eabacc41e27a283bb6db5dfde2fb7137eb9eb4bf599851e875bdaaf29f32f0157682c27759d1df23845
7
- data.tar.gz: d5ce53af19a71d1c32d89d3cfe566e0eec8f103655c1514644e639c3ff0200316ea559d2ecc2685309e4a393bf0889b8e3953358e8aaea5748681aec213a4990
6
+ metadata.gz: 6e5e9f3c4b84168f41b6afaacffebcaba29bc732ea338a3834a01621dec956e568d5863789da54b4df297bda2915e9119164c0fbf3e4de1c703fc5317df0c4fb
7
+ data.tar.gz: c9bde04bd107cb50213b2beb8019251dd1cf2452ce842f926420576202df65ee3d54ea1a665afb441ff5d47a367fab8230de0612d88767b73bc9454a35cd9218
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- folio_client (0.21.0)
4
+ folio_client (1.0.0)
5
5
  activesupport (>= 4.2)
6
6
  deprecation
7
7
  dry-monads
@@ -66,7 +66,7 @@ GEM
66
66
  faraday-net_http (3.4.2)
67
67
  net-http (~> 0.5)
68
68
  hashdiff (1.2.1)
69
- http-cookie (1.1.0)
69
+ http-cookie (1.1.4)
70
70
  domain_name (~> 0.5)
71
71
  i18n (1.14.8)
72
72
  concurrent-ruby (~> 1.0)
@@ -93,7 +93,7 @@ GEM
93
93
  nokogiri (1.19.2-x86_64-linux-gnu)
94
94
  racc (~> 1.4)
95
95
  ostruct (0.6.3)
96
- parallel (1.28.0)
96
+ parallel (2.0.1)
97
97
  parser (3.3.11.1)
98
98
  ast (~> 2.4.1)
99
99
  racc
@@ -129,11 +129,11 @@ GEM
129
129
  diff-lcs (>= 1.2.0, < 2.0)
130
130
  rspec-support (~> 3.13.0)
131
131
  rspec-support (3.13.7)
132
- rubocop (1.86.0)
132
+ rubocop (1.86.1)
133
133
  json (~> 2.3)
134
134
  language_server-protocol (~> 3.17.0.2)
135
135
  lint_roller (~> 1.1.0)
136
- parallel (~> 1.10)
136
+ parallel (>= 1.10)
137
137
  parser (>= 3.3.0.2)
138
138
  rainbow (>= 2.2.2, < 4.0)
139
139
  regexp_parser (>= 2.9.3, < 3.0)
@@ -203,4 +203,4 @@ DEPENDENCIES
203
203
  webmock
204
204
 
205
205
  BUNDLED WITH
206
- 4.0.9
206
+ 4.0.10
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class FolioClient
4
- VERSION = '0.21.0'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/folio_client.rb CHANGED
@@ -11,45 +11,47 @@ require 'ostruct'
11
11
  require 'singleton'
12
12
  require 'zeitwerk'
13
13
 
14
- # Load the gem's internal dependencies: use Zeitwerk instead of needing to manually require classes
14
+ # Autoload gem internals.
15
15
  Zeitwerk::Loader.for_gem.setup
16
16
 
17
- # Client for interacting with the Folio API
18
- # rubocop:disable Metrics/ClassLength
19
- class FolioClient
17
+ # Client for interacting with the Folio API.
18
+ class FolioClient # rubocop:disable Metrics/ClassLength
20
19
  include Singleton
21
20
 
22
- # Base class for all FolioClient errors
21
+ # Base class for all FolioClient errors.
23
22
  class Error < StandardError; end
24
23
 
25
- # Error raised by the Folio Auth API returns a 401 Unauthorized
24
+ # Raised when the Folio Auth API returns 401 Unauthorized.
26
25
  class UnauthorizedError < Error; end
27
26
 
28
- # Error raised when the Folio API returns a 404 NotFound, or returns 0 results when one was expected
27
+ # Raised when the Folio API returns 404 Not Found, or when 0 results are
28
+ # returned where one was expected.
29
29
  class ResourceNotFound < Error; end
30
30
 
31
- # Error raised when e.g. exactly one result was expected, but more than one was returned
31
+ # Raised when exactly one resource was expected but multiple were returned.
32
32
  class MultipleResourcesFound < Error; end
33
33
 
34
- # Error raised when the Folio API returns a 403 Forbidden
34
+ # Raised when the Folio API returns 403 Forbidden.
35
35
  class ForbiddenError < Error; end
36
36
 
37
- # Error raised when the Folio API returns a 500
37
+ # Raised when the Folio API returns 500-level availability errors.
38
38
  class ServiceUnavailable < Error; end
39
39
 
40
- # Error raised when the Folio API returns a 422 Unprocessable Entity
40
+ # Raised when the Folio API returns 422 Unprocessable Entity.
41
41
  class ValidationError < Error; end
42
42
 
43
- # Error raised when the Folio API returns a 409 Conflict
43
+ # Raised when the Folio API returns 409 Conflict.
44
44
  class ConflictError < Error; end
45
45
 
46
- # Error raised when the Folio API returns a 400 Bad Request
46
+ # Raised when the Folio API returns 400 Bad Request.
47
47
  class BadRequestError < Error; end
48
48
 
49
49
  class << self
50
50
  extend Deprecation
51
51
 
52
52
  Config = Struct.new('Config', :url, :login_params, :timeout, :tenant_id, :user_agent) do
53
+ # Build default headers for Folio-bound requests.
54
+ # @return [Hash<Symbol,String>] default request headers
53
55
  def headers
54
56
  {
55
57
  accept: 'application/json, text/plain',
@@ -60,47 +62,48 @@ class FolioClient
60
62
  end
61
63
  end
62
64
 
63
- # @param url [String] the folio API URL
64
- # @param login_params [Hash] the folio client login params (username:, password:)
65
- # @param tenant_id [String] the ID of the Folio tenant
66
- # @param user_agent [String] the user agent string to send in API requests
67
- # @param timeout [Integer] the timeout in seconds for API requests
68
- # @param unsupported_kwargs [Hash] any additional keyword arguments that are not explicitly supported.
69
- # This is to allow for backward compatibility with previous versions of the client that accepted
70
- # additional configuration options, such as `okapi_headers`, without raising an error. The values
71
- # of any recognized keys in this hash will be used to set the corresponding configuration options,
72
- # and a deprecation warning will be issued for any keys present in this hash.
73
- # @return [FolioClient] the configured Singleton class
74
- def configure(url:, login_params:, tenant_id: nil, user_agent: default_user_agent, # rubocop:disable Metrics/ParameterLists
75
- timeout: default_timeout, **unsupported_kwargs)
76
- Deprecation.warn(FolioClient, "Deprecated keywords: #{unsupported_kwargs.keys.sort.join(', ')}") if unsupported_kwargs.any?
77
-
65
+ # Configure the singleton FolioClient instance.
66
+ # @example
67
+ # FolioClient.configure(
68
+ # url: 'https://folio.example.edu',
69
+ # login_params: { username: 'svc-user', password: 'secret' },
70
+ # tenant_id: 'sul'
71
+ # )
72
+ # @param url [String] Folio API base URL
73
+ # @param login_params [Hash] authentication payload (e.g., +username+, +password+)
74
+ # @param tenant_id [String, nil] Folio tenant identifier
75
+ # @param user_agent [String, nil] user agent string included on outbound requests
76
+ # @param timeout [Integer] request timeout, in seconds
77
+ # @return [Class<FolioClient>] the configured singleton class for chaining
78
+ def configure(url:, login_params:, tenant_id: nil, user_agent: nil, timeout: nil)
78
79
  instance.config = Config.new(
79
80
  url: url,
80
81
  login_params: login_params,
81
- timeout: timeout,
82
- tenant_id: tenant_id.presence || unsupported_kwargs.dig(:okapi_headers, :'X-Okapi-Tenant'),
83
- user_agent: user_agent.presence || unsupported_kwargs.dig(:okapi_headers, :'User-Agent')
82
+ tenant_id: tenant_id,
83
+ timeout: timeout || default_timeout,
84
+ user_agent: user_agent || default_user_agent
84
85
  )
85
86
 
86
87
  self
87
88
  end
88
89
 
89
- delegate :config, :connection, :cookie_jar, :create_holdings, :data_import, :default_timeout,
90
- :default_user_agent, :edit_marc_json, :fetch_external_id, :fetch_holdings, :fetch_hrid,
91
- :fetch_instance_info, :fetch_location, :fetch_marc_hash, :fetch_marc_xml,
92
- :force_token_refresh!, :get, :has_instance_status?, :http_get_headers,
93
- :http_post_and_put_headers, :interface_details, :job_profiles,
94
- :organization_interfaces, :organizations, :update_holdings, :users, :user_details,
95
- :post, :put, to: :instance
90
+ # The client is intended to be used as a singleton via {.configure}, but the
91
+ # instance methods are also available on the class itself for convenience.
92
+ # Instead of maintaining a giant list of delegations to the singleton
93
+ # instance, we can just delegate everything. This makes the client easier to
94
+ # extend with additional instance methods without needing to update the
95
+ # delegation list.
96
+ delegate_missing_to :instance
96
97
  end
97
98
 
99
+ # @return [FolioClient::Config, nil] active runtime configuration
98
100
  attr_accessor :config
99
101
 
100
- # Send an authenticated get request
101
- # @param path [String] the path to the Folio API request
102
- # @param params [Hash] params to get to the API
103
- # @return [Hash, nil] the parsed response body or nil
102
+ # Send an authenticated GET request.
103
+ # @param path [String] API path relative to configured +url+
104
+ # @param params [Hash] query parameters
105
+ # @return [Hash, Array, nil] parsed JSON body, or +nil+ for empty body
106
+ # @raise [FolioClient::Error] when Folio responds with an unexpected status
104
107
  def get(path, params = {})
105
108
  response = with_token_refresh_when_unauthorized do
106
109
  connection.get(path, params)
@@ -111,11 +114,14 @@ class FolioClient
111
114
  JSON.parse(response.body) if response.body.present?
112
115
  end
113
116
 
114
- # Send an authenticated post request
115
- # If the body is JSON, it will be automatically serialized
116
- # @param path [String] the path to the Folio API request
117
- # @param body [Object] body to post to the API as JSON
118
- # @return [Hash, nil] the parsed response body or nil
117
+ # Send an authenticated POST request.
118
+ # If +content_type+ is +application/json+, +body+ is serialized with +to_json+.
119
+ # Otherwise +body+ is sent unchanged.
120
+ # @param path [String] API path relative to configured +url+
121
+ # @param body [Hash, String, nil] request payload
122
+ # @param content_type [String] MIME type of request body
123
+ # @return [Hash, Array, nil] parsed JSON body, or +nil+ for empty body
124
+ # @raise [FolioClient::Error] when Folio responds with an unexpected status
119
125
  def post(path, body = nil, content_type: 'application/json')
120
126
  req_body = content_type == 'application/json' ? body&.to_json : body
121
127
  response = with_token_refresh_when_unauthorized do
@@ -127,11 +133,15 @@ class FolioClient
127
133
  JSON.parse(response.body) if response.body.present?
128
134
  end
129
135
 
130
- # Send an authenticated put request
131
- # If the body is JSON, it will be automatically serialized
132
- # @param path [String] the path to the Folio API request
133
- # @param body [Object] body to put to the API as JSON
134
- # @return [Hash, nil] the parsed response body or nil
136
+ # Send an authenticated PUT request.
137
+ # If +content_type+ is +application/json+, +body+ is serialized with +to_json+.
138
+ # Otherwise +body+ is sent unchanged.
139
+ # @param path [String] API path relative to configured +url+
140
+ # @param body [Hash, String, nil] request payload
141
+ # @param content_type [String] MIME type of request body
142
+ # @param exception_args [Hash] supplemental context forwarded to +UnexpectedResponse+
143
+ # @return [Hash, Array, nil] parsed JSON body, or +nil+ for empty body
144
+ # @raise [FolioClient::Error] when Folio responds with an unexpected status
135
145
  def put(path, body = nil, content_type: 'application/json', **exception_args)
136
146
  req_body = content_type == 'application/json' ? body&.to_json : body
137
147
  response = with_token_refresh_when_unauthorized do
@@ -143,7 +153,8 @@ class FolioClient
143
153
  JSON.parse(response.body) if response.body.present?
144
154
  end
145
155
 
146
- # the base connection to the Folio API
156
+ # Build (or memoize) the base Faraday connection.
157
+ # @return [Faraday::Connection] configured HTTP connection
147
158
  def connection
148
159
  @connection ||= Faraday.new(
149
160
  url: config.url,
@@ -155,174 +166,215 @@ class FolioClient
155
166
  end
156
167
  end
157
168
 
169
+ # Build (or memoize) the cookie jar used by Faraday to store authentication cookies.
170
+ # @return [HTTP::CookieJar] cookie storage for session-aware requests
158
171
  def cookie_jar
159
172
  @cookie_jar ||= HTTP::CookieJar.new
160
173
  end
161
174
 
162
- # Public methods available on the FolioClient below
163
-
175
+ # Fetch a Folio HRID by instance identifier or query context.
164
176
  # @see Inventory#fetch_hrid
177
+ # @return [Object] delegated return value from +Inventory#fetch_hrid+
165
178
  def fetch_hrid(...)
166
- Inventory
167
- .new
168
- .fetch_hrid(...)
179
+ inventory.fetch_hrid(...)
169
180
  end
170
181
 
182
+ # Fetch the Folio external id for a matching record.
171
183
  # @see Inventory#fetch_external_id
184
+ # @return [Object] delegated return value from +Inventory#fetch_external_id+
172
185
  def fetch_external_id(...)
173
- Inventory
174
- .new
175
- .fetch_external_id(...)
186
+ inventory.fetch_external_id(...)
176
187
  end
177
188
 
189
+ # Fetch inventory instance details.
178
190
  # @see Inventory#fetch_instance_info
191
+ # @return [Object] delegated return value from +Inventory#fetch_instance_info+
179
192
  def fetch_instance_info(...)
180
- Inventory
181
- .new
182
- .fetch_instance_info(...)
193
+ inventory.fetch_instance_info(...)
183
194
  end
184
195
 
196
+ # Fetch location details from inventory.
185
197
  # @see Inventory#fetch_location
198
+ # @return [Object] delegated return value from +Inventory#fetch_location+
186
199
  def fetch_location(...)
187
- Inventory
188
- .new
189
- .fetch_location(...)
200
+ inventory.fetch_location(...)
190
201
  end
191
202
 
203
+ # Fetch holdings associated with a record.
192
204
  # @see Inventory#fetch_holdings
205
+ # @return [Object] delegated return value from +Inventory#fetch_holdings+
193
206
  def fetch_holdings(...)
194
- Inventory
195
- .new
196
- .fetch_holdings(...)
207
+ inventory.fetch_holdings(...)
197
208
  end
198
209
 
210
+ # Update an existing holdings record.
199
211
  # @see Inventory#update_holdings
212
+ # @return [Object] delegated return value from +Inventory#update_holdings+
200
213
  def update_holdings(...)
201
- Inventory
202
- .new
203
- .update_holdings(...)
214
+ inventory.update_holdings(...)
204
215
  end
205
216
 
217
+ # Create a new holdings record.
206
218
  # @see Inventory#create_holdings
219
+ # @return [Object] delegated return value from +Inventory#create_holdings+
207
220
  def create_holdings(...)
208
- Inventory
209
- .new
210
- .create_holdings(...)
221
+ inventory.create_holdings(...)
211
222
  end
212
223
 
224
+ # Fetch MARC data as a Ruby hash.
213
225
  # @see SourceStorage#fetch_marc_hash
226
+ # @return [Object] delegated return value from +SourceStorage#fetch_marc_hash+
214
227
  def fetch_marc_hash(...)
215
- SourceStorage
216
- .new
217
- .fetch_marc_hash(...)
228
+ source_storage.fetch_marc_hash(...)
218
229
  end
219
230
 
231
+ # Fetch MARC data as XML.
220
232
  # @see SourceStorage#fetch_marc_xml
233
+ # @return [Object] delegated return value from +SourceStorage#fetch_marc_xml+
221
234
  def fetch_marc_xml(...)
222
- SourceStorage
223
- .new
224
- .fetch_marc_xml(...)
235
+ source_storage.fetch_marc_xml(...)
225
236
  end
226
237
 
238
+ # Determine whether an instance has the requested status.
227
239
  # @see Inventory#has_instance_status?
240
+ # @return [Boolean] delegated predicate result
228
241
  def has_instance_status?(...) # rubocop:disable Naming/PredicatePrefix
229
- Inventory
230
- .new
231
- .has_instance_status?(...)
242
+ inventory.has_instance_status?(...)
232
243
  end
233
244
 
234
- # @ see DataImport#import
245
+ # Run an inventory data import workflow.
246
+ # @see DataImport#import
247
+ # @return [Object] delegated return value from +DataImport#import+
235
248
  def data_import(...)
236
- DataImport
237
- .new
238
- .import(...)
249
+ data_import_service.import(...)
239
250
  end
240
251
 
241
- # @ see DataImport#job_profiles
252
+ # List available data-import job profiles.
253
+ # @see DataImport#job_profiles
254
+ # @return [Object] delegated return value from +DataImport#job_profiles+
242
255
  def job_profiles(...)
243
- DataImport
244
- .new
245
- .job_profiles(...)
256
+ data_import_service.job_profiles(...)
246
257
  end
247
258
 
259
+ # Edit MARC-in-JSON records.
248
260
  # @see RecordsEditor#edit_marc_json
261
+ # @return [Object] delegated return value from +RecordsEditor#edit_marc_json+
249
262
  def edit_marc_json(...)
250
- RecordsEditor
251
- .new
252
- .edit_marc_json(...)
263
+ records_editor.edit_marc_json(...)
253
264
  end
254
265
 
266
+ # List organizations.
255
267
  # @see Organizations#fetch_list
268
+ # @return [Object] delegated return value from +Organizations#fetch_list+
256
269
  def organizations(...)
257
- Organizations
258
- .new
259
- .fetch_list(...)
270
+ organizations_service.fetch_list(...)
260
271
  end
261
272
 
273
+ # List interfaces for organizations.
262
274
  # @see Organizations#fetch_interface_list
275
+ # @return [Object] delegated return value from +Organizations#fetch_interface_list+
263
276
  def organization_interfaces(...)
264
- Organizations
265
- .new
266
- .fetch_interface_list(...)
277
+ organizations_service.fetch_interface_list(...)
267
278
  end
268
279
 
280
+ # Fetch detailed interface information for an organization interface.
269
281
  # @see Organizations#fetch_interface_details
282
+ # @return [Object] delegated return value from +Organizations#fetch_interface_details+
270
283
  def interface_details(...)
271
- Organizations
272
- .new
273
- .fetch_interface_details(...)
284
+ organizations_service.fetch_interface_details(...)
274
285
  end
275
286
 
287
+ # List users.
276
288
  # @see Users#fetch_list
289
+ # @return [Object] delegated return value from +Users#fetch_list+
277
290
  def users(...)
278
- Users
279
- .new
280
- .fetch_list(...)
291
+ users_service.fetch_list(...)
281
292
  end
282
293
 
294
+ # Fetch details for a user.
283
295
  # @see Users#fetch_user_details
296
+ # @return [Object] delegated return value from +Users#fetch_user_details+
284
297
  def user_details(...)
285
- Users
286
- .new
287
- .fetch_user_details(...)
298
+ users_service.fetch_user_details(...)
299
+ end
300
+
301
+ # Force a refresh of the current auth token.
302
+ #
303
+ # @return [Object] return value from +Authenticator.refresh_token!+
304
+ def force_token_refresh!
305
+ Authenticator.refresh_token!
288
306
  end
289
307
 
308
+ # Default HTTP timeout in seconds.
309
+ #
310
+ # @return [Integer]
290
311
  def default_timeout
291
312
  180
292
313
  end
293
314
 
315
+ # Default user-agent string used for outbound requests.
316
+ #
317
+ # @return [String]
294
318
  def default_user_agent
295
- "folio_client #{FolioClient::VERSION}"
296
- end
297
-
298
- def force_token_refresh!
299
- Authenticator.refresh_token!
319
+ "folio_client #{VERSION}"
300
320
  end
301
321
 
302
322
  private
303
323
 
304
- # Wraps API operations to request new access token if expired.
305
- # @yieldreturn response [Faraday::Response] the response to inspect
306
- #
307
- # @note You likely want to make sure you're wrapping a _single_ HTTP request in this
308
- # method, because 1) all calls in the block will be retried from the top if there's
309
- # an authN failure detected, and 2) only the response returned by the block will be
310
- # inspected for authN failure.
311
- # Related: consider that the client instance and its token will live across many
312
- # invocations of the FolioClient methods once the client is configured by a consuming application,
313
- # since this class is a Singleton. Thus, a token may expire between any two calls (i.e. it
314
- # isn't necessary for a set of operations to collectively take longer than the token lifetime for
315
- # expiry to fall in the middle of that related set of HTTP calls).
324
+ STATUSES_REQUIRING_TOKEN_REFRESH = [401, 403].freeze
325
+ private_constant :STATUSES_REQUIRING_TOKEN_REFRESH
326
+
327
+ # Wrap API operations to refresh and retry when auth has expired.
328
+ # @yieldreturn [Faraday::Response] response from a single HTTP request
329
+ # @return [Faraday::Response] original or retried response
330
+ # @note Wrap one HTTP call per block. If auth fails, the entire block is retried.
331
+ # Only the final response yielded by the block is inspected for auth failure.
332
+ # @note Because this class is a Singleton, its token can outlive many client calls.
333
+ # Expiration can occur between any two invocations, even when those calls are
334
+ # logically related from the caller's perspective.
316
335
  def with_token_refresh_when_unauthorized
317
336
  response = yield
318
337
 
319
- # if unauthorized, token has likely expired. try to get a new token and then retry the same request(s).
320
- if [401, 403].include?(response.status)
321
- force_token_refresh!
322
- response = yield
323
- end
338
+ return response unless STATUSES_REQUIRING_TOKEN_REFRESH.include?(response.status)
339
+
340
+ force_token_refresh!
341
+
342
+ yield
343
+ end
344
+
345
+ # Build an inventory service object.
346
+ # @return [Inventory]
347
+ def inventory
348
+ Inventory.new
349
+ end
350
+
351
+ # Build a source-storage service object.
352
+ # @return [SourceStorage]
353
+ def source_storage
354
+ SourceStorage.new
355
+ end
356
+
357
+ # Build a data-import service object.
358
+ # @return [DataImport]
359
+ def data_import_service
360
+ DataImport.new
361
+ end
362
+
363
+ # Build a records-editor service object.
364
+ # @return [RecordsEditor]
365
+ def records_editor
366
+ RecordsEditor.new
367
+ end
368
+
369
+ # Build an organizations service object.
370
+ # @return [Organizations]
371
+ def organizations_service
372
+ Organizations.new
373
+ end
324
374
 
325
- response
375
+ # Build a users service object.
376
+ # @return [Users]
377
+ def users_service
378
+ Users.new
326
379
  end
327
380
  end
328
- # rubocop:enable Metrics/ClassLength
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: folio_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Mangiafico