folio_client 0.19.0 → 0.21.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: 548e644e584af5ba697c8c626c9dace7e06115106068e67ae8b4fbc49c1daa98
4
- data.tar.gz: 5ae2b2251ffb4814ea8ed4bab229fc5d4349769b6a2b2ad271cbec10e8526da4
3
+ metadata.gz: d8c16843f867a9647afa4265ac90ce5d853e3e4ed4bd543dd209913302e8fd1e
4
+ data.tar.gz: e9e6f447bd7a23b75e2b38ccfbb641d26880ab9c6db815a13f47bae0a6ef6788
5
5
  SHA512:
6
- metadata.gz: 6b8a03e8784b4e730d066960659a034fe3bc4d95ef4b81f792f29b5a353540aa1fae72970ee65f99a7859d8eeddfb3076ef5153fff51000ebabe296d4e2fb959
7
- data.tar.gz: e6ad98a7fb6dd23c15400b9827f68059079fc845d2bfde25cd9bd518249ef5ce6c43edc440520d7aed36ab6dbf4c5e26ee133e56ca51a795922ec23a634a774a
6
+ metadata.gz: caa83ad6b9dd3d56312791ca3d5917b86895c456067b8eabacc41e27a283bb6db5dfde2fb7137eb9eb4bf599851e875bdaaf29f32f0157682c27759d1df23845
7
+ data.tar.gz: d5ce53af19a71d1c32d89d3cfe566e0eec8f103655c1514644e639c3ff0200316ea559d2ecc2685309e4a393bf0889b8e3953358e8aaea5748681aec213a4990
data/.rubocop.yml CHANGED
@@ -6,7 +6,7 @@ plugins:
6
6
  - rubocop-rspec
7
7
 
8
8
  AllCops:
9
- TargetRubyVersion: 3.0
9
+ TargetRubyVersion: 3.4
10
10
  DisplayCopNames: true
11
11
  SuggestExtensions: false
12
12
  Exclude:
@@ -442,4 +442,52 @@ RSpec/IncludeExamples: # new in 3.6
442
442
  Capybara/FindAllFirst: # new in 2.22
443
443
  Enabled: true
444
444
  Capybara/NegationMatcherAfterVisit: # new in 2.22
445
- Enabled: true
445
+ Enabled: true
446
+ Gemspec/AttributeAssignment: # new in 1.77
447
+ Enabled: true
448
+ Layout/EmptyLinesAfterModuleInclusion: # new in 1.79
449
+ Enabled: true
450
+ Style/ArrayIntersectWithSingleElement: # new in 1.81
451
+ Enabled: true
452
+ Style/CollectionQuerying: # new in 1.77
453
+ Enabled: true
454
+ Style/EmptyClassDefinition: # new in 1.84
455
+ Enabled: true
456
+ Style/ModuleMemberExistenceCheck: # new in 1.82
457
+ Enabled: true
458
+ Style/NegativeArrayIndex: # new in 1.84
459
+ Enabled: true
460
+ Style/ReverseFind: # new in 1.84
461
+ Enabled: true
462
+ RSpecRails/HttpStatusNameConsistency: # new in 2.32
463
+ Enabled: true
464
+ RSpec/LeakyLocalVariable: # new in 3.8
465
+ Enabled: true
466
+ RSpec/Output: # new in 3.9
467
+ Enabled: true
468
+ Lint/DataDefineOverride: # new in 1.85
469
+ Enabled: true
470
+ Lint/UnreachablePatternBranch: # new in 1.85
471
+ Enabled: true
472
+ Style/FileOpen: # new in 1.85
473
+ Enabled: true
474
+ Style/MapJoin: # new in 1.85
475
+ Enabled: true
476
+ Style/OneClassPerFile: # new in 1.85
477
+ Enabled: true
478
+ Style/PartitionInsteadOfDoubleSelect: # new in 1.85
479
+ Enabled: true
480
+ Style/PredicateWithKind: # new in 1.85
481
+ Enabled: true
482
+ Style/ReduceToHash: # new in 1.85
483
+ Enabled: true
484
+ Style/RedundantMinMaxBy: # new in 1.85
485
+ Enabled: true
486
+ Style/RedundantStructKeywordInit: # new in 1.85
487
+ Enabled: true
488
+ Style/SelectByKind: # new in 1.85
489
+ Enabled: true
490
+ Style/SelectByRange: # new in 1.85
491
+ Enabled: true
492
+ Style/TallyMethod: # new in 1.85
493
+ Enabled: true
data/Gemfile CHANGED
@@ -5,4 +5,4 @@ source 'https://rubygems.org'
5
5
  # Specify your gem's dependencies in folio_client.gemspec
6
6
  gemspec
7
7
 
8
- gem 'byebug'
8
+ gem 'debug'
data/Gemfile.lock CHANGED
@@ -1,18 +1,20 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- folio_client (0.19.0)
4
+ folio_client (0.21.0)
5
5
  activesupport (>= 4.2)
