Skip to content
Open
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
26 changes: 26 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,32 @@ public static boolean isNanopubLoaded(String nanopubId) throws FailedApiCallExce
return r != null && !r.getData().isEmpty();
}

/**
* Checks whether the given IRI has already been used as the identifier of a resource,
* i.e. whether some nanopublication already introduces it.
* <p>
* This is the question an identifier that carries no artifact code raises: nothing makes
* it unique, so the same form filled with the same name twice yields the same IRI, and
* the second nanopublication silently attaches itself to the first one's resource (#646).
* The lookup runs against the meta repository, where {@code npx:introduces} is indexed.
* <p>
* A query service that cannot be reached answers false: a check that cannot be made is
* not evidence of a collision, and publishing should not depend on the query services
* being up.
*
* @param uri The IRI to check.
* @return True if a nanopublication introducing the IRI was found.
*/
public static boolean isUriIntroduced(String uri) {
try {
ApiResponse r = get(new QueryRef(GET_INTRODUCING_NANOPUB, "thing", uri));
return r != null && !r.getData().isEmpty();
} catch (Exception ex) {
logger.error("Could not check whether IRI '{}' is already introduced", uri, ex);
return false;
}
}

/**
* Extracts the query ID from a given query IRI.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,9 @@ protected void onSubmit() {
try {
Nanopub np = createNanopub();
logger.info("Nanopublication created: {}", np.getUri());
if (!areMintedIdsUnused()) {
return;
}
TransformContext tc = new TransformContext(SignatureAlgorithm.RSA, NanodashSession.get().getKeyPair(), NanodashSession.get().getUserIri(), false, false, false);
signedNp = SignNanopub.signAndTransform(np, tc);
logger.info("Nanopublication signed: {}", signedNp.getUri());
Expand Down Expand Up @@ -1045,6 +1048,11 @@ public void onSubmit() {
}

Nanopub np = createNanopub();
// Checked here too: the preview page publishes the nanopublication it
// was given, without coming back through this form.
if (!areMintedIdsUnused()) {
return;
}
TransformContext tc = new TransformContext(SignatureAlgorithm.RSA, NanodashSession.get().getKeyPair(), NanodashSession.get().getUserIri(), false, false, false);
Nanopub signedNp = SignNanopub.signAndTransform(np, tc);
String previewId = signedNp.getUri().stringValue();
Expand Down Expand Up @@ -1180,6 +1188,7 @@ private static void collectTemplateErrors(String part, TemplateContext context,

private synchronized Nanopub createNanopub() throws MalformedNanopubException, NanopubAlreadyFinalizedException {
assertionContext.getIntroducedIris().clear();
assertionContext.getPrefixMintedIris().clear();
assertionContext.getRolePropertyPins().clear();
NanopubCreator npCreator = new NanopubCreator(targetNamespace);
npCreator.setAssertionUri(vf.createIRI(targetNamespace + "assertion"));
Expand Down Expand Up @@ -1338,6 +1347,50 @@ private boolean canPublishFromSource(PageParameters pageParams) {
return false;
}

private boolean areMintedIdsUnused() {
IRI takenId = findTakenMintedId(assertionContext);
if (takenId == null) {
return true;
}
feedbackPanel.error("The identifier " + takenId.stringValue()
+ " is already in use. Pick a different one, or use a template for describing"
+ " an existing resource if that is what you mean to do.");
return false;
}

/**
* Returns the first identifier the given assertion context mints under a fixed prefix --
* the IRI of a new space, say -- that is already in use, or null if all of them are free.
* <p>
* An identifier minted under the new nanopublication's own namespace picks up its
* artifact code and is unique by construction; one minted under a prefix the template or
* the space supplies is not, so filling the same form with the same name twice yields the
* same IRI, and the second nanopublication silently extends the first one's resource
* instead of defining a new one. A nanopublication cannot be edited afterwards, so the
* collision is worth catching before publishing rather than after (#646).
* <p>
* Not every identifier is checked. An IRI the user typed out in full names an existing
* thing rather than minting one -- templates such as "Defining an open-ended Space with
* existing URI" exist precisely to do that -- and neither superseding nor overriding
* mints anything: keeping the source's identifier is the point of both.
*
* @param assertionContext the assertion context, after its values have been processed
* @return the first minted identifier that is already in use, or null if none is
*/
public static IRI findTakenMintedId(TemplateContext assertionContext) {
FillMode fillMode = assertionContext.getFillMode();
if (fillMode == FillMode.SUPERSEDE || fillMode == FillMode.OVERRIDE) {
return null;
}
for (IRI mintedIri : assertionContext.getPrefixMintedIris()) {
// Only identifiers the nanopublication declares as newly introduced resources: a
// prefixed field can just as well point at something that already exists.
if (!assertionContext.getIntroducedIris().contains(mintedIri)) continue;
if (QueryApiAccess.isUriIntroduced(mintedIri.stringValue())) return mintedIri;
}
return null;
}

