Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<MaintainedResource>() {
@Override
protected MaintainedResource load() {
Expand Down Expand Up @@ -207,6 +209,18 @@ protected boolean hasAutoRefreshEnabled() {
return true;
}

/**
* {@inheritDoc}
* <p>
* The resource's declaring nanopublication describes it.
*/
@Override
protected RdfSource getRdfSource() {
MaintainedResource resource = resourceModel.getObject();
List<Nanopub> declarations = resource != null && resource.getNanopub() != null ? List.of(resource.getNanopub()) : List.of();
return new RdfSource("resource", resourceId, null, declarations);
}

/**
* {@inheritDoc}
*/
Expand Down
61 changes: 61 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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("<script type=\"application/ld+json\">\n" + jsonLd + "\n</script>\n"));
}

/**
* Renders the description, canonical URL, Open Graph and Twitter card tags that
* search engines and link previews read (issue #704).
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
* <p>
* 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<Variant> 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<String> OFFERED_TYPES;

static {
List<String> 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;
}

}
108 changes: 108 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/page/RdfSource.java
Original file line number Diff line number Diff line change
@@ -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<Nanopub> declarations) {

/**
* Prefixes a nanopublication declares for its own URI space, which mean nothing outside it.
*/
private static final List<String> 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 <script>} block in the
* page head, so that HTML-reading tools and search engines find the resource's own
* triples. A {@code void:dataDump} triple points them at the complete download.
* <p>
* The document keeps the nanopublications' own prefixes as its context, identifies
* the resource by its IRI rather than by the page's URL, and has every {@code <}
* escaped, so that a text value cannot close the script block it sits in.
*
* @param dumpUrl the URL of the complete TriG download
* @return the JSON-LD document, or null when there is nothing to declare
*/
public String toEmbeddedJsonLd(String dumpUrl) {
if (declarations.isEmpty()) return null;
Model model = new LinkedHashModel();
Map<String, String> namespaces = new LinkedHashMap<>();
for (Nanopub np : declarations) {
if (np instanceof NanopubWithNs withNs) {
withNs.getNs().forEach((prefix, ns) -> {
if (!NANOPUB_LOCAL_PREFIXES.contains(prefix)) namespaces.put(prefix, ns);
});
}
for (Statement st : np.getAssertion()) {
model.add(st.getSubject(), st.getPredicate(), st.getObject());
}
}
if (model.isEmpty()) return null;
namespaces.putIfAbsent("void", VOID.NAMESPACE);
model.add(Utils.vf.createIRI(id), VOID.DATA_DUMP, Utils.vf.createIRI(dumpUrl));

StringWriter out = new StringWriter();
RDFWriter writer = Rio.createWriter(RDFFormat.JSONLD, out);
writer.getWriterConfig().set(JSONLDSettings.JSONLD_MODE, JSONLDMode.COMPACT);
writer.getWriterConfig().set(BasicWriterSettings.PRETTY_PRINT, true);
writer.startRDF();
namespaces.forEach(writer::handleNamespace);
model.forEach(writer::handleStatement);
writer.endRDF();
return out.toString().replace("<", "\\u003c");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,29 @@ public String getPartLabel() {
return partLabel;
}

/**
* {@inheritDoc}
* <p>
* The nanopublication defining the part declares it; a part without one has nothing
* to embed, but its download still lists what the views show about it.
*/
@Override
protected RdfSource getRdfSource() {
Nanopub definition = Utils.getAsNanopub(definitionNanopubId);
List<Nanopub> declarations = definition == null ? List.of() : List.of(definition);
return new RdfSource("part", getPartId(), getPageParameters().get("context").toString(), declarations);
}

/**
* Resource with profile (Space or MaintainedResource) object with the data shown on this page.
*/
private AbstractResourceWithProfile resourceWithProfile;

/**
* The nanopublication defining this part, or null when none is known.
*/
private String definitionNanopubId;

/**
* The part's label as resolved for the title, handed to links out of this page so
* their back-link can name the part.
Expand Down Expand Up @@ -130,6 +148,7 @@ public ResourcePartPage(final PageParameters parameters) {
throw new IllegalArgumentException("Not a resource, space, or user: " + contextId);
}
}
redirectIfRdfRequested(new RdfSource("part", id, contextId, List.of()));

QueryRef getDefQuery = ViewDataFetcher.partDefinitionQueryRef(id, contextId, resourceWithProfile);
ApiResponse getDefResp = ApiCache.retrieveResponseSync(getDefQuery, false);
Expand Down Expand Up @@ -161,6 +180,7 @@ public ResourcePartPage(final PageParameters parameters) {
} else {
nanopubId = null;
}
definitionNanopubId = nanopubId;
// if (getDefResp == null || getDefResp.getData().isEmpty()) {
// throw new RestartResponseException(ExplorePage.class, parameters);
// }
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/page/SpacePage.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.apache.wicket.model.Model;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.eclipse.rdf4j.model.IRI;
import org.nanopub.Nanopub;

import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -79,6 +80,7 @@ public SpacePage(final PageParameters parameters) {

Space space = resolveSpace(parameters);
spaceId = space.getId();
redirectIfRdfRequested(new RdfSource("space", spaceId, null, List.of()));
spaceModel = new LoadableDetachableModel<Space>() {
@Override
protected Space load() {
Expand Down Expand Up @@ -304,6 +306,18 @@ private static String spaceMetaDescription(Space space) {
return "The " + space.getLabel() + " space on Nanodash, with its nanopublications, members and views.";
}

/**
* {@inheritDoc}
* <p>
* The space's root nanopublication declares it.
*/
@Override
protected RdfSource getRdfSource() {
Space space = spaceModel.getObject();
List<Nanopub> declarations = space != null && space.getNanopub() != null ? List.of(space.getNanopub()) : List.of();
return new RdfSource("space", spaceId, null, declarations);
}

/**
* Resolves the {@link Space} from the repository, or redirects as needed.
*
Expand Down
Loading