diff --git a/invenio_app_rdm/records_ui/views/decorators.py b/invenio_app_rdm/records_ui/views/decorators.py index 122c2af18..48cb3ca34 100644 --- a/invenio_app_rdm/records_ui/views/decorators.py +++ b/invenio_app_rdm/records_ui/views/decorators.py @@ -11,7 +11,7 @@ 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, @@ -19,6 +19,7 @@ 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 @@ -365,8 +366,49 @@ 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 + 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): @@ -374,11 +416,36 @@ def view(*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) + return response return view diff --git a/invenio_app_rdm/records_ui/views/records.py b/invenio_app_rdm/records_ui/views/records.py index 304ff01c9..0ae36faa1 100644 --- a/invenio_app_rdm/records_ui/views/records.py +++ b/invenio_app_rdm/records_ui/views/records.py @@ -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, @@ -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 ): @@ -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 ): @@ -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")) diff --git a/tests/ui/test_signposting_ui.py b/tests/ui/test_signposting_ui.py index 49babd79f..4af2b141d 100644 --- a/tests/ui/test_signposting_ui.py +++ b/tests/ui/test_signposting_ui.py @@ -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' ; 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. + ' ; rel="type"', + ' ; rel="type"', + f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"', + ] + +@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' ; 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"', + ]