Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/oai_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class OaiController < ApplicationController
# GET /oai-pmh
def index
provider = TrainingProvider.new({ provider_context: :instance_based })
provider.model = OAI::Provider::ActiveRecordWrapper.new(Material.in_current_space.where(visible: true))
provider.model = MultiModel.new([Material.in_current_space.where(visible: true), Event.in_current_space.where(visible: true)])
response = provider.process_request(oai_params.to_h)

# add XSLT prefix
Expand Down
70 changes: 64 additions & 6 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -493,20 +493,78 @@ def self.from_varied_providers(events, count)

events = []
events_left = true
while events_left && (events.length < count) do
while events_left && (events.length < count)
events_left = false
provider_events.each_value do |p_events|
if p_events.any?
events << p_events.shift
break if events.length == count
events_left ||= p_events.any?
end
next unless p_events.any?

events << p_events.shift
break if events.length == count

events_left ||= p_events.any?
end
end

events
end

def to_rdf
jsonld_str = to_bioschemas[0].to_json

graph = RDF::Graph.new
Comment on lines +511 to +514
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)
xml.tag!('dc:creator', organizer) if organizer.present?
instructors.each { |c| xml.tag!('dc:creator', c.display_name) }
contributors.each { |c| xml.tag!('dc:contributor', c.display_name) }
xml.tag!('dc:publisher', contact) if contact.present?

xml.tag!('dc:format', 'text/html')
xml.tag!('dc:language', language) if language.present?

[start, self.end].compact.each do |d|
xml.tag!('dc:date', d.iso8601)
end

xml.tag!('dc:identifier', url)

(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/Event')
xml.tag!('dc:type', 'https://schema.org/Event')
xml.tag!('dc:type', presence.to_s + ' event')

xml.tag!('dc:relation', "#{TeSS::Config.base_url}#{Rails.application.routes.url_helpers.event_path(self)}")
xml.tag!('dc:relation', content_provider.url) if content_provider&.url
materials.each do |m|
if m.doi.present?
doi_iri = m.doi.start_with?('http://', 'https://') ? m.doi : "https://doi.org/#{m.doi}"
xml.tag!('dc:relation', doi_iri)
else
xml.tag!('dc:relation', m.url)
end
end
end
xml.target!
end

private

def allowed_url
Expand Down
92 changes: 92 additions & 0 deletions config/initializers/oai_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,98 @@
require 'oai'
require 'uri'

class MultiModel < OAI::Provider::Model
# Represents multiple rails models in one OAI-PMH model.
# It assumes OAI-PMH identifiers are of the form: <model_route_key>/<id> (e.g. materials/142)

attr_reader :model_scopes

def initialize(model_scopes, limit = nil, timestamp_field = 'updated_at', identifier_field = 'id')
super(limit || 100, timestamp_field, identifier_field)
@model_scopes = model_scopes
end

def earliest
model_scopes.filter_map { |scope| scope.minimum(timestamp_field) }.min || Time.at(0).utc
end

def latest
model_scopes.filter_map { |scope| scope.maximum(timestamp_field) }.max || Time.at(0).utc
end

def sets
model_scopes.map do |scope|
n = scope.model.model_name
OAI::Set.new({ spec: n.route_key, name: n.plural.titleize, description: "Set of all training #{n.plural.humanize.downcase}" })
end
end

# <tt>selector</tt> can be a singular id, or the symbol :all
def find(selector, options = {})
return find_by_id(selector) unless selector == :all

skip_until = nil
if options[:resumption_token]
resumption_token = OAI::Provider::ResumptionToken.parse(options[:resumption_token])
options = resumption_token.to_conditions_hash
skip_until = resumption_token.last_str
end

scopes = model_scopes
scopes = scopes.select { |scope| scope.model.model_name.route_key == options[:set] } if options[:set]
scopes = scopes.map { |scope| scope.where("#{timestamp_field} >= ?", options[:from]) } if options[:from]
scopes = scopes.map { |scope| scope.where("#{timestamp_field} < ?", options[:until] + 1.second) } if options[:until]
enumerators = scopes.map { |scope| scope.order(timestamp_field => :asc, id: :asc).to_enum }
Comment thread
eilmiv marked this conversation as resolved.
Outdated

results = []
skipped_results = []
while results.size < limit + 1
enumerators = enumerators.filter do |enum|
enum.peek
rescue StopIteration
false
end
break if enumerators.empty?

# min_by returns the first minimum resulting in deterministic order if different scopes have the same timestamp.
min_enum = enumerators.min_by { |enum| enum.peek.send(timestamp_field) }
result = min_enum.next
if skip_until && result.oai_identifier == skip_until
skip_until = nil
next
end
if skip_until && result.send(timestamp_field) > options[:from] + 1.days
# If the marker record is gone or changed, stop skipping so records are not lost.
skip_until = nil
results.concat(skipped_results)
end
Comment thread
eilmiv marked this conversation as resolved.
if skip_until
skipped_results << result
next
end

results << result
end
return results if results.size <= limit

results.pop

last_returned = results.last
resumption_token = OAI::Provider::ResumptionToken.new(options.merge(from: last_returned.send(timestamp_field), last: last_returned.oai_identifier))
OAI::Provider::PartialResult.new(results, resumption_token)
end

private

def find_by_id(selector)
route_key, id = selector.to_s.split('/', 2)
scope = model_scopes.find { |s| s.model.model_name.route_key == route_key }
return nil unless scope

scope.find_by(id:)
end
end

class OAIRDF < OAI::Provider::Metadata::Format
def initialize
@prefix = 'rdf'
Expand Down
102 changes: 102 additions & 0 deletions test/controllers/oai_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class OaiControllerTest < ActionDispatch::IntegrationTest
setup do
@material = materials(:good_material)
@event = events(:two)
@user = users(:regular_user)
@material.user_id = @user.id
@material.save!
Expand Down Expand Up @@ -38,6 +39,17 @@ class OaiControllerTest < ActionDispatch::IntegrationTest
assert_includes prefixes, 'rdf'
end

test 'OAI ListSets exposes sets for materials and events' do
get '/oai-pmh', params: { verb: 'ListSets' }
assert_response :success

parsed = Nokogiri::XML(@response.body)
set_specs = parsed.xpath('//oai:ListSets/oai:set/oai:setSpec', @ns).map(&:text)

assert_includes set_specs, 'materials'
assert_includes set_specs, 'events'
end

test 'OAI ListRecords returns material in oai_dc format' do
get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' }
assert_response :success
Expand All @@ -53,6 +65,22 @@ class OaiControllerTest < ActionDispatch::IntegrationTest
assert_includes identifiers, @material.doi
end

test 'OAI ListRecords returns event in oai_dc format' do
get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' }
assert_response :success

parsed = Nokogiri::XML(@response.body)
event_records = parsed.xpath("//oai:record[contains(oai:header/oai:identifier, 'events/') ]", @ns)

refute_empty event_records
event_titles = event_records.xpath('.//dc:title', @ns).map(&:text)
assert_includes event_titles, @event.title

event_types = event_records.xpath('.//dc:type', @ns).map(&:text)
assert_includes event_types, 'http://purl.org/dc/dcmitype/Event'
assert_includes event_types, 'https://schema.org/Event'
end

test 'OAI-PMH endpoint respects current space' do
get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'oai_dc' }
parsed = Nokogiri::XML(@response.body)
Expand Down Expand Up @@ -83,6 +111,19 @@ class OaiControllerTest < ActionDispatch::IntegrationTest
assert_includes keywords, 'good'
end

