Skip to content

Commit 19dd2fe

Browse files
committed
views: FAIR signposting level 1 support
1 parent 6687ccb commit 19dd2fe

File tree

3 files changed

+138
-22
lines changed

3 files changed

+138
-22
lines changed

invenio_app_rdm/records_ui/views/decorators.py

+78-7
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111

1212
from functools import wraps
1313

14-
from flask import g, make_response, redirect, request, session, url_for
14+
from flask import current_app, g, make_response, redirect, request, session, url_for
1515
from flask_login import login_required
1616
from invenio_communities.communities.resources.serializer import (
1717
UICommunityJSONSerializer,
1818
)
1919
from invenio_communities.proxies import current_communities
2020
from invenio_pidstore.errors import PIDDoesNotExistError
2121
from invenio_rdm_records.proxies import current_rdm_records
22+
from invenio_rdm_records.resources.serializers.signposting import (
23+
FAIRSignpostingProfileLvl1Serializer,
24+
)
2225
from invenio_records_resources.services.errors import PermissionDeniedError
2326
from sqlalchemy.orm.exc import NoResultFound
2427

@@ -365,20 +368,88 @@ def view(**kwargs):
365368
return view
366369

367370

368-
def add_signposting(f):
369-
"""Add signposting link to view's response headers."""
371+
def _get_header(rel, value, link_type=None):
372+
header = f'<{value}> ; rel="{rel}"'
373+
if link_type:
374+
header += f' ; type="{link_type}"'
375+
return header
376+
377+
378+
def _get_signposting_collection(pid_value):
379+
ui_url = record_url_for(pid_value=pid_value)
380+
return _get_header("collection", ui_url, "text/html")
381+
382+
383+
def _get_signposting_describes(pid_value):
384+
ui_url = record_url_for(pid_value=pid_value)
385+
return _get_header("describes", ui_url, "text/html")
386+
387+
388+
def _get_signposting_linkset(pid_value):
389+
api_url = record_url_for(_app="api", pid_value=pid_value)
390+
return _get_header("linkset", api_url, "application/linkset+json")
391+
392+
393+
def add_signposting_landing_page(f):
394+
"""Add signposting links to the landing page view's response headers."""
370395

371396
@wraps(f)
372397
def view(*args, **kwargs):
373398
response = make_response(f(*args, **kwargs))
374399

375400
# Relies on other decorators having operated before it
376-
pid_value = kwargs["pid_value"]
377-
signposting_link = record_url_for(_app="api", pid_value=pid_value)
401+
record = kwargs["record"]
378402

