diff --git a/Gemfile b/Gemfile index 59a20e9d7..485ba2354 100644 --- a/Gemfile +++ b/Gemfile @@ -89,6 +89,7 @@ gem "devise" gem "devise-guests" gem 'devise-remote-user' gem "faraday" +gem 'oauth2' gem "config" gem "mods_display", "~> 1.1" gem "font-awesome-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 51f667a7d..9e0ff3850 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -730,6 +730,7 @@ DEPENDENCIES mysql2 newrelic_rpm nokogiri (>= 1.7.1) + oauth2 okcomputer parslet (~> 2.0) puma (~> 6.0) diff --git a/app/components/citations/citation_component.html.erb b/app/components/citations/citation_component.html.erb new file mode 100644 index 000000000..03bf0696c --- /dev/null +++ b/app/components/citations/citation_component.html.erb @@ -0,0 +1,12 @@ +<% citations.each do |style, citation| %> +
No citation available for this record
'.html_safe }.freeze + + # @param document [SolrDocument] A document to generate citations for + def initialize(document) @document = document - @formats = formats end + # @return [Boolean] Whether or not the document is citable def citable? - field.present? || citations_from_mods.present? || citations_from_eds.present? + show_oclc_citation? || citations_from_mods.present? || citations_from_eds.present? end + # @return [Hash] A hash of all citations for the document + # in the form of { citation_style => [citation_text] } def citations - return null_citation if return_null_citation? - return all_citations if all_formats_requested? - - all_citations.select do |format, _| - desired_formats.include?(format) - end + all_citations.presence || NULL_CITATION end - def api_url - "#{base_url}/#{field}?cformat=all&wskey=#{api_key}" + # @return [Hash] A hash of MODS citations for the document + # Used when assembling citations for multiple documents + # in the form of { citation_style => [citation_text] } + def mods_citations + citations_from_mods.presence || {} end - class << self - def grouped_citations(all_citations) - citations = all_citations.each_with_object({}) do |cites, hash| - cites.each do |format, citation| - hash[format] ||= [] - hash[format] << citation - end - end - # Append preferred citations to front of hash - citations = { - preferred_citation_key => citations[preferred_citation_key] - }.merge(citations.except(preferred_citation_key)) if citations[preferred_citation_key] - citations - end - - def preferred_citation_key - 'PREFERRED CITATION' - end - - # This being a valid test URL is predicated on the fact - # that passing no OCLC number to the citations API responds successfully - def test_api_url - new(SolrDocument.new).api_url - end + # @return [Hash] A hash of EDS citations for the document + # Used when assembling citations for multiple documents + # in the form of { citation_style => [citation_text] } + def eds_citations + citations_from_eds.presence || {} end private - attr_reader :document, :formats + attr_reader :document - def return_null_citation? - all_citations.blank? || (field.blank? && all_citations.blank?) - end - - def element_is_citation?(element) - element.attributes && - element.attributes['class'] && - element.attributes['class'].value =~ /^citation_style_/i - end - - def all_formats_requested? - desired_formats == ['ALL'] - end + delegate :oclc_number, to: :document def all_citations @all_citations ||= begin citation_hash = {} - if citations_from_mods.present? - citation_hash[self.class.preferred_citation_key] = "#{citations_from_mods}
".html_safe - end + citation_hash.merge!(citations_from_mods) if citations_from_mods.present? citation_hash.merge!(citations_from_eds) if citations_from_eds.present? + citation_hash.merge!(citations_from_oclc) if citations_from_oclc.present? - citation_hash.merge!(citations_from_oclc_response) if field.present? citation_hash end end - def citations_from_oclc_response - Nokogiri::HTML(response).css('p').each_with_object({}) do |element, hash| - next unless element_is_citation?(element) + def citations_from_oclc + return unless show_oclc_citation? - element.attributes['class'].value[/^citation_style_(.*)$/i] - hash[Regexp.last_match[1].upcase] = element.to_html.html_safe - end + @citations_from_oclc ||= Citations::OclcCitation.new(oclc_numbers: oclc_number).citations_by_oclc_number.fetch(oclc_number, {}) end def citations_from_mods return unless document.mods && document.mods.note.present? - document.mods.note.find do |note| - note.label.downcase =~ /preferred citation:?/ - end.try(:values).try(:join) + @citations_from_mods ||= Citations::ModsCitation.new(notes: document.mods.note).all_citations end def citations_from_eds return unless document.eds? && document['eds_citation_styles'].present? - document['eds_citation_styles'].each_with_object({}) do |citation, hash| - next unless citation['id'] && citation['data'] - - hash[citation['id'].upcase] = citation['data'].html_safe - end - end - - def response - @response ||= begin - Faraday.get(api_url).body - rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e - Rails.logger.warn("HTTP GET for #{api_url} failed with #{e}") - '' - end - end - - def field - Array(document[config.DOCUMENT_FIELD]).try(:first) - end - - def desired_formats - return config.CITATION_FORMATS.map(&:upcase) unless formats.present? - - formats.map(&:upcase) - end - - def base_url - config.BASE_URL - end - - def api_key - config.API_KEY - end - - def config - Settings.OCLC + @citations_from_eds ||= Citations::EdsCitation.new(eds_citation_styles: document['eds_citation_styles']).all_citations end - def null_citation - { 'NULL' => 'No citation available for this record
'.html_safe } + def show_oclc_citation? + Settings.oclc_discovery.citations.enabled && oclc_number.present? end end diff --git a/app/models/citations/eds_citation.rb b/app/models/citations/eds_citation.rb new file mode 100644 index 000000000..d761a2ef1 --- /dev/null +++ b/app/models/citations/eds_citation.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +### +# Returns an EDS citation formatted for use by SearchWorks +module Citations + class EdsCitation + CITATION_STYLES = %w[apa chicago harvard mla turabian].freeze + + attr_reader :eds_citation_styles + + # @param eds_citation_styles [Array#{mods_citation}
".html_safe } if mods_citation.present? # rubocop:disable Rails/OutputSafety + + {} + end + + private + + def mods_citation + notes.find { |note| note.label.downcase.match?(/preferred citation:?/) }&.values&.join + end + end +end diff --git a/app/models/citations/oclc_citation.rb b/app/models/citations/oclc_citation.rb new file mode 100644 index 000000000..94fe388e1 --- /dev/null +++ b/app/models/citations/oclc_citation.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +### +# Returns an OCLC citation formatted for use by SearchWorks +module Citations + class OclcCitation + CITATION_STYLES = %w[apa + chicago-author-date + harvard-cite-them-right + modern-language-association + turabian-author-date].freeze + + attr_reader :oclc_numbers + + # @param oclc_numbers [ArrayMLA Citation
' }, citable?: true) visit root_path end diff --git a/spec/features/bookmarking_items_spec.rb b/spec/features/bookmarking_items_spec.rb index f9e26acfd..8ca002432 100644 --- a/spec/features/bookmarking_items_spec.rb +++ b/spec/features/bookmarking_items_spec.rb @@ -3,13 +3,17 @@ require 'rails_helper' RSpec.feature 'Bookmarking Items' do - context 'Citations', :js do - let(:citations) { 'MLA Citation
' } + let(:oclc_citation) { instance_double(Citations::OclcCitation) } - before { stub_oclc_response(citations, for: '12345') } + before do + allow(Citations::OclcCitation).to receive(:new).and_return(oclc_citation) + allow(oclc_citation).to receive_messages( + citations_by_oclc_number: { '12345' => { 'mla' => 'MLA Citation
' } } + ) + end + context 'Citations', :js do it 'is viewable grouped by title and citation format' do - skip('Fails intermitently on Travis.') if ENV['CI'] visit root_path fill_in :q, with: '' click_button 'search' @@ -35,10 +39,9 @@ wait_for_ajax within('.modal-dialog') do - expect(page).to have_css('h4', text: 'MLA', count: 2) + expect(page).to have_css('div#all') click_button 'By citation format' - expect(page).to have_css('h4', text: 'MLA', count: 1) - expect(page).to have_css('p.citation_style_MLA', count: 2) + expect(page).to have_css('div#biblio') end end end diff --git a/spec/features/responsive/record_toolbar_responsive_spec.rb b/spec/features/responsive/record_toolbar_responsive_spec.rb index 23f015351..b5fdf2a1c 100644 --- a/spec/features/responsive/record_toolbar_responsive_spec.rb +++ b/spec/features/responsive/record_toolbar_responsive_spec.rb @@ -3,8 +3,11 @@ require 'rails_helper' RSpec.describe "Record toolbar", :feature, :js do + let(:citation) { instance_double(Citation) } + before do - stub_oclc_response('', for: '12345') + allow(Citation).to receive(:new).and_return(citation) + allow(citation).to receive_messages(all_citations: { 'mla' => 'MLA Citation
' }, citable?: true) end describe " - tablet view (768px - 980px) - " do diff --git a/spec/helpers/catalog_helper_spec.rb b/spec/helpers/catalog_helper_spec.rb index 43d9c87b3..b51d743ae 100644 --- a/spec/helpers/catalog_helper_spec.rb +++ b/spec/helpers/catalog_helper_spec.rb @@ -73,17 +73,6 @@ end end - describe '#grouped_citations' do - it 'sends all the given document citations to the grouped_citations method of the Citation class' do - documents = [ - double('Document', citations: :abc), - double('Document', citations: :def) - ] - expect(Citation).to receive(:grouped_citations).with([:abc, :def]) - grouped_citations(documents) - end - end - describe '#tech_details' do context 'marc document' do let(:document) { SolrDocument.new(id: '12345', marc_json_struct: metadata1) } diff --git a/spec/models/citation_spec.rb b/spec/models/citation_spec.rb index ea14a330f..af24d94c2 100644 --- a/spec/models/citation_spec.rb +++ b/spec/models/citation_spec.rb @@ -4,163 +4,77 @@ RSpec.describe Citation do include ModsFixtures - subject { described_class.new(document, formats) } - - let(:citations) do - [ - 'MLA Citation
', - 'APA Citation
' - ] - end let(:document) { SolrDocument.new } - let(:eds_document) do - SolrDocument.new( - eds_title: 'The Title', - eds_citation_styles: [ - { 'id': 'APA', 'data': 'Citation Content' }, - { 'status': 'error', 'description': 'Could not do a thing' } - ] - ) + let(:mods_citation) { instance_double(Citations::ModsCitation, all_citations: { 'preferred' => 'Mods citation content' }) } + let(:eds_citation) { instance_double(Citations::EdsCitation, all_citations: { 'apa' => 'EDS citation content' }) } + let(:oclc_citation) do + instance_double(Citations::OclcCitation, citations_by_oclc_number: { '12345' => { 'harvard' => 'OCLC citation content' } }) end - let(:formats) { [] } - let(:oclc_response) { '' } - let(:stub_opts) { {} } + let(:oclc_enabled) { true } - before { stub_oclc_response(oclc_response, stub_opts) } + subject { described_class.new(document) } - describe '#citable?' do - context 'when there is no OCLC number, MODS citation, or EDS citation' do - it 'is false' do - expect(subject).not_to be_citable - end - end + before do + allow(Settings.oclc_discovery.citations).to receive(:enabled).and_return(oclc_enabled) + end - context 'when there is an OCLC number' do - let(:stub_opts) { { for: '12345' } } + context 'when OCLC is not configured and there are no other citations' do + let(:oclc_enabled) { false } + let(:document) { SolrDocument.new(oclc: '12345') } + + it { expect(subject).not_to be_citable } - it 'is true' do - expect(subject).to be_citable - end + it 'returns the null citation' do + expect(subject.citations).to eq({ 'NULL' => 'No citation available for this record
' }) end + end - context 'when there is a MODS citation' do - let(:document) { SolrDocument.new(modsxml: mods_preferred_citation) } + context 'when there is an OCLC number' do + let(:document) { SolrDocument.new(oclc: '12345') } - it 'is true' do - skip('Passes locally, not on Travis.') if ENV['CI'] - expect(subject).to be_citable - end + before do + allow(Citations::OclcCitation).to receive(:new).and_return(oclc_citation) end - context 'when there is an EDS citation' do - let(:document) { eds_document } + it { expect(subject).to be_citable } - it 'is true' do - expect(subject).to be_citable - end + it 'returns the OCLC citations' do + expect(subject.citations).to eq({ 'harvard' => 'OCLC citation content' }) end end - describe '#citations' do - context 'from OCLC' do - context 'when there is no OCLC number' do - it 'returns the NULL citation' do - expect(subject.citations.keys.length).to eq 1 - expect(subject.citations['NULL']).to eq 'No citation available for this record
' - end - end - - context 'when there is no data returned from OCLC' do - let(:document) { SolrDocument.new(oclc: '12345') } - - it 'returns the NULL citation' do - expect(subject.citations.keys.length).to eq 1 - expect(subject.citations['NULL']).to eq 'No citation available for this record
' - end - end - - context 'when all formats are requested' do - let(:document) { SolrDocument.new(oclc: '12345') } - let(:formats) { ['ALL'] } - let(:oclc_response) { citations.join } - - it 'all formats from the OCLC response are returned' do - expect(subject.citations.keys.length).to eq 2 - expect(subject.citations['MLA']).to match %r{^MLA Citation
$} - expect(subject.citations['APA']).to match %r{^APA Citation
$} - end - end - - context 'when a specific format is requested' do - let(:document) { SolrDocument.new(oclc: '12345') } - let(:formats) { ['APA'] } - let(:oclc_response) { citations.join } - - it 'only the requested format is returned from the OCLC response' do - expect(subject.citations.keys.length).to eq 1 - expect(subject.citations['APA']).to match %r{^APA Citation
$} - end - end + context 'when there is an EDS citation' do + let(:document) do + SolrDocument.new( + eds_title: 'The Title', + eds_citation_styles: [ + { 'id': 'APA', 'data': 'Citation Content' } + ] + ) end - context 'from MODS' do - let(:document) { SolrDocument.new(modsxml: mods_preferred_citation) } - - it 'returns the preferred citation note' do - skip('Passes locally, not on Travis.') if ENV['CI'] - expect(subject.citations.keys).to eq ['PREFERRED CITATION'] - expect(subject.citations['PREFERRED CITATION']).to eq 'This is the preferred citation data
' - end + before do + allow(Citations::EdsCitation).to receive(:new).and_return(eds_citation) end - context 'from EDS' do - let(:document) { eds_document } + it { expect(subject).to be_citable } - it 'returns the citations from the formatted EDS data' do - expect(subject.citations.keys).to eq(['APA']) - expect(subject.citations['APA']).to eq 'Citation Content' - end + it 'returns the EDS citations' do + expect(subject.citations).to eq({ 'apa' => 'EDS citation content' }) end end - describe '#api_url' do - let(:document) { SolrDocument.new(oclc: '12345') } - - it 'returns a URL with the given document field' do - expect(subject.api_url).to match %r{/citations/12345\?cformat=all} - end - end + context 'when there is a MODS citation' do + let(:document) { SolrDocument.new(modsxml: mods_preferred_citation) } - describe '.grouped_citations' do - it 'groups the citations based on their format' do - citations = [ - { 'APA' => 'APA Citation1' }, - { 'MLA' => 'MLA Citation1' }, - { 'APA' => 'APA Citation2' } - ] - - grouped_citations = described_class.grouped_citations(citations) - expect(grouped_citations.keys.length).to eq 2 - expect(grouped_citations['APA']).to eq ['APA Citation1', 'APA Citation2'] - expect(grouped_citations['MLA']).to eq ['MLA Citation1'] + before do + allow(Citations::ModsCitation).to receive(:new).and_return(mods_citation) end - it 'assures the preferred citation shows up first' do - citations = [ - { 'APA' => 'APA Citation1' }, - { 'PREFERRED CITATION' => 'Preferred Citation1' }, - { 'APA' => 'APA Citation2' } - ] - - grouped_citations = described_class.grouped_citations(citations) - expect(grouped_citations.keys.length).to eq 2 - expect(grouped_citations.keys.first).to eq 'PREFERRED CITATION' - end - end + it { expect(subject).to be_citable } - describe '.test_api_url' do - it 'is a URL without the field present' do - expect(described_class.test_api_url).to match %r{/citations/\?cformat=all} + it 'returns the MODS citations' do + expect(subject.citations).to eq({ 'preferred' => 'Mods citation content' }) end end end diff --git a/spec/models/citations/eds_citation_spec.rb b/spec/models/citations/eds_citation_spec.rb new file mode 100644 index 000000000..8042116c3 --- /dev/null +++ b/spec/models/citations/eds_citation_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Citations::EdsCitation do + let(:eds_citation_styles) do + [ + { 'id' => 'apa', 'data' => 'Citation Content' }, + { 'status' => 'error', 'description' => 'Could not do a thing' }, + { 'id' => 'somestyle', 'data' => 'Citation that should not display' }, + { 'id' => 'mla' }, + { 'data' => 'Citation that should not display' } + ] + end + + subject(:eds_citation) { described_class.new(eds_citation_styles:) } + + describe '#all_citations' do + it 'returns a hash with the available citations' do + expect(eds_citation.all_citations).to eq('apa' => 'Citation Content') + end + end +end diff --git a/spec/models/citations/mods_citation_spec.rb b/spec/models/citations/mods_citation_spec.rb new file mode 100644 index 000000000..4d17be227 --- /dev/null +++ b/spec/models/citations/mods_citation_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Citations::ModsCitation do + include ModsFixtures + + let(:note) { SolrDocument.new(modsxml: mods_preferred_citation).mods.note } + + subject(:mods_citation) { described_class.new(notes: note) } + + describe '#all_citations' do + it 'returns a hash with the preferred citation' do + expect(mods_citation.all_citations).to eq({ 'preferred' => 'This is the preferred citation data
' }) + end + end +end diff --git a/spec/models/citations/oclc_citation_spec.rb b/spec/models/citations/oclc_citation_spec.rb new file mode 100644 index 000000000..5710c9817 --- /dev/null +++ b/spec/models/citations/oclc_citation_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Citations::OclcCitation do + let(:oclc_numbers) { '123456' } + let(:oclc_citation) { described_class.new(oclc_numbers:) } + let(:oclc_client) { instance_double(OclcDiscoveryClient) } + let(:oclc_enabled) { true } + + before do + allow(OclcDiscoveryClient).to receive(:new).and_return(oclc_client) + allow(oclc_client).to receive(:citations).and_return( + ['entries' => [{ 'oclcNumber' => '12345', 'style' => 'apa', 'citationText' => 'Citation Content' }]] + ) + allow(Settings.oclc_discovery.citations).to receive(:enabled).and_return(oclc_enabled) + end + + describe '#citations_by_oclc_number' do + it 'returns a hash with the available citations' do + expect(oclc_citation.citations_by_oclc_number).to( + eq({ '12345' => { 'apa' => 'Citation Content' } }) + ) + end + end + + context 'when OCLC is not configured' do + let(:oclc_enabled) { false } + + it 'returns an empty hash' do + expect(oclc_citation.citations_by_oclc_number).to eq({}) + end + end +end diff --git a/spec/services/oclc_discovery_client_spec.rb b/spec/services/oclc_discovery_client_spec.rb new file mode 100644 index 000000000..c5d6fb38e --- /dev/null +++ b/spec/services/oclc_discovery_client_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OclcDiscoveryClient do + subject(:client) { described_class.new(base_url:, client_key:, client_secret:, token_url:, authorize_url:) } + + let(:base_url) { 'https://oclc.example.edu' } + let(:client_key) { 'client-key' } + let(:client_secret) { 'client-secret' } + let(:token_url) { 'https://oclc.example.edu/token?scope=DISCOVERY_Citations&grant_type=client_credentials' } + let(:authorize_url) { 'https://oclc.example.edu/authorize' } + + let(:oauth_client) { instance_double(OAuth2::Client) } + + before do + allow(OAuth2::Client).to receive(:new).with(client_key, client_secret, site: base_url, token_url:, authorize_url:).and_return(oauth_client) + allow(oauth_client).to receive_message_chain(:client_credentials, :get_token, :token).and_return('token') # rubocop:disable RSpec/MessageChain + end + + describe '#ping' do + subject(:ping) { client.ping } + + it 'returns true if the session token is present' do + expect(ping).to be true + end + end + + describe '#citations' do + subject(:citations) { client.citations(oclc_numbers: '905869', citation_style: 'modern-language-association') } + + before do + stub_request(:get, "https://oclc.example.edu/reference/citations?oclcNumbers=905869&style=modern-language-association") + .with(headers: { 'Accept' => 'application/json', + 'Accept-Language' => 'en', + 'Authorization' => 'Bearer token', + 'Connection' => 'close', + 'Host' => 'oclc.example.edu', + 'User-Agent' => 'Stanford Libraries SearchWorks' }) + .to_return(status: 200, body: "{\"entries\":\"citation\"}", headers: {}) + end + + it 'returns citations' do + expect(citations).to eq([{ 'entries' => 'citation' }]) + end + end +end diff --git a/spec/support/stub_oclc_response.rb b/spec/support/stub_oclc_response.rb deleted file mode 100644 index 8adea78be..000000000 --- a/spec/support/stub_oclc_response.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -## -# Simple module included for RSpec tests to stub OCLC citation responses -module StubOclcResponse - def stub_oclc_response(response, opts = {}) - allow_any_instance_of(Citation).to receive(:field).and_return(opts[:for]) if opts[:for] - allow_any_instance_of(Citation).to receive(:response).and_return(response) - end -end - -RSpec.configure do |config| - config.include StubOclcResponse -end