Skip to content

Commit 9969bff

Browse files
Create integration with Libkey
** Why are these changes being introduced: The application needs to look up DOI or PMID values using the Libkey service in order to find the best possible fulfillment links for records that come back from our various indices. ** Relevant ticket(s): * https://mitlibraries.atlassian.net/browse/use-232 ** How does this address that need: This defines a route, controller, model, and view template that will receive a DOI or PMID, look them up in Libkey, and return the suggested link. Libkey's response object includes a "best integrator link", which we are choosing here to accept uncritically. There are two checks performed at various levels of this integration: - The controller makes sure that Libkey is enabled, by making sure that the proper env vars have been defined. - The model makes sure that the proper parameters have been passed - we require both a type (either "doi" or "pmid") and an identifier. The format of the identifier is not specified. This value is used to create the URL that is sent to Libkey, as part of the URL path. The env vars created here are optional, because GeoData doesn't use this integration. ** Document any side effects to this change: Rubocop is flagging the lookup method as being too complex and too long. I'm not sure the best way to resolve this, other than to split it into "part A, setup" and "part B, communication" - but this feels like it might be a distinction without a difference. Maybe a common "external connection" model class could inform both the Tacos and Libkey models, since they both use similar error handling patterns? There is a third value in the Libkey response, "link type", which is currently unused. I've flagged it as potentially being unneeded, but wanted to make sure Dave / UX knows it exists.
1 parent 3872738 commit 9969bff

File tree

12 files changed

+484
-0
lines changed

12 files changed

+484
-0
lines changed

.env.test

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ TACOS_SOURCE=FAKE_TACOS_SOURCE
1313
TIMDEX_GRAPHQL=https://FAKE_TIMDEX_HOST/graphql
1414
TIMDEX_HOST=FAKE_TIMDEX_HOST
1515
TIMDEX_INDEX=FAKE_TIMDEX_INDEX
16+
LIBKEY_ID=FAKE_LIBKEY_ID
17+
LIBKEY_KEY=FAKE_LIBKEY_KEY

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ may have unexpected consequences if applied to other TIMDEX UI apps.
112112
- `FILTER_SUBJECT`: The name to use instead of "Subject" for that filter / aggregation.
113113
- `GLOBAL_ALERT`: The main functionality for this comes from our theme gem, but when set the value will be rendered as
114114
safe html above the main header of the site.
115+
- `LIBKEY_KEY`: An access key assigned by Third Iron to enable this application to interact with the Libkey service.
116+
- `LIBKEY_ID`: An institutional ID value assigned by Third Iron to interact with the Libkey service.
115117
- `MATOMO_CONTAINER_URL`: This is one of two options for integrating a TIMDEX UI application with Matomo - the Tag Manager. This is the only parameter needed for using a tag manager container.
116118
- `MATOMO_SITE_ID`: Integrating with Matomo using the legacy approach (instead of Tag Manager) requires two values: the site id and a URL. This is one of those legacy values.
117119
- `MATOMO_URL`: Integrating with Matomo using the legacy approach (instead of Tag Manager) requires two values: the site id and a URL. This is one of those legacy values.
@@ -146,6 +148,8 @@ that matches `TACOS_URL`. Ex: If `TACOS_URL` is `http://localhost:3001/graphql`
146148

147149
When generating new cassettes for timdex-ui, update `.env.test` to have appropriate values for your test for `TIMDEX_GRAPHQL` and `TIMDEX_HOST`. This will allow the cassettes to be generated from any TIMDEX source with the data you need, but be sure to set them back to the original values after the cassette are generated. When the values are not set to the "fake" values we normally store, many tests will fail due to how the cassettes re-write values to normalize what we store.
148150

151+
If you need to regenerate any cassettes for interactions with Libkey, you will need to temporarily assign real values for the `LIBKEY_ID` and `LIBKEY_KEY` variables. These should also be reset back to the fake values after regenerating cassettes.
152+
149153
`.env.test` should be commited to the repository, but should not include real values for a TIMDEX source even though they are not secrets. We want to use fake values to allow us to normalize our cassettes without forcing us to always generate them from a single TIMDEX source.
150154

151155
### Updating GraphQL Schema
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class LibkeyController < ApplicationController
2+
layout false
3+
4+
def lookup
5+
return unless Libkey.enabled? && expected_params?
6+
7+
@libkey = Libkey.lookup(type: params[:type], identifier: params[:identifier])
8+
end
9+
10+
private
11+
12+
def expected_params?
13+
params[:type].present? && params[:identifier].present?
14+
end
15+
end

