Skip to content

Commit

Permalink
views: add signposting
Browse files Browse the repository at this point in the history
  • Loading branch information
fenekku committed Aug 21, 2023
1 parent ca1be16 commit 102b0e0
Show file tree
Hide file tree
Showing 11 changed files with 732 additions and 3 deletions.
2 changes: 2 additions & 0 deletions invenio_rdm_records/resources/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
DataCite43XMLSerializer,
DCATSerializer,
DublinCoreXMLSerializer,
FAIRSignpostingProfileLvl2Serializer,
GeoJSONSerializer,
MARCXMLSerializer,
StringCitationSerializer,
Expand Down Expand Up @@ -81,6 +82,7 @@ def csl_url_args_retriever():
headers={"content-type": "text/plain"},
),
"application/dcat+xml": ResponseHandler(DCATSerializer()),
"application/linkset+json": ResponseHandler(FAIRSignpostingProfileLvl2Serializer()),
}


Expand Down
32 changes: 31 additions & 1 deletion invenio_rdm_records/resources/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

"""Bibliographic Record Resource."""

from flask import abort, current_app, g, send_file
from flask import abort, g, send_file
from flask_cors import cross_origin
from flask_resources import (
HTTPJSONException,
Expand Down Expand Up @@ -47,6 +47,27 @@
IIIFManifestV2JSONSerializer,
IIIFSequenceV2JSONSerializer,
)
from .urls import record_url_for


def add_signposting(response):
"""Add signposting link to view's response headers.
response = full Flask Response
"""
if response.status_code != 200:
return response

pid_value = resource_requestctx.view_args["pid_value"]
signposting_link = record_url_for(_app="api", pid_value=pid_value)

response.headers.update(
{
"Link": f'<{signposting_link}> ; rel="linkset" ; type="application/linkset+json"', # noqa
}
)

return response


class RDMRecordResource(RecordResource):
Expand Down Expand Up @@ -87,6 +108,15 @@ def p(route):

return url_rules

def read(self):
"""Read an item.
Overrides parent `read` in order to add signposting.
"""
response = super().read()
add_signposting(response)
return response

#
# Review request
#
Expand Down
2 changes: 2 additions & 0 deletions invenio_rdm_records/resources/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
IIIFSequenceV2JSONSerializer,
)
from .marcxml import MARCXMLSerializer
from .signposting import FAIRSignpostingProfileLvl2Serializer
from .ui import UIJSONSerializer

