diff --git a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java index 6926ddcb..46f46f18 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java @@ -23,6 +23,7 @@ import org.apache.wicket.request.mapper.parameter.PageParameters; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.util.Values; +import org.nanopub.Nanopub; import java.util.List; import java.util.Optional; @@ -73,6 +74,7 @@ public MaintainedResourcePage(final PageParameters parameters) { MaintainedResource resource = MaintainedResourceRepository.get().findById(parameters.get("id").toString()); resourceId = resource.getId(); + redirectIfRdfRequested(new RdfSource("resource", resourceId, null, List.of())); resourceModel = new LoadableDetachableModel() { @Override protected MaintainedResource load() { @@ -207,6 +209,18 @@ protected boolean hasAutoRefreshEnabled() { return true; } + /** + * {@inheritDoc} + *

+ * The resource's declaring nanopublication describes it. + */ + @Override + protected RdfSource getRdfSource() { + MaintainedResource resource = resourceModel.getObject(); + List declarations = resource != null && resource.getNanopub() != null ? List.of(resource.getNanopub()) : List.of(); + return new RdfSource("resource", resourceId, null, declarations); + } + /** * {@inheritDoc} */ diff --git a/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java b/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java index d7cfa691..5b337c2e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java @@ -20,11 +20,13 @@ import org.apache.wicket.markup.head.JavaScriptHeaderItem; import org.apache.wicket.markup.head.JavaScriptReferenceHeaderItem; import org.apache.wicket.markup.head.MetaDataHeaderItem; +import org.apache.wicket.markup.head.StringHeaderItem; import org.apache.wicket.markup.html.WebPage; import org.apache.wicket.protocol.http.WebApplication; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.flow.RedirectToUrlException; import org.apache.wicket.request.http.WebRequest; +import org.apache.wicket.request.http.WebResponse; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.JavaScriptResourceReference; import org.slf4j.Logger; @@ -360,6 +362,64 @@ protected String getMetaTitle() { return title == null ? SITE_NAME : title.toString(); } + /** + * What this page offers as RDF (issue #710). Pages about a resource that the download + * page can serve override this; the default is nothing, which leaves the page HTML-only. + * + * @return the RDF source, or null for a page without one + */ + protected RdfSource getRdfSource() { + return null; + } + + /** + * Answers a client that asked for RDF in its {@code Accept} header with a 303 to the + * download page in the matching format, and lets everyone else have the HTML (issue + * #710). Pages call this from their constructor as soon as they know their resource, + * before building anything, so that a machine client gets its redirect without the + * page's own work being done first. Either way the response is marked as varying on + * the {@code Accept} header, so that caches keep the two apart. + * + * @param source what to serve; its declarations are not needed here and may be empty + * @throws RedirectToUrlException when the client asked for RDF + */ + protected void redirectIfRdfRequested(RdfSource source) { + if (getResponse() instanceof WebResponse webResponse) { + webResponse.setHeader("Vary", "Accept"); + } + String accept = getRequest() instanceof WebRequest webRequest ? webRequest.getHeader("Accept") : null; + RdfNegotiation.Variant variant = RdfNegotiation.negotiate(accept); + if (variant == null) return; + String url = source.downloadUrl(variant); + logger.info("RDF requested as {} for {} {}; redirecting to {}", variant.mediaType(), source.type(), source.id(), url); + throw new RedirectToUrlException(url, 303); + } + + /** + * Renders what lets HTML-reading tools find this page's RDF (issue #710): one + * alternate link per download format, and the declaring assertions as an embedded + * JSON-LD block. Nothing is rendered for a page without an RDF source. + * + * @param response the header response to render into + */ + private void renderRdfLinks(IHeaderResponse response) { + RdfSource source = getRdfSource(); + if (source == null) return; + for (RdfNegotiation.Variant variant : RdfNegotiation.VARIANTS) { + response.render(MetaDataHeaderItem.forLinkTag("alternate", source.downloadUrl(variant)) + .addTagAttribute("type", variant.mediaType())); + } + String jsonLd; + try { + jsonLd = source.toEmbeddedJsonLd(source.downloadUrl(RdfNegotiation.VARIANTS.get(0))); + } catch (Exception ex) { + logger.warn("Could not embed the JSON-LD for {} {}: {}", source.type(), source.id(), ex.getMessage()); + return; + } + if (jsonLd == null) return; + response.render(StringHeaderItem.forString("\n")); + } + /** * Renders the description, canonical URL, Open Graph and Twitter card tags that * search engines and link previews read (issue #704). @@ -409,6 +469,7 @@ private static MetaDataHeaderItem propertyMetaTag(String property, String conten public void renderHead(IHeaderResponse response) { super.renderHead(response); renderPageMetadata(response); + renderRdfLinks(response); response.render(CssHeaderItem.forUrl(getStyleSheetUrl())); response.render(JavaScriptHeaderItem.forReference(getApplication().getJavaScriptLibrarySettings().getJQueryReference())); response.render(JavaScriptReferenceHeaderItem.forReference(nanodashJs)); diff --git a/src/main/java/com/knowledgepixels/nanodash/page/RdfNegotiation.java b/src/main/java/com/knowledgepixels/nanodash/page/RdfNegotiation.java new file mode 100644 index 00000000..c59199f6 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/RdfNegotiation.java @@ -0,0 +1,82 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.Utils; +import org.commonjava.mimeparse.MIMEParse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Decides, from an HTTP {@code Accept} header, whether a resource page should answer with + * RDF instead of HTML, and in which of the download page's formats (issue #710). + *

+ * Graph-aware formats carry the complete nanopublications, as the download tab's top + * section does. Triple-only formats cannot hold named graphs, so they carry the merged + * assertions instead. + */ +public final class RdfNegotiation { + + /** + * One RDF representation the download page can serve. + * + * @param mediaType the media type a client asks for, and the download page answers with + * @param format the download page's {@code format} parameter + * @param assertionsOnly whether the download page's {@code assertions} switch is set + */ + public record Variant(String mediaType, String format, boolean assertionsOnly) { + } + + /** + * The representations offered, in the order a link list should show them. + */ + public static final List VARIANTS = List.of( + new Variant(Utils.TYPE_TRIG, "trig", false), + new Variant(Utils.TYPE_NQUADS, "nq", false), + new Variant(Utils.TYPE_JSONLD, "jsonld", false), + new Variant(Utils.TYPE_TRIX, "trix", false), + new Variant("text/turtle", "turtle", true), + new Variant("application/n-triples", "nt", true), + new Variant("application/rdf+xml", "rdfxml", true) + ); + + /** + * The types offered to the media-type matcher. HTML comes last on purpose: the matcher + * breaks ties in favour of the last entry, so a wildcard such as {@code *}{@code /}{@code *} + * from a command-line client or a browser resolves to the page, not to RDF. + */ + private static final List OFFERED_TYPES; + + static { + List types = new ArrayList<>(); + for (Variant v : VARIANTS) types.add(v.mediaType()); + types.add(Utils.TYPE_HTML); + OFFERED_TYPES = List.copyOf(types); + } + + private RdfNegotiation() { + } + + /** + * Picks the RDF representation a client asked for. + * + * @param acceptHeader the request's {@code Accept} header; may be null or blank + * @return the variant to serve, or null when the client gets HTML, which it does when + * it asks for it, when it accepts anything, when it names no type this page offers, or + * when the header cannot be parsed + */ + public static Variant negotiate(String acceptHeader) { + if (acceptHeader == null || acceptHeader.isBlank()) return null; + String best; + try { + best = MIMEParse.bestMatch(OFFERED_TYPES, acceptHeader); + } catch (Exception ex) { + return null; + } + if (best == null || best.isEmpty() || best.equals(Utils.TYPE_HTML)) return null; + for (Variant v : VARIANTS) { + if (v.mediaType().equals(best)) return v; + } + return null; + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/page/RdfSource.java b/src/main/java/com/knowledgepixels/nanodash/page/RdfSource.java new file mode 100644 index 00000000..40066611 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/RdfSource.java @@ -0,0 +1,108 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.Utils; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.eclipse.rdf4j.model.Model; +import org.eclipse.rdf4j.model.Statement; +import org.eclipse.rdf4j.model.impl.LinkedHashModel; +import org.eclipse.rdf4j.model.vocabulary.VOID; +import org.eclipse.rdf4j.rio.RDFFormat; +import org.eclipse.rdf4j.rio.RDFWriter; +import org.eclipse.rdf4j.rio.Rio; +import org.eclipse.rdf4j.rio.helpers.BasicWriterSettings; +import org.eclipse.rdf4j.rio.jsonld.JSONLDMode; +import org.eclipse.rdf4j.rio.jsonld.JSONLDSettings; +import org.nanopub.Nanopub; +import org.nanopub.NanopubWithNs; + +import java.io.StringWriter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * What a resource page has to offer as RDF (issue #710): the download page's view of the + * resource, plus the nanopublications that declare the resource itself, whose assertions + * are small enough to embed in the page. + * + * @param type the download page's {@code type}: user, space, resource or part + * @param id the resource IRI + * @param contextId the containing resource for a part, null otherwise + * @param declarations the nanopublications declaring the resource; empty when unknown + */ +public record RdfSource(String type, String id, String contextId, List declarations) { + + /** + * Prefixes a nanopublication declares for its own URI space, which mean nothing outside it. + */ + private static final List NANOPUB_LOCAL_PREFIXES = List.of("this", "sub"); + + /** + * The download page parameters for one representation of this source. + * + * @param variant the representation + * @return the parameters + */ + public PageParameters downloadParameters(RdfNegotiation.Variant variant) { + PageParameters params = new PageParameters() + .set("type", type) + .set("id", id); + if (contextId != null) params.set("context", contextId); + params.set("format", variant.format()); + if (variant.assertionsOnly()) params.set("assertions", ""); + return params; + } + + /** + * The absolute download URL for one representation of this source, on the configured + * website address and without any session id. + * + * @param variant the representation + * @return the URL + */ + public String downloadUrl(RdfNegotiation.Variant variant) { + return Utils.absolutePageUrl(DownloadRdfPage.class, downloadParameters(variant)); + } + + /** + * The declaring assertions as a JSON-LD document for a {@code