From 7cedfa60dd7feb4ae1cf97ac35c211412d2582fa Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 21:33:10 +0000 Subject: [PATCH 1/9] Add Detached RO-Crate generator for materials and events RoCrateExport::DetachedCrate builds an RO-Crate 1.3 metadata document for a single resource from its Bioschemas representation: the TeSS page URL is the Root Data Entity (typed Dataset plus the Bioschemas type), nested objects are flattened into the @graph, the Bioschemas profile is declared with conformsTo, and properties not defined by the RO-Crate context are left out. RoCrateExport::Identifier normalises DOIs (bare, doi:, http(s)://doi.org/, dx.doi.org/) to https://doi.org/ URLs. Co-Authored-By: Claude Opus 5.5 --- lib/ro_crate_export/detached_crate.rb | 121 +++++++++++++++++++ lib/ro_crate_export/identifier.rb | 19 +++ test/unit/ro_crate_export_identifier_test.rb | 29 +++++ 3 files changed, 169 insertions(+) create mode 100644 lib/ro_crate_export/detached_crate.rb create mode 100644 lib/ro_crate_export/identifier.rb create mode 100644 test/unit/ro_crate_export_identifier_test.rb diff --git a/lib/ro_crate_export/detached_crate.rb b/lib/ro_crate_export/detached_crate.rb new file mode 100644 index 000000000..6451fc9e8 --- /dev/null +++ b/lib/ro_crate_export/detached_crate.rb @@ -0,0 +1,121 @@ +module RoCrateExport + # Builds a Detached RO-Crate (https://w3id.org/ro/crate/1.3) describing a single TeSS resource. + # The Root Data Entity is the resource's Bioschemas representation, typed additionally as a Dataset and + # flattened into an RO-Crate @graph, with the TeSS page URL as its absolute @id. + class DetachedCrate + SPEC = 'https://w3id.org/ro/crate/1.3'.freeze + CONTEXT = "#{SPEC}/context".freeze + PROFILE = 'https://w3id.org/ro/crate'.freeze + MEDIA_TYPE = %(application/ld+json; profile="#{PROFILE}").freeze + METADATA_ID = 'ro-crate-metadata.json'.freeze + + # Bioschemas generator properties that are not defined by the RO-Crate JSON-LD context. + UNDEFINED_PROPERTIES = %w[contact eligibility event_types hostInstitution].freeze + + NO_LICENCE = 'No licence has been specified for this resource in TeSS.'.freeze + + attr_reader :resource + + def initialize(resource) + @resource = resource + @bioschemas = resource.to_bioschemas.first + end + + def generate + @entities = {} + @local_ids = Hash.new(0) + root = add_entity(root_data_entity) + + descriptor = { + '@id' => METADATA_ID, + '@type' => 'CreativeWork', + 'about' => root, + 'conformsTo' => { '@id' => SPEC } + } + + { '@context' => CONTEXT, '@graph' => [descriptor, *@entities.values] } + end + + def to_json(*_args) + JSON.pretty_generate(generate) + end + + private + + # The resource's Bioschemas properties, with those required of a Root Data Entity. + def root_data_entity + root = @bioschemas.generate.transform_keys(&:to_s).except('@context', 'dct:conformsTo', *UNDEFINED_PROPERTIES) + root.merge( + '@type' => ['Dataset', root['@type']], + 'identifier' => Identifier.url(root['identifier']), + 'description' => root['description'].presence || root['name'], + 'datePublished' => date_published, + 'license' => licence(root['license']), + 'conformsTo' => profile, + 'sdPublisher' => publisher + ) + end + + def date_published + date = resource.try(:date_published) || resource.created_at || Time.now + date.iso8601 + end + + def licence(value) + return NO_LICENCE if value.blank? + return value unless value.start_with?('http://', 'https://') + + title = LicenceDictionary.instance.lookup_value(resource.licence, 'title') + { '@id' => value, '@type' => 'CreativeWork', 'name' => title || value } + end + + def profile + return nil unless @bioschemas.class.bioschemas_profile + + { '@id' => @bioschemas.class.bioschemas_profile, '@type' => 'Profile', + 'name' => "Bioschemas #{@bioschemas.class.type} profile" } + end + + def publisher + url = Rails.application.routes.url_helpers.root_url + { '@id' => url, '@type' => 'Organization', 'name' => TeSS::Config.site['title'], 'url' => url } + end + + # Add an entity to the graph, replacing nested objects with references to flattened entities. + def add_entity(entity) + entity = flatten_properties(entity) + existing = @entities[entity['@id']] + @entities[entity['@id']] = existing ? existing.merge(entity) : entity + { '@id' => entity['@id'] } + end + + def flatten_properties(entity) + entity.to_h.transform_keys(&:to_s).each_with_object({}) do |(key, value), flat| + next if value.blank? + + flat[key] = key.start_with?('@') ? value : flatten_value(value) + end + end + + def flatten_value(value) + case value + when Array + values = value.map { |v| flatten_value(v) }.compact + values.length == 1 ? values.first : values + when Hash + nested = value.transform_keys(&:to_s) + return nested if nested.keys == ['@id'] + + nested['@id'] ||= local_id(nested['@type']) + add_entity(nested) + else + value + end + end + + def local_id(type) + name = Array(type).first.to_s.underscore.dasherize.presence || 'entity' + "##{name}-#{@local_ids[name] += 1}" + end + end +end diff --git a/lib/ro_crate_export/identifier.rb b/lib/ro_crate_export/identifier.rb new file mode 100644 index 000000000..ee300ad26 --- /dev/null +++ b/lib/ro_crate_export/identifier.rb @@ -0,0 +1,19 @@ +module RoCrateExport + # Normalizes identifiers for FAIR Signposting and RO-Crate. + module Identifier + DOI_RESOLVER = 'https://doi.org/'.freeze + DOI_PREFIX = %r{\A(?:doi:|https?://(?:dx\.|www\.)?doi\.org/)}i + DOI = %r{\A10\.\d{4,}(?:\.\d+)*/\S+\z} + + # Returns a DOI (bare, "doi:" or doi.org URL) as an https://doi.org/ URL, any other http(s) URL unchanged, + # or nil if the value is neither. + def self.url(value) + value = value.to_s.strip + doi = value.sub(DOI_PREFIX, '') + return "#{DOI_RESOLVER}#{doi}" if doi.match?(DOI) + return value if value.match?(%r{\Ahttps?://\S+\z}i) + + nil + end + end +end diff --git a/test/unit/ro_crate_export_identifier_test.rb b/test/unit/ro_crate_export_identifier_test.rb new file mode 100644 index 000000000..fb4321e7d --- /dev/null +++ b/test/unit/ro_crate_export_identifier_test.rb @@ -0,0 +1,29 @@ +require 'test_helper' + +class RoCrateExportIdentifierTest < ActiveSupport::TestCase + test 'normalizes DOIs to https://doi.org/ URLs' do + expected = 'https://doi.org/10.1109/SEAA.2009.59' + ['10.1109/SEAA.2009.59', + 'doi:10.1109/SEAA.2009.59', + 'DOI:10.1109/SEAA.2009.59', + ' doi:10.1109/SEAA.2009.59 ', + 'http://doi.org/10.1109/SEAA.2009.59', + 'https://doi.org/10.1109/SEAA.2009.59', + 'http://dx.doi.org/10.1109/SEAA.2009.59', + 'https://dx.doi.org/10.1109/SEAA.2009.59', + 'https://www.doi.org/10.1109/SEAA.2009.59'].each do |doi| + assert_equal expected, RoCrateExport::Identifier.url(doi), doi + end + end + + test 'keeps other http(s) URLs' do + assert_equal 'http://dx.doi.ac.uk/2q3093032', RoCrateExport::Identifier.url('http://dx.doi.ac.uk/2q3093032') + assert_equal 'https://hdl.handle.net/1234/5678', RoCrateExport::Identifier.url('https://hdl.handle.net/1234/5678') + end + + test 'rejects values that are not DOIs or URLs' do + [nil, '', 'not really a doi.', 'doi:', '10.12/too-short-prefix', 'https://doi.org/has spaces'].each do |value| + assert_nil RoCrateExport::Identifier.url(value), value.inspect + end + end +end From cd1a93a4d21338d06c5f185d8c65551947314421 Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 21:33:10 +0000 Subject: [PATCH 2/9] Add FAIR Signposting and RO-Crate to material and event pages Material and event pages now advertise FAIR Signposting links, in the HTTP Link header and as elements in the HTML head: describedby for the Bioschemas JSON-LD, the Detached RO-Crate and the JSON-API; cite-as for the DOI; author for ORCIDs; license; and type. The Detached RO-Crate is served at /materials/:id/ro-crate-metadata.json and /events/:id/ro-crate-metadata.json, and on the page URL by content negotiation for application/ld+json with the RO-Crate profile. Rails' own asset preload links in the Link header have unquoted attribute values (base64 integrity hashes) and a valueless nopush, which make strict Link header parsers reject the whole header. These values are now quoted and nopush is dropped where signposting is added. Modelled on SEEK's FairSignposting concern. Co-Authored-By: Claude Opus 5.5 --- app/controllers/application_controller.rb | 4 +- app/controllers/concerns/fair_signposting.rb | 105 +++++++++++ app/controllers/events_controller.rb | 11 +- app/controllers/materials_controller.rb | 11 +- app/views/layouts/_head.html.erb | 6 + config/routes.rb | 2 + test/integration/fair_signposting_test.rb | 188 +++++++++++++++++++ 7 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 app/controllers/concerns/fair_signposting.rb create mode 100644 test/integration/fair_signposting_test.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ce3223022..ebbdf5a61 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,13 +21,13 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception # Should allow token authentication for API calls - acts_as_token_authentication_handler_for User, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, + acts_as_token_authentication_handler_for User, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, :ro_crate, :redirect] #only: [:new, :create, :edit, :update, :destroy] # User auth should be required in the web interface as well; it's here rather than in routes so that it # doesn't override the token auth, above. before_action :set_current_user - before_action :authenticate_user!, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, :redirect] + before_action :authenticate_user!, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, :ro_crate, :redirect] before_action :set_current_space # Should prevent forgery errors for JSON posts. diff --git a/app/controllers/concerns/fair_signposting.rb b/app/controllers/concerns/fair_signposting.rb new file mode 100644 index 000000000..51ba14bd2 --- /dev/null +++ b/app/controllers/concerns/fair_signposting.rb @@ -0,0 +1,105 @@ +# Adds FAIR Signposting (https://signposting.org/FAIR/) links to the show page of a resource, as an HTTP Link header +# and as elements in the HTML head, and serves a Detached RO-Crate of the resource on request. +# +# Including controllers must define +fair_signposting_resource+, returning the resource being shown. +module FairSignposting + extend ActiveSupport::Concern + + SCHEMA_ORG = 'https://schema.org/'.freeze + JSON_LD = 'application/ld+json'.freeze + + included do + helper_method :fair_signposting_links + after_action :set_fair_signposting_header, only: :show, if: -> { response.successful? } + end + + # GET /materials/1/ro-crate-metadata.json + def ro_crate + resource = fair_signposting_resource + authorize resource, :show? + render_ro_crate(resource) + end + + private + + # Whether the RO-Crate is requested by content negotiation for JSON-LD with the RO-Crate profile, e.g. + # "Accept: application/ld+json; profile="https://w3id.org/ro/crate"". + def ro_crate_requested? + profile = Regexp.escape(RoCrateExport::DetachedCrate::PROFILE) + request.headers['Accept'].to_s.match?(%r{application/ld\+json\s*;[^,]*profile="?#{profile}(?=["\s,;]|\z)}i) + end + + def render_ro_crate(resource) + render plain: RoCrateExport::DetachedCrate.new(resource).to_json, + content_type: RoCrateExport::DetachedCrate::MEDIA_TYPE + end + + # An array of [url, { attribute => value }] pairs. + def fair_signposting_links + resource = fair_signposting_resource + return [] unless resource&.persisted? + + @fair_signposting_links ||= described_by_links(resource) + cite_as_links(resource) + author_links(resource) + + licence_links(resource) + type_links(resource) + end + + def described_by_links(resource) + [ + [polymorphic_url(resource, format: :jsonld), + { rel: 'describedby', type: JSON_LD, profile: bioschemas_generator(resource).bioschemas_profile }.compact], + [polymorphic_url([:ro_crate_metadata, resource]), + { rel: 'describedby', type: JSON_LD, profile: RoCrateExport::DetachedCrate::PROFILE }], + [polymorphic_url(resource, format: :json_api), { rel: 'describedby', type: 'application/vnd.api+json' }] + ] + end + + def cite_as_links(resource) + doi_url = RoCrateExport::Identifier.url(resource.try(:doi)) + doi_url ? [[doi_url, { rel: 'cite-as' }]] : [] + end + + def author_links(resource) + Array(resource.try(:authors)).filter_map(&:orcid_url).uniq.map { |orcid| [orcid, { rel: 'author' }] } + end + + def licence_links(resource) + licence = resource.try(:licence) + licence_url = LicenceDictionary.instance.lookup_value(licence, 'reference') if licence.present? + licence_url.present? ? [[licence_url, { rel: 'license' }]] : [] + end + + def type_links(resource) + ["#{SCHEMA_ORG}#{bioschemas_generator(resource).type}", "#{SCHEMA_ORG}AboutPage"].map { |t| [t, { rel: 'type' }] } + end + + def bioschemas_generator(resource) + resource.to_bioschemas.first.class + end + + def set_fair_signposting_header + links = fair_signposting_links + return if links.empty? + + signposting = links.map do |url, attributes| + (["<#{url}>"] + attributes.map { |key, value| %(#{key}="#{value}") }).join(' ; ') + end + # Keep any existing links, e.g. asset preload hints added by Rails. + existing = response.get_header('Link').presence + response.set_header('Link', [existing && quote_link_values(existing), *signposting].compact.join(', ')) + end + + # Rails' asset preload links have unquoted attribute values that are not valid RFC 8288 tokens (e.g. base64 + # integrity hashes ending in "="), and a valueless "nopush" (a hint for the obsolete HTTP/2 server push), which + # make strict Link header parsers reject the whole header, including the signposting links. Quote the values and + # drop "nopush". + def quote_link_values(header) + header.split(/,\s*(?=<)/).map do |link| + target, params = link.split(/(?<=>)/, 2) + params = params.to_s.gsub(/;\s*nopush\s*(?=;|\z)/, '') + params = params.gsub(/;\s*(?[\w*-]+)=(?[^";,]+?)\s*(?=;|\z)/) do + %(; #{Regexp.last_match[:key]}="#{Regexp.last_match[:value]}") + end + "#{target}#{params}" + end.join(', ') + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index bdfe153fb..f40fc1a16 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -3,7 +3,7 @@ # The controller for actions related to the Events model class EventsController < ApplicationController before_action :ensure_feature_enabled - before_action :set_event, only: %i[show edit clone update destroy update_collections add_term reject_term + before_action :set_event, only: %i[show ro_crate edit clone update destroy update_collections add_term reject_term redirect report update_report add_data reject_data] before_action :set_breadcrumbs before_action :disable_pagination, only: :index, if: ->(controller) { controller.request.format.ics? or controller.request.format.csv? or controller.request.format.rss? } @@ -12,6 +12,7 @@ class EventsController < ApplicationController include ActionView::Helpers::TextHelper include FieldLockEnforcement include TopicCuration + include FairSignposting # GET /events # GET /events.json @@ -73,7 +74,9 @@ def show format.json format.json_api { render json: @event } format.ics { send_data @event.to_ical, type: 'text/calendar', disposition: 'attachment', filename: "#{@event.slug}.ics" } - format.jsonld { render plain: @bioschemas.first.to_json } + format.jsonld do + ro_crate_requested? ? render_ro_crate(@event) : render(plain: @bioschemas.first.to_json) + end end end @@ -228,6 +231,10 @@ def redirect private + def fair_signposting_resource + @event if %w[show ro_crate].include?(action_name) + end + # Use callbacks to share common setup or constraints between actions. def set_event @event = Event.friendly.find(params[:id]) diff --git a/app/controllers/materials_controller.rb b/app/controllers/materials_controller.rb index 9a53fa0d3..67f8f8bf0 100644 --- a/app/controllers/materials_controller.rb +++ b/app/controllers/materials_controller.rb @@ -1,7 +1,7 @@ # The controller for actions related to the Materials model class MaterialsController < ApplicationController before_action :ensure_feature_enabled - before_action :set_material, only: %i[show edit update destroy update_collections clone + before_action :set_material, only: %i[show ro_crate edit update destroy update_collections clone add_term reject_term add_data reject_data] before_action :set_breadcrumbs before_action :set_learning_path_navigation, only: :show @@ -10,6 +10,7 @@ class MaterialsController < ApplicationController include ActionView::Helpers::TextHelper include FieldLockEnforcement include TopicCuration + include FairSignposting # GET /materials # GET /materials?q=queryparam @@ -35,7 +36,9 @@ def show format.html format.json format.json_api { render json: @material } - format.jsonld { render plain: @bioschemas.first.to_json } + format.jsonld do + ro_crate_requested? ? render_ro_crate(@material) : render(plain: @bioschemas.first.to_json) + end end end @@ -158,6 +161,10 @@ def update_collections private + def fair_signposting_resource + @material if %w[show ro_crate].include?(action_name) + end + # Use callbacks to share common setup or constraints between actions. def set_material @material = Material.friendly.find(params[:id]) diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 5b83760d8..9e199f87f 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -33,5 +33,11 @@ <% end %> <% end %> + <% if respond_to?(:fair_signposting_links) %> + <% fair_signposting_links.each do |url, attributes| %> + <%= tag.link(href: url, **attributes) %> + <% end %> + <% end %> + <%= yield :extra_head if content_for? :extra_head %> diff --git a/config/routes.rb b/config/routes.rb index 627892161..b204898f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,6 +76,7 @@ get :report patch :report, to: 'events#update_report' get :clone + get 'ro-crate-metadata.json', action: :ro_crate, as: :ro_crate_metadata, format: false end end @@ -114,6 +115,7 @@ post :add_term post :add_data get :clone + get 'ro-crate-metadata.json', action: :ro_crate, as: :ro_crate_metadata, format: false end collection do get :count diff --git a/test/integration/fair_signposting_test.rb b/test/integration/fair_signposting_test.rb new file mode 100644 index 000000000..b36df74de --- /dev/null +++ b/test/integration/fair_signposting_test.rb @@ -0,0 +1,188 @@ +require 'test_helper' + +class FairSignpostingTest < ActionDispatch::IntegrationTest + RO_CRATE_PROFILE = 'https://w3id.org/ro/crate'.freeze + SIGNPOSTING_RELS = %w[describedby cite-as author license type item].freeze + + test 'fair signposting for material' do + material = materials(:good_material) + material.authors.create!(name: 'Ada Lovelace', orcid: '0000-0002-1825-0097') + material.authors.create!(name: 'Charles Babbage') + + get material_path(material) + + assert_response :success + assert_match(/rel="preload"/, response.headers['Link'], 'Rails preload links should be kept') + links = parse_link_header + assert_equal 8, links.size + assert_link(links, material_url(material, format: :jsonld), rel: 'describedby', type: 'application/ld+json', + profile: 'https://bioschemas.org/profiles/TrainingMaterial/1.0-RELEASE') + assert_link(links, ro_crate_metadata_material_url(material), rel: 'describedby', type: 'application/ld+json', + profile: RO_CRATE_PROFILE) + assert_link(links, material_url(material, format: :json_api), rel: 'describedby', type: 'application/vnd.api+json') + assert_link(links, 'http://dx.doi.ac.uk/2q3093032', rel: 'cite-as') + assert_link(links, 'https://orcid.org/0000-0002-1825-0097', rel: 'author') + assert_link(links, 'https://spdx.org/licenses/CC-BY-4.0.html', rel: 'license') + assert_link(links, 'https://schema.org/LearningResource', rel: 'type') + assert_link(links, 'https://schema.org/AboutPage', rel: 'type') + + html_links = Nokogiri::HTML(response.body).css('head link[rel="describedby"]').map { |l| l['href'] } + assert_includes html_links, ro_crate_metadata_material_url(material) + end + + test 'fair signposting for material without doi, licence or authors' do + material = materials(:bad_material) + + get material_path(material) + + assert_response :success + links = parse_link_header + assert_equal 5, links.size + assert_empty(links.select { |l| %w[cite-as author license].include?(l[:rel]) }) + end + + test 'doi rewritten to https://doi.org/ in cite-as and ro-crate' do + material = materials(:biojs) + material.update_column(:doi, 'http://dx.doi.org/10.1109/SEAA.2009.59') + + get material_path(material) + + assert_response :success + assert_link(parse_link_header, 'https://doi.org/10.1109/SEAA.2009.59', rel: 'cite-as') + + get ro_crate_metadata_material_path(material) + + root = JSON.parse(response.body)['@graph'].detect { |e| e['@id'] == material_url(material) } + assert_equal 'https://doi.org/10.1109/SEAA.2009.59', root['identifier'] + end + + test 'fair signposting for event' do + event = events(:one) + + get event_path(event) + + assert_response :success + links = parse_link_header + assert_equal 5, links.size + assert_link(links, event_url(event, format: :jsonld), rel: 'describedby', type: 'application/ld+json', + profile: 'https://bioschemas.org/profiles/Course/1.0-RELEASE') + assert_link(links, ro_crate_metadata_event_url(event), rel: 'describedby', type: 'application/ld+json', + profile: RO_CRATE_PROFILE) + assert_link(links, 'https://schema.org/Course', rel: 'type') + end + + test 'no fair signposting on index' do + get materials_path + + assert_response :success + assert_empty parse_link_header + end + + test 'detached ro-crate for material' do + material = materials(:good_material) + + get ro_crate_metadata_material_path(material) + + assert_response :success + assert response.content_type.start_with?(%(application/ld+json; profile="#{RO_CRATE_PROFILE}")) + crate = JSON.parse(response.body) + assert_equal 'https://w3id.org/ro/crate/1.3/context', crate['@context'] + entities = crate['@graph'].index_by { |e| e['@id'] } + + descriptor = entities['ro-crate-metadata.json'] + assert_equal 'https://w3id.org/ro/crate/1.3', descriptor.dig('conformsTo', '@id') + assert_equal material_url(material), descriptor.dig('about', '@id') + + root = entities[material_url(material)] + assert_equal %w[Dataset LearningResource], root['@type'] + assert_equal 'Training Material Example', root['name'] + assert_equal 'This is a Training Material produced by an example organization', root['description'] + assert root['datePublished'].present? + assert_equal 'https://spdx.org/licenses/CC-BY-4.0.html', root.dig('license', '@id') + assert_equal 'Creative Commons Attribution 4.0 International', + entities['https://spdx.org/licenses/CC-BY-4.0.html']['name'] + assert_equal 'https://bioschemas.org/profiles/TrainingMaterial/1.0-RELEASE', root.dig('conformsTo', '@id') + assert_equal 'Profile', entities['https://bioschemas.org/profiles/TrainingMaterial/1.0-RELEASE']['@type'] + assert_nil root['dct:conformsTo'] + assert_nil root['@context'] + + assert_flattened(crate) + end + + test 'detached ro-crate for event' do + event = events(:one) + + get ro_crate_metadata_event_path(event) + + assert_response :success + crate = JSON.parse(response.body) + entities = crate['@graph'].index_by { |e| e['@id'] } + root = entities[event_url(event)] + assert_equal %w[Dataset Course], root['@type'] + assert_equal RoCrateExport::DetachedCrate::NO_LICENCE, root['license'] + RoCrateExport::DetachedCrate::UNDEFINED_PROPERTIES.each do |property| + assert_nil root[property] + end + + assert_flattened(crate) + end + + test 'detached ro-crate by content negotiation' do + material = materials(:good_material) + + get material_path(material), headers: { 'Accept' => %(application/ld+json; profile="#{RO_CRATE_PROFILE}") } + + assert_response :success + assert_equal 'ro-crate-metadata.json', JSON.parse(response.body)['@graph'].first['@id'] + end + + test 'bioschemas json-ld without ro-crate profile' do + material = materials(:good_material) + + get material_path(material), headers: { 'Accept' => 'application/ld+json' } + + assert_response :success + assert_equal 'LearningResource', JSON.parse(response.body)['@type'] + end + + test 'no ro-crate for material from unverified user' do + material = materials(:good_material) + material.update_column(:user_id, users(:unverified_user).id) + + get ro_crate_metadata_material_path(material) + + assert_response :forbidden + end + + private + + # Signposting links in the Link header, ignoring others such as asset preload hints. + def parse_link_header + links = response.headers['Link'].to_s.split(/,\s*(?=<)/).map { |link| parse_link(link) } + links.select { |link| SIGNPOSTING_RELS.include?(link[:rel]) } + end + + def parse_link(link) + url, *params = link.split(/\s*;\s*/) + params.map { |p| p.split('=', 2) }.to_h { |k, v| [k.to_sym, v&.delete('"')] }.merge(url: url[1..-2]) + end + + def assert_link(links, url, **attributes) + expected = attributes.merge(url:) + assert links.include?(expected), "Expected link #{expected.inspect} in:\n#{links.map(&:inspect).join("\n")}" + end + + # Every entity is at the top level of the @graph, and other entities are only referenced by @id. + def assert_flattened(crate) + ids = crate['@graph'].map { |e| e['@id'] } + assert_equal ids.uniq, ids + crate['@graph'].each do |entity| + assert entity['@type'].present?, "#{entity['@id']} has no @type" + entity.except('@id', '@type').each_value do |value| + Array(value).grep(Hash).each do |ref| + assert_equal ['@id'], ref.keys, "Nested entity in #{entity['@id']}: #{ref.inspect}" + end + end + end + end +end From 0a803336ee35d240bf432923311536fa830e390b Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 21:42:24 +0000 Subject: [PATCH 3/9] Add FAIR Signposting integration tests with the signposting library Python tests in test/signposting use the signposting library as an independent client against a running TeSS: they check the Link header and HTML elements of material and event pages, follow describedby to the Detached RO-Crate, check content negotiation and DOI rewriting, and validate the crates with rocrate-validator. A GitHub Actions workflow loads the test fixtures, starts TeSS and runs them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/signposting.yml | 78 +++++++ ...t_signposting.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 30977 bytes test/signposting/requirements.txt | 4 + test/signposting/test_signposting.py | 197 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 .github/workflows/signposting.yml create mode 100644 test/signposting/__pycache__/test_signposting.cpython-312-pytest-9.1.1.pyc create mode 100644 test/signposting/requirements.txt create mode 100644 test/signposting/test_signposting.py diff --git a/.github/workflows/signposting.yml b/.github/workflows/signposting.yml new file mode 100644 index 000000000..3aceae1dc --- /dev/null +++ b/.github/workflows/signposting.yml @@ -0,0 +1,78 @@ +name: Signposting + +on: [push, pull_request] + +jobs: + signposting: + runs-on: ubuntu-latest + env: + DB_HOST: localhost + DB_NAME: tess + DB_USER: tess + DB_PASSWORD: password + SECRET_BASE_KEY: test_key + RAILS_ENV: test + REDIS_TEST_URL: redis://localhost:6379/0 + TESS_URL: http://localhost:3000 + services: + postgres: + image: postgres + env: + POSTGRES_DB: ${{ env.DB_NAME }} + POSTGRES_USER: ${{ env.DB_USER }} + POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install imagemagick + - name: Check out code + uses: actions/checkout@v6 + - name: Install Ruby & gems + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Configure and initialize database + run: | + cp test/config/test_tess.yml config/tess.yml + cp config/secrets.github.yml config/secrets.yml + cp config/ingestion.example.yml config/ingestion.yml + bundle exec rake db:test:prepare + bundle exec rails db:fixtures:load + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile --non-interactive + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: test/signposting/requirements.txt + - name: Install Python dependencies + run: pip install -r test/signposting/requirements.txt + - name: Start TeSS + run: | + bundle exec rails server -p 3000 -d + timeout 120 bash -c 'until curl -sf $TESS_URL/up; do sleep 2; done' + - name: Run signposting tests + run: pytest test/signposting diff --git a/test/signposting/__pycache__/test_signposting.cpython-312-pytest-9.1.1.pyc b/test/signposting/__pycache__/test_signposting.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9084e049c2ab0218fbd44e43450c4cb4ce7beda0 GIT binary patch literal 30977 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 literal 0 HcmV?d00001 diff --git a/test/signposting/requirements.txt b/test/signposting/requirements.txt new file mode 100644 index 000000000..5d4dcf8ee --- /dev/null +++ b/test/signposting/requirements.txt @@ -0,0 +1,4 @@ +pytest>=8 +requests>=2.31 +signposting>=0.9.9 +roc-validator>=0.13 diff --git a/test/signposting/test_signposting.py b/test/signposting/test_signposting.py new file mode 100644 index 000000000..e019fcb9e --- /dev/null +++ b/test/signposting/test_signposting.py @@ -0,0 +1,197 @@ +"""FAIR Signposting and RO-Crate integration tests against a running TeSS instance. + +These use the `signposting` library (https://pypi.org/project/signposting/) as an independent client to check the +Link headers and HTML elements of material and event pages, then follow them to the Detached RO-Crate. + +Run against the test fixtures (see README.md), or any TeSS instance by setting TESS_URL: + + RAILS_ENV=test bin/rails db:fixtures:load + RAILS_ENV=test bin/rails server -p 3000 & + pip install -r test/signposting/requirements.txt + TESS_URL=http://localhost:3000 pytest test/signposting +""" +import json +import os +import shutil +import subprocess +from urllib.parse import urljoin, urlsplit + +import pytest +import requests +import signposting + +TESS_URL = os.environ.get("TESS_URL", "http://localhost:3000").rstrip("/") + "/" +RO_CRATE_PROFILE = "https://w3id.org/ro/crate" +RO_CRATE_SPEC = "https://w3id.org/ro/crate/1.3" +JSON_LD = "application/ld+json" +SCHEMA_TYPES = { + "materials": {"https://schema.org/LearningResource"}, + "events": {"https://schema.org/Event", "https://schema.org/Course"}, +} +SAMPLE_SIZE = int(os.environ.get("TESS_SAMPLE_SIZE", "5")) + + +def resource_urls(kind, sample_size=SAMPLE_SIZE): + """Page URLs of the first resources of a kind ("materials" or "events"), found through the JSON:API.""" + response = requests.get(urljoin(TESS_URL, f"{kind}.json_api"), params={"per_page": sample_size}, timeout=30) + response.raise_for_status() + return [urljoin(TESS_URL, item["links"]["self"]) for item in response.json()["data"]] + + +def resources(kind): + try: + urls = resource_urls(kind) + except requests.RequestException as e: + pytest.skip(f"TeSS not reachable at {TESS_URL}: {e}") + if not urls: + pytest.skip(f"No {kind} in TeSS at {TESS_URL}") + return urls + + +def ro_crate_link(signposts): + links = [d for d in signposts.describedBy if RO_CRATE_PROFILE in signposts_profiles(d)] + assert len(links) == 1, f"Expected one RO-Crate describedby link, got {signposts.describedBy}" + return links[0] + + +def signposts_profiles(signpost): + return {str(p) for p in (signpost.profiles or [])} + + +def root_entity(crate): + entities = {e["@id"]: e for e in crate["@graph"]} + descriptor = entities["ro-crate-metadata.json"] + return descriptor, entities[descriptor["about"]["@id"]], entities + + +@pytest.fixture(scope="module", params=["materials", "events"]) +def kind(request): + return request.param + + +@pytest.fixture(scope="module") +def page_urls(kind): + return resources(kind) + + +def test_http_signposting(kind, page_urls): + for url in page_urls: + signposts = signposting.find_signposting_http(url) + + assert signposts.signposts, f"No signposting in Link header of {url}" + link = ro_crate_link(signposts) + assert str(link.type) == JSON_LD + assert str(link.target) == url + "/ro-crate-metadata.json" + + bioschemas = [d for d in signposts.describedBy if str(d.target) == url + ".jsonld"] + assert len(bioschemas) == 1 + assert any(p.startswith("https://bioschemas.org/profiles/") for p in signposts_profiles(bioschemas[0])) + + types = {str(t.target) for t in signposts.types} + assert "https://schema.org/AboutPage" in types + assert types & SCHEMA_TYPES[kind], f"Expected one of {SCHEMA_TYPES[kind]} in {types}" + + if signposts.citeAs: + assert str(signposts.citeAs.target).startswith(("http://", "https://")) + assert not str(signposts.citeAs.target).startswith(("http://doi.org/", "http://dx.doi.org/", + "https://dx.doi.org/")), \ + "DOIs should be given as https://doi.org/ URLs" + + +def test_html_signposting_matches_http(page_urls): + for url in page_urls: + http = signposting.find_signposting_http(url) + html = signposting.find_signposting_html(url) + + def links(s): + return {(str(l.rel), str(l.target), str(l.type or ""), frozenset(signposts_profiles(l))) + for l in s.signposts} + + assert links(html) == links(http) + + +def test_follow_signposting_to_ro_crate(kind, page_urls): + for url in page_urls: + link = ro_crate_link(signposting.find_signposting_http(url)) + response = requests.get(str(link.target), timeout=30) + response.raise_for_status() + assert response.headers["Content-Type"].startswith(f'{JSON_LD}; profile="{RO_CRATE_PROFILE}"') + + crate = response.json() + assert crate["@context"] == f"{RO_CRATE_SPEC}/context" + descriptor, root, entities = root_entity(crate) + assert descriptor["conformsTo"] == {"@id": RO_CRATE_SPEC} + # Detached crate: the Root Data Entity is the page the signposting came from. Its host is TeSS's configured + # base_url, which may differ from the host the tests reach the server through. + assert urlsplit(root["@id"]).path == urlsplit(url).path + assert "Dataset" in root["@type"] + for prop in ("name", "description", "datePublished", "license"): + assert root.get(prop), f"Root Data Entity of {url} has no {prop}" + + # Flattened: nested objects are only references, and every reference is described + for entity in crate["@graph"]: + for key, value in entity.items(): + if key.startswith("@"): + continue + for v in value if isinstance(value, list) else [value]: + if isinstance(v, dict): + assert list(v) == ["@id"], f"Nested entity in {entity['@id']}.{key}: {v}" + if v["@id"].startswith("#"): + assert v["@id"] in entities, f"{v['@id']} is not described" + + +def test_content_negotiation(page_urls): + for url in page_urls: + response = requests.get(url, headers={"Accept": f'{JSON_LD}; profile="{RO_CRATE_PROFILE}"'}, timeout=30) + response.raise_for_status() + assert response.headers["Content-Type"].startswith(JSON_LD) + descriptor, root, _ = root_entity(response.json()) + assert descriptor["conformsTo"] == {"@id": RO_CRATE_SPEC} + + # Without the profile, the Bioschemas JSON-LD is served as before + bioschemas = requests.get(url, headers={"Accept": JSON_LD}, timeout=30).json() + assert "@graph" not in bioschemas + + +def test_doi_rewritten_to_https_doi_org(): + """Materials whose DOI is given as http://doi.org/, http://dx.doi.org/, doi: or bare are cited as https://doi.org/.""" + response = requests.get(urljoin(TESS_URL, "materials.json_api"), params={"per_page": 100}, timeout=30) + response.raise_for_status() + with_doi = [m for m in response.json()["data"] + if (m["attributes"].get("doi") or "").lower().startswith(("10.", "doi:", "http://doi.org/", + "http://dx.doi.org/", + "https://dx.doi.org/"))] + if not with_doi: + pytest.skip("No materials with a DOI to rewrite") + + for material in with_doi: + url = urljoin(TESS_URL, material["links"]["self"]) + doi = material["attributes"]["doi"].strip() + suffix = doi[doi.index("10."):] + signposts = signposting.find_signposting_http(url) + assert str(signposts.citeAs.target) == f"https://doi.org/{suffix}" + + crate = requests.get(str(ro_crate_link(signposts).target), timeout=30).json() + _, root, _ = root_entity(crate) + assert root["identifier"] == f"https://doi.org/{suffix}" + + +@pytest.mark.skipif(not shutil.which("rocrate-validator"), reason="rocrate-validator (roc-validator) not installed") +def test_ro_crate_validates(kind, page_urls, tmp_path): + """No REQUIRED issues from rocrate-validator for the RO-Crate 1.3 profile.""" + for i, url in enumerate(page_urls): + link = ro_crate_link(signposting.find_signposting_http(url)) + crate_dir = tmp_path / f"{kind}-{i}" + crate_dir.mkdir() + metadata = crate_dir / "ro-crate-metadata.json" + metadata.write_text(requests.get(str(link.target), timeout=30).text) + report = crate_dir / "report.json" + + subprocess.run(["rocrate-validator", "validate", "--profile-identifier", "ro-crate-1.3", "--no-paging", + "--metadata-only", "--requirement-severity", "REQUIRED", "--output-format", "json", + "--output-file", str(report), str(metadata)], + stdin=subprocess.DEVNULL, capture_output=True, timeout=600) + + result = json.loads(report.read_text()) + issues = [f"{i['violatingEntity']}: {i['message']}" for i in result["issues"]] + assert result["passed"], f"{url} crate has issues:\n" + "\n".join(issues) From 9fbe5ddf6fdf485116b77be2eab87e1c96d43d36 Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 21:42:24 +0000 Subject: [PATCH 4/9] Document Bioschemas, RO-Crate and FAIR Signposting in API docs Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/api.md b/docs/api.md index 5980765a9..b4233ee08 100644 --- a/docs/api.md +++ b/docs/api.md @@ -38,6 +38,28 @@ A record can be viewed through this API by appending `.json_api` to the URL, for http://localhost:3000/materials.json_api http://localhost:3000/materials/1.json_api +## Bioschemas, RO-Crate and FAIR Signposting + +The pages of Materials and Events embed [Bioschemas](https://bioschemas.org/) JSON-LD, which can also be retrieved by +appending `.jsonld` to the URL: + + http://localhost:3000/materials/1.jsonld + +Each Material and Event is also available as a [Detached RO-Crate](https://w3id.org/ro/crate/1.3), with the record's +URL as the Root Data Entity: + + http://localhost:3000/materials/1/ro-crate-metadata.json + curl -H 'Accept: application/ld+json; profile="https://w3id.org/ro/crate"' http://localhost:3000/materials/1 + +These representations are advertised by [FAIR Signposting](https://signposting.org/FAIR/) links, in the HTTP `Link` +header and as `` elements in the HTML head of the page: `describedby` for the Bioschemas JSON-LD, RO-Crate and +JSON-API, `cite-as` for the DOI (as an `https://doi.org/` URL), `author` for authors' ORCIDs, `license` and `type`. + + curl -I http://localhost:3000/materials/1 + +Integration tests that check these with the [signposting](https://pypi.org/project/signposting/) library against a +running TeSS are in [test/signposting](../test/signposting/test_signposting.py). + ## Legacy API A simple read/write API supporting Events and Materials. From 581f9f07574523f025054032cd25288ad27d4f15 Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 21:50:56 +0000 Subject: [PATCH 5/9] Remove Signposting CI workflow Keep the Python signposting tests as an opt-in suite run against a running TeSS, rather than adding a second CI job that duplicates the setup of the main test workflow. CI integration can be discussed with the maintainers separately. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/signposting.yml | 78 ------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/signposting.yml diff --git a/.github/workflows/signposting.yml b/.github/workflows/signposting.yml deleted file mode 100644 index 3aceae1dc..000000000 --- a/.github/workflows/signposting.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Signposting - -on: [push, pull_request] - -jobs: - signposting: - runs-on: ubuntu-latest - env: - DB_HOST: localhost - DB_NAME: tess - DB_USER: tess - DB_PASSWORD: password - SECRET_BASE_KEY: test_key - RAILS_ENV: test - REDIS_TEST_URL: redis://localhost:6379/0 - TESS_URL: http://localhost:3000 - services: - postgres: - image: postgres - env: - POSTGRES_DB: ${{ env.DB_NAME }} - POSTGRES_USER: ${{ env.DB_USER }} - POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - steps: - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install imagemagick - - name: Check out code - uses: actions/checkout@v6 - - name: Install Ruby & gems - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - name: Configure and initialize database - run: | - cp test/config/test_tess.yml config/tess.yml - cp config/secrets.github.yml config/secrets.yml - cp config/ingestion.example.yml config/ingestion.yml - bundle exec rake db:test:prepare - bundle exec rails db:fixtures:load - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: '20.x' - cache: 'yarn' - - name: Install JS dependencies - run: yarn install --frozen-lockfile --non-interactive - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - cache: 'pip' - cache-dependency-path: test/signposting/requirements.txt - - name: Install Python dependencies - run: pip install -r test/signposting/requirements.txt - - name: Start TeSS - run: | - bundle exec rails server -p 3000 -d - timeout 120 bash -c 'until curl -sf $TESS_URL/up; do sleep 2; done' - - name: Run signposting tests - run: pytest test/signposting From 4aeed9b8e451555e71914fda00fdd38650e6900d Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 22:16:35 +0000 Subject: [PATCH 6/9] Put FAIR Signposting links first in the Link header The Signposting Sniffing browser extension only looks at the first rel of a Link header and ignored the whole header when it started with Rails' asset preload links. Emit the signposting links first and keep the preload links after them. Co-Authored-By: Claude Opus 5.5 --- app/controllers/concerns/fair_signposting.rb | 5 +++-- test/integration/fair_signposting_test.rb | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/fair_signposting.rb b/app/controllers/concerns/fair_signposting.rb index 51ba14bd2..8d6252cd2 100644 --- a/app/controllers/concerns/fair_signposting.rb +++ b/app/controllers/concerns/fair_signposting.rb @@ -83,9 +83,10 @@ def set_fair_signposting_header signposting = links.map do |url, attributes| (["<#{url}>"] + attributes.map { |key, value| %(#{key}="#{value}") }).join(' ; ') end - # Keep any existing links, e.g. asset preload hints added by Rails. + # Keep any existing links, e.g. asset preload hints added by Rails, but after the signposting links: some clients, + # such as the Signposting Sniffing browser extension, ignore the whole header unless its first link is a signpost. existing = response.get_header('Link').presence - response.set_header('Link', [existing && quote_link_values(existing), *signposting].compact.join(', ')) + response.set_header('Link', [*signposting, existing && quote_link_values(existing)].compact.join(', ')) end # Rails' asset preload links have unquoted attribute values that are not valid RFC 8288 tokens (e.g. base64 diff --git a/test/integration/fair_signposting_test.rb b/test/integration/fair_signposting_test.rb index b36df74de..d32ba17c7 100644 --- a/test/integration/fair_signposting_test.rb +++ b/test/integration/fair_signposting_test.rb @@ -13,6 +13,7 @@ class FairSignpostingTest < ActionDispatch::IntegrationTest assert_response :success assert_match(/rel="preload"/, response.headers['Link'], 'Rails preload links should be kept') + assert_match(/\A<[^>]*>\s*;\s*rel="describedby"/, response.headers['Link'], 'Signposting links should come first') links = parse_link_header assert_equal 8, links.size assert_link(links, material_url(material, format: :jsonld), rel: 'describedby', type: 'application/ld+json', From 017f2d2c836fdefd9da2e0dfc87a1abc7b0c6411 Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 22:30:03 +0000 Subject: [PATCH 7/9] Make the resource the main entity of its Detached RO-Crate The Root Data Entity is now a plain Dataset for the TeSS record, with TeSS as publisher, instead of the resource's Bioschemas type mixed into the Dataset. The Material or Event is the root's mainEntity, with its Bioschemas properties, profile and sdPublisher, identified by its external URL (or a local identifier if it has no valid URL) rather than a plain url string. A Material is also a data entity of the crate (hasPart); an Event is not. Give SPDX licences as SPDX license URIs (http://spdx.org/licenses/), which match SPDX's RDF, in the crate and the Signposting license link, instead of the HTML page from the licence dictionary. Co-Authored-By: Claude Opus 5.5 --- app/controllers/concerns/fair_signposting.rb | 5 +- docs/api.md | 4 +- lib/ro_crate_export/detached_crate.rb | 62 +++++++++++++++----- lib/ro_crate_export/identifier.rb | 16 +++++ test/integration/fair_signposting_test.rb | 62 ++++++++++++++++---- test/signposting/test_signposting.py | 13 +++- test/unit/ro_crate_export_identifier_test.rb | 8 +++ 7 files changed, 138 insertions(+), 32 deletions(-) diff --git a/app/controllers/concerns/fair_signposting.rb b/app/controllers/concerns/fair_signposting.rb index 8d6252cd2..02c86a35f 100644 --- a/app/controllers/concerns/fair_signposting.rb +++ b/app/controllers/concerns/fair_signposting.rb @@ -63,9 +63,8 @@ def author_links(resource) end def licence_links(resource) - licence = resource.try(:licence) - licence_url = LicenceDictionary.instance.lookup_value(licence, 'reference') if licence.present? - licence_url.present? ? [[licence_url, { rel: 'license' }]] : [] + licence_url = RoCrateExport::Identifier.licence_url(resource.try(:licence)) + licence_url ? [[licence_url, { rel: 'license' }]] : [] end def type_links(resource) diff --git a/docs/api.md b/docs/api.md index b4233ee08..a49e1eedb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -46,7 +46,9 @@ appending `.jsonld` to the URL: http://localhost:3000/materials/1.jsonld Each Material and Event is also available as a [Detached RO-Crate](https://w3id.org/ro/crate/1.3), with the record's -URL as the Root Data Entity: +URL as the Root Data Entity, a `Dataset`. The Material or Event itself is its `mainEntity`: the Bioschemas +`LearningResource`, `Event` or `Course`, identified by its external URL. A Material is also a part of the crate +(`hasPart`). Licences are given as SPDX license URIs, e.g. `http://spdx.org/licenses/CC-BY-4.0`: http://localhost:3000/materials/1/ro-crate-metadata.json curl -H 'Accept: application/ld+json; profile="https://w3id.org/ro/crate"' http://localhost:3000/materials/1 diff --git a/lib/ro_crate_export/detached_crate.rb b/lib/ro_crate_export/detached_crate.rb index 6451fc9e8..2a958f597 100644 --- a/lib/ro_crate_export/detached_crate.rb +++ b/lib/ro_crate_export/detached_crate.rb @@ -1,7 +1,9 @@ module RoCrateExport # Builds a Detached RO-Crate (https://w3id.org/ro/crate/1.3) describing a single TeSS resource. - # The Root Data Entity is the resource's Bioschemas representation, typed additionally as a Dataset and - # flattened into an RO-Crate @graph, with the TeSS page URL as its absolute @id. + # The Root Data Entity is a Dataset for the TeSS record, with the TeSS page URL as its absolute @id. Its + # +mainEntity+ is the resource's Bioschemas representation (e.g. a LearningResource or Event), flattened into the + # RO-Crate @graph with the resource's external URL as its @id. A material is also a part (+hasPart+) of the crate; + # an event is not a data entity, so it is only the main entity. class DetachedCrate SPEC = 'https://w3id.org/ro/crate/1.3'.freeze CONTEXT = "#{SPEC}/context".freeze @@ -14,6 +16,9 @@ class DetachedCrate NO_LICENCE = 'No licence has been specified for this resource in TeSS.'.freeze + # Resources that are data entities of the crate, not only its main entity. + PART_CLASSES = %w[Material].freeze + attr_reader :resource def initialize(resource) @@ -24,7 +29,12 @@ def initialize(resource) def generate @entities = {} @local_ids = Hash.new(0) - root = add_entity(root_data_entity) + bioschemas = @bioschemas.generate.transform_keys(&:to_s) + main = main_entity(bioschemas) + main_ref = add_entity(main) + root = add_entity(root_data_entity(bioschemas['@id'], main, main_ref)) + # List the Root Data Entity first after the descriptor, for readers that do not look it up by @id + @entities = { root['@id'] => @entities[root['@id']] }.merge(@entities) descriptor = { '@id' => METADATA_ID, @@ -42,31 +52,53 @@ def to_json(*_args) private - # The resource's Bioschemas properties, with those required of a Root Data Entity. - def root_data_entity - root = @bioschemas.generate.transform_keys(&:to_s).except('@context', 'dct:conformsTo', *UNDEFINED_PROPERTIES) - root.merge( - '@type' => ['Dataset', root['@type']], - 'identifier' => Identifier.url(root['identifier']), - 'description' => root['description'].presence || root['name'], + # The TeSS record, with the properties required of a Root Data Entity. + def root_data_entity(page_url, main, main_ref) + { + '@id' => page_url, + '@type' => 'Dataset', + 'name' => main['name'], + 'description' => main['description'], 'datePublished' => date_published, - 'license' => licence(root['license']), + 'license' => licence || NO_LICENCE, + 'publisher' => publisher, + 'mainEntity' => main_ref, + 'hasPart' => (main_ref if PART_CLASSES.include?(resource.class.name) && main['@id'].start_with?('http')) + } + end + + # The resource's Bioschemas properties, identified by its external URL (or a local identifier if it has none). + # Its Bioschemas +url+ is a plain string, so it is replaced by the @id. + def main_entity(bioschemas) + main = bioschemas.except('@context', 'dct:conformsTo', *UNDEFINED_PROPERTIES) + url = external_url + main.merge( + '@id' => url || local_id(main['@type']), + 'url' => (main['url'] unless url), + 'identifier' => Identifier.url(main['identifier']), + 'description' => main['description'].presence || main['name'], + 'license' => licence || main['license'], 'conformsTo' => profile, 'sdPublisher' => publisher ) end + def external_url + url = resource.try(:url).to_s.strip + url if url.match?(%r{\Ahttps?://[^\s/]+\.[^\s/]+(/\S*)?\z}i) + end + def date_published date = resource.try(:date_published) || resource.created_at || Time.now date.iso8601 end - def licence(value) - return NO_LICENCE if value.blank? - return value unless value.start_with?('http://', 'https://') + def licence + url = Identifier.licence_url(resource.try(:licence)) + return nil unless url title = LicenceDictionary.instance.lookup_value(resource.licence, 'title') - { '@id' => value, '@type' => 'CreativeWork', 'name' => title || value } + { '@id' => url, '@type' => 'CreativeWork', 'name' => title || url } end def profile diff --git a/lib/ro_crate_export/identifier.rb b/lib/ro_crate_export/identifier.rb index ee300ad26..bc60f837e 100644 --- a/lib/ro_crate_export/identifier.rb +++ b/lib/ro_crate_export/identifier.rb @@ -15,5 +15,21 @@ def self.url(value) nil end + + SPDX_LICENCES = 'http://spdx.org/licenses/'.freeze + SPDX_REFERENCE = %r{\Ahttps?://spdx\.org/licenses/(?[^/]+?)(?:\.html|\.json)?\z}i + + # Returns the URL of a TeSS licence (e.g. "CC-BY-4.0"): for SPDX licences the SPDX license URI, e.g. + # http://spdx.org/licenses/CC-BY-4.0, which matches SPDX's RDF, rather than the HTML page in the licence + # dictionary; otherwise the dictionary's reference URL, or nil if there is none. + def self.licence_url(licence) + return nil if licence.blank? + + reference = LicenceDictionary.instance.lookup_value(licence, 'reference').presence + spdx = reference&.match(SPDX_REFERENCE) + return "#{SPDX_LICENCES}#{spdx[:id]}" if spdx + + reference if reference&.match?(%r{\Ahttps?://\S+\z}i) + end end end diff --git a/test/integration/fair_signposting_test.rb b/test/integration/fair_signposting_test.rb index d32ba17c7..98a9813b2 100644 --- a/test/integration/fair_signposting_test.rb +++ b/test/integration/fair_signposting_test.rb @@ -23,7 +23,7 @@ class FairSignpostingTest < ActionDispatch::IntegrationTest assert_link(links, material_url(material, format: :json_api), rel: 'describedby', type: 'application/vnd.api+json') assert_link(links, 'http://dx.doi.ac.uk/2q3093032', rel: 'cite-as') assert_link(links, 'https://orcid.org/0000-0002-1825-0097', rel: 'author') - assert_link(links, 'https://spdx.org/licenses/CC-BY-4.0.html', rel: 'license') + assert_link(links, 'http://spdx.org/licenses/CC-BY-4.0', rel: 'license') assert_link(links, 'https://schema.org/LearningResource', rel: 'type') assert_link(links, 'https://schema.org/AboutPage', rel: 'type') @@ -53,8 +53,8 @@ class FairSignpostingTest < ActionDispatch::IntegrationTest get ro_crate_metadata_material_path(material) - root = JSON.parse(response.body)['@graph'].detect { |e| e['@id'] == material_url(material) } - assert_equal 'https://doi.org/10.1109/SEAA.2009.59', root['identifier'] + main = JSON.parse(response.body)['@graph'].detect { |e| e['@id'] == material.url } + assert_equal 'https://doi.org/10.1109/SEAA.2009.59', main['identifier'] end test 'fair signposting for event' do @@ -95,21 +95,54 @@ class FairSignpostingTest < ActionDispatch::IntegrationTest assert_equal material_url(material), descriptor.dig('about', '@id') root = entities[material_url(material)] - assert_equal %w[Dataset LearningResource], root['@type'] + assert_equal crate['@graph'][1], root, 'Root Data Entity should follow the metadata descriptor' + assert_equal 'Dataset', root['@type'] assert_equal 'Training Material Example', root['name'] assert_equal 'This is a Training Material produced by an example organization', root['description'] assert root['datePublished'].present? - assert_equal 'https://spdx.org/licenses/CC-BY-4.0.html', root.dig('license', '@id') + assert_equal 'http://spdx.org/licenses/CC-BY-4.0', root.dig('license', '@id') assert_equal 'Creative Commons Attribution 4.0 International', - entities['https://spdx.org/licenses/CC-BY-4.0.html']['name'] - assert_equal 'https://bioschemas.org/profiles/TrainingMaterial/1.0-RELEASE', root.dig('conformsTo', '@id') + entities['http://spdx.org/licenses/CC-BY-4.0']['name'] + assert_equal root_url, root.dig('publisher', '@id') + assert_nil root['conformsTo'] + + # The material itself, at its external URL, is the main entity and a part of the crate + assert_equal({ '@id' => 'http://training.com/trainers/1359023' }, root['mainEntity']) + assert_equal({ '@id' => 'http://training.com/trainers/1359023' }, root['hasPart']) + main = entities['http://training.com/trainers/1359023'] + assert_equal 'LearningResource', main['@type'] + assert_equal 'Training Material Example', main['name'] + assert_equal 'This is a Training Material produced by an example organization', main['description'] + assert_nil main['url'] + assert_equal 'http://spdx.org/licenses/CC-BY-4.0', main.dig('license', '@id') + assert_equal 'https://bioschemas.org/profiles/TrainingMaterial/1.0-RELEASE', main.dig('conformsTo', '@id') assert_equal 'Profile', entities['https://bioschemas.org/profiles/TrainingMaterial/1.0-RELEASE']['@type'] - assert_nil root['dct:conformsTo'] - assert_nil root['@context'] + assert_equal root_url, main.dig('sdPublisher', '@id') + assert_nil main['dct:conformsTo'] + assert_nil main['@context'] + assert_equal 1, crate['@graph'].count { |e| e['name'] == 'Goblet' }, 'Provider should be flattened once' assert_flattened(crate) end + test 'local main entity and no part in detached ro-crate for material without a valid url' do + material = materials(:good_material) + material.update_column(:url, 'Not really a valid URL.com') + + get ro_crate_metadata_material_path(material) + + assert_response :success + crate = JSON.parse(response.body) + entities = crate['@graph'].index_by { |e| e['@id'] } + root = entities[material_url(material)] + assert_nil root['hasPart'] + main = entities[root.dig('mainEntity', '@id')] + assert main['@id'].start_with?('#') + assert_equal 'LearningResource', main['@type'] + assert_equal 'Not really a valid URL.com', main['url'] + assert_flattened(crate) + end + test 'detached ro-crate for event' do event = events(:one) @@ -119,10 +152,17 @@ class FairSignpostingTest < ActionDispatch::IntegrationTest crate = JSON.parse(response.body) entities = crate['@graph'].index_by { |e| e['@id'] } root = entities[event_url(event)] - assert_equal %w[Dataset Course], root['@type'] + assert_equal 'Dataset', root['@type'] assert_equal RoCrateExport::DetachedCrate::NO_LICENCE, root['license'] + + # The event is the main entity, but not a data entity + assert_equal({ '@id' => 'http://example.com/cool-event' }, root['mainEntity']) + assert_nil root['hasPart'] + main = entities['http://example.com/cool-event'] + assert_equal 'Course', main['@type'] + assert_equal 'event one', main['name'] RoCrateExport::DetachedCrate::UNDEFINED_PROPERTIES.each do |property| - assert_nil root[property] + assert_nil main[property] end assert_flattened(crate) diff --git a/test/signposting/test_signposting.py b/test/signposting/test_signposting.py index e019fcb9e..1968c0cb7 100644 --- a/test/signposting/test_signposting.py +++ b/test/signposting/test_signposting.py @@ -128,6 +128,15 @@ def test_follow_signposting_to_ro_crate(kind, page_urls): for prop in ("name", "description", "datePublished", "license"): assert root.get(prop), f"Root Data Entity of {url} has no {prop}" + # The resource is the main entity, with its Bioschemas type and profile; only materials are data entities + main = entities[root["mainEntity"]["@id"]] + assert "Dataset" not in main["@type"] + assert main["conformsTo"]["@id"].startswith("https://bioschemas.org/profiles/") + if kind == "materials" and not main["@id"].startswith("#"): + assert root["hasPart"] == {"@id": main["@id"]} + if kind == "events": + assert "hasPart" not in root + # Flattened: nested objects are only references, and every reference is described for entity in crate["@graph"]: for key, value in entity.items(): @@ -172,8 +181,8 @@ def test_doi_rewritten_to_https_doi_org(): assert str(signposts.citeAs.target) == f"https://doi.org/{suffix}" crate = requests.get(str(ro_crate_link(signposts).target), timeout=30).json() - _, root, _ = root_entity(crate) - assert root["identifier"] == f"https://doi.org/{suffix}" + _, root, entities = root_entity(crate) + assert entities[root["mainEntity"]["@id"]]["identifier"] == f"https://doi.org/{suffix}" @pytest.mark.skipif(not shutil.which("rocrate-validator"), reason="rocrate-validator (roc-validator) not installed") diff --git a/test/unit/ro_crate_export_identifier_test.rb b/test/unit/ro_crate_export_identifier_test.rb index fb4321e7d..57ab0c017 100644 --- a/test/unit/ro_crate_export_identifier_test.rb +++ b/test/unit/ro_crate_export_identifier_test.rb @@ -21,6 +21,14 @@ class RoCrateExportIdentifierTest < ActiveSupport::TestCase assert_equal 'https://hdl.handle.net/1234/5678', RoCrateExport::Identifier.url('https://hdl.handle.net/1234/5678') end + test 'gives SPDX licences as SPDX license URIs' do + assert_equal 'http://spdx.org/licenses/CC-BY-4.0', RoCrateExport::Identifier.licence_url('CC-BY-4.0') + assert_equal 'http://spdx.org/licenses/Apache-2.0', RoCrateExport::Identifier.licence_url('Apache-2.0') + assert_nil RoCrateExport::Identifier.licence_url(nil) + assert_nil RoCrateExport::Identifier.licence_url('') + assert_nil RoCrateExport::Identifier.licence_url('not-a-licence') + end + test 'rejects values that are not DOIs or URLs' do [nil, '', 'not really a doi.', 'doi:', '10.12/too-short-prefix', 'https://doi.org/has spaces'].each do |value| assert_nil RoCrateExport::Identifier.url(value), value.inspect From 66cf2e29e3eb5d6ee9db53b9acaaa71781584550 Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 22:31:28 +0000 Subject: [PATCH 8/9] Describe Bioschemas, RO-Crate and FAIR Signposting on the developers page Co-Authored-By: Claude Opus 5.5 --- app/views/about/developers.erb | 29 +++++++++++++++++++++++ test/controllers/about_controller_test.rb | 3 +++ 2 files changed, 32 insertions(+) diff --git a/app/views/about/developers.erb b/app/views/about/developers.erb index 9ee40c453..bc0dd28a6 100644 --- a/app/views/about/developers.erb +++ b/app/views/about/developers.erb @@ -112,6 +112,35 @@ <% end %>

+ <% if feature_enabled?('events') || feature_enabled?('materials') %> +

Bioschemas, RO-Crate and FAIR Signposting

+

+ The pages of events and training materials embed + Bioschemas + JSON-LD, which can also be retrieved by appending .jsonld to the URL. Each event and material + is also available as a + Detached RO-Crate, + with the <%= TeSS::Config.site['title_short'] %> record as its root and the event or material itself as + its main entity. For example: +

+

+ <% resource = feature_enabled?('materials') ? 'materials' : 'events' %> + <%= TeSS::Config.base_url %>/<%= resource %>/<id>.jsonld
+ <%= TeSS::Config.base_url %>/<%= resource %>/<id>/ro-crate-metadata.json
+ curl -H 'Accept: application/ld+json; profile="https://w3id.org/ro/crate"' <%= TeSS::Config.base_url %>/<%= resource %>/<id> +

+

+ These representations, and the DOI (cite-as), authors' ORCIDs (author), licence + (license) and type (type) of the resource, are advertised by + FAIR Signposting + links (describedby) in the HTTP Link header and the HTML + <link> elements of each page, so that clients can find them: +

+

+ curl -I <%= TeSS::Config.base_url %>/<%= resource %>/<id> +

+ <% end %> +

We recommend using the JSON API, but if you have already begun using our old API, technical information can still be found here: diff --git a/test/controllers/about_controller_test.rb b/test/controllers/about_controller_test.rb index 6f550d878..f58eb4f41 100644 --- a/test/controllers/about_controller_test.rb +++ b/test/controllers/about_controller_test.rb @@ -106,6 +106,9 @@ class AboutControllerTest < ActionController::TestCase test 'should get about developers' do get :developers assert_response :success + assert_select '#api #fair-signposting', text: 'Bioschemas, RO-Crate and FAIR Signposting' + assert_select '#api a[href="https://signposting.org/FAIR/"]' + assert_select '#api code', text: %r{/ro-crate-metadata\.json} end test 'should get about learning paths' do From 5866c822c51350d0aed687bcccdc6384b00d7678 Mon Sep 17 00:00:00 2001 From: Claude Sandbox Date: Thu, 22 Oct 2026 22:39:43 +0000 Subject: [PATCH 9/9] Remove committed Python bytecode and ignore __pycache__ Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 ++++ ...est_signposting.cpython-312-pytest-9.1.1.pyc | Bin 30977 -> 0 bytes 2 files changed, 4 insertions(+) delete mode 100644 test/signposting/__pycache__/test_signposting.cpython-312-pytest-9.1.1.pyc diff --git a/.gitignore b/.gitignore index 572ac21ba..dd6458da5 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ tess.crontab app-secrets* node_modules/ + +# Python (test/signposting) +__pycache__/ +*.pyc diff --git a/test/signposting/__pycache__/test_signposting.cpython-312-pytest-9.1.1.pyc b/test/signposting/__pycache__/test_signposting.cpython-312-pytest-9.1.1.pyc deleted file mode 100644 index 9084e049c2ab0218fbd44e43450c4cb4ce7beda0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30977 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