diff --git a/Gemfile b/Gemfile index 481a8af88..8ff21b104 100644 --- a/Gemfile +++ b/Gemfile @@ -34,6 +34,7 @@ gem 'kt-paperclip' gem 'linkeddata' gem 'maxmind-db' gem 'money-rails' +gem 'oai' gem 'omniauth_openid_connect' gem 'omniauth-rails_csrf_protection' gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index a2354aeaf..2948e9358 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -398,6 +398,11 @@ GEM mini_portile2 (~> 2.8.2) racc (~> 1.4) numerizer (0.1.1) + oai (1.3.0) + builder (>= 3.1.0) + faraday (< 3) + faraday-follow_redirects (>= 0.3.0, < 2) + rexml omniauth (2.1.1) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -853,6 +858,7 @@ DEPENDENCIES minitest minitest-reporters money-rails + oai omniauth-rails_csrf_protection omniauth_openid_connect pg diff --git a/app/controllers/oai_controller.rb b/app/controllers/oai_controller.rb new file mode 100644 index 000000000..907be2f97 --- /dev/null +++ b/app/controllers/oai_controller.rb @@ -0,0 +1,22 @@ +# The controller for actions related to OAI-PMH +class OaiController < ApplicationController + # CSRF token authentication causes problems with OAI-PMH POST requests and OAI-PMH POST is safe because it returns static public content + skip_before_action :verify_authenticity_token, only: [:index] + + # GET /oai-pmh + def index + provider = TrainingProvider.new + response = provider.process_request(oai_params.to_h) + + # add XSLT prefix + response.sub!(/<\?xml[^>]+\?>/, "\\0\n") + + render body: response, content_type: 'text/xml' + end + + private + + def oai_params + params.permit(:verb, :identifier, :metadataPrefix, :set, :from, :until, :resumptionToken) + end +end diff --git a/app/helpers/oai_helper.rb b/app/helpers/oai_helper.rb new file mode 100644 index 000000000..fc0f3af60 --- /dev/null +++ b/app/helpers/oai_helper.rb @@ -0,0 +1,2 @@ +module OaiHelper +end diff --git a/app/models/material.rb b/app/models/material.rb index 1fd6f21e1..9e1726c08 100644 --- a/app/models/material.rb +++ b/app/models/material.rb @@ -1,4 +1,8 @@ require 'rails/html/sanitizer' +require 'json/ld' +require 'rdf' +require 'rdf/rdfxml' +require 'builder' class Material < ApplicationRecord include PublicActivity::Common @@ -186,4 +190,58 @@ def duplicate def archived? status == 'archived' end + + def to_rdf + jsonld_str = to_bioschemas[0].to_json + + graph = RDF::Graph.new + JSON::LD::Reader.new(jsonld_str) do |reader| + reader.each_statement { |stmt| graph << stmt } + end + + rdfxml_str = graph.dump(:rdfxml, prefixes: { sdo: 'http://schema.org/', dc: 'http://purl.org/dc/terms/' }) + rdfxml_str.sub(/\A<\?xml.*?\?>\s*/, '') # remove XML declaration because this is used inside OAI-PMH response + end + + def to_oai_dc + xml = ::Builder::XmlMarkup.new + xml.tag!('oai_dc:dc', + 'xmlns:oai_dc' => 'http://www.openarchives.org/OAI/2.0/oai_dc/', + 'xmlns:dc' => 'http://purl.org/dc/elements/1.1/', + 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:schemaLocation' => 'http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd') do + xml.tag!('dc:title', title) + xml.tag!('dc:description', description) + authors.each { |a| xml.tag!('dc:creator', a) } + contributors.each { |a| xml.tag!('dc:contributor', a) } + xml.tag!('dc:publisher', content_provider.title) if content_provider + + xml.tag!('dc:format', 'text/html') + xml.tag!('dc:language', 'en') + xml.tag!('dc:rights', licence) if licence.present? + + [date_published, date_created, date_modified].compact.each do |d| + xml.tag!('dc:date', d.iso8601) + end + + if doi.present? + doi_iri = doi.start_with?('http://', 'https://') ? doi : "https://doi.org/#{doi}" + xml.tag!('dc:identifier', doi_iri) + else + xml.tag!('dc:identifier', url) + end + + (keywords + scientific_topics.map(&:uri) + operations.map(&:uri)).each do |s| + xml.tag!('dc:subject', s) + end + + xml.tag!('dc:type', 'http://purl.org/dc/dcmitype/Text') + xml.tag!('dc:type', 'https://schema.org/LearningResource') + resource_type.each { |t| xml.tag!('dc:type', t) } + + xml.tag!('dc:relation', "#{TeSS::Config.base_url}#{Rails.application.routes.url_helpers.material_path(self)}") + xml.tag!('dc:relation', content_provider.url) if content_provider&.url + end + xml.target! + end end diff --git a/app/views/static/home/_counters.html.erb b/app/views/static/home/_counters.html.erb index 11eb9315c..789f4f997 100644 --- a/app/views/static/home/_counters.html.erb +++ b/app/views/static/home/_counters.html.erb @@ -12,7 +12,6 @@
<%= @count_strings[feature] %>
<%= feature == 'events' ? 'Upcoming Events' : feature.titleize %>
- <% end %> <% end %> diff --git a/config/environment.rb b/config/environment.rb index cac531577..426333bb4 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,5 +1,5 @@ # Load the Rails application. -require_relative "application" +require_relative 'application' # Initialize the Rails application. Rails.application.initialize! diff --git a/config/initializers/oai_provider.rb b/config/initializers/oai_provider.rb new file mode 100644 index 000000000..9926f2469 --- /dev/null +++ b/config/initializers/oai_provider.rb @@ -0,0 +1,29 @@ +# Configure OAI-PMH library +# see comments in: https://github.com/code4lib/ruby-oai/blob/54ea6f7f5b1e2c1be5d0a7cc61cb696b5e653d8a/lib/oai/provider.rb#L98 +require 'oai' +require 'uri' + +class OAIRDF < OAI::Provider::Metadata::Format + def initialize + @prefix = 'rdf' + @schema = 'http://www.openarchives.org/OAI/2.0/rdf.xsd' + @namespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' + @element_namespace = 'rdf' + end +end + +class TrainingProvider < OAI::Provider::Base + repository_name TeSS::Config.site['title'] + repository_url "#{TeSS::Config.base_url}/oai-pmh" + record_prefix "oai:#{URI(TeSS::Config.base_url).host}" + admin_email TeSS::Config.contact_email + sample_id '142' # so that example id is oai:domain:142 + + register_format(OAIRDF.instance) +end + +Rails.application.config.after_initialize do + TrainingProvider.source_model OAI::Provider::ActiveRecordWrapper.new(Material.where(visible: true)) +rescue ActiveRecord::ActiveRecordError + Rails.logger.debug 'There is no database yet or some other error, so the OAI-PMH endpoint is not configured.' +end diff --git a/config/routes.rb b/config/routes.rb index 58304fb0c..ea28af4e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -177,6 +177,8 @@ get 'up' => 'health_check#show' + match 'oai-pmh', to: "oai#index", via: [:get, :post] + # The priority is based upon order of creation: first created -> highest priority. # See how all your routes lay out with "rake routes". diff --git a/public/oai2xhtml.xsl b/public/oai2xhtml.xsl new file mode 100644 index 000000000..1b4db2651 --- /dev/null +++ b/public/oai2xhtml.xsl @@ -0,0 +1,682 @@ + + + + + + + + + + + + + + + +td.value { + vertical-align: top; + padding-left: 1em; + padding: 3px; +} +td.key { + background-color: #e0e0ff; + padding: 3px; + text-align: right; + border: 1px solid #c0c0c0; + white-space: nowrap; + font-weight: bold; + vertical-align: top; +} +.dcdata td.key { + background-color: #ffffe0; +} +body { + margin: 1em 2em 1em 2em; +} +h1, h2, h3 { + font-family: sans-serif; + clear: left; +} +h1 { + padding-bottom: 4px; + margin-bottom: 0px; +} +h2 { + margin-bottom: 0.5em; +} +h3 { + margin-bottom: 0.3em; + font-size: medium; +} +.link { + border: 1px outset #88f; + background-color: #c0c0ff; + padding: 1px 4px 1px 4px; + font-size: 80%; + text-decoration: none; + font-weight: bold; + font-family: sans-serif; + color: black; +} +.link:hover { + color: red; +} +.link:active { + color: red; + border: 1px inset #88f; + background-color: #a0a0df; +} +.oaiRecord, .oaiRecordTitle { + background-color: #f0f0ff; + border-style: solid; + border-color: #d0d0d0; +} +h2.oaiRecordTitle { + background-color: #e0e0ff; + font-size: medium; + font-weight: bold; + padding: 10px; + border-width: 2px 2px 0px 2px; + margin: 0px; +} +.oaiRecord { + margin-bottom: 3em; + border-width: 2px; + padding: 10px; +} + +.results { + margin-bottom: 1.5em; +} +ul.quicklinks { + margin-top: 2px; + padding: 4px; + text-align: left; + border-bottom: 2px solid #ccc; + border-top: 2px solid #ccc; + clear: left; +} +ul.quicklinks li { + font-size: 80%; + display: inline; + list-stlye: none; + font-family: sans-serif; +} +p.intro { + font-size: 80%; +} + + + + + + + + + OAI 2.0 Request Results + + + +

