Skip to content

Commit

Permalink
views: FAIR signposting level 1 support
Browse files Browse the repository at this point in the history
  • Loading branch information
ptamarit committed Feb 21, 2025
1 parent 6687ccb commit 195f53f
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 20 deletions.
85 changes: 78 additions & 7 deletions invenio_app_rdm/records_ui/views/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@

from functools import wraps

from flask import g, make_response, redirect, request, session, url_for
from flask import current_app, g, make_response, redirect, request, session, url_for
from flask_login import login_required
from invenio_communities.communities.resources.serializer import (
UICommunityJSONSerializer,
)
from invenio_communities.proxies import current_communities
from invenio_pidstore.errors import PIDDoesNotExistError
from invenio_rdm_records.proxies import current_rdm_records
from invenio_rdm_records.resources.serializers.signposting import (
FAIRSignpostingProfileLvl1Serializer,
)
from invenio_records_resources.services.errors import PermissionDeniedError
from sqlalchemy.orm.exc import NoResultFound

Expand Down Expand Up @@ -365,20 +368,88 @@ def view(**kwargs):
return view


def add_signposting(f):
"""Add signposting link to view's response headers."""
def _get_header(rel, value, link_type=None):
header = f'<{value}> ; rel="{rel}"'
if link_type:
header += f' ; type="{link_type}"'
return header


def _get_signposting_collection(pid_value):
ui_url = record_url_for(pid_value=pid_value)
return _get_header("collection", ui_url, "text/html")


def _get_signposting_describes(pid_value):
ui_url = record_url_for(pid_value=pid_value)
return _get_header("describes", ui_url, "text/html")


def _get_signposting_linkset(pid_value):
api_url = record_url_for(_app="api", pid_value=pid_value)
return _get_header("linkset", api_url, "application/linkset+json")


def add_signposting_landing_page(f):
"""Add signposting links to the landing page view's response headers."""

@wraps(f)
def view(*args, **kwargs):
response = make_response(f(*args, **kwargs))

# Relies on other decorators having operated before it
pid_value = kwargs["pid_value"]
signposting_link = record_url_for(_app="api", pid_value=pid_value)
record = kwargs["record"]