6
+ deprecation
6
7
  dry-monads
7
8
  faraday
8
9
  faraday-cookie_jar
9
10
  marc
11
+ ostruct
10
12
  zeitwerk
11
13
 
12
14
  GEM
13
15
  remote: https://rubygems.org/
14
16
  specs:
15
- activesupport (8.1.2)
17
+ activesupport (8.1.3)
16
18
  base64
17
19
  bigdecimal
18
20
  concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -25,18 +27,22 @@ GEM
25
27
  securerandom (>= 0.3)
26
28
  tzinfo (~> 2.0, >= 2.0.5)
27
29
  uri (>= 0.13.1)
28
- addressable (2.8.8)
30
+ addressable (2.9.0)
29
31
  public_suffix (>= 2.0.2, < 8.0)
30
32
  ast (2.4.3)
31
33
  base64 (0.3.0)
32
- bigdecimal (4.0.1)
33
- byebug (13.0.0)
34
- reline (>= 0.6.0)
34
+ bigdecimal (4.1.1)
35
35
  concurrent-ruby (1.3.6)
36
36
  connection_pool (3.0.2)
37
37
  crack (1.0.1)
38
38
  bigdecimal
39
39
  rexml
40
+ date (3.5.1)
41
+ debug (1.11.1)
42
+ irb (~> 1.10)
43
+ reline (>= 0.3.8)
44
+ deprecation (1.1.0)
45
+ activesupport
40
46
  diff-lcs (1.6.2)
41
47
  docile (1.4.1)
42
48
  domain_name (0.6.20240107)
@@ -49,7 +55,8 @@ GEM
49
55
  concurrent-ruby (~> 1.0)
50
56
  dry-core (~> 1.1)
51
57
  zeitwerk (~> 2.6)
52
- faraday (2.14.0)
58
+ erb (6.0.2)
59
+ faraday (2.14.1)
53
60
  faraday-net_http (>= 2.0, < 3.5)
54
61
  json
55
62
  logger
@@ -64,33 +71,48 @@ GEM
64
71
  i18n (1.14.8)
65
72
  concurrent-ruby (~> 1.0)
66
73
  io-console (0.8.2)
67
- json (2.18.0)
74
+ irb (1.17.0)
75
+ pp (>= 0.6.0)
76
+ prism (>= 1.3.0)
77
+ rdoc (>= 4.0.0)
78
+ reline (>= 0.4.2)
79
+ json (2.19.3)
68
80
  language_server-protocol (3.17.0.5)
69
81
  lint_roller (1.1.0)
70
82
  logger (1.7.0)
71
83
  marc (1.4.0)
72
84
  nokogiri (~> 1.0)
73
85
  rexml
74
- minitest (6.0.1)
86
+ minitest (6.0.3)
87
+ drb (~> 2.0)
75
88
  prism (~> 1.5)
76
89
  net-http (0.9.1)
77
90
  uri (>= 0.11.1)
78
- nokogiri (1.19.0-arm64-darwin)
91
+ nokogiri (1.19.2-arm64-darwin)
79
92
  racc (~> 1.4)
80
- nokogiri (1.19.0-x86_64-darwin)
93
+ nokogiri (1.19.2-x86_64-linux-gnu)
81
94
  racc (~> 1.4)
82
- nokogiri (1.19.0-x86_64-linux-gnu)
83
- racc (~> 1.4)
84
- parallel (1.27.0)
85
- parser (3.3.10.1)
95
+ ostruct (0.6.3)
96
+ parallel (1.28.0)
97
+ parser (3.3.11.1)
86
98
  ast (~> 2.4.1)
87
99
  racc
100
+ pp (0.6.3)
101
+ prettyprint
102
+ prettyprint (0.2.0)
88
103
  prism (1.9.0)
89
- public_suffix (7.0.2)
104
+ psych (5.3.1)
105
+ date
106
+ stringio
107
+ public_suffix (7.0.5)
90
108
  racc (1.8.1)
91
109
  rainbow (3.1.1)
92
110
  rake (13.3.1)
93
- regexp_parser (2.11.3)
111
+ rdoc (7.2.0)
112
+ erb
113
+ psych (>= 4.0.0)
114
+ tsort
115
+ regexp_parser (2.12.0)
94
116
  reline (0.6.3)
95
117
  io-console (~> 0.5)
96
118
  rexml (3.4.4)
@@ -103,11 +125,11 @@ GEM
103
125
  rspec-expectations (3.13.5)
104
126
  diff-lcs (>= 1.2.0, < 2.0)
105
127
  rspec-support (~> 3.13.0)
106
- rspec-mocks (3.13.7)
128
+ rspec-mocks (3.13.8)
107
129
  diff-lcs (>= 1.2.0, < 2.0)
108
130
  rspec-support (~> 3.13.0)
109
131
  rspec-support (3.13.7)
