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| %> +
+ <% unless style == 'NULL' %> +

<%= t("searchworks.citations.styles.#{style}") %>

+ <% end %> + <% Array(citation).each do |cite| %> +
+ <%= cite %> +
+ <% end %> +
+<% end %> diff --git a/app/components/citations/citation_component.rb b/app/components/citations/citation_component.rb new file mode 100644 index 000000000..c4a09fee5 --- /dev/null +++ b/app/components/citations/citation_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Citations + class CitationComponent < ViewComponent::Base + attr_reader :citations + + def initialize(citations:) + @citations = citations + super() + end + + def render? + citations.present? + end + end +end diff --git a/app/components/citations/grouped_citation_component.html.erb b/app/components/citations/grouped_citation_component.html.erb new file mode 100644 index 000000000..1075b9356 --- /dev/null +++ b/app/components/citations/grouped_citation_component.html.erb @@ -0,0 +1 @@ +<%= render Citations::CitationComponent.new(citations: grouped_citations) %> diff --git a/app/components/citations/grouped_citation_component.rb b/app/components/citations/grouped_citation_component.rb new file mode 100644 index 000000000..84e0db0b0 --- /dev/null +++ b/app/components/citations/grouped_citation_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Citations + class GroupedCitationComponent < ViewComponent::Base + attr_reader :citations + + PREFERRED_CITATION_KEY = 'preferred' + + # @param [Array] citations in the form of [{ citation_style => citation_text }] + def initialize(citations:) + @citations = citations + super() + end + + # @return [Hash] A hash of citations grouped by style in the form of { citation_style => [citation_text] } + def grouped_citations + citation_styles.index_with { |style| citations.pluck(style).compact } + end + + def render? + citations.present? + end + + private + + def citation_styles + keys = citations.map(&:keys).flatten.uniq + # It doesn't make sense to display the NULL citation + # when grouping citations by style so remove it from the list + keys.delete('NULL') + + # If the preferred citation is present, move it to the front of the list + # so that it always displays first + return keys unless keys.include?(PREFERRED_CITATION_KEY) + + keys.delete(PREFERRED_CITATION_KEY) + keys.unshift(PREFERRED_CITATION_KEY) + end + end +end diff --git a/app/components/citations/multiple_citations_component.html.erb b/app/components/citations/multiple_citations_component.html.erb new file mode 100644 index 000000000..bd69227a5 --- /dev/null +++ b/app/components/citations/multiple_citations_component.html.erb @@ -0,0 +1,13 @@ +
+
+ <% @documents.each do |document| %> +

<%= helpers.document_presenter(document).heading %>