379-
response.headers["Link"] = (
380-
f'<{signposting_link}> ; rel="linkset" ; type="application/linkset+json"' # fmt: skip
403+
signposting_headers = FAIRSignpostingProfileLvl1Serializer().serialize_object(
404+
record.to_dict()
381405
)
406+
407+
response.headers["Link"] = signposting_headers
408+
409+
return response
410+
411+
return view
412+
413+
414+
def add_signposting_content_resources(f):
415+
"""Add signposting links to the content resources view's response headers."""
416+
417+
@wraps(f)
418+
def view(*args, **kwargs):
419+
response = make_response(f(*args, **kwargs))
420+
421+
# Relies on other decorators having operated before it
422+
pid_value = kwargs["pid_value"]
423+
424+
signposting_headers = [
425+
_get_signposting_collection(pid_value),
426+
_get_signposting_linkset(pid_value),
427+
]
428+
429+
response.headers["Link"] = " , ".join(signposting_headers)
430+
431+
return response
432+
433+
return view
434+
435+
436+
def add_signposting_metadata_resources(f):
437+
"""Add signposting links to the metadata resources view's response headers."""
438+
439+
@wraps(f)
440+
def view(*args, **kwargs):
441+
response = make_response(f(*args, **kwargs))
442+
443+
# Relies on other decorators having operated before it
444+
pid_value = kwargs["pid_value"]
445+
446+
signposting_headers = [
447+
_get_signposting_describes(pid_value),
448+
_get_signposting_linkset(pid_value),
449+
]
450+
451+
response.headers["Link"] = " , ".join(signposting_headers)
452+
382453
return response
383454

384455
return view

invenio_app_rdm/records_ui/views/records.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939

4040
from ..utils import get_external_resources
4141
from .decorators import (
42-
add_signposting,
42+
add_signposting_content_resources,
43+
add_signposting_landing_page,
44+
add_signposting_metadata_resources,
4345
pass_file_item,
4446
pass_file_metadata,
4547
pass_include_deleted,
@@ -141,7 +143,7 @@ def open(self):
141143
@pass_record_or_draft(expand=True)
142144
@pass_record_files
143145
@pass_record_media_files
144-
@add_signposting
146+
@add_signposting_landing_page
145147
def record_detail(
146148
pid_value, record, files, media_files, is_preview=False, include_deleted=False
147149
):
@@ -263,6 +265,7 @@ def record_detail(
263265

264266
@pass_is_preview
265267
@pass_record_or_draft(expand=False)
268+
@add_signposting_metadata_resources
266269
def record_export(
267270
pid_value, record, export_format=None, permissions=None, is_preview=False
268271
):
@@ -325,7 +328,7 @@ def record_file_preview(
325328

326329
@pass_is_preview
327330
@pass_file_item(is_media=False)
328-
@add_signposting
331+
@add_signposting_content_resources
329332
def record_file_download(pid_value, file_item=None, is_preview=False, **kwargs):
330333
"""Download a file from a record."""
331334
download = bool(request.args.get("download"))

tests/ui/test_signposting_ui.py

+54-12
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,67 @@
99
1010
See https://signposting.org/FAIR/#level2 for more information on Signposting
1111
"""
12+
import pytest
1213

1314

14-
def test_link_in_landing_page_response_headers(running_app, client, record):
15-
res = client.head(f"/records/{record.id}")
15+
@pytest.mark.parametrize("http_method", ["head", "get"])
16+
def test_link_in_landing_page_response_headers(
17+
running_app, client, record_with_file, http_method
18+
):
19+
client_http_method = getattr(client, http_method)
20+
res = client_http_method(f"/records/{record_with_file.id}")
21+
22+
# The link headers are already tested in details in `invenio-rdm-records` (see `test_signposting_serializer`).
23+
# Here we still want to issue the HTTP call to the URL in order to make sure that the decorator is working properly,
24+
# but the assertions are less detailed to avoid having to adapt this test every time we modify the logic in `invenio-rdm-records`.
25+
link_headers = res.headers["Link"].split(" , ")
26+
27+
# The test record does not have:
28+
# - an author with an identifier.
29+
# - a cite-as since it has no DOI.
30+
# - a license.
31+
32+
# There should be at least 10 export formats supported (e.g. "application/dcat+xml", "application/x-bibtex", etc.).
33+
assert sum('; rel="describedby" ;' in header for header in link_headers) >= 10
34+
35+
# There should be at least one file in the record.
36+
assert sum('; rel="item" ;' in header for header in link_headers) >= 1
37+
38+
# There should be at least one description of the type of the record (e.g. "https://schema.org/Photograph").
39+
assert sum('; rel="type"' in header for header in link_headers) >= 1
1640

17-
assert (
18-
res.headers["Link"]
19-
== f'<https://127.0.0.1:5000/api/records/{record.id}> ; rel="linkset" ; type="application/linkset+json"' # noqa
20-
)
41+
# There should be at least one link to a linkset (e.g. "application/linkset" and/or "application/linkset+json")
42+
assert sum('; rel="linkset" ;' in header for header in link_headers) >= 1
2143

2244

45+
@pytest.mark.parametrize("http_method", ["head", "get"])
2346
def test_link_in_content_resource_response_headers(
24-
running_app, client, record_with_file
47+
running_app, client, record_with_file, http_method
2548
):
49+
ui_url = f"https://127.0.0.1:5000/records/{record_with_file.id}"
50+
api_url = f"https://127.0.0.1:5000/api/records/{record_with_file.id}"
2651
filename = "article.txt"
2752

28-
res = client.head(f"/records/{record_with_file.id}/files/{filename}")
53+
client_http_method = getattr(client, http_method)
54+
res = client_http_method(f"/records/{record_with_file.id}/files/{filename}")
55+
56+
assert res.headers["Link"].split(" , ") == [
57+
f'<{ui_url}> ; rel="collection" ; type="text/html"',
58+
f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"',
59+
]
60+
61+
62+
@pytest.mark.parametrize("http_method", ["head", "get"])
63+
def test_link_in_metadata_resource_response_headers(
64+
running_app, client, record, http_method
65+
):
66+
ui_url = f"https://127.0.0.1:5000/records/{record.id}"
67+
api_url = f"https://127.0.0.1:5000/api/records/{record.id}"
68+
69+
client_http_method = getattr(client, http_method)
70+
res = client_http_method(f"/records/{record.id}/export/bibtex")
2971

30-
assert (
31-
res.headers["Link"]
32-
== f'<https://127.0.0.1:5000/api/records/{record_with_file.id}> ; rel="linkset" ; type="application/linkset+json"' # noqa
33-
)
72+
assert res.headers["Link"].split(" , ") == [
73+
f'<{ui_url}> ; rel="describes" ; type="text/html"',
74+
f'<{api_url}> ; rel="linkset" ; type="application/linkset+json"',
75+
]

0 commit comments

Comments
 (0)