Skip to content

Commit a86ecf7

Browse files
authored
Merge pull request #227 from MITLibraries/use-30-primo-models
Add models for Primo Search API integration
2 parents 0c0ae54 + bef712d commit a86ecf7

13 files changed

+2864
-3
lines changed

.env.test

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
TIMDEX_HOST=FAKE_TIMDEX_HOST
2-
TIMDEX_GRAPHQL=https://FAKE_TIMDEX_HOST/graphql
3-
TIMDEX_INDEX=FAKE_TIMDEX_INDEX
1+
ALMA_OPENURL=https://na06.alma.exlibrisgroup.com/view/uresolver/01MIT_INST/openurl?
42
GDT=false
3+
MIT_PRIMO_URL=https://mit.primo.exlibrisgroup.com
4+
PRIMO_API_KEY=FAKE_PRIMO_API_KEY
5+
PRIMO_API_URL=https://api-na.hosted.exlibrisgroup.com/primo/v1
6+
PRIMO_SCOPE=cdi
7+
PRIMO_TAB=all
8+
PRIMO_VID=01MIT_INST:MIT
9+
SYNDETICS_PRIMO_URL=https://syndetics.com/index.php?client=primo
10+
TIMDEX_GRAPHQL=https://FAKE_TIMDEX_HOST/graphql
11+
TIMDEX_HOST=FAKE_TIMDEX_HOST
12+
TIMDEX_INDEX=FAKE_TIMDEX_INDEX

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ See `Optional Environment Variables` for more information.
9191

9292
### Required Environment Variables
9393

