Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ tess.crontab

app-secrets*
node_modules/

# Python (test/signposting)
__pycache__/
*.pyc
4 changes: 2 additions & 2 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 105 additions & 0 deletions app/controllers/concerns/fair_signposting.rb
Original file line number Diff line number Diff line change
@@ -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 <link> 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_url = RoCrateExport::Identifier.licence_url(resource.try(:licence))
licence_url ? [[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, 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', [*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
# 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*(?<key>[\w*-]+)=(?<value>[^";,]+?)\s*(?=;|\z)/) do
%(; #{Regexp.last_match[:key]}="#{Regexp.last_match[:value]}")
end
"#{target}#{params}"
end.join(', ')
end
end
11 changes: 9 additions & 2 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? }
Expand All @@ -12,6 +12,7 @@ class EventsController < ApplicationController
include ActionView::Helpers::TextHelper
include FieldLockEnforcement
include TopicCuration
include FairSignposting

# GET /events
# GET /events.json
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down
11 changes: 9 additions & 2 deletions app/controllers/materials_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +10,7 @@ class MaterialsController < ApplicationController
include ActionView::Helpers::TextHelper
include FieldLockEnforcement
include TopicCuration
include FairSignposting

# GET /materials
# GET /materials?q=queryparam
Expand All @@ -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

Expand Down Expand Up @@ -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])
Expand Down
29 changes: 29 additions & 0 deletions app/views/about/developers.erb
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,35 @@
<% end %>
</p>

<% if feature_enabled?('events') || feature_enabled?('materials') %>
<h4 id="fair-signposting">Bioschemas, RO-Crate and FAIR Signposting</h4>
<p>
The pages of events and training materials embed
<a href="https://bioschemas.org/" target="_blank" rel="noopener">Bioschemas<i class="icon icon-md arrow-top-right-icon"></i></a>
JSON-LD, which can also be retrieved by appending <code>.jsonld</code> to the URL. Each event and material
is also available as a
<a href="https://w3id.org/ro/crate/1.3" target="_blank" rel="noopener">Detached RO-Crate<i class="icon icon-md arrow-top-right-icon"></i></a>,
with the <%= TeSS::Config.site['title_short'] %> record as its root and the event or material itself as
its main entity. For example:
</p>
<p>
<% resource = feature_enabled?('materials') ? 'materials' : 'events' %>
<code><%= TeSS::Config.base_url %>/<%= resource %>/&lt;id&gt;.jsonld</code><br/>
<code><%= TeSS::Config.base_url %>/<%= resource %>/&lt;id&gt;/ro-crate-metadata.json</code><br/>
<code>curl -H 'Accept: application/ld+json; profile="https://w3id.org/ro/crate"' <%= TeSS::Config.base_url %>/<%= resource %>/&lt;id&gt;</code>
</p>
<p>
These representations, and the DOI (<code>cite-as</code>), authors' ORCIDs (<code>author</code>), licence
(<code>license</code>) and type (<code>type</code>) of the resource, are advertised by
<a href="https://signposting.org/FAIR/" target="_blank" rel="noopener">FAIR Signposting<i class="icon icon-md arrow-top-right-icon"></i></a>
links (<code>describedby</code>) in the HTTP <code>Link</code> header and the HTML
<code>&lt;link&gt;</code> elements of each page, so that clients can find them:
</p>
<p>
<code>curl -I <%= TeSS::Config.base_url %>/<%= resource %>/&lt;id&gt;</code>
</p>
<% end %>

<p>
We recommend using the JSON API, but if you have already begun using our old API, technical information
can still be found here:
Expand Down
6 changes: 6 additions & 0 deletions app/views/layouts/_head.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
</head>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ 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, 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

These representations are advertised by [FAIR Signposting](https://signposting.org/FAIR/) links, in the HTTP `Link`
header and as `<link>` 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.
Expand Down
Loading
Loading