110
- rubocop (1.84.0)
132
+ rubocop (1.86.0)
111
133
  json (~> 2.3)
112
134
  language_server-protocol (~> 3.17.0.2)
113
135
  lint_roller (~> 1.1.0)
@@ -118,7 +140,7 @@ GEM
118
140
  rubocop-ast (>= 1.49.0, < 2.0)
119
141
  ruby-progressbar (~> 1.7)
120
142
  unicode-display_width (>= 2.4.0, < 4.0)
121
- rubocop-ast (1.49.0)
143
+ rubocop-ast (1.49.1)
122
144
  parser (>= 3.3.7.2)
123
145
  prism (~> 1.7)
124
146
  rubocop-capybara (2.22.1)
@@ -146,29 +168,28 @@ GEM
146
168
  simplecov_json_formatter (~> 0.1)
147
169
  simplecov-html (0.13.2)
148
170
  simplecov_json_formatter (0.1.4)
171
+ stringio (3.2.0)
172
+ tsort (0.2.0)
149
173
  tzinfo (2.0.6)
150
174
  concurrent-ruby (~> 1.0)
151
175
  unicode-display_width (3.2.0)
152
176
  unicode-emoji (~> 4.1)
153
177
  unicode-emoji (4.2.0)
154
178
  uri (1.1.1)
155
- webmock (3.26.1)
179
+ webmock (3.26.2)
156
180
  addressable (>= 2.8.0)
157
181
  crack (>= 0.3.2)
158
182
  hashdiff (>= 0.4.0, < 2.0.0)
159
- zeitwerk (2.7.4)
183
+ zeitwerk (2.7.5)
160
184
 
161
185
  PLATFORMS
162
186
  arm64-darwin-23
163
187
  arm64-darwin-24
164
- x86_64-darwin-19
165
- x86_64-darwin-20
166
- x86_64-darwin-21
167
- x86_64-darwin-22
188
+ arm64-darwin-25
168
189
  x86_64-linux
169
190
 
170
191
  DEPENDENCIES
171
- byebug
192
+ debug
172
193
  folio_client!
173
194
  rake (~> 13.0)
174
195
  rspec (~> 3.0)
@@ -182,4 +203,4 @@ DEPENDENCIES
182
203
  webmock
183
204
 
184
205
  BUNDLED WITH
185
- 4.0.5
206
+ 4.0.9
data/README.md CHANGED
@@ -25,9 +25,10 @@ require 'folio_client'
25
25
 
26
26
  # this will configure the client and request an access token
27
27
  client = FolioClient.configure(
28
- url: 'https://okapi-dev.stanford.edu',
28
+ url: 'https://folio-dev.stanford.edu',
29
29
  login_params: { username: 'xxx', password: 'yyy' },
30
- okapi_headers: { 'X-Okapi-Tenant': 'sul', 'User-Agent': 'FolioApiClient' }
30
+ tenant_id: 'sul',
31
+ user_agent: 'FolioApiClient'
31
32
  )
32
33
 
33
34
  response = client.get('/organizations/organizations', {query_string_param: 'abcdef'})
@@ -43,7 +44,7 @@ require 'folio_client'
43
44
  client = FolioClient.configure(
44
45
  url: Settings.okapi.url,
45
46
  login_params: Settings.okapi.login_params,
46
- okapi_headers: Settings.okapi.headers,
47
+ ...
47
48
  )