app/models/libkey.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# TODO: Need some documentation block to explain what we use Libkey for...
2+
class Libkey
3+
class LookupFailure < StandardError; end
4+
5+
BASEURL = 'https://public-api.thirdiron.com/public/v1/libraries'.freeze
6+
7+
# enabled? confirms that all required environment variables are set.
8+
#
9+
# @return Boolean
10+
def self.enabled?
11+
libkey_id.present? && libkey_key.present?
12+
end
13+
14+
def self.lookup(type:, identifier:, libkey_client: nil)
15+
return unless enabled?
16+
return unless %w[doi pmid].include?(type)
17+
18+
url = libkey_url(type, identifier)
19+
Rails.logger.debug(url)
20+
21+
libkey_http = setup(url, libkey_client)
22+
23+
begin
24+
raw_response = libkey_http.timeout(6).get(url)
25+
raise LookupFailure, raw_response.status unless raw_response.status == 200
26+
27+
json_response = JSON.parse(raw_response.to_s)
28+
extract_metadata(json_response)
29+
rescue LookupFailure => e
30+
Rails.logger.debug("Libkey responded with non-200 status: #{e.message}")
31+
nil
32+
rescue HTTP::Error
33+
Rails.logger.error('Libkey connection error')
34+
{ 'error' => 'A connection error has occurred' }
35+
rescue JSON::ParserError
36+
Rails.logger.error('Libkey parsing error')
37+
{ 'error' => 'A parsing error has occurred' }
38+
end
39+
end
40+
41+
def self.extract_metadata(external_data)
42+
return unless external_data['data']['bestIntegratorLink']
43+
44+
{
45+
link: external_data['data']['bestIntegratorLink']['bestLink'],
46+
text: external_data['data']['bestIntegratorLink']['recommendedLinkText'],
47+
type: external_data['data']['bestIntegratorLink']['linkType'] # Not sure whether this belongs here.
48+
}
49+
end
50+
51+
def self.libkey_id
52+
ENV.fetch('LIBKEY_ID', nil)
53+
end
54+
55+
def self.libkey_key
56+
ENV.fetch('LIBKEY_KEY', nil)
57+
end
58+
59+
def self.libkey_url(type, identifier)
60+
"#{BASEURL}/#{libkey_id}/articles/#{type}/#{identifier}?access_token=#{libkey_key}"
61+
end
62+
63+
def self.setup(url, libkey_client)
64+
libkey_client || HTTP.persistent(url)
65+
.headers(accept: 'application/json')
66+
end
67+
end

app/views/libkey/lookup.html.erb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<% if Libkey.enabled? && @libkey.present? %>
2+
<%= link_to( @libkey[:text], @libkey[:link], class: 'button button-primary' ) %>
3+
<% end %>

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
get 'analyze', to: 'tacos#analyze'
55

6+
get 'lookup', to: 'libkey#lookup'
7+
68
get 'record/(:id)',
79
to: 'record#view',
810
as: 'record',
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require 'test_helper'
2+
3+
class LibkeyControllerTest < ActionDispatch::IntegrationTest
4+
test 'lookup route exists with no content' do
5+
# No cassette because this never results in traffic to Libkey
6+
get '/lookup'
7+
8+
assert_response :success
9+
assert_equal response.body, ''
10+
end
11+
12+
test 'lookup route returns nothing without required parameters' do
13+
# No cassettes because these never result in traffic to Libkey
14+
# "type" value only
15+
get '/lookup?type=doi'
16+
17+
assert_equal response.body, ''
18+
19+
# "identifier" value only
20+
get '/lookup?identifier=10.1038/s41567-023-02305-y'
21+
22+
assert_equal response.body, ''
23+
end
24+
25+
test 'lookup route returns HTML for valid parameters' do
26+
VCR.use_cassette('libkey doi') do
27+
get '/lookup?type=doi&identifier=10.1038/s41567-023-02305-y'
28+
29+
assert_response :success
30+
assert_select 'a.button-primary', { count: 1 }
31+
end
32+
33+
VCR.use_cassette('libkey pmid') do
34+
get '/lookup?type=pmid&identifier=22110403'
35+
36+
assert_response :success
37+
assert_select 'a.button-primary', { count: 1 }
38+
end
39+
end
40+
41+
test 'lookup for non-existent identifier returns blank' do
42+
# Libkey responds here, so we have a cassette - but the response is empty
43+
VCR.use_cassette('libkey nonexistent') do
44+
get '/lookup?type=doi&identifier=foobar'
45+
46+
assert_response :success
47+
assert_equal response.body, ''
48+
end
49+
end
50+
51+
test 'no response when either env var is not set' do
52+
# No cassette because this never results in traffic to Libkey
53+
ClimateControl.modify(LIBKEY_ID: nil) do
54+
get '/lookup?type=doi&identifier=10.1038/s41567-023-02305-y'
55+
56+
assert_response :success
57+
assert_equal response.body, ''
58+
end
59+
60+
ClimateControl.modify(LIBKEY_KEY: nil) do
61+
get '/lookup?type=doi&identifier=10.1038/s41567-023-02305-y'
62+
63+
assert_response :success
64+
assert_equal response.body, ''
65+
end
66+
end
67+
end