test 'OAI ListRecords returns event in rdf format' do
get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' }
assert_response :success

parsed = Nokogiri::XML(@response.body)

event_names = parsed.xpath('//sdo:Event/sdo:name', @ns).map(&:text)
assert_includes event_names, @event.title

event_urls = parsed.xpath('//sdo:Event/sdo:url/@rdf:resource', @ns).map(&:value)
assert_includes event_urls, @event.url
end

test 'OAI ListRecords returns only visible materials' do
get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' }
assert_response :success
Expand All @@ -98,4 +139,65 @@ class OaiControllerTest < ActionDispatch::IntegrationTest
parsed = Nokogiri::XML(@response.body)
refute_includes parsed.xpath('//sdo:name', @ns).map(&:text), 'Training Material Example'
end

test 'OAI ListRecords resumes with resumption token' do
user = users(:regular_user)
base_count = Event.where(visible: true).count + Material.where(visible: true).count
records_needed = [0, 105 - base_count].max

records_needed.times do |i|
Event.create!(title: "Paged event #{i}", url: "https://example.org/paged-events/#{i}", user:)
end

get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' }
assert_response :success

parsed = Nokogiri::XML(@response.body)
token = parsed.at_xpath('//oai:ListRecords/oai:resumptionToken', @ns)&.text
assert token.present?, 'Expected non-empty resumptionToken for paginated result set'

get '/oai-pmh', params: { verb: 'ListRecords', resumptionToken: token }
assert_response :success

parsed = Nokogiri::XML(@response.body)
refute parsed.at_xpath("//oai:error[@code='cannotDisseminateFormat']", @ns)
assert_operator parsed.xpath('//oai:ListRecords/oai:record', @ns).length, :>, 0
end

test 'OAI resumption token does not repeat the last returned record at a same-timestamp page boundary' do
user = users(:regular_user)
boundary_space = Space.create!(title: 'Boundary Space', host: 'boundary-space.example', user:)
boundary_time = Time.utc(2026, 7, 28, 12, 0, 0)

records = 101.times.map do |i|
material = Material.create!(title: "Boundary material #{i}",
description: "Boundary material #{i}",
url: "https://example.org/boundary-material/#{i}",
user:,
visible: true,
space: boundary_space)
material.update_columns(updated_at: boundary_time, created_at: boundary_time)
material
end

with_host(boundary_space.host) do
get '/oai-pmh', params: { verb: 'ListRecords', metadataPrefix: 'rdf' }
assert_response :success

parsed = Nokogiri::XML(@response.body)
first_page_titles = parsed.xpath('//sdo:LearningResource/sdo:name', @ns).map(&:text)
assert_equal 100, first_page_titles.length

token = parsed.at_xpath('//oai:ListRecords/oai:resumptionToken', @ns)&.text
assert token.present?, 'Expected non-empty resumptionToken for the first page'

get '/oai-pmh', params: { verb: 'ListRecords', resumptionToken: token }
assert_response :success

parsed = Nokogiri::XML(@response.body)
second_page_titles = parsed.xpath('//sdo:LearningResource/sdo:name', @ns).map(&:text)
refute_includes second_page_titles, first_page_titles.last
assert_includes second_page_titles, records.last.title
end
end
end
Loading