response.headers["Link"] = (
f'<{signposting_link}> ; rel="linkset" ; type="application/linkset+json"' # fmt: skip
signposting_headers = FAIRSignpostingProfileLvl1Serializer().serialize_object(
record.to_dict()
)

response.headers["Link"] = signposting_headers

return response

return view


def add_signposting_content_resources(f):
"""Add signposting links to the content resources view's response headers."""

@wraps(f)
def view(*args, **kwargs):
response = make_response(f(*args, **kwargs))

# Relies on other decorators having operated before it
pid_value = kwargs["pid_value"]

signposting_headers = [
_get_signposting_collection(pid_value),
_get_signposting_linkset(pid_value),
]

response.headers["Link"] = " , ".join(signposting_headers)

return response

return view


def add_signposting_metadata_resources(f):
"""Add signposting links to the metadata resources view's response headers."""

@wraps(f)
def view(*args, **kwargs):
response = make_response(f(*args, **kwargs))

# Relies on other decorators having operated before it
pid_value = kwargs["pid_value"]

signposting_headers = [
_get_signposting_describes(pid_value),
_get_signposting_linkset(pid_value),
]

response.headers["Link"] = " , ".join(signposting_headers)

return response

return view
Expand Down
9 changes: 6 additions & 3 deletions invenio_app_rdm/records_ui/views/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@

from ..utils import get_external_resources
from .decorators import (
add_signposting,
add_signposting_content_resources,
add_signposting_landing_page,
add_signposting_metadata_resources,
pass_file_item,
pass_file_metadata,
pass_include_deleted,
Expand Down Expand Up @@ -141,7 +143,7 @@ def open(self):
@pass_record_or_draft(expand=True)
@pass_record_files
@pass_record_media_files
@add_signposting
@add_signposting_landing_page
def record_detail(
pid_value, record, files, media_files, is_preview=False, include_deleted=False
):
Expand Down Expand Up @@ -263,6 +265,7 @@ def record_detail(

@pass_is_preview
@pass_record_or_draft(expand=False)
@add_signposting_metadata_resources
def record_export(
pid_value, record, export_format=None, permissions=None, is_preview=False
):
Expand Down Expand Up @@ -325,7 +328,7 @@ def record_file_preview(

@pass_is_preview
@pass_file_item(is_media=False)
@add_signposting
@add_signposting_content_resources
def record_file_download(pid_value, file_item=None, is_preview=False, **kwargs):
"""Download a file from a record."""
download = bool(request.args.get("download"))
Expand Down
68 changes: 58 additions & 10 deletions tests/ui/test_signposting_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,73 @@
See https://signposting.org/FAIR/#level2 for more information on Signposting
"""
import pytest


def test_link_in_landing_page_response_headers(running_app, client, record):
res = client.head(f"/records/{record.id}")
@pytest.mark.parametrize("http_method", ["head", "get"])
def test_link_in_landing_page_response_headers(
running_app, client, record_with_file, http_method
):
client_http_method = getattr(client, http_method)
res = client_http_method(f"/records/{record_with_file.id}")

# The link headers are already tested in details in `invenio-rdm-records` (see `test_signposting_serializer`).
# Here we still want to issue the HTTP call to the URL in order to make sure that the decorator is working properly,
# but the assertions are less detailed to avoid having to adapt this test every time we modify the logic in `invenio-rdm-records`.
link_headers = res.headers["Link"].split(" , ")

# The test record does not have:
# - an author with an identifier.
# - a cite-as since it has no DOI.
# - a license.

# There should be at least 10 export formats supported (e.g. "application/dcat+xml", "application/x-bibtex", etc.).
assert sum('; rel="describedby" ;' in header for header in link_headers) >= 10

# There should be at least one file in the record.
assert sum('; rel="item" ;' in header for header in link_headers) >= 1

# There should be at least one description of the type of the record (e.g. "https://schema.org/Photograph").
assert sum('; rel="type"' in header for header in link_headers) >= 1

# There should be a link to the JSON linkset.
assert (
res.headers["Link"]
== f'<https://127.0.0.1:5000/api/records/{record.id}> ; rel="linkset" ; type="application/linkset+json"' # noqa
sum(
'; rel="linkset" ; type="application/linkset+json"' in header
for header in link_headers
)
== 1
)


@pytest.mark.parametrize("http_method", ["head", "get"])
def test_link_in_content_resource_response_headers(
running_app, client, record_with_file
running_app, client, record_with_file, http_method
):
ui_url = f"https://127.0.0.1:5000/records/{record_with_file.id}"
api_url = f"https://127.0.0.1:5000/api/records/{record_with_file.id}"
filename = "article.txt"

res = client.head(f"/records/{record_with_file.id}/files/{filename}")
client_http_method = getattr(client, http_method)
res = client_http_method(f"/records/{record_with_file.id}/files/{filename}")

assert (
res.headers["Link"]
== f'<https://127.0.0.1:5000/api/records/{record_with_file.id}> ; rel="linkset" ; type="application/linkset+json"' # noqa
)
assert res.headers["Link"].split(" , ") == [
f'<{ui_url}> ; rel="collection" ; type="text/html"',
f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"',
]


@pytest.mark.parametrize("http_method", ["head", "get"])
def test_link_in_metadata_resource_response_headers(
running_app, client, record, http_method
):
ui_url = f"https://127.0.0.1:5000/records/{record.id}"
api_url = f"https://127.0.0.1:5000/api/records/{record.id}"

client_http_method = getattr(client, http_method)
res = client_http_method(f"/records/{record.id}/export/bibtex")

assert res.headers["Link"].split(" , ") == [
f'<{ui_url}> ; rel="describes" ; type="text/html"',
f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"',
]

0 comments on commit 195f53f

Please sign in to comment.