folio_client 0.6.1 → 0.8.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 +4 -4
- data/.rspec +0 -1
- data/.rubocop/custom.yml +20 -0
- data/.rubocop_todo.yml +1 -24
- data/Gemfile.lock +24 -7
- data/README.md +21 -0
- data/Rakefile +2 -4
- data/folio_client.gemspec +2 -0
- data/lib/folio_client/data_import.rb +58 -0
- data/lib/folio_client/holdings.rb +25 -0
- data/lib/folio_client/inventory.rb +27 -0
- data/lib/folio_client/job_status.rb +93 -0
- data/lib/folio_client/records_editor.rb +41 -0
- data/lib/folio_client/unexpected_response.rb +1 -1
- data/lib/folio_client/version.rb +1 -1
- data/lib/folio_client.rb +79 -12
- metadata +34 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 726191de10d2f5974ac8294f2c4f93a75146631dd2c86200d2de53f9c72cd76f
|
|
4
|
+
data.tar.gz: 2ec95c6e87c4541fb1b2b7050c2bd84d5bd3a46f948b0b711b8f042f58ddf6e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce0de774a34bbc7eda7a97d26e9ac12d49297a804994425045fe7a8b85017401f54dd11fd43952e4f75e799e232979475f3881eb6f9a211e6f4e32df48168424
|
|
7
|
+
data.tar.gz: eb31f66db330ab973060794cfcfb30471c02d21529c5242f62fc11a5d97542f1ce13587af5f8d6b2057d5b709951f9c49690725937e39f3d84236df144da3b73
|
data/.rspec
CHANGED
data/.rubocop/custom.yml
CHANGED
|
@@ -9,6 +9,8 @@ AllCops:
|
|
|
9
9
|
# Per team developer playbook
|
|
10
10
|
RSpec/MultipleMemoizedHelpers:
|
|
11
11
|
Enabled: false
|
|
12
|
+
RSpec/MultipleExpectations:
|
|
13
|
+
Max: 3
|
|
12
14
|
|
|
13
15
|
RSpec/BeEq: # new in 2.9.0
|
|
14
16
|
Enabled: true
|
|
@@ -40,3 +42,21 @@ RSpec/Rails/HaveHttpStatus: # new in 2.12
|
|
|
40
42
|
Enabled: true
|
|
41
43
|
RSpec/Rails/InferredSpecType: # new in 2.14
|
|
42
44
|
Enabled: true
|
|
45
|
+
Capybara/MatchStyle: # new in 2.17
|
|
46
|
+
Enabled: true
|
|
47
|
+
Capybara/NegationMatcher: # new in 2.14
|
|
48
|
+
Enabled: true
|
|
49
|
+
Capybara/SpecificActions: # new in 2.14
|
|
50
|
+
Enabled: true
|
|
51
|
+
Capybara/SpecificFinders: # new in 2.13
|
|
52
|
+
Enabled: true
|
|
53
|
+
Capybara/SpecificMatcher: # new in 2.12
|
|
54
|
+
Enabled: true
|
|
55
|
+
RSpec/DuplicatedMetadata: # new in 2.16
|
|
56
|
+
Enabled: true
|
|
57
|
+
RSpec/PendingWithoutReason: # new in 2.16
|
|
58
|
+
Enabled: true
|
|
59
|
+
RSpec/FactoryBot/FactoryNameStyle: # new in 2.16
|
|
60
|
+
Enabled: true
|
|
61
|
+
RSpec/Rails/MinitestAssertions: # new in 2.17
|
|
62
|
+
Enabled: true
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,30 +1,7 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2023-
|
|
3
|
+
# on 2023-03-03 17:57:09 UTC using RuboCop version 1.44.1.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
7
7
|
# versions of RuboCop, may require this file to be generated again.
|
|
8
|
-
|
|
9
|
-
# Offense count: 20
|
|
10
|
-
# This cop supports safe autocorrection (--autocorrect).
|
|
11
|
-
# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces.
|
|
12
|
-
# SupportedStyles: space, no_space, compact
|
|
13
|
-
# SupportedStylesForEmptyBraces: space, no_space
|
|
14
|
-
Layout/SpaceInsideHashLiteralBraces:
|
|
15
|
-
Exclude:
|
|
16
|
-
- 'lib/folio_client.rb'
|
|
17
|
-
- 'spec/folio_client/authenticator_spec.rb'
|
|
18
|
-
- 'spec/folio_client_spec.rb'
|
|
19
|
-
|
|
20
|
-
# Offense count: 1
|
|
21
|
-
# This cop supports safe autocorrection (--autocorrect).
|
|
22
|
-
# Configuration parameters: EnforcedStyle.
|
|
23
|
-
# SupportedStyles: be, be_nil
|
|
24
|
-
RSpec/BeNil:
|
|
25
|
-
Exclude:
|
|
26
|
-
- 'spec/folio_client_spec.rb'
|
|
27
|
-
|
|
28
|
-
# Offense count: 1
|
|
29
|
-
RSpec/MultipleExpectations:
|
|
30
|
-
Max: 3
|
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
folio_client (0.
|
|
4
|
+
folio_client (0.8.0)
|
|
5
5
|
activesupport (>= 4.2, < 8)
|
|
6
|
+
dry-monads
|
|
6
7
|
faraday
|
|
8
|
+
marc
|
|
7
9
|
zeitwerk
|
|
8
10
|
|
|
9
11
|
GEM
|
|
@@ -23,6 +25,13 @@ GEM
|
|
|
23
25
|
rexml
|
|
24
26
|
diff-lcs (1.5.0)
|
|
25
27
|
docile (1.4.0)
|
|
28
|
+
dry-core (1.0.0)
|
|
29
|
+
concurrent-ruby (~> 1.0)
|
|
30
|
+
zeitwerk (~> 2.6)
|
|
31
|
+
dry-monads (1.6.0)
|
|
32
|
+
concurrent-ruby (~> 1.0)
|
|
33
|
+
dry-core (~> 1.0, < 2)
|
|
34
|
+
zeitwerk (~> 2.6)
|
|
26
35
|
faraday (2.7.4)
|
|
27
36
|
faraday-net_http (>= 2.0, < 3.1)
|
|
28
37
|
ruby2_keywords (>= 0.0.4)
|
|
@@ -32,9 +41,13 @@ GEM
|
|
|
32
41
|
concurrent-ruby (~> 1.0)
|
|
33
42
|
json (2.6.3)
|
|
34
43
|
language_server-protocol (3.17.0.3)
|
|
35
|
-
|
|
44
|
+
marc (1.2.0)
|
|
45
|
+
rexml
|
|
46
|
+
scrub_rb (>= 1.0.1, < 2)
|
|
47
|
+
unf
|
|
48
|
+
minitest (5.18.0)
|
|
36
49
|
parallel (1.22.1)
|
|
37
|
-
parser (3.2.1.
|
|
50
|
+
parser (3.2.1.1)
|
|
38
51
|
ast (~> 2.4.1)
|
|
39
52
|
public_suffix (5.0.1)
|
|
40
53
|
rainbow (3.1.1)
|
|
@@ -50,7 +63,7 @@ GEM
|
|
|
50
63
|
rspec-expectations (3.12.2)
|
|
51
64
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
52
65
|
rspec-support (~> 3.12.0)
|
|
53
|
-
rspec-mocks (3.12.
|
|
66
|
+
rspec-mocks (3.12.4)
|
|
54
67
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
55
68
|
rspec-support (~> 3.12.0)
|
|
56
69
|
rspec-support (3.12.0)
|
|
@@ -64,18 +77,19 @@ GEM
|
|
|
64
77
|
rubocop-ast (>= 1.24.1, < 2.0)
|
|
65
78
|
ruby-progressbar (~> 1.7)
|
|
66
79
|
unicode-display_width (>= 2.4.0, < 3.0)
|
|
67
|
-
rubocop-ast (1.
|
|
80
|
+
rubocop-ast (1.27.0)
|
|
68
81
|
parser (>= 3.2.1.0)
|
|
69
82
|
rubocop-capybara (2.17.1)
|
|
70
83
|
rubocop (~> 1.41)
|
|
71
84
|
rubocop-performance (1.15.2)
|
|
72
85
|
rubocop (>= 1.7.0, < 2.0)
|
|
73
86
|
rubocop-ast (>= 0.4.0)
|
|
74
|
-
rubocop-rspec (2.
|
|
87
|
+
rubocop-rspec (2.19.0)
|
|
75
88
|
rubocop (~> 1.33)
|
|
76
89
|
rubocop-capybara (~> 2.17)
|
|
77
|
-
ruby-progressbar (1.
|
|
90
|
+
ruby-progressbar (1.13.0)
|
|
78
91
|
ruby2_keywords (0.0.5)
|
|
92
|
+
scrub_rb (1.0.1)
|
|
79
93
|
simplecov (0.22.0)
|
|
80
94
|
docile (~> 1.1)
|
|
81
95
|
simplecov-html (~> 0.11)
|
|
@@ -88,6 +102,9 @@ GEM
|
|
|
88
102
|
rubocop-performance (= 1.15.2)
|
|
89
103
|
tzinfo (2.0.6)
|
|
90
104
|
concurrent-ruby (~> 1.0)
|
|
105
|
+
unf (0.1.4)
|
|
106
|
+
unf_ext
|
|
107
|
+
unf_ext (0.0.8.2)
|
|
91
108
|
unicode-display_width (2.4.2)
|
|
92
109
|
webmock (3.18.1)
|
|
93
110
|
addressable (>= 2.8.0)
|
data/README.md
CHANGED
|
@@ -70,6 +70,27 @@ client.fetch_marc_hash(instance_hrid: "a7927874")
|
|
|
70
70
|
=> {"fields"=>
|
|
71
71
|
[{"003"=>"FOLIO"}....]
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
# Import a MARC record
|
|
75
|
+
data_importer = client.data_import(marc: my_marc, job_profile_id: '4ba4f4ab', job_profile_name: 'ETDs')
|
|
76
|
+
# If called too quickly, might get Failure(:not_found)
|
|
77
|
+
data_importer.status
|
|
78
|
+
=> Failure(:pending)
|
|
79
|
+
data_importer.wait_until_complete
|
|
80
|
+
=> Success()
|
|
81
|
+
data_importer.instance_hrid
|
|
82
|
+
=> Success("in00000000010")
|
|
83
|
+
|
|
84
|
+
# Create a Holdings record
|
|
85
|
+
holdings_client = client.holdings(instance_id: "99a6d818-d523-42f3-9844-81cf3187dbad")
|
|
86
|
+
holdings_client.create(permanent_location_id: "1b14e21c-8d47-45c7-bc49-456a0086422b", holdings_type_id: "996f93e2-5b5e-4cf2-9168-33ced1f95eed")
|
|
87
|
+
=> {
|
|
88
|
+
"id" => "581f6289-001f-49d1-bab7-035f4d878cbd",
|
|
89
|
+
"_version" => 1,
|
|
90
|
+
"hrid" => "ho00000000065",
|
|
91
|
+
"holdingsTypeId" => "996f93e2-5b5e-4cf2-9168-33ced1f95eed"
|
|
92
|
+
...
|
|
93
|
+
}
|
|
73
94
|
```
|
|
74
95
|
|
|
75
96
|
## Development
|
data/Rakefile
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rspec/core/rake_task"
|
|
5
|
-
|
|
6
|
-
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
-
|
|
8
5
|
require "rubocop/rake_task"
|
|
9
6
|
|
|
7
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
10
8
|
RuboCop::RakeTask.new
|
|
11
9
|
|
|
12
|
-
task default: %i[spec
|
|
10
|
+
task default: %i[rubocop spec]
|
data/folio_client.gemspec
CHANGED
|
@@ -34,6 +34,8 @@ Gem::Specification.new do |spec|
|
|
|
34
34
|
spec.add_dependency "activesupport", ">= 4.2", "< 8"
|
|
35
35
|
spec.add_dependency "faraday"
|
|
36
36
|
spec.add_dependency "zeitwerk"
|
|
37
|
+
spec.add_dependency "marc"
|
|
38
|
+
spec.add_dependency "dry-monads"
|
|
37
39
|
|
|
38
40
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
39
41
|
spec.add_development_dependency "rspec", "~> 3.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "marc"
|
|
5
|
+
require "stringio"
|
|
6
|
+
|
|
7
|
+
class FolioClient
|
|
8
|
+
# Imports MARC records into FOLIO
|
|
9
|
+
class DataImport
|
|
10
|
+
# @param client [FolioClient] the configured client
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param record [MARC::Record] record to be imported
|
|
16
|
+
# @param job_profile_id [String] job profile id to use for import
|
|
17
|
+
# @param job_profile_name [String] job profile name to use for import
|
|
18
|
+
def import(marc:, job_profile_id:, job_profile_name:)
|
|
19
|
+
response_hash = client.post("/data-import/uploadDefinitions", {fileDefinitions: [{name: marc_filename}]})
|
|
20
|
+
upload_definition_id = response_hash.dig("fileDefinitions", 0, "uploadDefinitionId")
|
|
21
|
+
job_execution_id = response_hash.dig("fileDefinitions", 0, "jobExecutionId")
|
|
22
|
+
file_definition_id = response_hash.dig("fileDefinitions", 0, "id")
|
|
23
|
+
|
|
24
|
+
upload_file_response_hash = client.post("/data-import/uploadDefinitions/#{upload_definition_id}/files/#{file_definition_id}", marc_binary(marc), content_type: "application/octet-stream")
|
|
25
|
+
|
|
26
|
+
client.post(
|
|
27
|
+
"/data-import/uploadDefinitions/#{upload_definition_id}/processFiles",
|
|
28
|
+
{
|
|
29
|
+
uploadDefinition: upload_file_response_hash,
|
|
30
|
+
jobProfileInfo: {
|
|
31
|
+
id: job_profile_id,
|
|
32
|
+
name: job_profile_name,
|
|
33
|
+
dataType: "MARC"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
JobStatus.new(client, job_execution_id: job_execution_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :client, :marc, :job_profile_id, :job_profile_name
|
|
44
|
+
|
|
45
|
+
def marc_filename
|
|
46
|
+
@marc_filename ||= "#{DateTime.now.iso8601}.marc"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def marc_binary(marc)
|
|
50
|
+
StringIO.open do |io|
|
|
51
|
+
MARC::Writer.new(io) do |writer|
|
|
52
|
+
writer.write(marc)
|
|
53
|
+
end
|
|
54
|
+
io.string
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class FolioClient
|
|
4
|
+
# Manage holdings records in the Folio inventory
|
|
5
|
+
class Holdings
|
|
6
|
+
attr_accessor :client, :instance_id
|
|
7
|
+
|
|
8
|
+
# @param client [FolioClient] the configured client
|
|
9
|
+
# @param instance_id [String] the UUID of the instance to which the holdings records belongs
|
|
10
|
+
def initialize(client, instance_id:)
|
|
11
|
+
@client = client
|
|
12
|
+
@instance_id = instance_id
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param permanent_location_id [String] the UUID of the permanent location
|
|
16
|
+
# @param holdings_type_id [String] the UUID of the holdings type
|
|
17
|
+
def create(holdings_type_id:, permanent_location_id:)
|
|
18
|
+
client.post("/holdings-storage/holdings", {
|
|
19
|
+
instanceId: instance_id,
|
|
20
|
+
permanentLocationId: permanent_location_id,
|
|
21
|
+
holdingsTypeId: holdings_type_id
|
|
22
|
+
})
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -24,6 +24,33 @@ class FolioClient
|
|
|
24
24
|
result.dig("hrid")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# @param hrid [String] HRID to search by to fetch the external ID
|
|
28
|
+
# @return [String,nil] external ID if present, otherwise nil.
|
|
29
|
+
# @raise [ResourceNotFound, MultipleResourcesFound] if search does not return exactly 1 result
|
|
30
|
+
def fetch_external_id(hrid:)
|
|
31
|
+
instance_response = client.get("/search/instances", {query: "hrid==#{hrid}"})
|
|
32
|
+
record_count = instance_response["totalRecords"]
|
|
33
|
+
raise ResourceNotFound, "No matching instance found for #{hrid}" if instance_response["totalRecords"] == 0
|
|
34
|
+
raise MultipleResourcesFound, "Expected 1 record for #{hrid}, but found #{record_count}" if record_count > 1
|
|
35
|
+
|
|
36
|
+
instance_response.dig("instances", 0, "id")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Retrieve basic information about a record. Example usage: get the external ID and _version for update using
|
|
40
|
+
# optimistic locking when the HRID is available: `fetch_instance_info(hrid: 'a1234').slice('id', '_version')`
|
|
41
|
+
# (or vice versa if the external ID is available).
|
|
42
|
+
# @param external_id [String] an external ID for looking up info about the record
|
|
43
|
+
# @param hrid [String] an HRID for looking up info about the record
|
|
44
|
+
# @return [Hash] information about the record.
|
|
45
|
+
# @raise [ArgumentError] if the caller does not provide exactly one of external_id or hrid
|
|
46
|
+
def fetch_instance_info(external_id: nil, hrid: nil)
|
|
47
|
+
raise ArgumentError, "must pass exactly one of external_id or HRID" unless external_id.present? || hrid.present?
|
|
48
|
+
raise ArgumentError, "must pass exactly one of external_id or HRID" if external_id.present? && hrid.present?
|
|
49
|
+
|
|
50
|
+
external_id ||= fetch_external_id(hrid: hrid)
|
|
51
|
+
client.get("/inventory/instances/#{external_id}")
|
|
52
|
+
end
|
|
53
|
+
|
|
27
54
|
# @param hrid [String] folio instance HRID
|
|
28
55
|
# @param status_id [String] uuid for an instance status code
|
|
29
56
|
# @raise [ResourceNotFound] if search by hrid returns 0 results
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
|
|
6
|
+
class FolioClient
|
|
7
|
+
# Wraps operations waiting for results from jobs
|
|
8
|
+
class JobStatus
|
|
9
|
+
include Dry::Monads[:result]
|
|
10
|
+
|
|
11
|
+
attr_reader :job_execution_id
|
|
12
|
+
|
|
13
|
+
# @param client [FolioClient] the configured client
|
|
14
|
+
# @param job_execution_id [String] ID of the job to be checked on
|
|
15
|
+
def initialize(client, job_execution_id:)
|
|
16
|
+
@client = client
|
|
17
|
+
@job_execution_id = job_execution_id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Dry::Monads::Result] Success if job is complete,
|
|
21
|
+
# Failure(:pending) if job is still running,
|
|
22
|
+
# Failure(:error) if job has errors
|
|
23
|
+
# Failure(:not_found) if job is not found
|
|
24
|
+
def status
|
|
25
|
+
response_hash = client.get("/metadata-provider/jobSummary/#{job_execution_id}")
|
|
26
|
+
|
|
27
|
+
return Failure(:error) if response_hash["totalErrors"].positive?
|
|
28
|
+
return Failure(:pending) if response_hash.dig("sourceRecordSummary", "totalCreatedEntities").zero? && response_hash.dig("sourceRecordSummary", "totalUpdatedEntities").zero?
|
|
29
|
+
|
|
30
|
+
Success()
|
|
31
|
+
rescue ResourceNotFound
|
|
32
|
+
# Checking the status immediately after starting the import may result in a 404.
|
|
33
|
+
Failure(:not_found)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def wait_until_complete(wait_secs: default_wait_secs, timeout_secs: default_timeout_secs)
|
|
37
|
+
wait_with_timeout(wait_secs: wait_secs, timeout_secs: timeout_secs) { status }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def instance_hrid
|
|
41
|
+
current_status = status
|
|
42
|
+
return current_status unless current_status.success?
|
|
43
|
+
|
|
44
|
+
@instance_hrid ||= wait_with_timeout do
|
|
45
|
+
response = client
|
|
46
|
+
.get("/metadata-provider/journalRecords/#{job_execution_id}")
|
|
47
|
+
.fetch("journalRecords", [])
|
|
48
|
+
.find { |journal_record| journal_record["entityType"] == "INSTANCE" }
|
|
49
|
+
&.fetch("entityHrId", nil)
|
|
50
|
+
|
|
51
|
+
response.nil? ? Failure() : Success(response)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :client
|
|
58
|
+
|
|
59
|
+
def default_wait_secs
|
|
60
|
+
1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def default_timeout_secs
|
|
64
|
+
5 * 60
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def wait_with_timeout(wait_secs: default_wait_secs, timeout_secs: default_timeout_secs)
|
|
68
|
+
Timeout.timeout(timeout_secs) do
|
|
69
|
+
loop.with_index do |_, i|
|
|
70
|
+
result = yield
|
|
71
|
+
|
|
72
|
+
# If a 404, wait a bit longer before raising an error.
|
|
73
|
+
check_not_found(result, i)
|
|
74
|
+
return result if done_waiting?(result)
|
|
75
|
+
|
|
76
|
+
sleep(wait_secs)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
rescue Timeout::Error
|
|
80
|
+
Failure(:timeout)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def done_waiting?(result)
|
|
84
|
+
result.success? || (result.failure? && result.failure == :error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_not_found(result, index)
|
|
88
|
+
return unless result.failure? && result.failure == :not_found && index > 2
|
|
89
|
+
|
|
90
|
+
raise ResourceNotFound, "Job not found"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class FolioClient
|
|
4
|
+
class RecordsEditor
|
|
5
|
+
attr_accessor :client
|
|
6
|
+
|
|
7
|
+
# @param client [FolioClient] the configured client
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Given an HRID, retrieves the associated MARC JSON, yields it to the caller as a hash,
|
|
13
|
+
# and attempts to re-save it, using optimistic locking to prevent accidental overwrite,
|
|
14
|
+
# in case another user or process has updated the record in the time between retrieval and
|
|
15
|
+
# attempted save.
|
|
16
|
+
# @param hrid [String] the HRID of the MARC record to be edited and saved
|
|
17
|
+
# @yieldparam record_json [Hash] a hash representation of the MARC JSON for the
|
|
18
|
+
# HRID; the updated hash will be saved when control is returned from the block.
|
|
19
|
+
# @note in limited manual testing, optimistic locking behaved like so when two edit attempts collided:
|
|
20
|
+
# * One updating client would eventually raise a timeout. This updating client would actually write successfully, and version the record.
|
|
21
|
+
# * The other updating client would raise a StandardError, with a message like 'duplicate key value violates unique constraint \"idx_records_matched_id_gen\"'.
|
|
22
|
+
# This client would fail to write.
|
|
23
|
+
# * As opposed to the expected behavior of the "winner" getting a 200 ok response, and the "loser" getting a 409 conflict response.
|
|
24
|
+
# @todo If this is a problem in practice, see if it's possible to have Folio respond in a more standard way; or, workaround with error handling.
|
|
25
|
+
def edit_marc_json(hrid:)
|
|
26
|
+
instance_info = client.fetch_instance_info(hrid: hrid)
|
|
27
|
+
|
|
28
|
+
version = instance_info["_version"]
|
|
29
|
+
external_id = instance_info["id"]
|
|
30
|
+
|
|
31
|
+
record_json = client.get("/records-editor/records", {externalId: external_id})
|
|
32
|
+
|
|
33
|
+
parsed_record_id = record_json["parsedRecordId"]
|
|
34
|
+
record_json["relatedRecordVersion"] = version # setting this field on the JSON we send back is what will allow optimistic locking to catch stale updates
|
|
35
|
+
|
|
36
|
+
yield record_json
|
|
37
|
+
|
|
38
|
+
client.put("/records-editor/records/#{parsed_record_id}", record_json)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -13,7 +13,7 @@ class FolioClient
|
|
|
13
13
|
when 404
|
|
14
14
|
raise ResourceNotFound, "Endpoint not found or resource does not exist: #{response.body}"
|
|
15
15
|
when 422
|
|
16
|
-
raise
|
|
16
|
+
raise ValidationError, "There was a validation problem with the request: #{response.body} "
|
|
17
17
|
when 500
|
|
18
18
|
raise ServiceUnavailable, "The remote server returned an internal server error."
|
|
19
19
|
else
|
data/lib/folio_client/version.rb
CHANGED
data/lib/folio_client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/module/delegation"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
4
5
|
require "faraday"
|
|
5
6
|
require "singleton"
|
|
6
7
|
require "ostruct"
|
|
@@ -16,7 +17,7 @@ class FolioClient
|
|
|
16
17
|
# Base class for all FolioClient errors
|
|
17
18
|
class Error < StandardError; end
|
|
18
19
|
|
|
19
|
-
# Error raised by the Folio Auth API returns a
|
|
20
|
+
# Error raised by the Folio Auth API returns a 401 Unauthorized
|
|
20
21
|
class UnauthorizedError < Error; end
|
|
21
22
|
|
|
22
23
|
# Error raised when the Folio API returns a 404 NotFound, or returns 0 results when one was expected
|
|
@@ -31,6 +32,9 @@ class FolioClient
|
|
|
31
32
|
# Error raised when the Folio API returns a 500
|
|
32
33
|
class ServiceUnavailable < Error; end
|
|
33
34
|
|
|
35
|
+
# Error raised when the Folio API returns a 422 Unprocessable Entity
|
|
36
|
+
class ValidationError < Error; end
|
|
37
|
+
|
|
34
38
|
DEFAULT_HEADERS = {
|
|
35
39
|
accept: "application/json, text/plain",
|
|
36
40
|
content_type: "application/json"
|
|
@@ -48,8 +52,8 @@ class FolioClient
|
|
|
48
52
|
self
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
delegate :config, :connection, :get, :post, to: :instance
|
|
52
|
-
delegate :fetch_hrid, :fetch_marc_hash, :has_instance_status?, to: :instance
|
|
55
|
+
delegate :config, :connection, :get, :post, :put, to: :instance
|
|
56
|
+
delegate :fetch_hrid, :fetch_external_id, :fetch_instance_info, :fetch_marc_hash, :has_instance_status?, :data_import, :holdings, :edit_marc_json, to: :instance
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
attr_accessor :config
|
|
@@ -64,19 +68,50 @@ class FolioClient
|
|
|
64
68
|
|
|
65
69
|
UnexpectedResponse.call(response) unless response.success?
|
|
66
70
|
|
|
71
|
+
return nil if response.body.blank?
|
|
72
|
+
|
|
67
73
|
JSON.parse(response.body)
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
# Send an authenticated post request
|
|
77
|
+
# If the body is JSON, it will be automatically serialized
|
|
71
78
|
# @param path [String] the path to the Folio API request
|
|
72
|
-
# @param
|
|
73
|
-
def post(path,
|
|
79
|
+
# @param body [Object] body to post to the API as JSON
|
|
80
|
+
def post(path, body = nil, content_type: "application/json")
|
|
81
|
+
req_body = (content_type == "application/json") ? body&.to_json : body
|
|
74
82
|
response = TokenWrapper.refresh(config, connection) do
|
|
75
|
-
|
|
83
|
+
req_headers = {
|
|
84
|
+
"x-okapi-token": config.token,
|
|
85
|
+
"content-type": content_type
|
|
86
|
+
}
|
|
87
|
+
connection.post(path, req_body, req_headers)
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
UnexpectedResponse.call(response) unless response.success?
|
|
79
91
|
|
|
92
|
+
return nil if response.body.blank?
|
|
93
|
+
|
|
94
|
+
JSON.parse(response.body)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Send an authenticated put request
|
|
98
|
+
# If the body is JSON, it will be automatically serialized
|
|
99
|
+
# @param path [String] the path to the Folio API request
|
|
100
|
+
# @param body [Object] body to put to the API as JSON
|
|
101
|
+
def put(path, body = nil, content_type: "application/json")
|
|
102
|
+
req_body = (content_type == "application/json") ? body&.to_json : body
|
|
103
|
+
response = TokenWrapper.refresh(config, connection) do
|
|
104
|
+
req_headers = {
|
|
105
|
+
"x-okapi-token": config.token,
|
|
106
|
+
"content-type": content_type
|
|
107
|
+
}
|
|
108
|
+
connection.put(path, req_body, req_headers)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
UnexpectedResponse.call(response) unless response.success?
|
|
112
|
+
|
|
113
|
+
return nil if response.body.blank?
|
|
114
|
+
|
|
80
115
|
JSON.parse(response.body)
|
|
81
116
|
end
|
|
82
117
|
|
|
@@ -90,17 +125,49 @@ class FolioClient
|
|
|
90
125
|
|
|
91
126
|
# Public methods available on the FolioClient below
|
|
92
127
|
def fetch_hrid(...)
|
|
93
|
-
|
|
94
|
-
|
|
128
|
+
Inventory
|
|
129
|
+
.new(self)
|
|
130
|
+
.fetch_hrid(...)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def fetch_external_id(...)
|
|
134
|
+
Inventory
|
|
135
|
+
.new(self)
|
|
136
|
+
.fetch_external_id(...)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def fetch_instance_info(...)
|
|
140
|
+
Inventory
|
|
141
|
+
.new(self)
|
|
142
|
+
.fetch_instance_info(...)
|
|
95
143
|
end
|
|
96
144
|
|
|
97
145
|
def fetch_marc_hash(...)
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
SourceStorage
|
|
147
|
+
.new(self)
|
|
148
|
+
.fetch_marc_hash(...)
|
|
100
149
|
end
|
|
101
150
|
|
|
102
151
|
def has_instance_status?(...)
|
|
103
|
-
|
|
104
|
-
|
|
152
|
+
Inventory
|
|
153
|
+
.new(self)
|
|
154
|
+
.has_instance_status?(...)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def data_import(...)
|
|
158
|
+
DataImport
|
|
159
|
+
.new(self)
|
|
160
|
+
.import(...)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def holdings(...)
|
|
164
|
+
Holdings
|
|
165
|
+
.new(self, ...)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def edit_marc_json(...)
|
|
169
|
+
RecordsEditor
|
|
170
|
+
.new(self)
|
|
171
|
+
.edit_marc_json(...)
|
|
105
172
|
end
|
|
106
173
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: folio_client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Peter Mangiafico
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2023-03-
|
|
11
|
+
date: 2023-03-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -58,6 +58,34 @@ dependencies:
|
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
60
|
version: '0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: marc
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
type: :runtime
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
- !ruby/object:Gem::Dependency
|
|
76
|
+
name: dry-monads
|
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
type: :runtime
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
61
89
|
- !ruby/object:Gem::Dependency
|
|
62
90
|
name: rake
|
|
63
91
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -162,7 +190,11 @@ files:
|
|
|
162
190
|
- folio_client.gemspec
|
|
163
191
|
- lib/folio_client.rb
|
|
164
192
|
- lib/folio_client/authenticator.rb
|
|
193
|
+
- lib/folio_client/data_import.rb
|
|
194
|
+
- lib/folio_client/holdings.rb
|
|
165
195
|
- lib/folio_client/inventory.rb
|
|
196
|
+
- lib/folio_client/job_status.rb
|
|
197
|
+
- lib/folio_client/records_editor.rb
|
|
166
198
|
- lib/folio_client/source_storage.rb
|
|
167
199
|
- lib/folio_client/token_wrapper.rb
|
|
168
200
|
- lib/folio_client/unexpected_response.rb
|