+ <%= render Citations::CitationComponent.new(citations: citations(document)) %> + <% end %> +
+
+
+ <%= render Citations::GroupedCitationComponent.new(citations: @documents.map { |doc| citations(doc) }) %> +
+
+
diff --git a/app/components/citations/multiple_citations_component.rb b/app/components/citations/multiple_citations_component.rb new file mode 100644 index 000000000..5c803568f --- /dev/null +++ b/app/components/citations/multiple_citations_component.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Citations + class MultipleCitationsComponent < ViewComponent::Base + attr_reader :documents, :oclc_citations + + # @param [Array] documents to generate citations for + # @param [Hash] oclc_citations in the form of { oclc_number => { citation_style => citation_text } } + # for lookup of pre-fetched OCLC citations + def initialize(documents:, oclc_citations:) + @documents = documents + @oclc_citations = oclc_citations + super() + end + + # @return [Hash] A hash of citations for each document in the form of { citation_style => [citation_text] } + def citations(document) + citation_hash = {} + + citation_hash.merge!(document.mods_citations) + citation_hash.merge!(document.eds_citations) + citation_hash.merge!(oclc_citation(document)) + + citation_hash.presence || Citation::NULL_CITATION + end + + private + + def oclc_citation(document) + return {} if document.oclc_number.blank? + + oclc_citations.fetch(document.oclc_number, {}) + end + end +end diff --git a/app/controllers/catalog_controller.rb b/app/controllers/catalog_controller.rb index bb767eae7..d53e9aa02 100644 --- a/app/controllers/catalog_controller.rb +++ b/app/controllers/catalog_controller.rb @@ -508,6 +508,16 @@ def email end end + # Overridden from Blacklight to pre-fetch OCLC citations in bulk + # when more than one document's citation is being displayed. + def citation + @response, @documents = search_service.fetch(Array(params[:id])) + return unless @documents.size > 1 + + oclc_numbers = @documents.filter_map { |document| document.oclc_number.presence } + @oclc_citations = Citations::OclcCitation.new(oclc_numbers:).citations_by_oclc_number + end + def stackmap params.require(:library) # Sometimes bots are calling this service without providing required parameters. Raise an error in this case. render layout: !request.xhr? diff --git a/app/helpers/catalog_helper.rb b/app/helpers/catalog_helper.rb index 5f7ba78c2..70bd7c55e 100644 --- a/app/helpers/catalog_helper.rb +++ b/app/helpers/catalog_helper.rb @@ -50,10 +50,6 @@ def link_to_database_search(subject) link_to(subject, search_catalog_path(f: { db_az_subject: [subject], SolrDocument::FORMAT_KEY => ['Database'] })) end - def grouped_citations(documents) - Citation.grouped_citations(documents.map(&:citations)) - end - def tech_details(document) details = [] details.push link_to( diff --git a/app/models/citation.rb b/app/models/citation.rb index e251aad1c..25e54d7cc 100644 --- a/app/models/citation.rb +++ b/app/models/citation.rb @@ -2,147 +2,77 @@ ### # Citation is a simple class that takes a Hash like object (SolrDocument) -# and returns a hash of citations for the configured formats +# and returns a hash of citations class Citation - def initialize(document, formats = []) + NULL_CITATION = { 'NULL' => '

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] An array of EDS citations + def initialize(eds_citation_styles:) + @eds_citation_styles = eds_citation_styles + end + + # @return [Hash] A hash with citation styles as keys and citation text as values. + def all_citations + matching_styles.index_with do |id| + eds_citation_styles.select { |style| style.fetch('id', nil) == id }.pick('data')&.html_safe # rubocop:disable Rails/OutputSafety + end.compact + end + + private + + def matching_styles + eds_citation_styles.pluck('id').select { |id| CITATION_STYLES.include?(id) } + end + end +end diff --git a/app/models/citations/mods_citation.rb b/app/models/citations/mods_citation.rb new file mode 100644 index 000000000..42e0d912c --- /dev/null +++ b/app/models/citations/mods_citation.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +### +# Returns an MODS citation formatted for use by SearchWorks +module Citations + class ModsCitation + CITATION_STYLE = 'preferred' + + attr_reader :notes + + # @param notes [Array] An array of MODS notes that may contain a citation + def initialize(notes:) + @notes = notes + end + + # @return [Hash] A hash with the preferred citation style and citation text + def all_citations + return { CITATION_STYLE => "