test/models/libkey_test.rb

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
require 'test_helper'
2+
3+
class LibkeyMockResponse
4+
attr_reader :status
5+
6+
def initialize(status, body)
7+
@status = status
8+
@body = body
9+
end
10+
11+
def to_s
12+
@body
13+
end
14+
end
15+
16+
class LibkeyConnectionError
17+
def timeout(_)
18+
self
19+
end
20+
21+
def get(_url)
22+
raise HTTP::ConnectionError, 'forced connection failure'
23+
end
24+
end
25+
26+
class LibkeyParsingError
27+
def timeout(_)
28+
self
29+
end
30+
31+
def get(_url)
32+
LibkeyMockResponse.new(200, 'This is not valid json')
33+
end
34+
end
35+
36+
class LibkeyTest < ActiveSupport::TestCase
37+
test 'enabled? method returns true if both env are set' do
38+
ClimateControl.modify(LIBKEY_ID: 'foo', LIBKEY_KEY: 'bar') do
39+
assert Libkey.enabled?
40+
end
41+
end
42+
43+
test 'enabled? method returns false if either env not set' do
44+
ClimateControl.modify(LIBKEY_ID: nil) do
45+
refute Libkey.enabled?
46+
end
47+
48+
ClimateControl.modify(LIBKEY_KEY: nil) do
49+
refute Libkey.enabled?
50+
end
51+
end
52+
53+
test 'lookup does nothing with a type other than "doi" or "pmid"' do
54+
refute Libkey.lookup(type: 'foo', identifier: 'foobar')
55+
end
56+
57+
test 'lookup does work with any identifier value' do
58+
VCR.use_cassette('libkey nonexistent') do
59+
result = Libkey.lookup(type: 'doi', identifier: 'foobar')
60+
61+
refute result
62+
end
63+
end
64+
65+
test 'lookup gets a valid response for DOIs' do
66+
VCR.use_cassette('libkey doi') do
67+
result = Libkey.lookup(type: 'doi', identifier: '10.1038/s41567-023-02305-y')
68+
69+
assert_instance_of Hash, result
70+
assert_equal result.keys, %i[link text type]
71+
end
72+
end
73+
74+
test 'lookup gets a valid response for PMIDs' do
75+
VCR.use_cassette('libkey pmid') do
76+
result = Libkey.lookup(type: 'pmid', identifier: '22110403')
77+
78+
assert_instance_of Hash, result
79+
assert_equal result.keys, %i[link text type]
80+
end
81+
end
82+
83+
test 'libkey model catches connection errors' do
84+
libkey_client = LibkeyConnectionError.new
85+
86+
result = Libkey.lookup(type: 'doi', identifier: '10.1038/s41567-023-02305-y', libkey_client:)
87+
88+
assert_instance_of Hash, result
89+
assert_equal 'A connection error has occurred', result['error']
90+
end
91+
92+
test 'libkey model catches parsing errors' do
93+
libkey_client = LibkeyParsingError.new
94+
95+
result = Libkey.lookup(type: 'doi', identifier: '10.1038/s41567-023-02305-y', libkey_client:)
96+
97+
assert_instance_of Hash, result
98+
assert_equal 'A parsing error has occurred', result['error']
99+
end
100+
end

test/test_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
config.filter_sensitive_data('FAKE_TACOS_HOST') { ENV.fetch('TACOS_HOST').to_s }
3030
config.filter_sensitive_data('FAKE_TACOS_SOURCE') { ENV.fetch('TACOS_SOURCE').to_s }
3131
config.filter_sensitive_data('http://FAKE_TACOS_HOST/graphql/') { ENV.fetch('TACOS_URL').to_s }
32+
config.filter_sensitive_data('FAKE_LIBKEY_ID') { ENV.fetch('LIBKEY_ID').to_s }
33+
config.filter_sensitive_data('FAKE_LIBKEY_KEY') { ENV.fetch('LIBKEY_KEY').to_s }
3234
end
3335

3436
module ActiveSupport

test/vcr_cassettes/libkey_doi.yml

Lines changed: 65 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)