-
Notifications
You must be signed in to change notification settings - Fork 0
WIP towards Alma SRU model #404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,9 @@ | |
| .env | ||
| .env.development | ||
|
|
||
| # qlty | ||
| .qlty | ||
|
|
||
| ## Environment normalization: | ||
| /.bundle | ||
| /vendor/bundle | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Queries the Alma SRU endpoint for holdings data | ||
| # | ||
| # @reference https://developers.exlibrisgroup.com/alma/integrations/SRU/ | ||
| class AlmaSru | ||
| class LookupFailure < StandardError; end | ||
|
|
||
| class InvalidAlmaId < StandardError; end | ||
|
|
||
| NAMESPACE = { 'holding' => 'http://www.loc.gov/MARC21/slim' }.freeze | ||
|
|
||
| LOCATION_ORDER = { | ||
| 'Hayden Library' => 0, | ||
| 'Lewis Music Library' => 1, | ||
| 'Rotch Library' => 2, | ||
| 'Barker Library' => 3, | ||
| 'Dewey Library' => 4 | ||
| }.freeze | ||
|
|
||
| # lookup is the primary method of interacting with this model. | ||
| # | ||
| # It will receive an Alma ID, validate it, look it up in the Alma SRU, and return a formatted result. | ||
| # | ||
| # It accepts an "alma_client" argument for use when testing, but this is not used in normal operations. | ||
| def self.lookup(raw_identifier, alma_client: nil) | ||
| return [] unless alma_base_url | ||
|
|
||
| # Validate the raw identifier received. This will raise an InvalidAlmaId if validation fails. | ||
| identifier = validate_alma_id(raw_identifier) | ||
|
matt-bernhardt marked this conversation as resolved.
|
||
|
|
||
| # Build URL | ||
| url = alma_sru_url(identifier) | ||
|
|
||
| # Retrieve that URL | ||
| alma_http = setup(url, alma_client) | ||
|
|
||
| parse_response(alma_http.timeout(6).get(url), identifier) | ||
| rescue InvalidAlmaId | ||
| Rails.logger.debug("Invalid Alma ID: #{raw_identifier}") | ||
|
|
||
| [] | ||
| rescue LookupFailure => e | ||
| Rails.logger.debug("Alma lookup failure: #{e}") | ||
|
|
||
| [] | ||
| rescue HTTP::Error | ||
| Sentry.capture_message('Alma SRU connection failure') | ||
| Rails.logger.error('Alma SRU connection error') | ||
|
|
||
| [] | ||
|
qltysh[bot] marked this conversation as resolved.
JPrevost marked this conversation as resolved.
|
||
| end | ||
|
|
||
| # parse_response receives the raw response from the Alma SRU endpoint. | ||
| # | ||
| # For any non-200 response, it raises a LookupFailure. | ||
| # | ||
| # Other responses (in XML format) are parsed by Nokogiri, and we pluck content with an `AVA` tag. | ||
| def self.parse_response(raw_response, reference_identifier) | ||
| raise LookupFailure, raw_response.status unless raw_response.status == 200 | ||
|
|
||
| parsed = Nokogiri::XML(raw_response.body.to_s) | ||
|
|
||
| # Confirm that control field 001 matches the identifier we received. | ||
| parsed_controlfield = fetch_controlfield(parsed) | ||
| raise LookupFailure, 'Control field mismatch' unless parsed_controlfield == reference_identifier | ||
|
|
||
| # Look up all AVA tags | ||
| parsed_availabilities = fetch_availabilities(parsed) | ||
|
|
||
| parsed_availabilities.map(&method(:format_availability)) | ||
| end | ||
|
|
||
| # validate_alma_id ensures we are only submitting valid Alma IDs to the SRU endpoint. | ||
| # | ||
| # It needs to do two thigns: | ||
|
Copilot marked this conversation as resolved.
Outdated
|
||
| # 1. Remove the "alma" prefix if one is present. Otherwise, no manipulation of the submitted value should occur. | ||
| # 2. Enforce the formatting requirements for a valid alma identifier (start with "99", and end with "6761"). | ||
| def self.validate_alma_id(raw) | ||
| parsed = if raw.start_with?('alma') | ||
| raw.delete_prefix('alma') | ||
| else | ||
| raw | ||
| end | ||
|
|
||
| raise InvalidAlmaId unless parsed.to_s.start_with?('99') | ||
| raise InvalidAlmaId unless parsed.to_s.end_with?('6761') | ||
|
|
||
| parsed | ||
|
matt-bernhardt marked this conversation as resolved.
|
||
| end | ||
|
matt-bernhardt marked this conversation as resolved.
Comment on lines
+79
to
+89
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FWIW this really is a pretty good suggestion. I'm fine with not going in this direction as in theory we should be able to trust the input and the extra cost of running a lookup on potential nonsense like
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The objection in this comment is a bit different than the earlier round of comments that focused on non-numeric values like The behavior I think this comment is calling out is that the initial parsing will fail on a value like I'm still debating whether to add a condition in the validation to raise
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup. What I'm agreeing with is that In other words, our code doesn't break as-is, but it is missing an arguably important edge case about what an Alma ID looks like. My initial spec for this didn't account for this. It's likely no production alma id will ever not be an integer after we strip the prefix... but if it isn't an integer, we sure as heck shouldn't query Alma about it ;) |
||
|
|
||
| # ava_to_hash takes an XML element that represents a single availability record | ||
| # and converts it to a hash. Each code is a key, while its text is the value. | ||
| def self.ava_to_hash(node) | ||
| rebuilt = {} | ||
|
|
||
| node.children.each do |child| | ||
| rebuilt[child.attribute_nodes[0].value] = child.text if child.instance_of?(Nokogiri::XML::Element) | ||
| end | ||
|
|
||
| rebuilt | ||
| end | ||
|
Comment on lines
+93
to
+101
|
||
|
|
||
| # fetch_availabilities receives a parsed XML document (Nokogiri::XML::Document) | ||
| # | ||
| # This document is parsed using xpath to select on the nodes with an tag of AVA, | ||
| # and these are then sorted based on a preferred library order. | ||
| def self.fetch_availabilities(parsed_xml) | ||
| ava_list = parsed_xml.xpath("//holding:datafield[@tag='AVA']", NAMESPACE) | ||
|
|
||
| ava_list | ||
| .map { |el| ava_to_hash(el) } | ||
| .sort_by { |el| [LOCATION_ORDER.fetch(el['q'], 999), el['q'].to_s] } | ||
| end | ||
|
|
||
| # fetch_controlfield receives a parsed XML document (Nokogiri::XML::Document) | ||
| # and returns the controlfield with an 001 tag, if one exists. | ||
| def self.fetch_controlfield(parsed_xml) | ||
|
JPrevost marked this conversation as resolved.
|
||
| parsed_xml.xpath("//holding:controlfield[@tag='001']", NAMESPACE)&.text | ||
| end | ||
|
|
||
| # format_availability receives a hash representing a single availability | ||
| # statement, and formats it for human readability. | ||
| def self.format_availability(availability) | ||
| "#{availability['e']&.humanize} at #{availability['q']} #{availability['c']} (#{availability['d']})" | ||
| end | ||
|
JPrevost marked this conversation as resolved.
|
||
|
|
||
| def self.alma_base_url | ||
| ENV.fetch('MIT_ALMA_URL', nil) | ||
| end | ||
|
|
||
| def self.alma_sru_url(identifier) | ||
| # example identifier: 990000959610106761 | ||
| "#{alma_base_url}/view/sru/#{ENV.fetch('EXL_INST_ID')}?version=1.2&operation=searchRetrieve&recordSchema=marcxml" \ | ||
| "&query=alma.all_for_ui=#{identifier}" | ||
|
matt-bernhardt marked this conversation as resolved.
|
||
| end | ||
|
|
||
| def self.setup(url, alma_client) | ||
| alma_client || HTTP.persistent(url) | ||
| end | ||
| end | ||
Uh oh!
There was an error while loading. Please reload this page.