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.
+
+
+
+ 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 .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+ Metadata Rendering Rule
+
+ URL |
+ |
+ Namespace |
+ |
+ Mime Type |
+ |
+
+
+
+
+
+
+
+
+ Gateway Information
+
+ Source |
+ |
+ Description |
+ |
+
+
+ URL |
+ |
+
+
+ Notes |
+ |
+
+
+
+
+
+ Admin |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set
+
+
+
+
+
+
+
+
+ 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
+
+
+ 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