__all__ = (
Expand All @@ -31,6 +32,7 @@
"DataCite43XMLSerializer",
"DublinCoreJSONSerializer",
"DublinCoreXMLSerializer",
"FAIRSignpostingProfileLvl2Serializer",
"GeoJSONSerializer",
"IIIFCanvasV2JSONSerializer",
"IIIFInfoV2JSONSerializer",
Expand Down
24 changes: 24 additions & 0 deletions invenio_rdm_records/resources/serializers/signposting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Northwestern University.
#
# 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.

"""Signposting serializers."""

from flask_resources import MarshmallowSerializer
from flask_resources.serializers import JSONSerializer

from .schema import FAIRSignpostingProfileLvl2Schema


class FAIRSignpostingProfileLvl2Serializer(MarshmallowSerializer):
"""FAIR Signposting Profile level 2 serializer."""

def __init__(self):
"""Initialise Serializer."""
super().__init__(
format_serializer_cls=JSONSerializer,
object_schema_cls=FAIRSignpostingProfileLvl2Schema,
)
215 changes: 215 additions & 0 deletions invenio_rdm_records/resources/serializers/signposting/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Northwestern University.
#
# 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.

"""Signposting schemas."""

import idutils
from invenio_access.permissions import system_identity
from invenio_vocabularies.proxies import current_service as vocabulary_service
from marshmallow import Schema, fields, missing

from ...urls import download_url_for, record_url_for


class LandingPageSchema(Schema):
"""Schema for serialization of typed links pertaining to the landing page.
Serialization input is a whole record dict projection.
"""

anchor = fields.Method(serialize="serialize_anchor")
author = fields.Method(serialize="serialize_author")
cite_as = fields.Method(data_key="cite-as", serialize="serialize_cite_as")
describedby = fields.Method(serialize="serialize_describedby")
item = fields.Method(serialize="serialize_item")
license = fields.Method(serialize="serialize_license")
type = fields.Method(serialize="serialize_type")

def serialize_anchor(self, obj, **kwargs):
"""Seralize to landing page URL."""
return record_url_for(_app="ui", pid_value=obj["id"])

def serialize_author(self, obj, **kwargs):
"""Serialize author(s).
For now, the first linkable identifier is taken.
"""

def pick_linkable_id(identifiers):
for id_dict in identifiers:
try:
url = idutils.to_url(
id_dict["identifier"], id_dict["scheme"], url_scheme="https"
)
if url:
return url
else:
continue
except Exception as e:
print("pick linkable id exception")
print(e)
return None

metadata = obj["metadata"]
result = [
{"href": pick_linkable_id(c["person_or_org"].get("identifiers", []))}
for c in metadata.get("creators", [])
]
result = [r for r in result if r["href"]]
return result or missing

def serialize_cite_as(self, obj, **kwargs):
"""Serialize cite-as."""
identifier = obj.get("pids", {}).get("doi", {}).get("identifier")
if not identifier:
return missing

try:
url = idutils.to_url(identifier, "doi", "https")
except Exception as e:
url = None

return [{"href": url}] if url else missing

def serialize_describedby(self, obj, **kwargs):
"""Serialize describedby."""
# Has to be placed here to prevent circular dependency.
from invenio_rdm_records.resources.config import record_serializers

result = [
{"href": record_url_for(_app="api", pid_value=obj["id"]), "type": mimetype}
for mimetype in record_serializers
]

return result or missing

def serialize_item(self, obj, **kwargs):
"""Serialize item."""
file_entries = obj.get("files", {}).get("entries", [])

result = [
{
"href": download_url_for(pid_value=obj["id"], filename=entry["key"]),
"type": entry["mimetype"],
}
for entry in file_entries
]

return result or missing

def serialize_license(self, obj, **kwargs):
"""Serialize license.
Note that we provide an entry for each license (rather than just 1).
"""
rights = obj["metadata"].get("rights", [])

result = [{"href": right["link"]} for right in rights if right.get("link")]

return result or missing

def serialize_type(self, obj, **kwargs):
"""Serialize type."""
resource_type = obj["metadata"]["resource_type"]

rt_record = vocabulary_service.read(
system_identity, ("resourcetypes", resource_type["id"])
)
schema_org_url = rt_record._record["props"].get("schema.org")

result = []
if schema_org_url:
result.append({"href": schema_org_url})
# always provide About Page
result.append({"href": "https://schema.org/AboutPage"})

return result


class ContentResourceSchema(Schema):
"""Schema for serialization of typed links pertaining to the content resource.
Serialization input is a file entry dict projection.
Passing a context={"id"} to the constructor is required.
"""

anchor = fields.Method(serialize="serialize_anchor")
collection = fields.Method(serialize="serialize_collection")

def serialize_anchor(self, obj, **kwargs):
"""Serialize to download url."""
return download_url_for(pid_value=self.context["id"], filename=obj["key"])

def serialize_collection(self, obj, **kwargs):
"""Serialize to record landing page url."""
return [
{
"href": record_url_for(_app="ui", pid_value=self.context["id"]),
"type": "text/html",
}
]


class MetadataResourceSchema(Schema):
"""Schema for serialization of typed links pertaining to the metadata resource.
Serialization input is a mimetype.
Passing a context={"id"} to the constructor is required.
"""

anchor = fields.Method(serialize="serialize_anchor")
describes = fields.Method(serialize="serialize_describes")
type = fields.Method(serialize="serialize_type")

def serialize_anchor(self, obj, **kwargs):
"""Serialize to API url."""
return record_url_for(_app="api", pid_value=self.context["id"])

def serialize_describes(self, obj, **kwargs):
"""Serialize to record landing page url."""
return [
{
"href": record_url_for(_app="ui", pid_value=self.context["id"]),
"type": "text/html",
}
]

def serialize_type(self, obj, **kwargs):
"""Serialize to mimetype i.e. obj."""
return obj


class FAIRSignpostingProfileLvl2Schema(Schema):
"""FAIR Signposting Profile Lvl 2 Schema.
See https://signposting.org/FAIR/
"""

linkset = fields.Method(serialize="serialize_linkset")

def serialize_linkset(self, obj, **kwargs):
"""Serialize linkset."""
# Has to be placed here to prevent circular dependency.
from invenio_rdm_records.resources.config import record_serializers

result = [LandingPageSchema().dump(obj)]

content_resource_schema = ContentResourceSchema(context={"id": obj["id"]})
result += [
content_resource_schema.dump(entry)
for entry in obj.get("files", {}).get("entries", [])
]

metadata_resource_schema = MetadataResourceSchema(context={"id": obj["id"]})
result += [
metadata_resource_schema.dump(mimetype)
for mimetype in record_serializers
]

return result
57 changes: 57 additions & 0 deletions invenio_rdm_records/resources/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Northwestern University.
#
# 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.
- All other url generation use cases need to interact with this: generating urls outside
of a request context, generating canonical urls...
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.
"""

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>", 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>", pid_value)
.replace("<path:filename>", filename)
)

return "/".join(p.strip("/") for p in [url_prefix, url_path])
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ def app_config(app_config, mock_datacite_client):
app_config["USERS_RESOURCES_SERVICE_SCHEMA"] = NotificationsUserSchema

app_config["RDM_RESOURCE_ACCESS_TOKENS_ENABLED"] = True

app_config["APP_RDM_ROUTES"] = {
"record_detail": "/records/<pid_value>",
"record_file_download": "/records/<pid_value>/files/<path:filename>",
}

return app_config


Expand Down

0 comments on commit 102b0e0

Please sign in to comment.