OAI 2.0 Request Results

+ +

You are viewing an HTML version of the XML OAI response. To see the underlying XML use your web browsers view source option. More information about this XSLT is at the bottom of the page.

+ + +

About the XSLT

+

An XSLT file has converted the OAI-PMH 2.0 responses into XHTML which looks nice in a browser which supports XSLT such as Mozilla, Firebird and Internet Explorer. The XSLT file was created by Christopher Gutteridge at the University of Southampton as part of the GNU EPrints system, and is freely redistributable under the GPL.

If you want to use the XSL file on your own OAI interface you may but due to the way XSLT works you must install the XSL file on the same server as the OAI script, you can't just link to this copy.

For more information or to download the XSL file please see the OAI to XHTML XSLT homepage.

+ + + +
+ + + + + + + + + + + + +
Datestamp of response
Request URL
+ + + +

OAI Error(s)

+

The request could not be completed due to the following error or errors.

+
+ +
+
+ +

Request was of type .

+
+ + + + + + +
+
+
+
+ + + + + + + + +
Error Code
+

+
+ + + + + + + + + + + + + + + + + + +
Repository Name
Base URL
Protocol Version
Earliest Datestamp
Deleted Record Policy
Granularity
+ + +
+ + + Admin Email + + + + + + +

Unsupported Description Type

