diff --git a/.env.test b/.env.test index d4f76620..496457d6 100644 --- a/.env.test +++ b/.env.test @@ -13,3 +13,5 @@ TACOS_SOURCE=FAKE_TACOS_SOURCE TIMDEX_GRAPHQL=https://FAKE_TIMDEX_HOST/graphql TIMDEX_HOST=FAKE_TIMDEX_HOST TIMDEX_INDEX=FAKE_TIMDEX_INDEX +LIBKEY_ID=FAKE_LIBKEY_ID +LIBKEY_KEY=FAKE_LIBKEY_KEY diff --git a/README.md b/README.md index 13c8be9c..a3b8d214 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ may have unexpected consequences if applied to other TIMDEX UI apps. - `FILTER_SUBJECT`: The name to use instead of "Subject" for that filter / aggregation. - `GLOBAL_ALERT`: The main functionality for this comes from our theme gem, but when set the value will be rendered as safe html above the main header of the site. +- `LIBKEY_KEY`: An access key assigned by Third Iron to enable this application to interact with the Libkey service. +- `LIBKEY_ID`: An institutional ID value assigned by Third Iron to interact with the Libkey service. - `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. - `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. - `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` 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. +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. + `.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. ### Updating GraphQL Schema diff --git a/app/controllers/libkey_controller.rb b/app/controllers/libkey_controller.rb new file mode 100644 index 00000000..9d926c34 --- /dev/null +++ b/app/controllers/libkey_controller.rb @@ -0,0 +1,15 @@ +class LibkeyController < ApplicationController + layout false + + def lookup + return unless Libkey.enabled? && expected_params? + + @libkey = Libkey.lookup(type: params[:type], identifier: params[:identifier]) + end + + private + + def expected_params? + params[:type].present? && params[:identifier].present? + end +end diff --git a/app/models/libkey.rb b/app/models/libkey.rb new file mode 100644 index 00000000..201c29e9 --- /dev/null +++ b/app/models/libkey.rb @@ -0,0 +1,68 @@ +# TODO: Need some documentation block to explain what we use Libkey for... +class Libkey + class LookupFailure < StandardError; end + + BASEURL = 'https://public-api.thirdiron.com/public/v1/libraries'.freeze + + # enabled? confirms that all required environment variables are set. + # + # @return Boolean + def self.enabled? + libkey_id.present? && libkey_key.present? + end + + def self.lookup(type:, identifier:, libkey_client: nil) + return unless enabled? + return unless %w[doi pmid].include?(type) + + url = libkey_url(type, identifier) + + libkey_http = setup(url, libkey_client) + + begin + raw_response = libkey_http.timeout(6).get(url) + raise LookupFailure, raw_response.status unless raw_response.status == 200 + + json_response = JSON.parse(raw_response.to_s) + extract_metadata(json_response) + rescue LookupFailure => e + Sentry.set_tags('mitlib.libkeyurl': url) + Sentry.set_tags('mitlib.libkeystatus': e.message) + Sentry.capture_message('Unexpected Libkey response status') + nil + rescue HTTP::Error + Rails.logger.error('Libkey connection error') + { 'error' => 'A connection error has occurred' } + rescue JSON::ParserError + Rails.logger.error('Libkey parsing error') + { 'error' => 'A parsing error has occurred' } + end + end + + def self.extract_metadata(external_data) + return unless external_data['data']['bestIntegratorLink'] + + { + link: external_data['data']['bestIntegratorLink']['bestLink'], + text: external_data['data']['bestIntegratorLink']['recommendedLinkText'], + type: external_data['data']['bestIntegratorLink']['linkType'] # Not sure whether this belongs here. + } + end + + def self.libkey_id + ENV.fetch('LIBKEY_ID', nil) + end + + def self.libkey_key + ENV.fetch('LIBKEY_KEY', nil) + end + + def self.libkey_url(type, identifier) + "#{BASEURL}/#{libkey_id}/articles/#{type}/#{identifier}?access_token=#{libkey_key}" + end + + def self.setup(url, libkey_client) + libkey_client || HTTP.persistent(url) + .headers(accept: 'application/json') + end +end diff --git a/app/views/libkey/lookup.html.erb b/app/views/libkey/lookup.html.erb new file mode 100644 index 00000000..339b08f2 --- /dev/null +++ b/app/views/libkey/lookup.html.erb @@ -0,0 +1,3 @@ +<% if Libkey.enabled? && @libkey.present? %> + <%= link_to( @libkey[:text], @libkey[:link], class: 'button button-primary' ) %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 82746036..063e891a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ get 'analyze', to: 'tacos#analyze' + get 'lookup', to: 'libkey#lookup' + get 'record/(:id)', to: 'record#view', as: 'record', diff --git a/test/controllers/libkey_controller_test.rb b/test/controllers/libkey_controller_test.rb new file mode 100644 index 00000000..b77a91df --- /dev/null +++ b/test/controllers/libkey_controller_test.rb @@ -0,0 +1,67 @@ +require 'test_helper' + +class LibkeyControllerTest < ActionDispatch::IntegrationTest + test 'lookup route exists with no content' do + # No cassette because this never results in traffic to Libkey + get '/lookup' + + assert_response :success + assert_equal response.body, '' + end + + test 'lookup route returns nothing without required parameters' do + # No cassettes because these never result in traffic to Libkey + # "type" value only + get '/lookup?type=doi' + + assert_equal response.body, '' + + # "identifier" value only + get '/lookup?identifier=10.1038/s41567-023-02305-y' + + assert_equal response.body, '' + end + + test 'lookup route returns HTML for valid parameters' do + VCR.use_cassette('libkey doi') do + get '/lookup?type=doi&identifier=10.1038/s41567-023-02305-y' + + assert_response :success + assert_select 'a.button-primary', { count: 1 } + end + + VCR.use_cassette('libkey pmid') do + get '/lookup?type=pmid&identifier=22110403' + + assert_response :success + assert_select 'a.button-primary', { count: 1 } + end + end + + test 'lookup for non-existent identifier returns blank' do + # Libkey responds here, so we have a cassette - but the response is empty + VCR.use_cassette('libkey nonexistent') do + get '/lookup?type=doi&identifier=foobar' + + assert_response :success + assert_equal response.body, '' + end + end + + test 'no response when either env var is not set' do + # No cassette because this never results in traffic to Libkey + ClimateControl.modify(LIBKEY_ID: nil) do + get '/lookup?type=doi&identifier=10.1038/s41567-023-02305-y' + + assert_response :success + assert_equal response.body, '' + end + + ClimateControl.modify(LIBKEY_KEY: nil) do + get '/lookup?type=doi&identifier=10.1038/s41567-023-02305-y' + + assert_response :success + assert_equal response.body, '' + end + end +end diff --git a/test/models/libkey_test.rb b/test/models/libkey_test.rb new file mode 100644 index 00000000..25cfed0b --- /dev/null +++ b/test/models/libkey_test.rb @@ -0,0 +1,100 @@ +require 'test_helper' + +class LibkeyMockResponse + attr_reader :status + + def initialize(status, body) + @status = status + @body = body + end + + def to_s + @body + end +end + +class LibkeyConnectionError + def timeout(_) + self + end + + def get(_url) + raise HTTP::ConnectionError, 'forced connection failure' + end +end + +class LibkeyParsingError + def timeout(_) + self + end + + def get(_url) + LibkeyMockResponse.new(200, 'This is not valid json') + end +end + +class LibkeyTest < ActiveSupport::TestCase + test 'enabled? method returns true if both env are set' do + ClimateControl.modify(LIBKEY_ID: 'foo', LIBKEY_KEY: 'bar') do + assert Libkey.enabled? + end + end + + test 'enabled? method returns false if either env not set' do + ClimateControl.modify(LIBKEY_ID: nil) do + refute Libkey.enabled? + end + + ClimateControl.modify(LIBKEY_KEY: nil) do + refute Libkey.enabled? + end + end + + test 'lookup does nothing with a type other than "doi" or "pmid"' do + refute Libkey.lookup(type: 'foo', identifier: 'foobar') + end + + test 'lookup does work with any identifier value' do + VCR.use_cassette('libkey nonexistent') do + result = Libkey.lookup(type: 'doi', identifier: 'foobar') + + refute result + end + end + + test 'lookup gets a valid response for DOIs' do + VCR.use_cassette('libkey doi') do + result = Libkey.lookup(type: 'doi', identifier: '10.1038/s41567-023-02305-y') + + assert_instance_of Hash, result + assert_equal result.keys, %i[link text type] + end + end + + test 'lookup gets a valid response for PMIDs' do + VCR.use_cassette('libkey pmid') do + result = Libkey.lookup(type: 'pmid', identifier: '22110403') + + assert_instance_of Hash, result + assert_equal result.keys, %i[link text type] + end + end + + test 'libkey model catches connection errors' do + libkey_client = LibkeyConnectionError.new + + result = Libkey.lookup(type: 'doi', identifier: '10.1038/s41567-023-02305-y', libkey_client:) + + assert_instance_of Hash, result + assert_equal 'A connection error has occurred', result['error'] + end + + test 'libkey model catches parsing errors' do + libkey_client = LibkeyParsingError.new + + result = Libkey.lookup(type: 'doi', identifier: '10.1038/s41567-023-02305-y', libkey_client:) + + assert_instance_of Hash, result + assert_equal 'A parsing error has occurred', result['error'] + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index a4e9368e..b86f7845 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -29,6 +29,8 @@ config.filter_sensitive_data('FAKE_TACOS_HOST') { ENV.fetch('TACOS_HOST').to_s } config.filter_sensitive_data('FAKE_TACOS_SOURCE') { ENV.fetch('TACOS_SOURCE').to_s } config.filter_sensitive_data('http://FAKE_TACOS_HOST/graphql/') { ENV.fetch('TACOS_URL').to_s } + config.filter_sensitive_data('FAKE_LIBKEY_ID') { ENV.fetch('LIBKEY_ID').to_s } + config.filter_sensitive_data('FAKE_LIBKEY_KEY') { ENV.fetch('LIBKEY_KEY').to_s } end module ActiveSupport diff --git a/test/vcr_cassettes/libkey_doi.yml b/test/vcr_cassettes/libkey_doi.yml new file mode 100644 index 00000000..631677d7 --- /dev/null +++ b/test/vcr_cassettes/libkey_doi.yml @@ -0,0 +1,65 @@ +--- +http_interactions: +- request: + method: get + uri: https://public-api.thirdiron.com/public/v1/libraries/FAKE_LIBKEY_ID/articles/doi/10.1038/s41567-023-02305-y?access_token=FAKE_LIBKEY_KEY + body: + encoding: ASCII-8BIT + string: '' + headers: + Accept: + - application/json + Connection: + - Keep-Alive + Host: + - public-api.thirdiron.com + User-Agent: + - http.rb/5.3.1 + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - Content-Type, Authorization + Access-Control-Allow-Methods: + - DELETE,GET,PATCH,POST,PUT + Access-Control-Allow-Origin: + - "*" + Content-Length: + - '1307' + Content-Security-Policy: + - default-src 'none'; frame-ancestors 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 09 Dec 2025 20:25:09 GMT + Etag: + - W/"51b-nIymkzJ8mpv7d9OTDW10gnDZaQ4" + Nel: + - '{"report_to":"heroku-nel","response_headers":["Via"],"max_age":3600,"success_fraction":0.01,"failure_fraction":0.1}' + Report-To: + - '{"group":"heroku-nel","endpoints":[{"url":"https://nel.heroku.com/reports?s=AkvVQPazK0wer7ykqddKlqluxXaEpPm5bYgCco%2FZCt4%3D\u0026sid=67ff5de4-ad2b-4112-9289-cf96be89efed\u0026ts=1765311909"}],"max_age":3600}' + Reporting-Endpoints: + - heroku-nel="https://nel.heroku.com/reports?s=AkvVQPazK0wer7ykqddKlqluxXaEpPm5bYgCco%2FZCt4%3D&sid=67ff5de4-ad2b-4112-9289-cf96be89efed&ts=1765311909" + Server: + - Heroku + Vary: + - Accept-Encoding + Via: + - 1.1 heroku-router + X-Powered-By: + - Express + X-Ratelimit-Limit: + - '7000' + X-Ratelimit-Remaining: + - '6999' + X-Ratelimit-Reset: + - '1765311970' + body: + encoding: UTF-8 + string: '{"data":{"id":594034193,"type":"articles","title":"Light as fast as + electrons","date":"2023-11-09","authors":"","inPress":false,"abandoned":false,"doi":"10.1038/s41567-023-02305-y","linkResolverOpenUrl":"https://mit.primo.exlibrisgroup.com/openurl/01MIT_INST/01MIT_INST:MIT?genre=article&aulast=&issn=1745-2473&title=Nature%20Physics&atitle=Light%20as%20fast%20as%20electrons&volume=19&issue=11&spage=1520&epage=1520&date=2023-11-09&doi=10.1038%2Fs41567-023-02305-y&rft_id=info:pmid/&sid=LibKey","pmid":"","openAccess":false,"unpaywallUsable":true,"fullTextFile":"https://libkey.io/libraries/FAKE_LIBKEY_ID/articles/594034193/full-text-file?utm_source=api_3735&allow_speedbump=true","contentLocation":"https://libkey.io/libraries/FAKE_LIBKEY_ID/articles/594034193/content-location?utm_source=api_3735&allow_speedbump=true","availableThroughBrowzine":true,"hasSecondOrderRetractions":false,"startPage":"1520","endPage":"1520","avoidUnpaywallPublisherLinks":true,"browzineWebLink":"https://browzine.com/libraries/FAKE_LIBKEY_ID/journals/13123/issues/541037047?showArticleInContext=doi:10.1038%2Fs41567-023-02305-y&utm_source=api_3735","bestIntegratorLink":{"bestLink":"https://libkey.io/libraries/FAKE_LIBKEY_ID/articles/594034193/full-text-file?utm_source=api_3735&allow_speedbump=true","linkType":"fullTextFile","recommendedLinkText":"Download + PDF"}}}' + recorded_at: Tue, 09 Dec 2025 20:25:09 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/libkey_nonexistent.yml b/test/vcr_cassettes/libkey_nonexistent.yml new file mode 100644 index 00000000..429303bc --- /dev/null +++ b/test/vcr_cassettes/libkey_nonexistent.yml @@ -0,0 +1,57 @@ +--- +http_interactions: +- request: + method: get + uri: https://public-api.thirdiron.com/public/v1/libraries/FAKE_LIBKEY_ID/articles/doi/foobar?access_token=FAKE_LIBKEY_KEY + body: + encoding: ASCII-8BIT + string: '' + headers: + Accept: + - application/json + Connection: + - Keep-Alive + Host: + - public-api.thirdiron.com + User-Agent: + - http.rb/5.3.1 + response: + status: + code: 404 + message: Not Found + headers: + Access-Control-Allow-Headers: + - Content-Type, Authorization + Access-Control-Allow-Methods: + - DELETE,GET,PATCH,POST,PUT + Access-Control-Allow-Origin: + - "*" + Content-Security-Policy: + - default-src 'none'; frame-ancestors 'none' + Date: + - Wed, 10 Dec 2025 14:55:50 GMT + Nel: + - '{"report_to":"heroku-nel","response_headers":["Via"],"max_age":3600,"success_fraction":0.01,"failure_fraction":0.1}' + Report-To: + - '{"group":"heroku-nel","endpoints":[{"url":"https://nel.heroku.com/reports?s=6pmPm8djlU8BRDNDRKVl%2FIPFa2lO7srjRu4hTSRPgDE%3D\u0026sid=67ff5de4-ad2b-4112-9289-cf96be89efed\u0026ts=1765378550"}],"max_age":3600}' + Reporting-Endpoints: + - heroku-nel="https://nel.heroku.com/reports?s=6pmPm8djlU8BRDNDRKVl%2FIPFa2lO7srjRu4hTSRPgDE%3D&sid=67ff5de4-ad2b-4112-9289-cf96be89efed&ts=1765378550" + Server: + - Heroku + Via: + - 1.1 heroku-router + X-Powered-By: + - Express + X-Ratelimit-Limit: + - '7000' + X-Ratelimit-Remaining: + - '6999' + X-Ratelimit-Reset: + - '1765378611' + Content-Length: + - '0' + body: + encoding: ASCII-8BIT + string: '' + recorded_at: Wed, 10 Dec 2025 14:55:50 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/libkey_pmid.yml b/test/vcr_cassettes/libkey_pmid.yml new file mode 100644 index 00000000..c6d8b2b5 --- /dev/null +++ b/test/vcr_cassettes/libkey_pmid.yml @@ -0,0 +1,100 @@ +--- +http_interactions: +- request: + method: get + uri: https://public-api.thirdiron.com/public/v1/libraries/FAKE_LIBKEY_ID/articles/pmid/22110403?access_token=FAKE_LIBKEY_KEY + body: + encoding: ASCII-8BIT + string: '' + headers: + Accept: + - application/json + Connection: + - Keep-Alive + Host: + - public-api.thirdiron.com + User-Agent: + - http.rb/5.3.1 + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - Content-Type, Authorization + Access-Control-Allow-Methods: + - DELETE,GET,PATCH,POST,PUT + Access-Control-Allow-Origin: + - "*" + Content-Length: + - '4164' + Content-Security-Policy: + - default-src 'none'; frame-ancestors 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 09 Dec 2025 20:25:20 GMT + Etag: + - W/"1044-eDnlVGmO9KBtCy+GGTDdrzbkfxk" + Nel: + - '{"report_to":"heroku-nel","response_headers":["Via"],"max_age":3600,"success_fraction":0.01,"failure_fraction":0.1}' + Report-To: + - '{"group":"heroku-nel","endpoints":[{"url":"https://nel.heroku.com/reports?s=8gwp5cqf%2BB1r58WIKVwTcJbyCXv9S2nPUDlHGMmQ4Ss%3D\u0026sid=67ff5de4-ad2b-4112-9289-cf96be89efed\u0026ts=1765311920"}],"max_age":3600}' + Reporting-Endpoints: + - heroku-nel="https://nel.heroku.com/reports?s=8gwp5cqf%2BB1r58WIKVwTcJbyCXv9S2nPUDlHGMmQ4Ss%3D&sid=67ff5de4-ad2b-4112-9289-cf96be89efed&ts=1765311920" + Server: + - Heroku + Vary: + - Accept-Encoding + Via: + - 1.1 heroku-router + X-Powered-By: + - Express + X-Ratelimit-Limit: + - '7000' + X-Ratelimit-Remaining: + - '6998' + X-Ratelimit-Reset: + - '1765311970' + body: + encoding: UTF-8 + string: '{"data":{"id":22766308,"type":"articles","title":"Interplay between + BRCA1 and RHAMM Regulates Epithelial Apicobasal Polarization and May Influence + Risk of Breast Cancer","date":"2011-11-15","authors":"Maxwell, Christopher + A.; Benítez, Javier; Gómez-Baldó, Laia; Osorio, Ana; Bonifaci, Núria; Fernández-Ramires, + Ricardo; Costes, Sylvain V.; Guinó, Elisabet; Chen, Helen; Evans, Gareth J. + R.; Mohan, Pooja; Català, Isabel; Petit, Anna; Aguilar, Helena; Villanueva, + Alberto; Aytes, Alvaro; Serra-Musach, Jordi; Rennert, Gad; Lejbkowicz, Flavio; + Peterlongo, Paolo; Manoukian, Siranoush; Peissel, Bernard; Ripamonti, Carla + B.; Bonanni, Bernardo; Viel, Alessandra; Allavena, Anna; Bernard, Loris; Radice, + Paolo; Friedman, Eitan; Kaufman, Bella; Laitman, Yael; Dubrovsky, Maya; Milgrom, + Roni; Jakubowska, Anna; Cybulski, Cezary; Gorski, Bohdan; Jaworska, Katarzyna; + Durda, Katarzyna; Sukiennicki, Grzegorz; Lubiński, Jan; Shugart, Yin Yao; + Domchek, Susan M.; Letrero, Richard; Weber, Barbara L.; Hogervorst, Frans + B. L.; Rookus, Matti A.; Collee, J. Margriet; Devilee, Peter; Ligtenberg, + Marjolijn J.; van der Luijt, Rob B.; Aalfs, Cora M.; Waisfisz, Quinten; Wijnen, + Juul; van Roozendaal, Cornelis E. P.; Easton, Douglas F.; Peock, Susan; Cook, + Margaret; Oliver, Clare; Frost, Debra; Harrington, Patricia; Evans, D. Gareth; + Lalloo, Fiona; Eeles, Rosalind; Izatt, Louise; Chu, Carol; Eccles, Diana; + Douglas, Fiona; Brewer, Carole; Nevanlinna, Heli; Heikkinen, Tuomas; Couch, + Fergus J.; Lindor, Noralane M.; Wang, Xianshu; Godwin, Andrew K.; Caligo, + Maria A.; Lombardi, Grazia; Loman, Niklas; Karlsson, Per; Ehrencrona, Hans; + von Wachenfeldt, Anna; Bjork Barkardottir, Rosa; Hamann, Ute; Rashid, Muhammad + U.; Lasa, Adriana; Caldés, Trinidad; Andrés, Raquel; Schmitt, Michael; Assmann, + Volker; Stevens, Kristen; Offit, Kenneth; Curado, João; Tilgner, Hagen; Guigó, + Roderic; Aiza, Gemma; Brunet, Joan; Castellsagué, Joan; Martrat, Griselda; + Urruticoechea, Ander; Blanco, Ignacio; Tihomirova, Laima; Goldgar, David E.; + Buys, Saundra; John, Esther M.; Miron, Alexander; Southey, Melissa; Daly, + Mary B.; Schmutzler, Rita K.; Wappenschmidt, Barbara; Meindl, Alfons; Arnold, + Norbert; Deissler, Helmut; Varon-Mateeva, Raymonda; Sutter, Christian; Niederacher, + Dieter; Imyamitov, Evgeny; Sinilnikova, Olga M.; Stoppa-Lyonne, Dominique; + Mazoyer, Sylvie; Verny-Pierre, Carole; Castera, Laurent; de Pauw, Antoine; + Bignon, Yves-Jean; Uhrhammer, Nancy; Peyrat, Jean-Philippe; Vennin, Philippe; + Fert Ferrer, Sandra; Collonge-Rame, Marie-Agnès; Mortemousque, Isabelle; Spurdle, + Amanda B.; Beesley, Jonathan; Chen, Xiaoqing; Healey, Sue; Barcellos-Hoff, + Mary Helen; Vidal, Marc; Gruber, Stephen B.; Lázaro, Conxi; Capellá, Gabriel; + McGuffog, Lesley; Nathanson, Katherine L.; Antoniou, Antonis C.; Chenevix-Trench, + Georgia; Fleisch, Markus C.; Moreno, Víctor; Pujana, Miguel Angel","inPress":false,"abandoned":false,"doi":"10.1371/journal.pbio.1001199","linkResolverOpenUrl":"https://mit.primo.exlibrisgroup.com/openurl/01MIT_INST/01MIT_INST:MIT?genre=article&aulast=Maxwell&issn=1544-9173&title=PLoS%20Biology&atitle=Interplay%20between%20BRCA1%20and%20RHAMM%20Regulates%20Epithelial%20Apicobasal%20Polarization%20and%20May%20Influence%20Risk%20of%20Breast%20Cancer&volume=9&issue=11&spage=e1001199&epage=&date=2011-11-15&doi=10.1371%2Fjournal.pbio.1001199&rft_id=info:pmid/22110403&sid=LibKey","pmid":"22110403","openAccess":true,"unpaywallUsable":true,"fullTextFile":"https://libkey.io/libraries/FAKE_LIBKEY_ID/articles/22766308/full-text-file?utm_source=api_3735&allow_speedbump=true","contentLocation":"https://libkey.io/libraries/FAKE_LIBKEY_ID/articles/22766308/content-location?utm_source=api_3735&allow_speedbump=true","availableThroughBrowzine":true,"hasSecondOrderRetractions":true,"startPage":"e1001199","endPage":"","browzineWebLink":"https://browzine.com/libraries/FAKE_LIBKEY_ID/journals/8181/issues/4311778?showArticleInContext=doi:10.1371%2Fjournal.pbio.1001199&utm_source=api_3735","bestIntegratorLink":{"bestLink":"https://libkey.io/libraries/FAKE_LIBKEY_ID/articles/22766308/full-text-file?utm_source=api_3735&allow_speedbump=true","linkType":"fullTextFile","recommendedLinkText":"Download + PDF"}}}' + recorded_at: Tue, 09 Dec 2025 20:25:20 GMT +recorded_with: VCR 6.3.1