48
49
  ```
49
50
 
@@ -163,6 +164,86 @@ client.user_details(id: 'bbbadd51-c2f1-4107-a54d-52b39087725c')
163
164
  => {"username"=>"testing",
164
165
  "id"=>"bbbadd51-c2f1-4107-a54d-52b39087725c",
165
166
  "externalSystemId"=>"00324439", ... # same response as above, but for single user
167
+
168
+ # Get location details by UUID (useful for checking campusId when creating holdings)
169
+ # see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/p/location.html#locations__id__get
170
+ client.fetch_location(location_id: 'd9cd0bed-1b49-4b5e-a7bd-064b8d177231')
171
+ => {"id"=>"d9cd0bed-1b49-4b5e-a7bd-064b8d177231",
172
+ "name"=>"Miller General Stacks",
173
+ "code"=>"UA/CB/LC/GS",
174
+ "isActive"=>true,
175
+ "description"=>"The very general stacks of Miller",
176
+ "discoveryDisplayName"=>"Miller General",
177
+ "institutionId"=>"4b2a3d97-01c3-4ef3-98a5-ae4e853429b4",
178
+ "campusId"=>"b595d838-b1d5-409e-86ac-af3b41bde0be",
179
+ "libraryId"=>"e2889f93-92f2-4937-b944-5452a575367e",
180
+ "details"=>{"a"=>"b", "foo"=>"bar"},
181
+ "primaryServicePoint"=>"79faacf1-4ba4-42c7-8b2a-566b259e4641",
182
+ "servicePointIds"=>["79faacf1-4ba4-42c7-8b2a-566b259e4641"]}
183
+
184
+ # Get holdings records for an instance by HRID (useful for checking permanentLocationId and discoverySuppress)
185
+ # see https://github.com/folio-org/mod-search#search-api
186
+ client.fetch_holdings(hrid: 'in00000000067')
187
+ => [{"id"=>"7f89e96c-478c-4ca2-bb85-0a1c5b0c6f3e",
188
+ "instanceId"=>"5108040a-65bc-40ed-bd50-265958301ce4",
189
+ "permanentLocationId"=>"d9cd0bed-1b49-4b5e-a7bd-064b8d177231",
190
+ "discoverySuppress"=>false,
191
+ "hrid"=>"ho00000000010",
192
+ "holdingsTypeId"=>"03c9c400-b9e3-4a07-ac0e-05ab470233ed",
193
+ "callNumber"=>"ABC 123"},
194
+ {"id"=>"8a89e96c-478c-4ca2-bb85-0a1c5b0c6f3f",
195
+ "instanceId"=>"5108040a-65bc-40ed-bd50-265958301ce4",
196
+ "permanentLocationId"=>"b595d838-b1d5-409e-86ac-af3b41bde0be",
197
+ "discoverySuppress"=>true,
198
+ "hrid"=>"ho00000000011",
199
+ "holdingsTypeId"=>"03c9c400-b9e3-4a07-ac0e-05ab470233ed",
200
+ "callNumber"=>"DEF 456"}]
201
+
202
+ # Update a holdings record for an instance HRID
203
+ holdings_record =
204
+ { 'id' => '7f89e96c-478c-4ca2-bb85-0a1c5b0c6f3e',
205
+ '_version' => 1,
206
+ 'sourceId' => 'f32d531e-df79-46b3-8932-cdd35f7a2264',
207
+ 'hrid' => 'ah1994253_1',
208
+ 'holdingsTypeId' => '5684e4a3-9279-4463-b6ee-20ae21bbec07',
209
+ 'instanceId' => '54ec1f1a-d039-5a39-95f2-71df00061664',
210
+ 'permanentLocationId' => '4573e824-9273-4f13-972f-cff7bf504217',
211
+ 'effectiveLocationId' => '4573e824-9273-4f13-972f-cff7bf504217',
212
+ 'discoverySuppress' => false }
213
+ client.update_holdings(holdings_id: '7f89e96c-478c-4ca2-bb85-0a1c5b0c6f3e', holdings_record:)
214
+
215
+ #Create a holdings record
216
+ holdings_record =
217
+ { "instance_id" => "f1b301ce-f5d2-53b5-85eb-e4452bb5a591",
218
+ "permanent_location_id" => '1b14e21c-8d47-45c7-bc49-456a0086422b',
219
+ "source_id" => "f32d531e-df79-46b3-8932-cdd35f7a2264",
220
+ "holdings_type_id" => "996f93e2-5b5e-4cf2-9168-33ced1f95eed",
221
+ "discovery_suppress" => false }
222
+ client.create_holdings(holdings_record:)
223
+ => {
224
+ "id" => "c65bb9dc-ebca-41fc-9c50-0d39085c1987",
225
+ "_version" => 1,
226
+ "sourceId" => "f32d531e-df79-46b3-8932-cdd35f7a2264",
227
+ "hrid" => "ho00000927052",
228
+ "holdingsTypeId" => "996f93e2-5b5e-4cf2-9168-33ced1f95eed",
229
+ "formerIds" => [],
230
+ "instanceId" => "54ec1f1a-d039-5a39-95f2-71df00061664",
231
+ "permanentLocationId" => "1b14e21c-8d47-45c7-bc49-456a0086422b",
232
+ "effectiveLocationId" => "1b14e21c-8d47-45c7-bc49-456a0086422b",
233
+ "electronicAccess" => [],
234
+ "administrativeNotes" => [],
235
+ "notes" => [],
236
+ "holdingsStatements" => [],
237
+ "holdingsStatementsForIndexes" => [],
238
+ "holdingsStatementsForSupplements" => [],
239
+ "discoverySuppress" => false,
240
+ "statisticalCodeIds" => [],
241
+ "metadata" =>
242
+ {"createdDate" => "2026-03-12T18:58:03.576+00:00",
243
+ "createdByUserId" => "709fdac6-d3f3-5784-8839-fe36ad6ed0b3",
244
+ "updatedDate" => "2026-03-12T18:58:03.576+00:00",
245
+ "updatedByUserId" => "709fdac6-d3f3-5784-8839-fe36ad6ed0b3"}
246
+ }
166
247
  ```
167
248
 
168
249
  ## Development
data/api_test.rb CHANGED
@@ -13,10 +13,8 @@ client =
13
13
  username: ENV.fetch('OKAPI_USER', nil),
14
14
  password: ENV.fetch('OKAPI_PASSWORD', nil)
15
15
  },
