Skip to content

Commit ddabb1b

Browse files
Implement Alma SRU model for availability
** Why are these changes being introduced: For performance reasons, we stripped out holdings information from our initial Primo API call. However, we do want to display these holdings to users where they are available - so we need to add them back as an async API lookup. ** Relevant ticket(s): * https://mitlibraries.atlassian.net/browse/use-598 ** How does this address that need: This adds an AlmaSru model that will handle these async lookups. This ticket calls for only the model, the rest of the integration will happen in subsequent tickets. The model provides a .lookup method which accepts an identifier argument. It validates that identifier, submits it to the Alma SRU endpoint, receives the result, and parses that response into a human-friendly format. ** Document any side effects to this change: We consult a variety of external APIs in this application, and each of them follows slightly different patterns. Hopefully this does not exacerbate that range terribly.
1 parent 2cbfc1f commit ddabb1b

10 files changed

Lines changed: 2166 additions & 0 deletions

.env.test

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
ALMA_OPENURL=https://na06.alma.exlibrisgroup.com/view/uresolver/01MIT_INST/openurl?
2+
EXL_INST_ID=01MIT_INST
23
TURNSTILE_SITEKEY=test-sitekey
34
TURNSTILE_SECRET=test-secret
45
FEATURE_TIMDEX_FULLTEXT=true
56
FEATURE_GEODATA=false
67
FEATURE_PRIMO_NDE_LINKS=false
8+
MIT_ALMA_URL=https://mit.alma.exlibrisgroup.com
79
MIT_PRIMO_URL=https://mit.primo.exlibrisgroup.com
810
OPENALEX_EMAIL=FAKE_OPENALEX_EMAIL
911
PRIMO_API_KEY=FAKE_PRIMO_API_KEY

app/models/alma_sru.rb

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# frozen_string_literal: true
2+
3+
# Queries the Alma SRU endpoint for holdings data
4+
#
5+
# @reference https://developers.exlibrisgroup.com/alma/integrations/SRU/
6+
class AlmaSru
7+
class LookupFailure < StandardError; end
8+
9+
class InvalidAlmaId < StandardError; end
10+
11+
NAMESPACE = { 'holding' => 'http://www.loc.gov/MARC21/slim' }.freeze
12+
13+
LOCATION_ORDER = {
14+
'Hayden Library' => 0,
15+
'Lewis Music Library' => 1,
16+
'Rotch Library' => 2,
17+
'Barker Library' => 3,
18+
'Dewey Library' => 4
19+
}.freeze
20+
21+
# lookup is the primary method of interacting with this model.
22+
#
23+
# It will receive an Alma ID, validate it, look it up in the Alma SRU, and return a formatted result.
24+
#
25+
# It accepts an "alma_client" argument for use when testing, but this is not used in normal operations.
26+
def self.lookup(raw_identifier, alma_client: nil)
27+
return [] unless alma_base_url
28+
29+
# Validate the raw identifier received. This will raise an InvalidAlmaId if validation fails.
30+
identifier = validate_alma_id(raw_identifier)
31+
32+
# Build URL
33+
url = alma_sru_url(identifier)
34+
35+
# Retrieve that URL
36+
alma_http = setup(url, alma_client)
37+
38+
parse_response(alma_http.timeout(6).get(url), identifier)
39+
rescue InvalidAlmaId
40+
Rails.logger.debug("Invalid Alma ID: #{raw_identifier}")
41+
42+
[]
43+
rescue LookupFailure => e
44+
Rails.logger.debug("Alma lookup failure: #{e}")
45+
46+
[]
47+
rescue HTTP::Error
48+
Sentry.capture_message('Alma SRU connection failure')
49+
Rails.logger.error('Alma SRU connection error')
50+
51+
[]
52+
end
53+
54+
# parse_response receives the raw response from the Alma SRU endpoint.
55+
#
56+
# For any non-200 response, it raises a LookupFailure.
57+
#
58+
# Other responses (in XML format) are parsed by Nokogiri, and we pluck content with an `AVA` tag.
59+
def self.parse_response(raw_response, reference_identifier)
60+
raise LookupFailure, raw_response.status unless raw_response.status == 200
61+
62+
parsed = Nokogiri::XML(raw_response.body.to_s)
63+
64+
# Confirm that control field 001 matches the identifier we received.
65+
parsed_controlfield = fetch_controlfield(parsed)
66+
raise LookupFailure, 'Control field mismatch' unless parsed_controlfield == reference_identifier
67+
68+
# Look up all AVA tags
69+
parsed_availabilities = fetch_availabilities(parsed)
70+
71+
parsed_availabilities.map(&method(:format_availability))
72+
end
73+
74+
# validate_alma_id ensures we are only submitting valid Alma IDs to the SRU endpoint.
75+
#
76+
# It needs to do two thigns:
77+
# 1. Remove the "alma" prefix if one is present. Otherwise, no manipulation of the submitted value should occur.
78+
# 2. Enforce the formatting requirements for a valid alma identifier (start with "99", and end with "6761").
79+
def self.validate_alma_id(raw)
80+
parsed = if raw.start_with?('alma')
81+
raw.delete_prefix('alma')
82+
else
83+
raw
84+
end
85+
86+
raise InvalidAlmaId unless parsed.to_s.start_with?('99')
87+
raise InvalidAlmaId unless parsed.to_s.end_with?('6761')
88+
89+
parsed
90+
end
91+
92+
# ava_to_hash takes an XML element that represents a single availability record
93+
# and converts it to a hash. Each code is a key, while its text is the value.
94+
def self.ava_to_hash(node)
95+
rebuilt = {}
96+
97+
node.children.each do |child|
98+
rebuilt[child.attribute_nodes[0].value] = child.text if child.instance_of?(Nokogiri::XML::Element)
99+
end
100+
101+
rebuilt
102+
end
103+
104+
# fetch_availabilities receives a parsed XML document (Nokogiri::XML::Document)
105+
#
106+
# This document is parsed using xpath to select on the nodes with an tag of AVA,
107+
# and these are then sorted based on a preferred library order.
108+
def self.fetch_availabilities(parsed_xml)
109+
ava_list = parsed_xml.xpath("//holding:datafield[@tag='AVA']", NAMESPACE)
110+
111+
ava_list
112+
.map { |el| ava_to_hash(el) }
113+
.sort_by { |el| [LOCATION_ORDER.fetch(el['q'], 999), el['q'].to_s] }
114+
end
115+
116+
# fetch_controlfield receives a parsed XML document (Nokogiri::XML::Document)
117+
# and returns the controlfield with an 001 tag, if one exists.
118+
def self.fetch_controlfield(parsed_xml)
119+
parsed_xml.xpath("//holding:controlfield[@tag='001']", NAMESPACE)&.text
120+
end
121+
122+
# format_availability receives a hash representing a single availability
123+
# statement, and formats it for human readability.
124+
def self.format_availability(availability)
125+
"#{availability['e']&.humanize} at #{availability['q']} #{availability['c']} (#{availability['d']})"
126+
end
127+
128+
def self.alma_base_url
129+
ENV.fetch('MIT_ALMA_URL', nil)
130+
end
131+
132+
def self.alma_sru_url(identifier)
133+
# example identifier: 990000959610106761
134+
"#{alma_base_url}/view/sru/#{ENV.fetch('EXL_INST_ID')}?version=1.2&operation=searchRetrieve&recordSchema=marcxml" \
135+
"&query=alma.all_for_ui=#{identifier}"
136+
end
137+
138+
def self.setup(url, alma_client)
139+
alma_client || HTTP.persistent(url)
140+
end
141+
end

0 commit comments

Comments
 (0)