Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

views: FAIR signposting level 1 support (HTTP Link headers) #2938

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
81 changes: 74 additions & 7 deletions invenio_app_rdm/records_ui/views/decorators.py
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite a lot of methods added to decorators.py, should it be moved to a signposting-specific file?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Agree, I thought the was already some signposting-related directory.

Copy link
Member Author

@ptamarit ptamarit Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • [ ] Move the code to a signposting-related file or directory.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is now much less code in decorators.py now that I rely on invenio_rdm_records/resources/serializers/signposting/schema.py.

Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@

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 +366,86 @@ 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")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this is required for level 2 support and was already added in a previous pull request.
Here we only include a link of the type "application/linkset+json", but the docs requires to also include a link of type "application/linkset".



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
record = kwargs["record"]

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_link = record_url_for(_app="api", pid_value=pid_value)

response.headers["Link"] = (
f'<{signposting_link}> ; rel="linkset" ; type="application/linkset+json"' # fmt: skip
)
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)

Comment on lines +410 to +448
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that, unlike the Landing Page which relies on invenio_rdm_records.resources.serializers.signposting, the Content Resources and Metadata Resources are not relying on invenio_rdm_records.resources.serializers.signposting because:

  1. ContentResourceSchema and ContentResourceSchema expect the record to be passed via context={"record_dict"} which makes it more difficult to reuse here.
  2. The logic is pretty simple to add only the collection, describes and linkset headers, so re-implementing it here is not that bad.

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 @@ -247,6 +249,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 @@ -309,7 +312,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
72 changes: 60 additions & 12 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
):
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"

assert (
res.headers["Link"]
== f'<https://127.0.0.1:5000/api/records/{record.id}> ; rel="linkset" ; type="application/linkset+json"' # noqa
)
client_http_method = getattr(client, http_method)
res = client_http_method(f"/records/{record_with_file.id}")

assert res.headers["Link"].split(" , ") == [
# The test record does not have an author with an identifier.
# The test record does not have a cite-as since it has no DOI.
f'<{api_url}> ; rel="describedby" ; type="application/dcat+xml"',
f'<{api_url}> ; rel="describedby" ; type="application/json"',
f'<{api_url}> ; rel="describedby" ; type="application/ld+json"',
f'<{api_url}> ; rel="describedby" ; type="application/marcxml+xml"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.citationstyles.csl+json"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.datacite.datacite+json"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.datacite.datacite+xml"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.geo+json"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.inveniordm.v1+json"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.inveniordm.v1.full+csv"',
f'<{api_url}> ; rel="describedby" ; type="application/vnd.inveniordm.v1.simple+csv"',
f'<{api_url}> ; rel="describedby" ; type="application/x-bibtex"',
f'<{api_url}> ; rel="describedby" ; type="application/x-dc+xml"',
f'<{api_url}> ; rel="describedby" ; type="text/x-bibliography"',
f'<{ui_url}/files/{filename}> ; rel="item" ; type="text/plain"',
# The test record does not have a license.
'<https://schema.org/Photograph> ; rel="type"',
'<https://schema.org/AboutPage> ; rel="type"',
f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for the landing page is implemented in FAIRSignpostingProfileLvl1Serializer in invenio-rdm-records and is already tested there (see inveniosoftware/invenio-rdm-records#1908).
It stills makes sense to at least issue the HTTP call to the endpoint here, to make sure that the decorator is working properly, but maybe the assertion should be less detailed to avoid having to adapt this test every time we modify the other module?

]


@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"].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"]
== 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="describes" ; type="text/html"',
f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"',
]
Loading