16
- okapi_headers: {
17
- 'X-Okapi-Tenant': ENV.fetch('OKAPI_TENANT', nil),
18
- 'User-Agent': 'folio_client gem (testing)'
19
- }
16
+ tenant_id: ENV.fetch('OKAPI_TENANT', nil),
17
+ user_agent: 'folio_client gem (testing)'
20
18
  )
21
19
 
22
20
  pp(client.fetch_marc_hash(instance_hrid: 'a666'))
data/folio_client.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |spec|
13
13
  spec.summary = 'Interface for interacting with the Folio ILS API.'
14
14
  spec.description = 'This provides API interaction with the Folio ILS API'
15
15
  spec.homepage = 'https://github.com/sul-dlss/folio_client'
16
- spec.required_ruby_version = '>= 3.0.0'
16
+ spec.required_ruby_version = '>= 3.4'
17
17
 
18
18
  spec.metadata['homepage_uri'] = spec.homepage
19
19
  spec.metadata['source_code_uri'] = 'https://github.com/sul-dlss/folio_client'
@@ -32,10 +32,12 @@ Gem::Specification.new do |spec|
32
32
  spec.require_paths = ['lib']
33
33
 
34
34
  spec.add_dependency 'activesupport', '>= 4.2'
35
+ spec.add_dependency 'deprecation', '>= 0'
35
36
  spec.add_dependency 'dry-monads'
36
37
  spec.add_dependency 'faraday'
37
38
  spec.add_dependency 'faraday-cookie_jar'
38
39
  spec.add_dependency 'marc'
40
+ spec.add_dependency 'ostruct'
39
41
  spec.add_dependency 'zeitwerk'
40
42
 
41
43
  spec.add_development_dependency 'rake', '~> 13.0'
@@ -3,27 +3,26 @@
3
3
  class FolioClient
4
4
  # Fetch a token from the Folio API using login_params
5
5
  class Authenticator
6
- def self.token
7
- new.token
8
- end
6
+ LOGIN_ENDPOINT = '/authn/login-with-expiry'
9
7
 
10
8
  # Request an access_token
11
- def token
12
- response = FolioClient.connection.post(login_endpoint, FolioClient.config.login_params.to_json)
9
+ #
10
+ # @raise [UnauthorizedError] if the response is not successful or if the
11
+ # @return [String] the access token
12
+ def self.refresh_token!
13
+ response = FolioClient.connection.post(LOGIN_ENDPOINT, FolioClient.config.login_params.to_json)
13
14
 
14
15
  UnexpectedResponse.call(response) unless response.success?
15
16
 
16
17
  access_cookie = FolioClient.cookie_jar.cookies.find { |cookie| cookie.name == 'folioAccessToken' }
17
18
 
18
- raise StandardError, "Problem with folioAccessToken cookie: #{response.headers}, #{response.body}" unless access_cookie
19
+ # NOTE: The client typically delegates raising exceptions (based on HTTP
20
+ # responses) to the UnexpectedResponse class, but this call in
21
+ # Authenticator is a one-off, unlike any other in the app, so we
22
+ # allow it to customize its exception handling.
23
+ raise UnauthorizedError, "Problem with folioAccessToken cookie: #{response.headers}, #{response.body}" unless access_cookie
19
24
 
20
25
  access_cookie.value
21
26
  end
22
-
23
- private
24
-
25
- def login_endpoint
26
- '/authn/login-with-expiry'
27
- end
28
27
  end
29
28
  end
@@ -64,6 +64,54 @@ class FolioClient
64
64
  false
65
65
  end
66
66
 
67
+ # Get location details by UUID
68
+ # @param location_id [String] UUID of the location
69
+ # @return [Hash] location data including campusId and other location information
70
+ # @raise [ResourceNotFound] if location with the given UUID is not found
71
+ def fetch_location(location_id:)
72
+ client.get("/locations/#{location_id}")
73
+ end
74
+
75
+ # Get holdings records for an instance by HRID
76
+ # @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/p/inventory-view.html
77
+ # @param hrid [String] instance HRID
78
+ # @return [Array<Hash>] array of holdings records with permanentLocationId, _version, discoverySuppress, and other fields
79
+ def fetch_holdings(hrid:)
80
+ instance_uuid = fetch_external_id(hrid: hrid)
81
+ instance_response = client.get('/inventory-view/instances', { query: "id==#{instance_uuid}" })
82
+
83
+ instance_response.dig('instances', 0, 'holdingsRecords') || []
84
+ end
85
+
86
+ # Put an updated holdings record
87
+ # @see https://s3.amazonaws.com/foliodocs/api/mod-inventory/p/inventory.html#inventory_holdings__holdingsid__put
88
+ # @param holdings_id [String] UUID of the holdings record to update
89
+ # @param holdings_record [Hash] holdings record
90
+ # @raise [ResourceNotFound] if holdings record with the given UUID is not found
91
+ # @raise [BadRequestError] may occur if the update includes invalid fields or values
92
+ # @raise [UnexpectedResponse] if the API returns some other error response
93
+ def update_holdings(holdings_id:, holdings_record:)
94
+ client.put("/inventory/holdings/#{holdings_id}", holdings_record, exception_subject: "holdings record with ID #{holdings_id}")
95
+ end
96
+
97
+ # Post a new holdings record
98
+ # @param holdings_record [Hash] holdings record
99
+ # @return [Hash] the created holdings record, including its id and other fields
100
+ # @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/p/holdings-storage.html#holdings_storage_holdings_post
101
+ def create_holdings(holdings_record:)
102
+ required = %w[instance_id permanent_location_id source_id holdings_type_id]
103
+ missing = required.select { |field| !holdings_record.key?(field) || holdings_record[field].blank? }
104
+ raise ArgumentError, "Missing required fields: #{missing.join(', ')}" if missing.any?
105
+
106
+ client.post('/holdings-storage/holdings', {
107
+ instanceId: holdings_record['instance_id'],
108
+ permanentLocationId: holdings_record['permanent_location_id'],
109
+ sourceId: holdings_record['source_id'],
110
+ holdingsTypeId: holdings_record['holdings_type_id'],
111
+ discoverySuppress: false
112
+ })
113
+ end
114
+
67
115
  private