#{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 [Array] An array of OCLC numbers + def initialize(oclc_numbers:) + @oclc_numbers = oclc_numbers + end + + # @return [Hash] A hash with OCLC numbers as keys and hashes of citation styles and citation texts as values. + def citations_by_oclc_number + return {} unless Settings.oclc_discovery.citations.enabled + + @citations_by_oclc_number ||= group_citations_by_oclc_number + end + + private + + # Transforms one or more OCLC Citation responses and groups the response entries by OCLC number + # transforms data from: [{ oclcNumber: oclc_number, entries: [{ style: citation_style, citationText: citation_text }] }] + # to data like: { oclc_number => { citation_style => citation_text } } + def group_citations_by_oclc_number + grouped_citations = oclc_citations.flat_map { |citation| citation['entries'] }.group_by { |citation| citation['oclcNumber'] } + transform_grouped_citations(grouped_citations) + end + + # Transforms a hash of grouped citation responses into a hash of citation styles and citation texts + # transforms data from: { oclc_number => [{ style: citation_style, citationText: citation_text }] } + # into data like: { oclc_number => { citation_style => citation_text } } + def transform_grouped_citations(grouped_citations) + grouped_citations.transform_values do |citations| + citations.to_h { |citation| [searchworks_style_code(citation['style']), citation['citationText']&.html_safe] } # rubocop:disable Rails/OutputSafety + end + end + + def oclc_citations + CITATION_STYLES.flat_map do |citation_style| + Thread.new { oclc_client.citations(oclc_numbers:, citation_style:) } + end.flat_map(&:value) + end + + def oclc_client + @oclc_client ||= OclcDiscoveryClient.new + end + + def searchworks_style_code(oclc_style_code) + case oclc_style_code + when 'chicago-author-date' + 'chicago' + when 'harvard-cite-them-right' + 'harvard' + when 'modern-language-association' + 'mla' + when 'turabian-author-date' + 'turabian' + else + oclc_style_code + end + end + end +end diff --git a/app/models/concerns/citable.rb b/app/models/concerns/citable.rb index cb28793bb..27c338de1 100644 --- a/app/models/concerns/citable.rb +++ b/app/models/concerns/citable.rb @@ -6,7 +6,7 @@ module Citable extend ActiveSupport::Concern included do - delegate :citable?, :citations, to: :citation_object + delegate :citable?, :citations, :mods_citations, :eds_citations, to: :citation_object end private diff --git a/app/models/solr_document.rb b/app/models/solr_document.rb index f9491a1f6..d68ca5868 100644 --- a/app/models/solr_document.rb +++ b/app/models/solr_document.rb @@ -117,6 +117,7 @@ def to_semantic_values attribute :course_ids, :array, :courses_folio_id_ssim attribute :document_formats, :array, FORMAT_KEY attribute :live_lookup_id, :string, 'uuid_ssi' + attribute :oclc_number, :string, 'oclc' def db_az_subject self[:db_az_subject] if is_a_database? diff --git a/app/services/oclc_discovery_client.rb b/app/services/oclc_discovery_client.rb new file mode 100644 index 000000000..bcd28e8ab --- /dev/null +++ b/app/services/oclc_discovery_client.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'http' + +class OclcDiscoveryClient + MAX_CITATIONS_PER_REQUEST = 50 + DEFAULT_HEADERS = { + accept: 'application/json', + accept_language: 'en', + user_agent: 'Stanford Libraries SearchWorks' + }.freeze + + attr_reader :base_url, :token_url, :authorize_url + + def initialize(base_url: Settings.oclc_discovery.base_url, + client_key: Settings.oclc_discovery.client_key, + client_secret: Settings.oclc_discovery.client_secret, + token_url: Settings.oclc_discovery.token_url, + authorize_url: Settings.oclc_discovery.authorize_url) + @base_url = base_url + @client_key = client_key + @client_secret = client_secret + @token_url = token_url + @authorize_url = authorize_url + end + + # Overridden so that we don't display client key and secret + def inspect + "#<#{self.class.name}:#{object_id} " \ + "@base_url=\"#{base_url}\" @token_url=\"#{token_url}\" @authorize_url=\"#{authorize_url}\">" + end + + def ping + session_token.present? + rescue HTTP::Error + false + end + + # Fetch one or more citations from the OCLC Discovery API + # @param [Array] or [String] the OCLC numbers to fetch citations for + # @param [String] citation_style the citation style to fetch (only one style may be requested at a time) + # @return [Array] one or more citation responses + def citations(oclc_numbers:, citation_style: 'apa') + Array(oclc_numbers).each_slice(MAX_CITATIONS_PER_REQUEST).map do |ids| + Thread.new { get_json(citation_query(ids.join(','), citation_style)) } + end.map(&:value) + end + + private + + # OCLC Citation API documentation: + # https://developer.api.oclc.org/citations-api + def citation_query(oclc_number, citation_style) + query = { oclcNumbers: oclc_number, style: citation_style }.to_query + "/reference/citations?#{query}" + end + + def get_json(path) + parse(authenticated_request(path)) + end + + def parse(response) + raise response unless response.status.ok? + return nil if response.body.empty? + + JSON.parse(response.body) + end + + def authenticated_request(path) + get_request(path, headers: { authorization: "Bearer #{session_token}" }) + end + + def get_request(path, headers: {}) + HTTP.headers(DEFAULT_HEADERS.merge(headers)).request(:get, base_url + path) + end + + def session_token + @session_token ||= oauth_client.client_credentials.get_token.token + end + + def oauth_client + OAuth2::Client.new(@client_key, @client_secret, site: base_url, token_url:, authorize_url:) + end +end diff --git a/app/views/catalog/_grouped_citations.html.erb b/app/views/catalog/_grouped_citations.html.erb index e462a1e47..c5a416934 100644 --- a/app/views/catalog/_grouped_citations.html.erb +++ b/app/views/catalog/_grouped_citations.html.erb @@ -12,25 +12,6 @@ - -
-
- <% @documents.each do |document| %> -

<%= document_presenter(document).heading %>