94+
- `ALMA_OPENURL`: The base URL for Alma openurls found in CDI records.
95+
- `MIT_PRIMO_URL`: The base URL for MIT Libraries' Primo instance (used to generate record links).
96+
- `PRIMO_API_KEY`: The Primo Search API key.
97+
- `PRIMO_API_URL`: The Primo Search API base URL.
98+
- `PRIMO_SCOPE`: The Primo Search API `scope` param (set to `cdi` for CDI-scoped results).
99+
- `PRIMO_TAB`: The Primo Search API `tab` param (typically `all`).
100+
- `PRIMO_VID`: The Primo Search API `vid` (or 'view ID`) param.
101+
- `SYNDETICS_PRIMO_URL`: The Syndetics API URL for Primo. This is used to construct thumbnail URLs.
94102
- `TIMDEX_GRAPHQL`: Set this to the URL of the GraphQL endpoint. There is no default value in the application.
95103

96104
### Optional Environment Variables
@@ -121,6 +129,7 @@ may have unexpected consequences if applied to other TIMDEX UI apps.
121129
- `GLOBAL_ALERT`: The main functionality for this comes from our theme gem, but when set the value will be rendered as
122130
safe html above the main header of the site.
123131
- `PLATFORM_NAME`: The value set is added to the header after the MIT Libraries logo. The logic and CSS for this comes from our theme gem.
132+
- `PRIMO_TIMEOUT`: The number of seconds before a Primo request times out (default 6).
124133
- `REQUESTS_PER_PERIOD` - number of requests that can be made for general throttles per `REQUEST_PERIOD`
125134
- `REQUEST_PERIOD` - time in minutes used along with `REQUESTS_PER_PERIOD`
126135
- `REDIRECT_REQUESTS_PER_PERIOD`- number of requests that can be made that the query string starts with our legacy redirect parameter to throttle per `REQUEST_PERIOD`
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
# Transforms a PNX doc from Primo Search API into a normalized record.
2+
class NormalizePrimoRecord
3+
def initialize(record, query)
4+
@record = record
5+
@query = query
6+
end
7+
8+
def normalize
9+
{
10+
'title' => title,
11+
'creators' => creators,
12+
'source' => source,
13+
'year' => year,
14+
'format' => format,
15+
'links' => links,
16+
'citation' => citation,
17+
'container' => container_title,
18+
'identifier' => record_id,
19+
'summary' => summary,
20+
'numbering' => numbering,
21+
'chapter_numbering' => chapter_numbering,
22+
'thumbnail' => thumbnail,
23+
'publisher' => publisher,
24+
'location' => best_location,
25+
'subjects' => subjects,
26+
'availability' => best_availability,
27+
'other_availability' => other_availability?
28+
}
29+
end
30+
31+
private
32+
33+
def title
34+
if @record['pnx']['display']['title'].present?
35+
@record['pnx']['display']['title'].join
36+
else
37+
'unknown title'
38+
end
39+
end
40+
41+
def creators
42+
return [] unless @record['pnx']['display']['creator'] || @record['pnx']['display']['contributor']
43+
44+
author_list = []
45+
46+
if @record['pnx']['display']['creator']
47+
creators = sanitize_authors(@record['pnx']['display']['creator'])
48+
creators.each do |creator|
49+
author_list << { value: creator, link: author_link(creator) }
50+
end
51+
end
52+
53+
if @record['pnx']['display']['contributor']
54+
contributors = sanitize_authors(@record['pnx']['display']['contributor'])
55+
contributors.each do |contributor|
56+
author_list << { value: contributor, link: author_link(contributor) }
57+
end
58+
end
59+
60+
author_list.uniq
61+
end
62+
63+
def source
64+
'Primo'
65+
end
66+
67+
def year
68+
if @record['pnx']['display']['creationdate'].present?
69+
@record['pnx']['display']['creationdate'].join
70+
else
71+
return unless @record['pnx']['search'] && @record['pnx']['search']['creationdate']
72+
73+
@record['pnx']['search']['creationdate'].join
74+
end
75+
end
76+
77+
def format
78+
return unless @record['pnx']['display']['type']
79+
80+
normalize_type(@record['pnx']['display']['type'].join)
81+
end
82+
83+
# While the links object in the Primo response often contains more than the Alma openurl, that is
84+
# the one that is most predictably useful to us. The record_link is constructed.
85+
def links
86+
links = []
87+
88+
# Use dedup URL as the full record link if available, otherwise use record link
89+
if dedup_url.present?
90+
links << { 'url' => dedup_url, 'kind' => 'full record' }
91+
elsif record_link.present?
92+
links << { 'url' => record_link, 'kind' => 'full record' }
93+
end
94+
95+
# Add openurl if available
96+
links << { 'url' => openurl, 'kind' => 'openurl' } if openurl.present?
97+
98+
# Return links if we found any
99+
links.any? ? links : []
100+
end
101+
102+
def citation
103+
return unless @record['pnx']['addata']
104+
105+
if @record['pnx']['addata']['volume'].present?
106+
if @record['pnx']['addata']['issue'].present?
107+
"volume #{@record['pnx']['addata']['volume'].join} issue #{@record['pnx']['addata']['issue'].join}"
108+
else
109+
"volume #{@record['pnx']['addata']['volume'].join}"
110+
end
111+
elsif @record['pnx']['addata']['date'].present? && @record['pnx']['addata']['pages'].present?
112+
"#{@record['pnx']['addata']['date'].join}, pp. #{@record['pnx']['addata']['pages'].join}"
113+
end
114+
end
115+
116+
def container_title
117+
return unless @record['pnx']['addata']
118+
119+
if @record['pnx']['addata']['jtitle'].present?
120+
@record['pnx']['addata']['jtitle'].join
121+
elsif @record['pnx']['addata']['btitle'].present?
122+
@record['pnx']['addata']['btitle'].join
123+
end
124+
end
125+
126+
def record_id
127+
return unless @record['pnx']['control']['recordid']
128+
129+
@record['pnx']['control']['recordid'].join
130+
end
131+
132+
def summary
133+
return unless @record['pnx']['display']['description']
134+
135+
@record['pnx']['display']['description'].join(' ')
136+
end
137+
138+
# This constructs a link to the record in Primo.
139+
#
140+
# We've altered this method slightly to address bugs introduced in the Primo VE November 2021
141+
# release. The search_scope param is now required for CDI fulldisplay links, and the context param
142+
# is now required for local (catalog) fulldisplay links.
143+
#
144+
# In order to avoid more surprises, we're adding all of the params included in the fulldisplay
145+
# example links provided here, even though not all of them are actually required at present:
146+
# https://developers.exlibrisgroup.com/primo/apis/deep-links-new-ui/
147+
#
148+
# We should keep an eye on this over subsequent Primo reeleases and revert it to something more
149+
# minimalist/sensible when Ex Libris fixes this issue.
150+
def record_link
151+
return unless @record['pnx']['control']['recordid']
152+
return unless @record['context']
153+
154+
record_id = @record['pnx']['control']['recordid'].join
155+
base = [ENV.fetch('MIT_PRIMO_URL'), '/discovery/fulldisplay?'].join
156+
query = {
157+
docid: record_id,
158+
vid: ENV.fetch('PRIMO_VID'),
159+
context: @record['context'],
160+
search_scope: 'all',
161+
lang: 'en',
162+
tab: ENV.fetch('PRIMO_TAB')
163+
}.to_query
164+
[base, query].join
165+
end
166+
167+
def numbering
168+
return unless @record['pnx']['addata']
169+
return unless @record['pnx']['addata']['volume']
170+
171+
if @record['pnx']['addata']['issue'].present?
172+
"volume #{@record['pnx']['addata']['volume'].join} issue #{@record['pnx']['addata']['issue'].join}"
173+
else
174+
"volume #{@record['pnx']['addata']['volume'].join}"
175+
end
176+
end
177+
178+
def chapter_numbering
179+
return unless @record['pnx']['addata']
180+
return unless @record['pnx']['addata']['btitle']
181+
return unless @record['pnx']['addata']['date'] && @record['pnx']['addata']['pages']
182+
183+
"#{@record['pnx']['addata']['date'].join}, pp. #{@record['pnx']['addata']['pages'].join}"
184+
end
185+
186+
def sanitize_authors(authors)
187+
authors.map! { |author| author.split(';') }.flatten! if authors.any? { |author| author.include?(';') }
188+
authors.map { |author| author.strip.gsub(/\$\$Q.*$/, '') }
189+
end
190+
191+
def author_link(author)
192+
[ENV.fetch('MIT_PRIMO_URL'),
193+
'/discovery/search?query=creator,exact,',
194+
encode_author(author),
195+
'&tab=', ENV.fetch('PRIMO_TAB'),
196+
'&search_scope=all&vid=',
197+
ENV.fetch('PRIMO_VID')].join
198+
end
199+
200+
def encode_author(author)
201+
URI.encode_uri_component(author)
202+
end
203+
204+
def normalize_type(type)
205+
r_types = {
206+
'BKSE' => 'eBook',
207+
'reference_entry' => 'Reference Entry',
208+
'Book_chapter' => 'Book Chapter'
209+
}
210+
r_types[type] || type.capitalize
211+
end
212+
213+
# It's possible we'll encounter records that use a different server,
214+
# so we want to test against our expected server to guard against
215+
# malformed URLs. This assumes all URL strings begin with https://.
216+
def openurl
217+
return unless @record['delivery'] && @record['delivery']['almaOpenurl']
218+
219+
# Check server match
220+
openurl_server = ENV.fetch('ALMA_OPENURL', nil)[8, 4]
221+
record_openurl_server = @record['delivery']['almaOpenurl'][8, 4]
222+
if openurl_server == record_openurl_server
223+
construct_primo_openurl
224+
else
225+
Rails.logger.warn "Alma openurl server mismatch. Expected #{openurl_server}, but received #{record_openurl_server}. (record ID: #{record_id})"
226+
@record['delivery']['almaOpenurl']
227+
end
228+
end
229+
230+
def construct_primo_openurl
231+
return unless @record['delivery']['almaOpenurl']
232+
233+
# Here we are converting the Alma link resolver URL provided by the Primo
234+
# Search API to redirect to the Primo UI. This is done for UX purposes,
235+
# as the regular Alma link resolver URLs redirect to a plaintext
236+
# disambiguation page.
237+
primo_openurl_base = [ENV.fetch('MIT_PRIMO_URL', nil),
238+
'/discovery/openurl?institution=',
239+
ENV.fetch('EXL_INST_ID', nil),
240+
'&vid=',
241+
ENV.fetch('PRIMO_VID', nil),
242+
'&'].join
243+
primo_openurl = @record['delivery']['almaOpenurl'].gsub(ENV.fetch('ALMA_OPENURL', nil), primo_openurl_base)
244+
245+
# The ctx params appear to break Primo openurls, so we need to remove them.
246+
params = Rack::Utils.parse_nested_query(primo_openurl)
247+
filtered = params.delete_if { |key, _value| key.starts_with?('ctx') }
248+
URI::DEFAULT_PARSER.unescape(filtered.to_param)
249+
end
250+
251+
def thumbnail
252+
return unless @record['pnx']['addata'] && @record['pnx']['addata']['isbn']
253+
254+
# A record can have multiple ISBNs, so we are assuming here that
255+
# the thumbnail URL can be constructed from the first occurrence
256+
isbn = @record['pnx']['addata']['isbn'].first
257+
[ENV.fetch('SYNDETICS_PRIMO_URL', nil), '&isbn=', isbn, '/sc.jpg'].join
258+
end
259+
260+
def publisher
261+
return unless @record['pnx']['addata'] && @record['pnx']['addata']['pub']
262+
263+
@record['pnx']['addata']['pub'].first
264+
end
265+
266+
def best_location
267+
return unless @record['delivery']
268+
return unless @record['delivery']['bestlocation']
269+
270+
loc = @record['delivery']['bestlocation']
271+
["#{loc['mainLocation']} #{loc['subLocation']}", loc['callNumber']]
272+
end
273+
274+
def subjects
275+
return [] unless @record['pnx']['display']['subject']
276+
277+
@record['pnx']['display']['subject']
278+
end
279+
280+
def best_availability
281+
return unless best_location
282+
283+
@record['delivery']['bestlocation']['availabilityStatus']
284+
end
285+
286+
def other_availability?
287+
return unless @record['delivery']['bestlocation']
288+
return unless @record['delivery']['holding']
289+
290+
@record['delivery']['holding'].length > 1
291+
end
292+
293+
# FRBR Group check based on:
294+
# https://knowledge.exlibrisgroup.com/Primo/Knowledge_Articles/Primo_Search_API_-_how_to_get_FRBR_Group_members_after_a_search
295+
def frbrized?
296+
return unless @record['pnx']['facets']
297+
return unless @record['pnx']['facets']['frbrtype']
298+
299+
@record['pnx']['facets']['frbrtype'].join == '5'
300+
end
301+
302+
def dedup_url
303+
return unless frbrized?
304+
return unless @record['pnx']['facets']['frbrgroupid'] &&
305+
@record['pnx']['facets']['frbrgroupid'].length == 1
306+
307+
frbr_group_id = @record['pnx']['facets']['frbrgroupid'].join
308+
base = [ENV.fetch('MIT_PRIMO_URL', nil), '/discovery/search?'].join
309+
310+
query = {
311+
query: "any,contains,#{@query}",
312+
tab: ENV.fetch('PRIMO_TAB', nil),
313+
search_scope: ENV.fetch('PRIMO_SCOPE', nil),
314+
sortby: 'date_d',
315+
vid: ENV.fetch('PRIMO_VID', nil),
316+
facet: "frbrgroupid,include,#{frbr_group_id}"
317+
}.to_query
318+
[base, query].join
319+
end
320+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Batch normalization for Primo Search API results
2+
class NormalizePrimoResults
3+
def initialize(results, query)
4+
@results = results
5+
@query = query
6+
end
7+
8+
def normalize
9+
return [] unless @results&.dig('docs')
10+
11+
@results['docs'].filter_map do |doc|
12+
NormalizePrimoRecord.new(doc, @query).normalize
13+
end
14+
end
15+
16+
def total_results
17+
@results&.dig('info', 'total') || 0
18+
end
19+
end

0 commit comments

Comments
 (0)