68
116
 
69
117
  def client
@@ -98,6 +98,10 @@ class FolioClient
98
98
  def check_not_found(result, index, max_checks)
99
99
  return unless result.failure? && result.failure == :not_found && index > max_checks
100
100
 
101
+ # NOTE: The client typically delegates raising exceptions (based on HTTP
102
+ # responses) to the UnexpectedResponse class, but the interaction in
103
+ # JobStatus is more complex due to waits/loops, so we allow this
104
+ # class to do some of its own exception handling.
101
105
  raise ResourceNotFound,
102
106
  "Job #{job_execution_id} not found after #{index} retries. The data import job may still have completed."
103
107
  end
@@ -21,7 +21,7 @@ class FolioClient
21
21
  "Expected 1 record for #{instance_hrid}, but found #{record_count}"
22
22
  end
23
23
 
24
- response_hash['sourceRecords'].first['parsedRecord']['content']
24
+ response_hash.dig('sourceRecords', 0, 'parsedRecord', 'content')
25
25
  end
26
26
 
27
27
  # get marc bib data as MARCXML from folio given an instance HRID
@@ -30,9 +30,7 @@ class FolioClient
30
30
  # @return [String] MARCXML string
31
31
  # @raise [ResourceNotFound]
32
32
  # @raise [MultipleResourcesFound]