- <%= render partial: 'catalog/single_citation', locals: { document: document } %> - <% end %> -
-
- <% grouped_citations(@documents).each do |format, citations| %> - <% unless format == 'NULL' %> -

<%= format %>

- - <% citations.each do |citation| %> - <%= citation %> - <% end %> - <% end %> - <% end %> -
-
+ <%= render Citations::MultipleCitationsComponent.new(documents: @documents, oclc_citations: @oclc_citations) %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/catalog/_single_citation.html.erb b/app/views/catalog/_single_citation.html.erb deleted file mode 100644 index f5b7fee68..000000000 --- a/app/views/catalog/_single_citation.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -<% document.citations.each do |format, citation| %> - <% unless format == 'NULL' %> -

<%= format %>

- <% end %> - <%= citation %> -<% end %> \ No newline at end of file diff --git a/app/views/catalog/_single_citation_with_modal.html.erb b/app/views/catalog/_single_citation_with_modal.html.erb index a71a6869e..1a4e4f79c 100644 --- a/app/views/catalog/_single_citation_with_modal.html.erb +++ b/app/views/catalog/_single_citation_with_modal.html.erb @@ -2,5 +2,5 @@ <% component.with_header do %> <% end %> - <%= render 'single_citation', document: %> -<% end %> \ No newline at end of file + <%= render Citations::CitationComponent.new(citations: document.citations) %> +<% end %> diff --git a/config/initializers/okcomputer.rb b/config/initializers/okcomputer.rb index 18e529fea..3f678c12e 100644 --- a/config/initializers/okcomputer.rb +++ b/config/initializers/okcomputer.rb @@ -62,13 +62,12 @@ def check Rails.application.reloader.to_prepare do OkComputer::Registry.register('live_lookups', OkapiCheck.new) if Settings.folio.url - OkComputer::Registry.register 'oclc_citation_service', OkComputer::HttpCheck.new(Citation.test_api_url) Settings.NEW_RELIC_API.policies.each do |policy| OkComputer::Registry.register policy.key, PerformanceCheck.new(policy) end - OkComputer.make_optional(%w[live_lookups oclc_citation_service]).concat( + OkComputer.make_optional(%w[live_lookups]).concat( Settings.NEW_RELIC_API.policies.map(&:key) ) end diff --git a/config/locales/searchworks.en.yml b/config/locales/searchworks.en.yml index 5de2b59e6..5fb154445 100644 --- a/config/locales/searchworks.en.yml +++ b/config/locales/searchworks.en.yml @@ -34,6 +34,14 @@ en: catalog: clear: action_confirm: Clear your catalog selections? + citations: + styles: + apa: APA + chicago: Chicago + harvard: Harvard + mla: MLA + turabian: Turabian + preferred: Preferred citation navigation: skip_links: Skip links search_bar: Search bar diff --git a/config/settings.yml b/config/settings.yml index e1f07af1d..7e0f3516b 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -30,17 +30,6 @@ throttling: - 172.16.0.0/12 THROTTLE_TRAFFIC: false SEND_THROTTLE_NOTIFICATIONS_TO_HONEYBADGER: true -OCLC: - API_KEY: the-oclc-api-key - BASE_URL: http://www.worldcat.org/webservices/catalog/content/citations - CITATION_FORMATS: - - APA - - CHICAGO - - HARVARD - - MLA - - TURABIAN - - PREFERRED CITATION - DOCUMENT_FIELD: 'oclc' EDS_ENABLED: true EDS_USER: the-eds-user EDS_PASS: the-eds-password @@ -91,6 +80,15 @@ folio: live_lookup_service: LiveLookup::Folio +oclc_discovery: + citations: + enabled: false + base_url: oclc-discovery-base-url + client_key: <%= ENV['OCLC_DISCOVERY_CLIENT_KEY'] %> + client_secret: <%= ENV['OCLC_DISCOVERY_CLIENT_SECRET'] %> + authorize_url: oclc-discovery-authorize-url + token_url: oclc-discovery-token-url + # 'select' (the default) or 'export' (requires indexing with docvalues) dynamic_sitemap_solr_endpoint: 'select' diff --git a/spec/components/citations/citation_component_spec.rb b/spec/components/citations/citation_component_spec.rb new file mode 100644 index 000000000..46f9c0b46 --- /dev/null +++ b/spec/components/citations/citation_component_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Citations::CitationComponent, type: :component do + let(:component) { described_class.new(citations:) } + + let(:citations) do + { 'mla' => 'MLA Citation', 'apa' => 'APA Citation' } + end + + subject(:page) { render_inline(component) } + + it 'includes the citations on the page' do + expect(page).to have_css('h4', text: 'MLA') + expect(page).to have_css('h4', text: 'APA') + expect(page).to have_css('div', text: 'MLA Citation') + expect(page).to have_css('div', text: 'APA Citation') + end +end diff --git a/spec/components/citations/grouped_citation_component_spec.rb b/spec/components/citations/grouped_citation_component_spec.rb new file mode 100644 index 000000000..3ca71c926 --- /dev/null +++ b/spec/components/citations/grouped_citation_component_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Citations::GroupedCitationComponent, type: :component do + let(:component) { described_class.new(citations:) } + + let(:citations) do + [{ 'mla' => 'MLA Citation 1', 'apa' => 'APA Citation 1' }, + { 'mla' => 'MLA Citation 2', 'apa' => 'APA Citation 2' }, + { 'preferred' => 'Preferred Citation 1' }] + end + + subject(:page) { render_inline(component) } + + it 'includes the citations on the page' do + expect(page).to have_css('h4', text: 'Preferred citation').once + expect(page).to have_css('div', text: 'Preferred Citation 1') + expect(page).to have_css('h4', text: 'MLA').once + expect(page).to have_css('div', text: 'MLA Citation 1') + expect(page).to have_css('div', text: 'MLA Citation 2') + expect(page).to have_css('h4', text: 'APA').once + expect(page).to have_css('div', text: 'APA Citation 1') + expect(page).to have_css('div', text: 'APA Citation 2') + end + + describe '#grouped_citations' do + it 'groups the citations by style and puts the preferred style first' do + expect(component.grouped_citations).to eq('preferred' => ['Preferred Citation 1'], + 'mla' => ['MLA Citation 1', 'MLA Citation 2'], + 'apa' => ['APA Citation 1', 'APA Citation 2']) + end + end +end diff --git a/spec/components/citations/multiple_citations_component_spec.rb b/spec/components/citations/multiple_citations_component_spec.rb new file mode 100644 index 000000000..2634d091e --- /dev/null +++ b/spec/components/citations/multiple_citations_component_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Citations::MultipleCitationsComponent, type: :component do + let(:component) { described_class.new(documents:, oclc_citations:) } + + let(:oclc_citations) do + { '123456' => { 'mla' => ['MLA Citation 1'], + 'apa' => ['APA Citation 1'] } } + end + let(:document_with_oclc_citations) { instance_double(SolrDocument, oclc_number: '123456', eds_citations: {}, mods_citations: {}, fetch: 'OCLC Title') } + let(:document_with_mods_citations) { instance_double(SolrDocument, oclc_number: nil, eds_citations: {}, mods_citations: { 'preferred' => ['Preferred Citation 1'] }, fetch: 'MODS Title') } + let(:documents) { [document_with_oclc_citations, document_with_mods_citations] } + + subject(:page) { render_inline(component) } + + describe '#citations' do + it 'returns the citations for the document' do + expect(component.citations(document_with_oclc_citations)).to eq('mla' => ['MLA Citation 1'], 'apa' => ['APA Citation 1']) + expect(component.citations(document_with_mods_citations)).to eq('preferred' => ['Preferred Citation 1']) + end + end + + it 'renders the citations to the page' do + expect(page).to have_css('h3', text: 'OCLC Title').once + expect(page).to have_css('h3', text: 'MODS Title').once + expect(page).to have_css('h4', text: 'Preferred citation') + expect(page).to have_css('div', text: 'Preferred Citation 1') + expect(page).to have_css('h4', text: 'MLA') + expect(page).to have_css('div', text: 'MLA Citation 1') + expect(page).to have_css('h4', text: 'APA') + expect(page).to have_css('div', text: 'APA Citation 1') + end +end diff --git a/spec/features/blacklight_customizations/record_toolbar_spec.rb b/spec/features/blacklight_customizations/record_toolbar_spec.rb index e7198efd5..030f62da2 100644 --- a/spec/features/blacklight_customizations/record_toolbar_spec.rb +++ b/spec/features/blacklight_customizations/record_toolbar_spec.rb @@ -3,8 +3,11 @@ require 'rails_helper' RSpec.feature "Record Toolbar" 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) 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