/**
* Returns the ID of the nanopublication that the given page parameters ask
* to supersede or override, or null if they ask for neither.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class TemplateContext implements Serializable {
private final Map<IRI, IModel<?>> componentModels = new HashMap<>();
private Set<IRI> introducedIris = new HashSet<>();
private Set<IRI> embeddedIris = new HashSet<>();
private Set<IRI> prefixMintedIris = new LinkedHashSet<>();
private Map<IRI, IRI> rolePropertyPins = new LinkedHashMap<>();
private List<StatementItem> statementItems;
private Set<IRI> iriSet = new HashSet<>();
Expand Down Expand Up @@ -420,6 +421,23 @@ public Set<IRI> getEmbeddedIris() {
return embeddedIris;
}

/**
* Returns the IRIs this context minted by putting a name the user typed under a prefix
* the template or the navigation context supplied, rather than under the namespace of
* the nanopublication being created.
* <p>
* Such an IRI carries no artifact code, so nothing makes it unique: two people filling
* the same form with the same name arrive at the same identifier. That is what makes it
* worth checking against what has already been published, and the reason this set is
* kept apart from {@link #getIntroducedIris()} (see #646). An IRI the user typed out in
* full is not in here: naming an existing thing is not minting one.
*
* @return a set of IRIs minted under a prefix, in the order they were processed
*/
public Set<IRI> getPrefixMintedIris() {
return prefixMintedIris;
}

/**
* Returns the role-instantiation direction pins collected in this context, mapping
* each filled/constant role predicate to its pin class
Expand Down Expand Up @@ -516,6 +534,7 @@ public Value processValue(Value value) {
if (v.matches("[^:# ]+")) v = targetNamespace + v;
if (Utils.isUriValue(v)) {
processedValue = vf.createIRI(v);
recordIfMintedUnderPrefix(iri, (IRI) processedValue, prefix);
} else {
processedValue = vf.createLiteral(tfObject);
}
Expand Down Expand Up @@ -549,6 +568,7 @@ public Value processValue(Value value) {
if (!unresolvedPrefix) {
if (v.matches("[^:# ]+")) v = targetNamespace + v;
processedValue = vf.createIRI(v);
recordIfMintedUnderPrefix(iri, (IRI) processedValue, prefix);
}
}
} else if (template.isIntroducedResource(iri)
Expand Down Expand Up @@ -638,6 +658,32 @@ public Value processValue(Value value) {
return processedValue;
}

/**
* Records an IRI that was formed by appending what the user typed to a prefix, so that
* the publish form can check it against the identifiers already in use (#646).
* <p>
* Three kinds of IRI are deliberately left out:
* <ul>
* <li>one the user typed out in full, which names a thing rather than minting one;</li>
* <li>one minted under the new nanopublication's own namespace, whose artifact code is
* substituted at signing time and makes it unique by construction;</li>
* <li>one built by an auto-escaping placeholder, where the IRI is derived from the text
* itself. AIDA sentences are the case in point: two people writing the same sentence are
* <em>meant</em> to arrive at the same IRI, so an existing one is agreement, not a
* collision.</li>
* </ul>
*
* @param placeholder the placeholder the value was entered into
* @param mintedIri the IRI that was just formed
* @param prefix the prefix it was formed under, empty if there was none
*/
private void recordIfMintedUnderPrefix(IRI placeholder, IRI mintedIri, String prefix) {
if (prefix == null || prefix.isEmpty()) return;
if (mintedIri.stringValue().startsWith(targetNamespace)) return;
if (template.isAutoEscapePlaceholder(placeholder)) return;
prefixMintedIris.add(mintedIri);
}

/**
* Returns the statement items associated with this context.
*
Expand Down
37 changes: 37 additions & 0 deletions src/test/java/com/knowledgepixels/nanodash/QueryApiAccessTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,41 @@ void getQueryNameExtractsQueryNameFromValidIRI() {
assertEquals(queryName, result);
}

// An identifier minted under a prefix carries no artifact code, so whether it is free has
// to be asked of what has already been published (#646).
@Test
void isUriIntroducedReportsAnIdentifierThatIsAlreadyTaken() {
ApiResponse response = new ApiResponse();
ApiResponseEntry entry = new ApiResponseEntry();
entry.add("np", "https://w3id.org/np/RAMVfH7NHhyXFkvbdYYBtimoivyFpQl6CrXgoKgmjGE6I");
response.getData().add(entry);

try (MockedStatic<QueryAccess> mockQueryAccess = mockStatic(QueryAccess.class)) {
mockQueryAccess.when(() -> QueryAccess.get(any(QueryRef.class))).thenReturn(response);

assertTrue(QueryApiAccess.isUriIntroduced("https://w3id.org/spaces/example/bar"));
}
}

@Test
void isUriIntroducedReportsAnIdentifierThatIsFree() {
try (MockedStatic<QueryAccess> mockQueryAccess = mockStatic(QueryAccess.class)) {
mockQueryAccess.when(() -> QueryAccess.get(any(QueryRef.class))).thenReturn(new ApiResponse());

assertFalse(QueryApiAccess.isUriIntroduced("https://w3id.org/spaces/example/unused"));
}
}

// A check that cannot be made is not evidence of a collision: an unreachable query
// service must not be what stops someone from publishing.
@Test
void isUriIntroducedAnswersFalseWhenTheQueryFails() {
try (MockedStatic<QueryAccess> mockQueryAccess = mockStatic(QueryAccess.class)) {
mockQueryAccess.when(() -> QueryAccess.get(any(QueryRef.class)))
.thenThrow(new APINotReachableException("no instance reachable"));

assertFalse(QueryApiAccess.isUriIntroduced("https://w3id.org/spaces/example/bar"));
}
}

}
Loading