33
- # rubocop:disable Metrics/MethodLength
34
- # rubocop:disable Metrics/AbcSize
35
- def fetch_marc_xml(instance_hrid: nil, barcode: nil)
33
+ def fetch_marc_xml(instance_hrid: nil, barcode: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
36
34
  if barcode.nil? && instance_hrid.nil?
37
35
  raise ArgumentError,
38
36
  'Either a barcode or a Folio instance HRID must be provided'
@@ -62,8 +60,6 @@ class FolioClient
62
60
  updated_marc.fields << MARC::ControlField.new('003', 'FOLIO')
63
61
  updated_marc.to_xml.to_s
64
62
  end
65
- # rubocop:enable Metrics/MethodLength
66
- # rubocop:enable Metrics/AbcSize
67
63
 
68
64
  private
69
65
 
@@ -4,16 +4,18 @@ class FolioClient
4
4
  # Handles unexpected responses when communicating with Folio
5
5
  class UnexpectedResponse
6
6
  # @param [Faraday::Response] response
7
- # rubocop:disable Metrics/MethodLength
8
- # rubocop:disable Metrics/AbcSize
9
- def self.call(response)
7
+ def self.call(response, **kwargs) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
8
+ subject = kwargs.fetch(:exception_subject, 'resource')
9
+
10
10
  case response.status
11
+ when 400
12
+ raise BadRequestError, "Bad request for #{subject}: #{response.body}"
11
13
  when 401
12
14
  raise UnauthorizedError, "There was a problem with the access token: #{response.body}"
13
15
  when 403
14
16
  raise ForbiddenError, "The operation requires privileges which the client does not have: #{response.body}"
15
17
  when 404
16
- raise ResourceNotFound, "Endpoint not found or resource does not exist: #{response.body}"
18
+ raise ResourceNotFound, "Endpoint not found or #{subject} does not exist: #{response.body}"
17
19
  when 409
18
20
  raise ConflictError, "Resource cannot be updated: #{response.body}"
19
21
  when 422
@@ -21,10 +23,8 @@ class FolioClient
21
23
  when 500
22
24
  raise ServiceUnavailable, "The remote server returned an internal server error: #{response.body}"
23
25
  else
24
- raise StandardError, "Unexpected response: #{response.status} #{response.body}"
26
+ raise Error, "Unexpected response: #{response.status} #{response.body}"
25
27
  end
26
28
  end
27
29
  end
28
- # rubocop:enable Metrics/MethodLength
29
- # rubocop:enable Metrics/AbcSize
30
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class FolioClient
4
- VERSION = '0.19.0'
4
+ VERSION = '0.21.0'
5
5
  end
data/lib/folio_client.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'http/cookie' # Workaround for https://github.com/sparklemotion/http-cookie/issues/62
3
4
  require 'active_support/core_ext/module/delegation'
4
5
  require 'active_support/core_ext/object/blank'
6
+ require 'deprecation'
5
7
  require 'faraday'
6
8
  require 'faraday-cookie_jar'
7
9
  require 'marc'
@@ -41,47 +43,56 @@ class FolioClient
41
43
  # Error raised when the Folio API returns a 409 Conflict
42
44
  class ConflictError < Error; end
43
45
 
44
- DEFAULT_HEADERS = {
45
- accept: 'application/json, text/plain',
46
- content_type: 'application/json'
47
- }.freeze
46
+ # Error raised when the Folio API returns a 400 Bad Request
47
+ class BadRequestError < Error; end
48
48
 
49
49
  class << self
50
+ extend Deprecation
51
+
52
+ Config = Struct.new('Config', :url, :login_params, :timeout, :tenant_id, :user_agent) do
53
+ def headers
54
+ {
55
+ accept: 'application/json, text/plain',
56
+ content_type: 'application/json',
57
+ user_agent: user_agent,
58
+ 'X-Okapi-Tenant': tenant_id
59
+ }
60
+ end
61
+ end
62
+
50
63
  # @param url [String] the folio API URL
51
64
  # @param login_params [Hash] the folio client login params (username:, password:)
52
- # @param okapi_headers [Hash] the okapi specific headers to add (X-Okapi-Tenant:, User-Agent:)
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.
53
73
  # @return [FolioClient] the configured Singleton class
54
- def configure(url:, login_params:, okapi_headers:, timeout: default_timeout, **)
55
- # rubocop:disable Style/OpenStructUse
56
- instance.config = OpenStruct.new(
57
- # For the initial token, use a dummy value to avoid hitting any APIs
58
- # during configuration, allowing `with_token_refresh_when_unauthorized` to handle
59
- # auto-magic token refreshing. Why not immediately get a valid token? Our apps
60
- # commonly invoke client `.configure` methods in the initializer in all
61
- # application environments, even those that are never expected to
62
- # connect to production APIs, such as local development machines.
63
- #
64
- # NOTE: `nil` and blank string cannot be used as dummy values here as
65
- # they lead to a malformed request to be sent, which triggers an
66
- # exception not rescued by `with_token_refresh_when_unauthorized`
67
- token: 'a temporary dummy token to avoid hitting the API before it is needed',
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
+
78
+ instance.config = Config.new(
68
79
  url: url,
69
80
  login_params: login_params,
70
- okapi_headers: okapi_headers,
71
- timeout: timeout
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')
72
84
  )
73
- # rubocop:enable Style/OpenStructUse
74
85
 
75
86
  self
76
87
  end
77
88
 
78
- delegate :config, :connection, :cookie_jar, :data_import, :default_timeout,
79
- :edit_marc_json, :fetch_external_id, :fetch_hrid,
80
- :fetch_instance_info, :fetch_marc_hash, :fetch_marc_xml,
81
- :force_token_refresh!, :get, :has_instance_status?,
82
- :http_get_headers, :http_post_and_put_headers, :interface_details,
83
- :job_profiles, :organization_interfaces, :organizations, :users,
84
- :user_details, :post, :put, to: :instance
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
85
96
  end
86
97
 
87
98
  attr_accessor :config
@@ -89,69 +100,54 @@ class FolioClient
89
100
  # Send an authenticated get request
90
101
  # @param path [String] the path to the Folio API request
91
102
  # @param params [Hash] params to get to the API
103
+ # @return [Hash, nil] the parsed response body or nil
92
104
  def get(path, params = {})
93
105
  response = with_token_refresh_when_unauthorized do
94
- connection.get(path, params, { 'x-okapi-token': config.token })
106
+ connection.get(path, params)
95
107
  end
96
108
 
97
109
  UnexpectedResponse.call(response) unless response.success?
98
110
 
99
- return nil if response.body.blank?
100
-
101
- JSON.parse(response.body)
111
+ JSON.parse(response.body) if response.body.present?
102
112
  end
103
113
 
104
114
  # Send an authenticated post request
105
115
  # If the body is JSON, it will be automatically serialized
106
116
  # @param path [String] the path to the Folio API request
107
117
  # @param body [Object] body to post to the API as JSON
108
- # rubocop:disable Metrics/MethodLength
118
+ # @return [Hash, nil] the parsed response body or nil
109
119
  def post(path, body = nil, content_type: 'application/json')
110
120
  req_body = content_type == 'application/json' ? body&.to_json : body
111
121
  response = with_token_refresh_when_unauthorized do
112
- req_headers = {
113
- 'x-okapi-token': config.token,
114
- 'content-type': content_type
115
- }
116
- connection.post(path, req_body, req_headers)
122
+ connection.post(path, req_body, { content_type: content_type })
117
123
  end
118
124
 
119
125
  UnexpectedResponse.call(response) unless response.success?
120
126
 
121
- return nil if response.body.blank?
122
-
123
- JSON.parse(response.body)
127
+ JSON.parse(response.body) if response.body.present?
124
128
  end
125
- # rubocop:enable Metrics/MethodLength
126
129
 
127
130
  # Send an authenticated put request
128
131
  # If the body is JSON, it will be automatically serialized
129
132
  # @param path [String] the path to the Folio API request
130
133
  # @param body [Object] body to put to the API as JSON
131
- # rubocop:disable Metrics/MethodLength
132
- def put(path, body = nil, content_type: 'application/json')
134
+ # @return [Hash, nil] the parsed response body or nil
135
+ def put(path, body = nil, content_type: 'application/json', **exception_args)
133
136
  req_body = content_type == 'application/json' ? body&.to_json : body
134
137
  response = with_token_refresh_when_unauthorized do
135
- req_headers = {
136
- 'x-okapi-token': config.token,
137
- 'content-type': content_type
138
- }
139
- connection.put(path, req_body, req_headers)
138
+ connection.put(path, req_body, { content_type: content_type })
140
139
  end
141
140
 
142
- UnexpectedResponse.call(response) unless response.success?
143
-
144
- return nil if response.body.blank?
141
+ UnexpectedResponse.call(response, **exception_args) unless response.success?
145
142
 
146
- JSON.parse(response.body)
143
+ JSON.parse(response.body) if response.body.present?
147
144
  end
148
- # rubocop:enable Metrics/MethodLength
149
145
 
150
146
  # the base connection to the Folio API
151
147
  def connection
152
148
  @connection ||= Faraday.new(
153
149
  url: config.url,
154
- headers: DEFAULT_HEADERS.merge(config.okapi_headers || {}),
150
+ headers: config.headers,
155
151
  request: { timeout: config.timeout }
156
152
  ) do |faraday|
157
153
  faraday.use :cookie_jar, jar: cookie_jar
@@ -186,6 +182,34 @@ class FolioClient
186
182
  .fetch_instance_info(...)
187
183
  end
188
184
 
185
+ # @see Inventory#fetch_location
186
+ def fetch_location(...)
187
+ Inventory
188
+ .new
189
+ .fetch_location(...)
190
+ end
191
+
192
+ # @see Inventory#fetch_holdings
193
+ def fetch_holdings(...)
194
+ Inventory
195
+ .new
196
+ .fetch_holdings(...)
197
+ end
198
+
199
+ # @see Inventory#update_holdings
200
+ def update_holdings(...)
201
+ Inventory
202
+ .new
203
+ .update_holdings(...)
204
+ end
205
+
206
+ # @see Inventory#create_holdings
207
+ def create_holdings(...)
208
+ Inventory
209
+ .new
210
+ .create_holdings(...)
211
+ end
212
+
189
213
  # @see SourceStorage#fetch_marc_hash
190
214
  def fetch_marc_hash(...)
191
215
  SourceStorage
@@ -264,11 +288,15 @@ class FolioClient
264
288
  end
265
289
 
266
290
  def default_timeout
267
- 120
291
+ 180
292
+ end
293
+
294
+ def default_user_agent
295
+ "folio_client #{FolioClient::VERSION}"
268
296
  end
269
297
 
270
298
  def force_token_refresh!
271
- config.token = Authenticator.token
299
+ Authenticator.refresh_token!
272
300
  end
273
301
 
274
302
  private
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: folio_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Mangiafico
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-06 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '4.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: deprecation
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: dry-monads
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +93,20 @@ dependencies:
79
93
  - - ">="
80
94
  - !ruby/object:Gem::Version
81
95
  version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: ostruct
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
82
110
  - !ruby/object:Gem::Dependency
83
111
  name: zeitwerk
84
112
  requirement: !ruby/object:Gem::Requirement
@@ -274,14 +302,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
274
302
  requirements:
275
303
  - - ">="
276
304
  - !ruby/object:Gem::Version
277
- version: 3.0.0
305
+ version: '3.4'
278
306
  required_rubygems_version: !ruby/object:Gem::Requirement
279
307
  requirements:
280
308
  - - ">="
281
309
  - !ruby/object:Gem::Version
282
310
  version: '0'
283
311
  requirements: []
284
- rubygems_version: 3.6.2
312
+ rubygems_version: 4.0.8
285
313
  specification_version: 4
286
314
  summary: Interface for interacting with the Folio ILS API.
287
315
  test_files: []