diff --git a/django_signposting/middleware.py b/django_signposting/middleware.py index be1d929..3371630 100644 --- a/django_signposting/middleware.py +++ b/django_signposting/middleware.py @@ -1,14 +1,12 @@ from typing import Callable from django.http import HttpRequest, HttpResponse -from signposting import Signpost, LinkRel +from signposting import Signpost from bs4 import BeautifulSoup -import re import json from django.utils.deprecation import MiddlewareMixin from django.conf import settings -from rdflib import Graph -from . import sparql +from .utils import jsonld_to_signposts class SignpostingMiddleware: @@ -45,68 +43,6 @@ class SignpostingMiddleware: class JsonLdSignpostingParserMiddleware(MiddlewareMixin): - def is_url(self, url: str) -> bool: - url_pattern = re.compile( - r"^(https?|ftp)://" # protocol - r"(?:(?:[a-zA-Z0-9-_]+\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,6})" # domain - r"(?::\d{1,5})?" # optional port - r"(?:/.*)?$" # path - ) - return bool(url_pattern.match(url)) - - def select_url(self, elements: tuple[str, ...]) -> str | None: - for elem in elements[::-1]: - if self.is_url(str(elem)): - return str(elem) - return None - - def _jsonld_to_signposts(self, jsonld: dict) -> dict: - signposts = [] - # TODO use jsonld context in query as prefix - g = Graph().parse(data=json.dumps(jsonld), format="json-ld") - - rootElement = next(iter(sparql.root_element_query(g)), None) - if rootElement: - rootElement = rootElement[0] - else: - print("No root element found") - return {} - - types = sparql.type_query(g, rootElement) - for type in types: - signposts.append(Signpost(LinkRel.type, str(type[0]))) - - authors = sparql.author_query(g, rootElement) - for author in authors: - author = self.select_url(author) - if author: - signposts.append(Signpost(LinkRel.author, author)) - - license = next(iter(sparql.license_query(g, rootElement)), []) - license = self.select_url(license) - if license: - signposts.append(Signpost(LinkRel.license, license)) - - citations = sparql.cite_query(g, rootElement) - for citation in citations: - citation = self.select_url(citation) - if citation: - signposts.append(Signpost(LinkRel.cite_as, citation)) - - sameas = sparql.sameas_query(g, rootElement) - for sa in sameas: - sa_media_type = sa[-1] - sa = self.select_url(sa[:-1]) - if sa: - signposts.append(Signpost(LinkRel.describedby, sa, sa_media_type)) - - items = sparql.item_query(g, rootElement) - for item in items: - item_media_type = item[-1] - item = self.select_url(item[:-1]) - if item: - signposts.append(Signpost(LinkRel.item, item, item_media_type)) - return signposts def process_response( self, request: HttpRequest, response: HttpResponse @@ -119,7 +55,7 @@ class JsonLdSignpostingParserMiddleware(MiddlewareMixin): for script in soup.find_all("script", type="application/ld+json"): try: jsonld = json.loads(script.string) - signposts = self._jsonld_to_signposts(jsonld) + signposts = jsonld_to_signposts(jsonld) response._signposts = signposts except json.JSONDecodeError as e: diff --git a/django_signposting/sparql.py b/django_signposting/sparql.py index dbc68a1..936af63 100644 --- a/django_signposting/sparql.py +++ b/django_signposting/sparql.py @@ -14,7 +14,21 @@ WHERE { (schema:license | schema:author | schema:creator ) ?value . # No incoming edges for ?rootElement - FILTER NOT EXISTS { ?s ?p ?rootElement } + FILTER NOT EXISTS { + ?incoming ?p ?rootElement . + + # also allow incoming edges that link entities BACK to the dataset + FILTER(?p != schema:isPartOf) + FILTER(?p != schema:mainEntityOfPage) + FILTER(?p != schema:recordedIn) + FILTER(?p != schema:exampleOfWork) + FILTER(?p != schema:includedInDataCatalogue) + FILTER(?p != schema:subjectOf) + FILTER(?p != schema:dataset) + + # allow incoming edges from ro-crate-metadata.json + FILTER(CONTAINS(?incoming, "ro-crate-metadata.json")) . + } } LIMIT 1 """) diff --git a/django_signposting/utils.py b/django_signposting/utils.py index f86c8bf..353e3b0 100644 --- a/django_signposting/utils.py +++ b/django_signposting/utils.py @@ -1,5 +1,10 @@ +import json from django.http import HttpResponse -from signposting import Signpost +from rdflib import Graph +from signposting import Signpost, LinkRel +import re + +from django_signposting import sparql def add_signposts(response: HttpResponse, *args: Signpost): @@ -15,3 +20,67 @@ def add_signposts(response: HttpResponse, *args: Signpost): for signpost in args: if signpost not in response._signposts: response._signposts.append(signpost) + + +def select_url(elements: tuple[str, ...]) -> str | None: + def is_url(url: str) -> bool: + url_pattern = re.compile( + r"^(https?|ftp)://" # protocol + r"(?:(?:[a-zA-Z0-9-_]+\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,6})" # domain + r"(?::\d{1,5})?" # optional port + r"(?:/.*)?$" # path + ) + return bool(url_pattern.match(url)) + + for elem in elements[::-1]: + if is_url(str(elem)): + return str(elem) + return None + + +def jsonld_to_signposts(jsonld: dict) -> []: + signposts = [] + # TODO use jsonld context in query as prefix + g = Graph().parse(data=json.dumps(jsonld), format="json-ld") + + rootElement = next(iter(sparql.root_element_query(g)), None) + if rootElement: + rootElement = rootElement[0] + else: + print("No root element found") + return {} + types = sparql.type_query(g, rootElement) + for type in types: + signposts.append(Signpost(LinkRel.type, str(type[0]))) + + authors = sparql.author_query(g, rootElement) + for author in authors: + author = select_url(author) + if author: + signposts.append(Signpost(LinkRel.author, author)) + + license = next(iter(sparql.license_query(g, rootElement)), []) + license = select_url(license) + if license: + signposts.append(Signpost(LinkRel.license, license)) + + citations = sparql.cite_query(g, rootElement) + for citation in citations: + citation = select_url(citation) + if citation: + signposts.append(Signpost(LinkRel.cite_as, citation)) + + sameas = sparql.sameas_query(g, rootElement) + for sa in sameas: + sa_media_type = sa[-1] + sa = select_url(sa[:-1]) + if sa: + signposts.append(Signpost(LinkRel.describedby, sa, sa_media_type)) + + items = sparql.item_query(g, rootElement) + for item in items: + item_media_type = item[-1] + item = select_url(item[:-1]) + if item: + signposts.append(Signpost(LinkRel.item, item, item_media_type)) + return signposts diff --git a/example/example/urls.py b/example/example/urls.py index 362c17d..34d83ea 100644 --- a/example/example/urls.py +++ b/example/example/urls.py @@ -21,4 +21,5 @@ from . import views urlpatterns = [ path("", views.SimpleView.as_view()), path("jsonld", views.JsonLdView.as_view()), + path("ro-crate", views.rocrate.as_view()), ] diff --git a/example/example/views.py b/example/example/views.py index 1011748..3090335 100644 --- a/example/example/views.py +++ b/example/example/views.py @@ -1,15 +1,13 @@ from django.http import HttpResponse -from django.views import View from django.shortcuts import render - +from django.views import View from django_json_ld.views import JsonLdContextMixin -from django_signposting.utils import add_signposts +from signposting import LinkRel, Signpost -from signposting import Signpost, LinkRel +from django_signposting.utils import add_signposts, jsonld_to_signposts class SimpleView(View): - def get(self, request): response = HttpResponse("Hello, world!") @@ -17,14 +15,13 @@ class SimpleView(View): add_signposts( response, Signpost(LinkRel.type, "http://schema.org/Dataset"), - Signpost(LinkRel.author, "https://orcid.org/0000-0001-9447-460X") + Signpost(LinkRel.author, "https://orcid.org/0000-0001-9447-460X"), ) return response class JsonLdView(JsonLdContextMixin, View): - sd = { "@context": "https://schema.org", "@type": ["WebSite", "Dataset"], @@ -35,12 +32,12 @@ class JsonLdView(JsonLdContextMixin, View): { "@type": "MediaObject", "contentUrl": "https://example.com/download.zip", - "encodingFormat": "application/zip" + "encodingFormat": "application/zip", }, { "@type": "MediaObject", "contentUrl": "https://example.com/metadata.json", - } + }, ], "author": { "@type": "Person", @@ -50,21 +47,85 @@ class JsonLdView(JsonLdContextMixin, View): "license": { "@type": "CreativeWork", "name": "CC BY 4.0", - "url": "https://creativecommons.org/licenses/by/4.0/" + "url": "https://creativecommons.org/licenses/by/4.0/", }, "hasPart": [ { "@type": "ImageObject", "url": "http://example.com/image.png", - "encodingFormat": "image/png" + "encodingFormat": "image/png", }, { "@type": "ImageObject", "url": "http://example.com/image2.png", - "encodingFormat": "image/png" - } - ] + "encodingFormat": "image/png", + }, + ], } def get(self, request): return render(request, "jsonld.html", context={"sd": self.sd}) + + +class rocrate(JsonLdContextMixin, View): + def get(self, request): + import json + import os + import tempfile + + # Build an RO-Crate and serve its preview + from rocrate.model.person import Person + from rocrate.model.creativework import CreativeWork + from rocrate.rocrate import ROCrate + + with tempfile.TemporaryDirectory() as d: + crate = ROCrate(gen_preview=True) + crate.add_file( + "http://example.com/test.pdf", + properties={"name": "test file", "encodingFormat": "application/pdf"}, + ) + author = crate.add( + Person( + crate, + "https://orcid.org/0000-0001-9447-460X", + properties={"name": "Daniel Bauer"}, + ) + ) + license = crate.add( + CreativeWork( + crate, + "https://spdx.org/licenses/CC0-1.0", + properties={ + "@id": "https://spdx.org/licenses/CC0-1.0", + "@type": "CreativeWork", + "name": "CC0-1.0", + "description": "Creative Commons Zero v1.0 Universal", + }, + ) + ) + sameAs = crate.add( + CreativeWork( + crate, + "https://example.com/ro-crate-metadata.json", + properties={"encodingFormat": "application/ld+json"}, + ) + ) + + crate.root_dataset["name"] = "My Dataset" + crate.root_dataset["description"] = "A dataset of things." + crate.root_dataset["author"] = author + crate.root_dataset["creator"] = author + crate.root_dataset["license"] = license + crate.root_dataset["url"] = "https://example.com" + crate.root_dataset["sameAs"] = sameAs + crate.write(d) + + # Extract JSON-LD from RO-Crate metadata and build signposts from it + metadata = open(os.path.join(d, "ro-crate-metadata.json"), "r").read() + metadata = json.loads(metadata) + signposts = jsonld_to_signposts(metadata) + + preview = open(os.path.join(d, "ro-crate-preview.html"), "r").read() + response = HttpResponse(preview) + add_signposts(response, *signposts) + return response diff --git a/example/requirements.txt b/example/requirements.txt index 27ab8d5..acc125c 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -15,3 +15,4 @@ signposting==0.9.9 soupsieve==2.6 sqlparse==0.5.1 urllib3==2.2.3 +rocrate==0.11.0 diff --git a/pyproject.toml b/pyproject.toml index 87169c5..3bbfd88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ {name = "Daniel Bauer", email = "github@dbauer.me"} ] description = "Add FAIR signpostings to response headers in Django" -version = "0.10.1" +version = "0.10.2" readme = "README.md" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_jsonld_signposting_middleware.py b/tests/test_jsonld_signposting_middleware.py index e16e4fe..26273c2 100644 --- a/tests/test_jsonld_signposting_middleware.py +++ b/tests/test_jsonld_signposting_middleware.py @@ -218,3 +218,53 @@ def test_jsonld_signposting_multiple_types(): Signpost(LinkRel.author, "http://example.com/author"), ], ) + + +def test_jsonld_signposting_rocrate(): + jsonld_test_runner( + { + "@context": "http://w3id.org/ro/crate/1.1/context", + "@graph": [ + { + "@type": "CreativeWork", + "@id": "ro-crate-metadata.json", + "conformsTo": {"@id": "http://w3id.org/ro/crate/1.1"}, + "about": {"@id": "http://example.com/myCrate"} + }, + { + "@type": "Dataset", + "@id": "http://example.com/myCrate", + "author": {"@id": "http://example.com/author"}, + "datePublished": "2024", + "name": "Test crate", + "description": "This is a detached test create", + "license": {"@id": "http://example.com/license"}, + "hasPart": [ + {"@id": "http://example.com/file"} + ] + }, + { + "@id": "http://example.com/author", + "@type": "Person", + "name": "Daniel Bauer" + }, + { + "@id": "http://example.com/license", + "@type": "CreativeWork", + "name": "Test License" + }, + { + "@id": "http://example.com/file", + "@type": "File", + "contentUrl": "http://example.com/image1.jpg", + "encodingFormat": "image/jpeg" + } + ] + }, + [ + Signpost(LinkRel.type, "http://schema.org/Dataset"), + Signpost(LinkRel.author, "http://example.com/author"), + Signpost(LinkRel.license, "http://example.com/license"), + Signpost(LinkRel.item, "http://example.com/image1.jpg", "image/jpeg"), + ], + )