diff --git a/invenio_app_rdm/records_ui/views/decorators.py b/invenio_app_rdm/records_ui/views/decorators.py index 71fb505ca..c45fcf6fa 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, redirect, request, url_for +from flask import g, make_response, redirect, request, url_for from invenio_communities.communities.resources.serializer import ( UICommunityJSONSerializer, ) @@ -21,6 +21,8 @@ from invenio_records_resources.services.errors import PermissionDeniedError from sqlalchemy.orm.exc import NoResultFound +from invenio_app_rdm.urls import record_url_for + def service(): """Get the record service.""" @@ -103,11 +105,7 @@ def pass_is_preview(f): @wraps(f) def view(**kwargs): - preview = request.args.get("preview") - is_preview = False - if preview == "1": - is_preview = True - kwargs["is_preview"] = is_preview + kwargs["is_preview"] = request.args.get("preview") == "1" return f(**kwargs) return view @@ -334,7 +332,6 @@ def view(**kwargs): pid_value = kwargs.get("pid_value") files = draft_files_service().list_files(id_=pid_value, identity=g.identity) kwargs["draft_files"] = files - except PermissionDeniedError: # this is handled here because we don't want a 404 on the landing # page when a user is allowed to read the metadata but not the @@ -365,3 +362,22 @@ def view(**kwargs): return f(**kwargs) return view + + +def add_signposting(f): + """Add signposting link to 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 + ) + 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 655be2536..2921e6ffa 100644 --- a/invenio_app_rdm/records_ui/views/records.py +++ b/invenio_app_rdm/records_ui/views/records.py @@ -39,6 +39,7 @@ from ..utils import get_external_resources from .decorators import ( + add_signposting, pass_file_item, pass_file_metadata, pass_include_deleted, @@ -139,6 +140,7 @@ def open(self): @pass_record_or_draft(expand=True) @pass_record_files @pass_record_media_files +@add_signposting def record_detail( pid_value, record, files, media_files, is_preview=False, include_deleted=False ): @@ -300,6 +302,7 @@ def record_file_preview( @pass_is_preview @pass_file_item(is_media=False) +@add_signposting 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/invenio_app_rdm/urls.py b/invenio_app_rdm/urls.py new file mode 100644 index 000000000..1bc34c131 --- /dev/null +++ b/invenio_app_rdm/urls.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023 Northwestern University. +# +# This file is lifted from Invenio-RDM-Records . +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Convenient URL generation. + +InvenioRDM poses challenges to url generation that Flask's url_for cannot handle out +of the gate. + +- InvenioRDM is actually 2 applications mounted on different url_prefixes: + `url_for` in the API application isn't aware of the UI application endpoints +- The endpoint names are relatively hidden / spread out and APP_RDM_ROUTES is usually + the interface to name them. +- Need to be able to generate urls outside of request context without thinking about it, + +This module contains minimal methods to generate URLs correctly without much +engineering. Over time, it can be made more abstract, complex and powerful and even +extracted into its own package to solve url generation across InvenioRDM once and for +all. + +Design decisions: + +- Generated urls are absolute for now +""" + +from flask import current_app + + +def record_url_for(_app="ui", pid_value=""): + """Return url for record route.""" + assert _app in ["ui", "api"] + + site_app = _app.upper() + url_prefix = current_app.config.get(f"SITE_{site_app}_URL", "") + + # We use [] so that this fails and brings to attention the configuration + # problem if APP_RDM_ROUTES.record_detail is missing + url_path = current_app.config["APP_RDM_ROUTES"]["record_detail"].replace( + "", pid_value + ) + + return "/".join(p.strip("/") for p in [url_prefix, url_path]) + + +def download_url_for(pid_value="", filename=""): + """Return url for download route.""" + url_prefix = current_app.config.get(f"SITE_UI_URL", "") + + # We use [] so that this fails and brings to attention the configuration + # problem if APP_RDM_ROUTES.record_file_download is missing + url_path = ( + current_app.config["APP_RDM_ROUTES"]["record_file_download"] + .replace("", pid_value) + .replace("", filename) + ) + + return "/".join(p.strip("/") for p in [url_prefix, url_path]) diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 30db25d16..1f8375105 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -8,6 +8,8 @@ """Pytest fixtures and plugins for the UI application.""" +from io import BytesIO + import pytest from flask_webpackext.manifest import ( JinjaManifest, @@ -68,3 +70,35 @@ def record(running_app, minimal_record): s = current_rdm_records.records_service draft = s.create(system_identity, minimal_record) return s.publish(system_identity, draft.id) + + +@pytest.fixture() +def record_with_file(running_app, minimal_record): + """Create and publish a record with file.""" + minimal_record["files"] = {"enabled": True} + + record_service = current_rdm_records.records_service + file_service = record_service.draft_files + + draft = record_service.create(system_identity, minimal_record) + file_to_initialise = [ + { + "key": "article.txt", + "checksum": "md5:c785060c866796cc2a1708c997154c8e", + "size": 17, # 2kB + "metadata": { + "description": "Published article PDF.", + }, + } + ] + file_service.init_files(system_identity, draft.id, file_to_initialise) + content = BytesIO(b"test file content") + file_service.set_file_content( + system_identity, + draft.id, + file_to_initialise[0]["key"], + content, + content.getbuffer().nbytes, + ) + file_service.commit_file(system_identity, draft.id, "article.txt") + return record_service.publish(system_identity, draft.id) diff --git a/tests/ui/test_signposting_ui.py b/tests/ui/test_signposting_ui.py new file mode 100644 index 000000000..49babd79f --- /dev/null +++ b/tests/ui/test_signposting_ui.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023 Northwestern University. +# +# Invenio-App-RDM is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Test Signposting. + +See https://signposting.org/FAIR/#level2 for more information on Signposting +""" + + +def test_link_in_landing_page_response_headers(running_app, client, record): + res = client.head(f"/records/{record.id}") + + assert ( + res.headers["Link"] + == f' ; rel="linkset" ; type="application/linkset+json"' # noqa + ) + + +def test_link_in_content_resource_response_headers( + running_app, client, record_with_file +): + filename = "article.txt" + + res = client.head(f"/records/{record_with_file.id}/files/{filename}") + + assert ( + res.headers["Link"] + == f' ; rel="linkset" ; type="application/linkset+json"' # noqa + )