+

The XSL currently does not support this type of description.

+
+ +
+
+ + + + + +

OAI-Identifier

+ + + + + + + + + +
Scheme
Repository Identifier
Delimiter
Sample OAI Identifier
+
+ + + + + +

EPrints Description

+ +

Content

+ +
+ +

Submission Policy

+ +
+

Metadata Policy

+ +

Data Policy

+ + +
+ + + +

+
+ +
+
+
+ + +

Comment

+
+
+ + + + + +

Friends

+
    + +
+
+ + +
  • + +Identify
  • +
    + + + + + +

    Branding

    + + +
    + + +

    Icon

    + + + {br:title} + + + {br:title} + + +
    + + +

    Metadata Rendering Rule

    + + + + + + + +
    URL
    Namespace
    Mime Type
    +
    + + + + + + +

    Gateway Information

    + + + + + + + + + + + + + + +
    Source
    Description
    URL
    Notes
    +
    + + + Admin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    Set

    + + + + +
    setName
    +
    + + + + + + +

    This is a list of metadata formats available for the record "". Use these links to view the metadata:

    +
    + +

    This is a list of metadata formats available from this archive.

    +
    +
    + +
    + + +

    Metadata Format

    + + + + + + + +
    metadataPrefix
    metadataNamespace
    schema
    +
    + + + + + + + + +

    OAI Record:

    +
    + + + +
    +
    + + +

    OAI Record Header

    + + + + + + +
    OAI Identifier + + oai_dc + rdf + formats +
    Datestamp
    + +

    This record has been deleted.

    +
    +
    + + + +

    "about" part of record container not supported by the XSL

    +
    + + +   + + + + + + + + + + setSpec + + Identifiers + Records + + + + + + + + +

    There are more results.

    + + + +
    resumptionToken: + +Resume
    +
    + + + + +

    Unknown Metadata Format

    +
    + +
    +
    + + + + +
    +

    Dublin Core Metadata (oai_dc)

    + + +
    +
    +
    + + +Title + + +Author or Creator + + +Subject and Keywords + + +Description + + +Publisher + + +Other Contributor + + +Date + + +Resource Type + + +Format + + +Resource Identifier + + +Source + + +Language + + +Relation + + + + + URL + URL not shown as it is very long. + + + + + + + + + + + + + +Coverage + + +Rights Management + + + + + +
    +

    RDF Metadata

    + + +

    Prefixes

    + + +

    Raw RDF

    +
    + +
    +
    +
    + + + + +
    + <></> +
    +
    + + + + + ="" + + + +.xmlSource { + font-size: 70%; + border: solid #c0c0a0 1px; + background-color: #ffffe0; + padding: 2em 2em 2em 0em; +} +.xmlBlock { + padding-left: 2em; +} +.xmlTagName { + color: #800000; + font-weight: bold; +} +.xmlAttrName { + font-weight: bold; +} +.xmlAttrValue { + color: #0000c0; +} + + +
    diff --git a/test/controllers/oai_controller_test.rb b/test/controllers/oai_controller_test.rb new file mode 100644 index 000000000..e8397c814 --- /dev/null +++ b/test/controllers/oai_controller_test.rb @@ -0,0 +1,84 @@ +require 'test_helper' + +class OaiControllerTest < ActionDispatch::IntegrationTest + setup do + @material = materials(:good_material) + @user = users(:regular_user) + @material.user_id = @user.id + @material.save! + @ns = { + 'oai' => 'http://www.openarchives.org/OAI/2.0/', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'sdo' => 'http://schema.org/', + 'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' + } + end + + test 'should get endpoint' do + get '/oai-pmh' + assert_response :success + assert_includes @response.body, 'xml-stylesheet' + end + + test 'OAI Identify verb returns expected repository info' do + get '/oai-pmh', params: { verb: 'Identify' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + assert_equal '2.0', parsed.at_xpath('//oai:protocolVersion', @ns).text + end + + test 'OAI ListMetadataFormats verb returns expected repository info' do + get '/oai-pmh', params: { verb: 'ListMetadataFormats' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + prefixes = parsed.xpath('//oai:ListMetadataFormats/oai:metadataFormat/oai:metadataPrefix', @ns).map(&:text) + assert_includes prefixes, 'oai_dc' + assert_includes prefixes, 'rdf' + end + + test 'OAI ListRecords returns material in oai_dc format' do + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + titles = parsed.xpath('//dc:title', @ns).map(&:text) + assert_includes titles, @material.title + + subjects = parsed.xpath('//dc:subject', @ns).map(&:text) + @material.keywords.each { |kw| assert_includes subjects, kw } + + identifiers = parsed.xpath('//dc:identifier', @ns).map(&:text) + assert_includes identifiers, @material.doi + end + + test 'OAI ListRecords returns material in rdf format' do + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + + names = parsed.xpath('//sdo:LearningResource/sdo:name', @ns).map(&:text) + assert_includes names, 'Training Material Example' + + keywords = parsed.xpath('//sdo:LearningResource/sdo:keywords', @ns).map(&:text) + assert_includes keywords, 'good' + end + + test 'OAI ListRecords returns only visible materials' do + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } + assert_response :success + + parsed = Nokogiri::XML(@response.body) + + assert_includes parsed.xpath('//sdo:name', @ns).map(&:text), 'Training Material Example' + + @material.update!(visible: false) + + get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' } + assert_response :success + parsed = Nokogiri::XML(@response.body) + refute_includes parsed.xpath('//sdo:name', @ns).map(&:text), 'Training Material Example' + end +end