+ * This validator assumes the input links originate from a Level 2 Link Set interpretation where
+ * each {@link WebLink} may carry an {@code anchor} attribute that represents the link
+ * context (i.e., the origin resource to which the typed link applies).
+ *
+ *
+ *
Scope
+ *
+ * The validator checks whether the provided collection can be interpreted as a single landing page
+ * context and whether mandatory/optional relation types satisfy the expected cardinalities for the
+ * landing page recipe.
+ *
+ *
+ *
Single-origin requirement
+ *
+ * The validator does not assume that the input has been grouped by {@code anchor}.
+ * Therefore:
+ *
+ *
+ *
If any {@link WebLink} has a missing {@code anchor} value, an {@link IssueType#ERROR} is recorded.
+ *
If multiple distinct {@code anchor} values are present, an {@link IssueType#ERROR} is recorded and
+ * recipe validation is aborted early (because completeness cannot be determined reliably).
+ *
+ *
+ *
Relations validated
+ *
+ * The validator counts relation occurrences using {@link WebLink#rel()} and validates required relations
+ * and cardinalities:
+ *
+ *
+ *
{@code cite-as}: mandatory, cardinality (1)
+ *
{@code describedby}: mandatory, cardinality (1..n) (only presence is checked)
+ *
{@code item}: mandatory, cardinality (1..n) (only presence is checked)
+ *
{@code type}: mandatory, cardinality (1,2)
+ *
{@code license}: optional, cardinality (0,1)
+ *
+ *
+ *
+ * The produced {@link SignPostingResult} always contains a {@link SignPostingView} over the original
+ * input links (non-destructive) and a report with all recorded issues.
+ *
+ *
+ * @author Sven Fillinger
+ * @since 1.0.0
*/
public class Level2LandingPageValidator implements SignPostingValidator {
+ /**
+ * Relation type {@code cite-as}.
+ */
+ public static final String CITE_AS = "cite-as";
+
+ /**
+ * Relation type {@code describedby}.
+ */
+ public static final String DESCRIBEDBY = "describedby";
+
+ /**
+ * Relation type {@code item}.
+ */
+ public static final String ITEM = "item";
+
+ /**
+ * Relation type {@code type}.
+ */
+ public static final String TYPE = "type";
+
+ /**
+ * Private constructor. Use {@link #create()} to obtain an instance.
+ */
private Level2LandingPageValidator() {
}
+ /**
+ * Creates a new validator instance.
+ *
+ *
+ * The returned instance is stateless and can be reused.
+ *
+ *
+ * @return a {@link SignPostingValidator} that validates the Level 2 Landing Page recipe
+ */
public static SignPostingValidator create() {
return new Level2LandingPageValidator();
}
+ /**
+ * Validate the given list of {@link WebLink}s against the Level 2 Landing Page recipe.
+ *
+ *
+ * The validation collects issues rather than failing fast (except for the ambiguous multi-anchor
+ * case where further recipe validation is not meaningful).
+ *
+ *
+ * @param webLinks the web links to validate; the list is treated as read-only
+ * @return a {@link SignPostingResult} containing a {@link SignPostingView} over the input links
+ * and an {@link IssueReport} describing any detected violations
+ */
@Override
public SignPostingResult validate(List webLinks) {
- // TODO implement
- throw new RuntimeException("Not yet implemented");
+ var issues = new ArrayList();
+ validateForLandingPage(webLinks, issues);
+
+ return new SignPostingResult(new SignPostingView(webLinks), new IssueReport(issues));
+ }
+
+ /**
+ * Performs Landing Page recipe validation.
+ *
+ *
+ * This method enforces the single-origin requirement:
+ * it selects the first encountered non-null {@code anchor} as the expected origin and rejects any
+ * subsequent link with a different {@code anchor}.
+ *
+ *
+ *
+ * During iteration, relation types are counted using {@link WebLink#rel()}, but only for links that
+ * match the selected anchor. Links with missing anchors are recorded and reported after the scan.
+ *
+ *
+ *
+ * If multiple different anchors are found, an error is recorded and the method returns early because
+ * it cannot reliably determine whether the Landing Page recipe is complete for any single origin.
+ *
+ *
+ * @param webLinks the input links to validate
+ * @param issues the mutable list used to record validation issues
+ */
+ private void validateForLandingPage(List webLinks, List issues) {
+ var linksWithoutAnchor = new ArrayList();
+ String selectedAnchor = null;
+ var recordedRelations = new HashMap();
+
+ for (WebLink currentLink : webLinks) {
+ var currentAnchor = currentLink.anchor().orElse(null);
+ if (currentAnchor == null) {
+ linksWithoutAnchor.add(currentLink);
+ continue;
+ }
+ // Set the first available anchor value as selected for this context
+ if (selectedAnchor == null) {
+ selectedAnchor = currentAnchor;
+ }
+
+ // Check for equal anchors. In case of different anchors the validation fails, since
+ // we cannot reliably determine the completeness of a Landing Page recipe
+ if (currentAnchor.equals(selectedAnchor)) {
+ currentLink.rel().forEach(rel -> {
+ var currentCount = recordedRelations.getOrDefault(rel, 0);
+ recordedRelations.put(rel, currentCount + 1);
+ });
+ } else {
+ issues.add(Issue.error(
+ "Weblinks with multiple anchors are not allowed. Found new anchor '%s' but expected '%s'".formatted(
+ currentAnchor, selectedAnchor)));
+ // We can stop validation, since without a single origin, we cannot reliably validate the Landing Page recipe
+ return;
+ }
+ }
+
+ // Validate and record issues for links without anchors
+ // Missing anchors violate Level 2 FAIR Signposting recipes, since the origin of the link
+ // cannot be determined
+ linksWithoutAnchor.forEach(link -> issues.add(Issue.error(
+ "Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
+ link.target()))));
+
+ validateCiteAs(recordedRelations, issues);
+ validateDescribedBy(recordedRelations, issues);
+ validateItem(recordedRelations, issues);
+ validateLicense(recordedRelations, issues);
+ validateType(recordedRelations, issues);
+ }
+
+ /**
+ * Validates the {@code type} relation for the landing page recipe.
+ *
+ *
+ * The landing page must have at least one {@code type} link and no more than two.
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ */
+ private static void validateType(HashMap recordedRelations, List issues) {
+ validatePresenceOfMandatoryRelation(recordedRelations, issues, TYPE);
+ var count = recordedRelations.getOrDefault(TYPE, 0);
+ if (count > 2) {
+ issues.add(Issue.error(
+ "Too many links with relation type 'type' found (%d). Expected a cardinality of (1,2)"
+ .formatted(count)));
+ }
+ }
+
+ /**
+ * Validates presence of the {@code item} relation.
+ *
+ *
+ * The landing page recipe requires at least one {@code item} link.
+ * This method currently checks presence only (cardinality (1..n)).
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ */
+ private static void validateItem(HashMap recordedRelations, List issues) {
+ validatePresenceOfMandatoryRelation(recordedRelations, issues, ITEM);
+ }
+
+ /**
+ * Validates presence of the {@code describedby} relation.
+ *
+ *
+ * The landing page recipe requires at least one {@code describedby} link.
+ * This method currently checks presence only (cardinality (1..n)).
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ */
+ private static void validateDescribedBy(HashMap recordedRelations,
+ List issues) {
+ validatePresenceOfMandatoryRelation(recordedRelations, issues, DESCRIBEDBY);
+ }
+
+ /**
+ * Records an error if a mandatory relation is missing.
+ *
+ *
+ * Presence is defined by the existence of the relation key in {@code recordedRelations}.
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ * @param relation the relation type to validate
+ */
+ private static void validatePresenceOfMandatoryRelation(
+ HashMap recordedRelations,
+ List issues, String relation) {
+ if (!recordedRelations.containsKey(relation)) {
+ issues.add(Issue.error("Missing mandatory relation type '%s'".formatted(relation)));
+ }
+ }
+
+ /**
+ * Validates the {@code cite-as} relation.
+ *
+ *
+ * The landing page recipe requires exactly one {@code cite-as} link (cardinality (1)).
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ */
+ private static void validateCiteAs(HashMap recordedRelations,
+ List issues) {
+ validatePresenceOfMandatoryRelation(recordedRelations, issues, CITE_AS);
+ var count = recordedRelations.getOrDefault(CITE_AS, 0);
+ if (count > 1) {
+ issues.add(Issue.error(
+ "Multiple links with relation type 'cite-as' found (%d). Expected a cardinality of (1)"
+ .formatted(count)));
+ }
+ }
+
+ /**
+ * Validates the {@code license} relation.
+ *
+ *
+ * The landing page recipe allows the {@code license} relation to be omitted, but if present it must
+ * occur at most once (cardinality (0,1)).
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ */
+ private static void validateLicense(HashMap recordedRelations,
+ List issues) {
+ var count = recordedRelations.getOrDefault("license", 0);
+ if (count > 1) {
+ issues.add(Issue.error(
+ "Multiple links for with relation type 'license' are not allowed (%d). Expected a cardinality of (0,1)"
+ .formatted(count)));
+ }
}
}
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy
index 552747a..1d224ba 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy
@@ -1,6 +1,8 @@
package life.qbic.compass.validation
import life.qbic.compass.parsing.LinkSetJsonParser
+import life.qbic.linksmith.model.WebLink
+import life.qbic.linksmith.model.WebLinkParameter
import spock.lang.Specification
import spock.lang.Unroll
@@ -30,28 +32,6 @@ class Level2LandingPageValidatorSpec extends Specification implements OfficialSi
{ "href": "https://example.org/meta/7507/citeproc", "type": "application/vnd.citationstyles.csl+json" }
],
"license": [ { "href": "https://spdx.org/licenses/CC-BY-4.0" } ]
- },
- {
- "anchor": "https://example.org/file/7507/1",
- "collection": [ { "href": "https://example.org/page/7507", "type": "text/html" } ]
- },
- {
- "anchor": "https://example.org/file/7507/2",
- "collection": [ { "href": "https://example.org/page/7507", "type": "text/html" } ],
- "type": [ { "href": "https://schema.org/Dataset" } ]
- },
- {
- "anchor": "https://gitmodo.io/johnd/ct.zip",
- "collection": [ { "href": "https://example.org/page/7507", "type": "text/html" } ],
- "type": [ { "href": "https://schema.org/SoftwareSourceCode" } ]
- },
- {
- "anchor": "https://doi.org/10.5061/dryad.5d23f",
- "describes": [ { "href": "https://example.org/page/7507", "type": "text/html" } ]
- },
- {
- "anchor": "https://example.org/meta/7507/bibtex",
- "describes": [ { "href": "https://example.org/page/7507", "type": "text/html" } ]
}
]
}
@@ -120,12 +100,9 @@ class Level2LandingPageValidatorSpec extends Specification implements OfficialSi
result.issueReport().issues()*.message().any { it.toLowerCase().contains("cite-as") && it.toLowerCase().contains("multiple") }
}
- def "unhappy path: insecure http target in a landing relation should raise a warning"() {
- given: "replace one describedby href with insecure http"
- def brokenJson = OFFICIAL.replace(
- '"href": "https://example.org/meta/7507/bibtex"',
- '"href": "http://example.org/meta/7507/bibtex"'
- )
+ def "unhappy path: landing anchor missing entirely should raise an error in landing validation"() {
+ given: "remove the anchor field from the landing link context object"
+ def brokenJson = OFFICIAL.replaceFirst(/"anchor"\s*:\s*"https:\/\/example\.org\/page\/7507"\s*,?/, '')
def weblinks = parser.parse(asStream(brokenJson))
def validator = Level2LandingPageValidator.create()
@@ -133,22 +110,61 @@ class Level2LandingPageValidatorSpec extends Specification implements OfficialSi
def result = validator.validate(weblinks)
then:
- result.issueReport().hasWarnings()
- result.issueReport().issues()*.message().any { it.toLowerCase().contains("http") || it.toLowerCase().contains("https") }
+ result.issueReport().hasErrors()
+ result.issueReport().issues()*.message().any { it.toLowerCase().contains("anchor") }
}
- def "unhappy path: landing anchor missing entirely should raise an error in landing validation"() {
- given: "remove the anchor field from the landing link context object"
- def brokenJson = OFFICIAL.replaceFirst(/"anchor"\s*:\s*"https:\/\/example\.org\/page\/7507"\s*,?/, '')
- def weblinks = parser.parse(asStream(brokenJson))
+ def "unhappy path: validator must not validate landing recipe if multiple anchors are present"() {
+ given:
+ def validator = Level2LandingPageValidator.create()
+ def weblinks = parser.parse(asStream(OFFICIAL))
+
+ and: "inject one additional weblink with a different anchor"
+ // This depends on your WebLink API (adjust constructor/factory accordingly)
+ def foreign = WebLink.create(
+ URI.create("https://spdx.org/licenses/0BSD.html"),
+ [
+ new WebLinkParameter("rel", "license"),
+ new WebLinkParameter("anchor", "https://example.org/page/7507")
+ ]
+ )
+ weblinks = new ArrayList<>(weblinks)
+ weblinks.add(foreign)
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ result.issueReport().hasErrors()
+ result.issueReport().issues()*.message().any { it.toLowerCase().contains("license") && it.toLowerCase().contains("multiple") }
+ }
+
+ def "unhappy path: the landing page license property must have a cardinality of (0,1). Multiple licences should raise an error"() {
+ given:
def validator = Level2LandingPageValidator.create()
+ def weblinks = parser.parse(asStream(OFFICIAL))
+
+ and: "inject one additional weblink with another license"
+ // This depends on your WebLink API (adjust constructor/factory accordingly)
+ def foreign = WebLink.create(
+ URI.create("https://example.org/somewhere"),
+ [
+ new WebLinkParameter("rel", "license"),
+ new WebLinkParameter("anchor", "https://example.org/OTHER-LANDING")
+ ]
+ )
+ weblinks = new ArrayList<>(weblinks)
+ weblinks.add(foreign)
when:
def result = validator.validate(weblinks)
then:
result.issueReport().hasErrors()
- result.issueReport().issues()*.message().any { it.toLowerCase().contains("anchor") }
+ result.issueReport().issues()*.message().any { it.toLowerCase().contains("anchor") && it.toLowerCase().contains("multiple") }
+
+ and: "optional: ensure it did not also report missing mandatory relations (because it should not attempt recipe validation)"
+ !result.issueReport().issues()*.message().any { it.toLowerCase().contains("missing relation") }
}
// -------------------------
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
index 13fc573..758ba36 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
@@ -21,7 +21,7 @@ class Level2MetadataResourceValidatorSpec extends Specification implements Offic
!result.issueReport().hasErrors()
and:
- def describesTargets = result.signPostingView().withRelationType("describes")*.target
+ def describesTargets = result.signPostingView().withRelationType("describes")*.target()
describesTargets.contains(URI.create("https://example.org/page/7507"))
}
From dbd5ef4a6078646a67f3db7bbde210b08ce239ad Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Thu, 8 Jan 2026 17:18:27 +0100
Subject: [PATCH 03/10] Start with Level 2 Metadata Resource validation
---
.../Level2LandingPageValidator.java | 74 +++-----
.../Level2MetadataResourceValidator.java | 36 +++-
.../qbic/compass/validation/Level2Util.java | 146 +++++++++++++++
...Level2MetadataResourceValidatorSpec.groovy | 171 ++++++++++++++++--
4 files changed, 363 insertions(+), 64 deletions(-)
create mode 100644 src/main/java/life/qbic/compass/validation/Level2Util.java
diff --git a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
index 7fbe871..53375ec 100644
--- a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
@@ -34,9 +34,9 @@
* Therefore:
*
*
- *
If any {@link WebLink} has a missing {@code anchor} value, an {@link IssueType#ERROR} is recorded.
+ *
If any {@link WebLink} has a missing {@code anchor} value, an {@link IssueType#ERROR} is recorded for its link target and recipe validation is aborted early. No cardinality validation will be done in this case.
*
If multiple distinct {@code anchor} values are present, an {@link IssueType#ERROR} is recorded and
- * recipe validation is aborted early (because completeness cannot be determined reliably).
+ * recipe validation is aborted early (because completeness cannot be determined reliably). No cardinality validation will be done in this case.
*
- * This method enforces the single-origin requirement:
- * it selects the first encountered non-null {@code anchor} as the expected origin and rejects any
- * subsequent link with a different {@code anchor}.
+ * This method enforces the single-origin requirement: it selects the first encountered non-null
+ * {@code anchor} as the expected origin and rejects any subsequent link with a different
+ * {@code anchor}.
*
*
*
- * During iteration, relation types are counted using {@link WebLink#rel()}, but only for links that
- * match the selected anchor. Links with missing anchors are recorded and reported after the scan.
+ * During iteration, relation types are counted using {@link WebLink#rel()}, but only for links
+ * that match the selected anchor. Links with missing anchors are recorded and reported after the
+ * scan.
*
*
*
- * If multiple different anchors are found, an error is recorded and the method returns early because
- * it cannot reliably determine whether the Landing Page recipe is complete for any single origin.
+ * If multiple different anchors are found, an error is recorded and the method returns early
+ * because it cannot reliably determine whether the Landing Page recipe is complete for any single
+ * origin.
*
*
* @param webLinks the input links to validate
@@ -145,42 +147,24 @@ public SignPostingResult validate(List webLinks) {
*/
private void validateForLandingPage(List webLinks, List issues) {
var linksWithoutAnchor = new ArrayList();
- String selectedAnchor = null;
var recordedRelations = new HashMap();
- for (WebLink currentLink : webLinks) {
- var currentAnchor = currentLink.anchor().orElse(null);
- if (currentAnchor == null) {
- linksWithoutAnchor.add(currentLink);
- continue;
- }
- // Set the first available anchor value as selected for this context
- if (selectedAnchor == null) {
- selectedAnchor = currentAnchor;
- }
-
- // Check for equal anchors. In case of different anchors the validation fails, since
- // we cannot reliably determine the completeness of a Landing Page recipe
- if (currentAnchor.equals(selectedAnchor)) {
- currentLink.rel().forEach(rel -> {
- var currentCount = recordedRelations.getOrDefault(rel, 0);
- recordedRelations.put(rel, currentCount + 1);
- });
- } else {
- issues.add(Issue.error(
- "Weblinks with multiple anchors are not allowed. Found new anchor '%s' but expected '%s'".formatted(
- currentAnchor, selectedAnchor)));
- // We can stop validation, since without a single origin, we cannot reliably validate the Landing Page recipe
- return;
- }
+ // Validates for a unique context (anchor)
+ var isContextUnique = Level2Util.validateForSingleAnchor(webLinks, issues, linksWithoutAnchor,
+ recordedRelations);
+ if (!isContextUnique) {
+ return;
}
// Validate and record issues for links without anchors
// Missing anchors violate Level 2 FAIR Signposting recipes, since the origin of the link
// cannot be determined
- linksWithoutAnchor.forEach(link -> issues.add(Issue.error(
- "Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
- link.target()))));
+ if (!linksWithoutAnchor.isEmpty()) {
+ linksWithoutAnchor.forEach(link -> issues.add(Issue.error(
+ "Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
+ link.target()))));
+ return;
+ }
validateCiteAs(recordedRelations, issues);
validateDescribedBy(recordedRelations, issues);
@@ -213,8 +197,8 @@ private static void validateType(HashMap recordedRelations, Lis
* Validates presence of the {@code item} relation.
*
*
- * The landing page recipe requires at least one {@code item} link.
- * This method currently checks presence only (cardinality (1..n)).
+ * The landing page recipe requires at least one {@code item} link. This method currently checks
+ * presence only (cardinality (1..n)).
*
*
* @param recordedRelations relation counts collected for the selected anchor context
@@ -228,8 +212,8 @@ private static void validateItem(HashMap recordedRelations, Lis
* Validates presence of the {@code describedby} relation.
*
*
- * The landing page recipe requires at least one {@code describedby} link.
- * This method currently checks presence only (cardinality (1..n)).
+ * The landing page recipe requires at least one {@code describedby} link. This method currently
+ * checks presence only (cardinality (1..n)).
*
*
* @param recordedRelations relation counts collected for the selected anchor context
@@ -284,8 +268,8 @@ private static void validateCiteAs(HashMap recordedRelations,
* Validates the {@code license} relation.
*
*
- * The landing page recipe allows the {@code license} relation to be omitted, but if present it must
- * occur at most once (cardinality (0,1)).
+ * The landing page recipe allows the {@code license} relation to be omitted, but if present it
+ * must occur at most once (cardinality (0,1)).
*
*
* @param recordedRelations relation counts collected for the selected anchor context
@@ -296,7 +280,7 @@ private static void validateLicense(HashMap recordedRelations,
var count = recordedRelations.getOrDefault("license", 0);
if (count > 1) {
issues.add(Issue.error(
- "Multiple links for with relation type 'license' are not allowed (%d). Expected a cardinality of (0,1)"
+ "Multiple links with relation type 'license' are not allowed (%d). Expected a cardinality of (0,1)"
.formatted(count)));
}
}
diff --git a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
index c7be9ce..dfe5152 100644
--- a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
@@ -1,9 +1,15 @@
package life.qbic.compass.validation;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
import life.qbic.linksmith.model.WebLink;
+import life.qbic.linksmith.spi.WebLinkValidator;
+import life.qbic.linksmith.spi.WebLinkValidator.Issue;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
*
@@ -12,7 +18,8 @@
*/
public class Level2MetadataResourceValidator implements SignPostingValidator {
- private Level2MetadataResourceValidator() {}
+ private Level2MetadataResourceValidator() {
+ }
public static SignPostingValidator create() {
return new Level2MetadataResourceValidator();
@@ -20,7 +27,30 @@ public static SignPostingValidator create() {
@Override
public SignPostingResult validate(List webLinks) {
- // TODO implement
- throw new RuntimeException("Not yet implemented");
+ var issues = new ArrayList();
+ validateForMetadataResource(webLinks, issues);
+
+ return new SignPostingResult(new SignPostingView(webLinks), new IssueReport(issues));
+ }
+
+ private static void validateForMetadataResource(List webLinks, List issues) {
+ var linksWithoutAnchor = new ArrayList();
+ var recordedRelations = new HashMap();
+
+ var isContextUnique = Level2Util.validateForSingleAnchor(webLinks, issues, linksWithoutAnchor,
+ recordedRelations);
+
+ if (!isContextUnique) {
+ // Validate and record issues for links without anchors
+ // Missing anchors violate Level 2 FAIR Signposting recipes, since the origin of the link
+ // cannot be determined
+ linksWithoutAnchor.forEach(link -> issues.add(Issue.error(
+ "Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
+ link.target()))));
+ // Early abort without further cardinality evaluation
+ return;
+ }
+
+ //validateCiteAs(recordedRelations, issues);
}
}
diff --git a/src/main/java/life/qbic/compass/validation/Level2Util.java b/src/main/java/life/qbic/compass/validation/Level2Util.java
new file mode 100644
index 0000000..0fa20a9
--- /dev/null
+++ b/src/main/java/life/qbic/compass/validation/Level2Util.java
@@ -0,0 +1,146 @@
+package life.qbic.compass.validation;
+
+import java.util.List;
+import java.util.Map;
+import life.qbic.linksmith.model.WebLink;
+import life.qbic.linksmith.spi.WebLinkValidator.Issue;
+
+/**
+ * Utility functions shared by FAIR Signposting Level 2 validators.
+ *
+ *
+ * This class provides common precondition checks required by all
+ * Level 2 Signposting recipes (Landing Page, Metadata Resource, Content Resource).
+ * In particular, it validates that a collection of {@link WebLink}s
+ * represents a single, unambiguous link context.
+ *
+ *
+ *
Conceptual background
+ *
+ * In FAIR Signposting Level 2 (RFC 9264), links are expressed in a link set
+ * and grouped by their {@code anchor}, which represents the origin resource
+ * for which a recipe is defined.
+ *
+ *
+ *
+ * A Level 2 recipe (e.g. Landing Page) can only be validated if:
+ *
+ *
+ *
all links belong to the same anchor, and
+ *
every link explicitly declares its {@code anchor}.
+ *
+ *
+ *
+ * If either condition is violated, the recipe is considered
+ * not safely verifiable, and further validation must be skipped.
+ *
+ *
+ *
Design intent
+ *
+ *
This class performs structural precondition checks only.
+ *
It does not validate relation cardinalities or recipe completeness.
+ *
It allows validators to fail fast before emitting misleading errors.
+ *
+ *
+ *
+ * This class is stateless and thread-safe.
+ *
+ *
+ * @since 1.0.0
+ * @author Sven Fillinger
+ */
+final class Level2Util {
+
+ private Level2Util() {
+ }
+
+ /**
+ * Validates that the provided WebLinks form a single, well-defined Level 2 link context.
+ *
+ *
+ * This method enforces the following invariants:
+ *
+ *
+ *
All {@link WebLink}s that declare an {@code anchor} must declare the same anchor value.
+ *
Any WebLink missing an {@code anchor} is recorded but does not immediately fail validation.
+ *
Relation types ({@code rel}) are counted only for links with the selected anchor.
+ *
+ *
+ *
+ * If multiple distinct anchor values are encountered, validation fails immediately,
+ * because the recipe context becomes ambiguous.
+ *
+ *
+ *
Side effects
+ *
+ *
{@code issues} may receive an error describing anchor ambiguity.
+ *
{@code missingAnchor} collects links without an {@code anchor}.
+ *
{@code relationsCount} is populated with relation frequencies
+ * only if all anchors are consistent.
+ *
+ *
+ *
Caller responsibilities
+ *
+ *
Decide whether missing-anchor links should abort validation or merely be reported.
+ *
Stop further recipe validation if this method returns {@code false}.
+ *
Interpret {@code relationsCount} according to the specific recipe being validated.
+ *
+ *
+ *
+ * This method does not:
+ *
+ *
+ *
verify mandatory relations,
+ *
check relation cardinalities,
+ *
infer resource type (landing/metadata/content), or
+ *
dereference any link targets.
+ *
+ *
+ * @param webLinks
+ * the WebLinks to validate as a single Level 2 context
+ * @param issues
+ * a mutable list used to record validation issues
+ * @param missingAnchor
+ * a mutable list that will collect all WebLinks without an {@code anchor}
+ * @param relationsCount
+ * a mutable map that will be populated with relation-type occurrence counts
+ * @return
+ * {@code true} if all WebLinks share a single anchor context;
+ * {@code false} if multiple distinct anchors are detected
+ */
+ static boolean validateForSingleAnchor(
+ List webLinks,
+ List issues,
+ List missingAnchor,
+ Map relationsCount) {
+ String selectedAnchor = null;
+ for (WebLink currentLink : webLinks) {
+ var currentAnchor = currentLink.anchor().orElse(null);
+ if (currentAnchor == null) {
+ missingAnchor.add(currentLink);
+ continue;
+ }
+ // Set the first available anchor value as selected for this context
+ if (selectedAnchor == null) {
+ selectedAnchor = currentAnchor;
+ }
+
+ // Check for equal anchors. In case of different anchors the validation fails, since
+ // we cannot reliably determine the completeness of a Landing Page recipe
+ if (currentAnchor.equals(selectedAnchor)) {
+ currentLink.rel().forEach(rel -> {
+ var currentCount = relationsCount.getOrDefault(rel, 0);
+ relationsCount.put(rel, currentCount + 1);
+ });
+ } else {
+ issues.add(Issue.error(
+ "Input contains multiple anchors; context is ambiguous. Found new anchor '%s' but expected '%s'".formatted(
+ currentAnchor, selectedAnchor)));
+ // We can stop validation, since without a single origin, we cannot reliably validate the Landing Page recipe
+ return false;
+ }
+ }
+ return true;
+ }
+
+}
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
index 758ba36..8ba37ce 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
@@ -1,39 +1,178 @@
package life.qbic.compass.validation
-import life.qbic.compass.parsing.LinkSetJsonParser
-import life.qbic.compass.spi.SignPostingValidator
+
+import life.qbic.linksmith.model.WebLink
+import life.qbic.linksmith.model.WebLinkParameter
import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Unit tests for Level 2 Metadata Resource recipe validation.
+ *
+ * Notes:
+ * - These tests assume the validator consumes a flat list of WebLinks and MUST enforce a single anchor context.
+ */
+class Level2MetadataResourceValidatorSpec extends Specification {
-class Level2MetadataResourceValidatorSpec extends Specification implements OfficialSignpostingLevel2Fixture {
+ // Example anchors and targets used in tests
+ private static final String LANDING = "https://example.org/page/7507"
+ private static final String META_1 = "https://example.org/meta/7507/bibtex"
+ private static final String META_2 = "https://example.org/meta/7507/citeproc"
+
+ /**
+ * Helper: create a WebLink with anchor + rel.
+ * The validator counts relations using WebLink.rel().
+ */
+ private static WebLink link(String target, String anchor, String... rels) {
+ def params = []
+ params << new WebLinkParameter("anchor", anchor)
+ params << new WebLinkParameter("rel", rels.join(" "))
+ return WebLink.create(URI.create(target), params)
+ }
- def parser = LinkSetJsonParser.create()
+ private static WebLink linkMissingAnchor(String target, String... rels) {
+ def params = []
+ params << new WebLinkParameter("rel", rels.join(" "))
+ return WebLink.create(URI.create(target), params)
+ }
+
+ def "happy path: metadata resource list with single anchor and one describes passes"() {
+ given:
+ def validator = Level2MetadataResourceValidator.create()
+
+ and: "a metadata resource context (anchor is the metadata resource URI)"
+ // In the official linkset example, metadata resource anchors have rel=describes pointing to landing
+ def webLinks = [
+ link(LANDING, META_1, "describes")
+ ]
+
+ when:
+ def result = validator.validate(webLinks)
+
+ then:
+ !result.issueReport().hasErrors()
+ }
- def "happy path: official example passes metadata resource validation with no errors"() {
+ def "happy path: metadata resource may contain describes plus other relations but still passes"() {
given:
- def weblinks = parser.parse(officialJsonStream())
- SignPostingValidator validator = Level2MetadataResourceValidator.create() // adjust
+ def validator = Level2MetadataResourceValidator.create()
+
+ and:
+ def webLinks = [
+ link(LANDING, META_1, "describes"),
+ link("https://example.org/extra", META_1, "something-else")
+ ]
when:
- def result = validator.validate(weblinks)
+ def result = validator.validate(webLinks)
then:
- result != null
+ // If the validator *warns* about unknown relations, change to:
+ // !result.issueReport().hasErrors() && result.issueReport().hasWarnings()
!result.issueReport().hasErrors()
+ }
+
+ def "unhappy path: missing describes relation is an error"() {
+ given:
+ def validator = Level2MetadataResourceValidator.create()
and:
- def describesTargets = result.signPostingView().withRelationType("describes")*.target()
- describesTargets.contains(URI.create("https://example.org/page/7507"))
+ def webLinks = [
+ link("https://example.org/whatever", META_1, "item") // no describes
+ ]
+
+ when:
+ def result = validator.validate(webLinks)
+
+ then:
+ result.issueReport().hasErrors()
+ result.issueReport().issues()*.message().any { msg ->
+ def m = msg.toLowerCase()
+ m.contains("describes") && (m.contains("missing") || m.contains("mandatory"))
+ }
}
- def "happy path: at least two metadata resources describe the landing page in official example"() {
+ def "unhappy path: multiple describes links in same metadata context is an error (if cardinality=1)"() {
given:
- def weblinks = parser.parse(officialJsonStream())
- def validator = Level2MetadataResourceValidator.create() // adjust
+ def validator = Level2MetadataResourceValidator.create()
+
+ and:
+ def webLinks = [
+ link(LANDING, META_1, "describes"),
+ link("https://example.org/page/other", META_1, "describes")
+ ]
when:
- def result = validator.validate(weblinks)
+ def result = validator.validate(webLinks)
then:
- result.signPostingView().withRelationType("describes").size() >= 2
+ result.issueReport().hasErrors()
+ result.issueReport().issues()*.message().any { msg ->
+ def m = msg.toLowerCase()
+ m.contains("describes") && (m.contains("multiple") || m.contains("cardinality") || m.contains("too many"))
+ }
+ }
+
+ def "unhappy path: weblink with missing anchor is reported as error"() {
+ given:
+ def validator = Level2MetadataResourceValidator.create()
+
+ and:
+ def webLinks = [
+ linkMissingAnchor(LANDING, "describes")
+ ]
+
+ when:
+ def result = validator.validate(webLinks)
+
+ then:
+ result.issueReport().hasErrors()
+ result.issueReport().issues()*.message().any { msg ->
+ def m = msg.toLowerCase()
+ m.contains("anchor") && (m.contains("missing") || m.contains("without") || m.contains("null"))
+ }
+ }
+
+ def "unhappy path: multiple anchors in list makes metadata recipe ambiguous and aborts recipe validation"() {
+ given:
+ def validator = Level2MetadataResourceValidator.create()
+
+ and: "two different metadata anchors mixed"
+ def webLinks = [
+ link(LANDING, META_1, "describes"),
+ link(LANDING, META_2, "describes")
+ ]
+
+ when:
+ def result = validator.validate(webLinks)
+
+ then:
+ result.issueReport().hasErrors()
+ result.issueReport().issues()*.message().any { msg ->
+ def m = msg.toLowerCase()
+ m.contains("anchor") && (m.contains("multiple") || m.contains("not allowed") || m.contains("expected"))
+ }
+
+ and: "optional: ensure it did not also report missing describes for the second anchor context"
+ !result.issueReport().issues()*.message().any { it.toLowerCase().contains("missing") && it.toLowerCase().contains("describes") }
+ }
+
+ @Unroll
+ def "unhappy path: null handling - #caseName"(String caseName, List webLinks) {
+ given:
+ def validator = Level2MetadataResourceValidator.create()
+
+ when:
+ def result = validator.validate(webLinks)
+
+ then:
+ // Depending on your design, you might throw NPE instead of reporting.
+ // If you throw, change this test accordingly.
+ result.issueReport().hasErrors()
+
+ where:
+ caseName | webLinks
+ "null element in list" | [null]
+ "contains null among links" | [link(LANDING, META_1, "describes"), null]
}
}
From 025ac13f46e02942308d36df5ba95d3d3fa84f62 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Fri, 9 Jan 2026 14:33:35 +0100
Subject: [PATCH 04/10] Implement Level 2 metadata resource validator
---
.../Level2LandingPageValidator.java | 15 +-
.../Level2MetadataResourceValidator.java | 179 +++++++++++++++++-
.../qbic/compass/validation/Level2Util.java | 103 +++++-----
...Level2MetadataResourceValidatorSpec.groovy | 5 +-
4 files changed, 243 insertions(+), 59 deletions(-)
diff --git a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
index 53375ec..b506c73 100644
--- a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
@@ -3,6 +3,8 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
@@ -115,6 +117,7 @@ public static SignPostingValidator create() {
*/
@Override
public SignPostingResult validate(List webLinks) {
+ Objects.requireNonNull(webLinks);
var issues = new ArrayList();
validateForLandingPage(webLinks, issues);
@@ -183,7 +186,7 @@ private void validateForLandingPage(List webLinks, List issues)
* @param recordedRelations relation counts collected for the selected anchor context
* @param issues the list used to record validation issues
*/
- private static void validateType(HashMap recordedRelations, List issues) {
+ private static void validateType(Map recordedRelations, List issues) {
validatePresenceOfMandatoryRelation(recordedRelations, issues, TYPE);
var count = recordedRelations.getOrDefault(TYPE, 0);
if (count > 2) {
@@ -204,7 +207,7 @@ private static void validateType(HashMap recordedRelations, Lis
* @param recordedRelations relation counts collected for the selected anchor context
* @param issues the list used to record validation issues
*/
- private static void validateItem(HashMap recordedRelations, List issues) {
+ private static void validateItem(Map recordedRelations, List issues) {
validatePresenceOfMandatoryRelation(recordedRelations, issues, ITEM);
}
@@ -219,7 +222,7 @@ private static void validateItem(HashMap recordedRelations, Lis
* @param recordedRelations relation counts collected for the selected anchor context
* @param issues the list used to record validation issues
*/
- private static void validateDescribedBy(HashMap recordedRelations,
+ private static void validateDescribedBy(Map recordedRelations,
List issues) {
validatePresenceOfMandatoryRelation(recordedRelations, issues, DESCRIBEDBY);
}
@@ -236,7 +239,7 @@ private static void validateDescribedBy(HashMap recordedRelatio
* @param relation the relation type to validate
*/
private static void validatePresenceOfMandatoryRelation(
- HashMap recordedRelations,
+ Map recordedRelations,
List issues, String relation) {
if (!recordedRelations.containsKey(relation)) {
issues.add(Issue.error("Missing mandatory relation type '%s'".formatted(relation)));
@@ -253,7 +256,7 @@ private static void validatePresenceOfMandatoryRelation(
* @param recordedRelations relation counts collected for the selected anchor context
* @param issues the list used to record validation issues
*/
- private static void validateCiteAs(HashMap recordedRelations,
+ private static void validateCiteAs(Map recordedRelations,
List issues) {
validatePresenceOfMandatoryRelation(recordedRelations, issues, CITE_AS);
var count = recordedRelations.getOrDefault(CITE_AS, 0);
@@ -275,7 +278,7 @@ private static void validateCiteAs(HashMap recordedRelations,
* @param recordedRelations relation counts collected for the selected anchor context
* @param issues the list used to record validation issues
*/
- private static void validateLicense(HashMap recordedRelations,
+ private static void validateLicense(Map recordedRelations,
List issues) {
var count = recordedRelations.getOrDefault("license", 0);
if (count > 1) {
diff --git a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
index dfe5152..b62091f 100644
--- a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
@@ -3,36 +3,151 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
import life.qbic.linksmith.model.WebLink;
-import life.qbic.linksmith.spi.WebLinkValidator;
import life.qbic.linksmith.spi.WebLinkValidator.Issue;
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Validates a Level 2 FAIR Signposting metadata resource recipe over a collection of
+ * {@link WebLink}s.
*
- * @since
+ *
What this validator checks
+ *
+ * FAIR Signposting Level 2 uses a Link Set (e.g. {@code application/linkset+json}) to provide a
+ * comprehensive set of typed links for multiple resources. Within that set, each recipe is
+ * validated per origin (anchor context).
+ *
+ *
+ *
+ * This validator implements the metadata resource recipe at Level 2. In that
+ * recipe, the metadata resource (the "anchor") must point back to the landing page via
+ * {@code rel=describes} and must do so with the expected cardinality.
+ *
+ *
+ *
Precondition: single anchor context
+ *
+ * The validator does not assume that the passed list has already been grouped by
+ * anchor. Therefore, it first enforces that the input can be interpreted as a single
+ * metadata-resource context:
+ *
+ *
+ *
All non-null WebLinks must have an {@code anchor} value.
+ *
All anchors must be identical (a single origin context).
+ *
+ *
+ *
+ * If the input violates these context requirements, validation is considered ambiguous and
+ * the validator will record issues and abort further recipe checks (i.e. it will not report
+ * "missing describes" if it cannot reliably determine the context).
+ *
+ *
+ *
Recipe requirements validated
+ *
+ *
Mandatory: at least one {@code rel=describes} must be present.
+ *
Cardinality: {@code rel=describes} must not occur more than once for the
+ * validated anchor context (expected cardinality: exactly 1).
+ *
+ *
+ *
Null handling and client contract
+ *
+ *
{@code webLinks == null}:
+ * this implementation will currently throw a {@link NullPointerException} (because the input
+ * list is used without a null guard). Clients must pass a non-null list.
+ *
null elements inside the list:
+ * null items are treated as invalid input. The underlying {@link Level2Util} is expected
+ * to record an {@link Issue#error(String)} including the index and then continue scanning.
+ * (No {@link NullPointerException} should be thrown for null items.)
+ *
missing anchor values:
+ * WebLinks without an {@code anchor} are collected and recorded as errors, and validation
+ * aborts early (no further cardinality evaluation).
+ *
+ *
+ *
+ * The returned {@link SignPostingResult} always contains a {@link SignPostingView} created from the
+ * original input list; no filtering, mutation, dereferencing, or remote retrieval is performed.
+ *
+ *
+ * @author Sven Fillinger
+ * @since 1.0.0
*/
public class Level2MetadataResourceValidator implements SignPostingValidator {
+ /**
+ * The relation type required by the Level 2 metadata resource recipe.
+ *
+ * A metadata resource must contain a {@code rel=describes} link that points to the landing page
+ * it describes.
+ *
+ */
+ public static final String DESCRIBES = "describes";
+
private Level2MetadataResourceValidator() {
}
+ /**
+ * Factory method to create a new validator instance.
+ *
+ * The validator is stateless; callers may reuse the returned instance.
+ *
+ *
+ * @return a new {@link SignPostingValidator} implementing the Level 2 metadata resource recipe
+ */
public static SignPostingValidator create() {
return new Level2MetadataResourceValidator();
}
+ /**
+ * Validates the provided WebLinks against the Level 2 metadata resource Signposting recipe.
+ *
+ *
+ * The validator:
+ *
+ *
+ *
checks that the input can be interpreted as a single anchor context
+ * (no missing anchors, no mixed anchors),
+ *
records issues for ambiguous/invalid context,
+ *
and only if the context is valid, validates mandatory relations and cardinalities.
+ *
+ *
+ *
+ * No network access is performed. The validator inspects only the supplied WebLinks.
+ *
+ *
+ * @param webLinks the WebLinks to validate (must not be {@code null})
+ * @return the signposting validation result containing a view over the original WebLinks and an
+ * issue report
+ * @throws NullPointerException if {@code webLinks} is {@code null}
+ */
@Override
public SignPostingResult validate(List webLinks) {
+ Objects.requireNonNull(webLinks);
var issues = new ArrayList();
validateForMetadataResource(webLinks, issues);
return new SignPostingResult(new SignPostingView(webLinks), new IssueReport(issues));
}
+ /**
+ * Performs validation for the metadata resource recipe.
+ *
+ *
+ * This method enforces the Level 2 precondition of a single anchor context using
+ * {@link Level2Util#validateForSingleAnchor(List, List, List, Map)}.
+ *
+ *
+ *
+ * If the context is ambiguous (multiple anchors) or incomplete (missing anchors), issues are
+ * recorded and recipe validation is aborted early.
+ *
+ *
+ * @param webLinks the input WebLinks (must not be {@code null})
+ * @param issues the mutable list used to record errors and warnings
+ */
private static void validateForMetadataResource(List webLinks, List issues) {
var linksWithoutAnchor = new ArrayList();
var recordedRelations = new HashMap();
@@ -41,9 +156,14 @@ private static void validateForMetadataResource(List webLinks, List issues.add(Issue.error(
"Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
link.target()))));
@@ -51,6 +171,51 @@ private static void validateForMetadataResource(List webLinks, List
+ * Requirements enforced:
+ *
+ *
+ *
{@code describes} must be present (mandatory relation).
+ *
{@code describes} must not occur more than once (expected cardinality: exactly 1).
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ */
+ private static void validateDescribes(Map recordedRelations,
+ List issues) {
+ validatePresenceOfMandatoryRelation(recordedRelations, issues, DESCRIBES);
+ var count = recordedRelations.getOrDefault(DESCRIBES, 0);
+ var expectedCardinality = 1;
+ if (count > expectedCardinality) {
+ issues.add(Issue.error(
+ "Multiple links with relation type '%s' found. Expected a cardinality of (%d)".formatted(
+ DESCRIBES, expectedCardinality)));
+ }
+ }
+
+ /**
+ * Records an error if a mandatory relation is missing.
+ *
+ *
+ * Presence is defined by the existence of the relation key in {@code recordedRelations}.
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ * @param relation the relation type to validate
+ */
+ private static void validatePresenceOfMandatoryRelation(
+ Map recordedRelations,
+ List issues, String relation) {
+ if (!recordedRelations.containsKey(relation)) {
+ issues.add(Issue.error("Missing mandatory relation type '%s'".formatted(relation)));
+ }
}
}
diff --git a/src/main/java/life/qbic/compass/validation/Level2Util.java b/src/main/java/life/qbic/compass/validation/Level2Util.java
index 0fa20a9..cd5bae0 100644
--- a/src/main/java/life/qbic/compass/validation/Level2Util.java
+++ b/src/main/java/life/qbic/compass/validation/Level2Util.java
@@ -9,17 +9,15 @@
* Utility functions shared by FAIR Signposting Level 2 validators.
*
*
- * This class provides common precondition checks required by all
- * Level 2 Signposting recipes (Landing Page, Metadata Resource, Content Resource).
- * In particular, it validates that a collection of {@link WebLink}s
- * represents a single, unambiguous link context.
+ * This class provides common precondition checks required by all Level 2 Signposting recipes
+ * (Landing Page, Metadata Resource, Content Resource). In particular, it validates that a
+ * collection of {@link WebLink}s represents a single, unambiguous link context.
*
*
*
Conceptual background
*
- * In FAIR Signposting Level 2 (RFC 9264), links are expressed in a link set
- * and grouped by their {@code anchor}, which represents the origin resource
- * for which a recipe is defined.
+ * In FAIR Signposting Level 2 (RFC 9264), links are expressed in a link set and grouped by
+ * their {@code anchor}, which represents the origin resource for which a recipe is defined.
*
*
*
@@ -46,8 +44,8 @@
* This class is stateless and thread-safe.
*
*
- * @since 1.0.0
* @author Sven Fillinger
+ * @since 1.0.0
*/
final class Level2Util {
@@ -96,51 +94,70 @@ private Level2Util() {
*
dereference any link targets.
*
*
- * @param webLinks
- * the WebLinks to validate as a single Level 2 context
- * @param issues
- * a mutable list used to record validation issues
- * @param missingAnchor
- * a mutable list that will collect all WebLinks without an {@code anchor}
- * @param relationsCount
- * a mutable map that will be populated with relation-type occurrence counts
- * @return
- * {@code true} if all WebLinks share a single anchor context;
- * {@code false} if multiple distinct anchors are detected
+ * @param webLinks the WebLinks to validate as a single Level 2 context
+ * @param issues a mutable list used to record validation issues
+ * @param missingAnchor a mutable list that will collect all WebLinks without an {@code anchor}
+ * @param relationsCount a mutable map that will be populated with relation-type occurrence
+ * counts
+ * @return {@code true} if all WebLinks share a single anchor context; {@code false} if multiple
+ * iple distinct anchors are detected
*/
static boolean validateForSingleAnchor(
List webLinks,
List issues,
List missingAnchor,
Map relationsCount) {
- String selectedAnchor = null;
- for (WebLink currentLink : webLinks) {
- var currentAnchor = currentLink.anchor().orElse(null);
- if (currentAnchor == null) {
- missingAnchor.add(currentLink);
- continue;
- }
- // Set the first available anchor value as selected for this context
- if (selectedAnchor == null) {
- selectedAnchor = currentAnchor;
- }
-
- // Check for equal anchors. In case of different anchors the validation fails, since
- // we cannot reliably determine the completeness of a Landing Page recipe
- if (currentAnchor.equals(selectedAnchor)) {
- currentLink.rel().forEach(rel -> {
- var currentCount = relationsCount.getOrDefault(rel, 0);
- relationsCount.put(rel, currentCount + 1);
- });
- } else {
- issues.add(Issue.error(
- "Input contains multiple anchors; context is ambiguous. Found new anchor '%s' but expected '%s'".formatted(
- currentAnchor, selectedAnchor)));
- // We can stop validation, since without a single origin, we cannot reliably validate the Landing Page recipe
+ AnchorHolder selectedAnchor = new AnchorHolder();
+ WebLink currentLink;
+ for (int index = 0; index < webLinks.size(); index++) {
+ currentLink = webLinks.get(index);
+ if (!processLink(currentLink, index, selectedAnchor, issues, missingAnchor, relationsCount)) {
return false;
}
}
return true;
}
+ private static boolean processLink(WebLink currentLink,
+ int index,
+ AnchorHolder selectedAnchor,
+ List issues,
+ List missingAnchor,
+ Map relationsCount) {
+ if (currentLink == null) {
+ issues.add(Issue.error("Skipped null value for weblink at index %d".formatted(index)));
+ return true;
+ }
+ var currentAnchor = currentLink.anchor().orElse(null);
+ if (currentAnchor == null) {
+ missingAnchor.add(currentLink);
+ // Return early, no need to validate a link with unknown context
+ return true;
+ }
+
+ // Set the first available anchor value as selected for this context
+ if (selectedAnchor.value == null) {
+ selectedAnchor.value = currentAnchor;
+ }
+ // Check for equal anchors. In case of different anchors the validation fails, since
+ // we cannot reliably determine the completeness of a Landing Page recipe
+ if (!currentAnchor.equals(selectedAnchor.value)) {
+ issues.add(Issue.error(
+ "Input contains multiple anchors; context is ambiguous. Found new anchor '%s' but expected '%s'".formatted(
+ currentAnchor, selectedAnchor)));
+ // We can stop validation, since without a single origin, we cannot reliably validate the Landing Page recipe
+ return false;
+ }
+
+ currentLink.rel().forEach(rel -> {
+ var currentCount = relationsCount.getOrDefault(rel, 0);
+ relationsCount.put(rel, currentCount + 1);
+ });
+
+ return true;
+ }
+
+ private static final class AnchorHolder {
+ String value;
+ }
}
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
index 8ba37ce..93fc841 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
@@ -166,9 +166,8 @@ class Level2MetadataResourceValidatorSpec extends Specification {
def result = validator.validate(webLinks)
then:
- // Depending on your design, you might throw NPE instead of reporting.
- // If you throw, change this test accordingly.
- result.issueReport().hasErrors()
+ // null values for passed weblink collections are skipped and recorded as warning
+ result.issueReport().hasWarnings()
where:
caseName | webLinks
From b296ccd555d7bacb9ddd8822554b5bc2c4fcff82 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Fri, 9 Jan 2026 15:44:18 +0100
Subject: [PATCH 05/10] Implement content resource validator
---
pgp-keys-override.list | 8 +-
.../Level2ContentResourceValidator.java | 117 ++++++-
.../Level2LandingPageValidator.java | 7 +-
.../Level2MetadataResourceValidator.java | 7 +-
.../Level2ContentResourceValidatorSpec.groovy | 287 ++++++++++++++++--
.../Level2LandingPageValidatorSpec.groovy | 2 +-
.../OfficialSignpostingLevel2Fixture.groovy | 128 --------
7 files changed, 397 insertions(+), 159 deletions(-)
delete mode 100644 src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy
diff --git a/pgp-keys-override.list b/pgp-keys-override.list
index 7d78345..7bf5d71 100644
--- a/pgp-keys-override.list
+++ b/pgp-keys-override.list
@@ -11,4 +11,10 @@ org.cyclonedx:cyclonedx-maven-plugin = 0xFA77DCFEF2EE6EB2DEBEDD2C012579464D01C06
org.sonatype.central:central-publishing-maven-plugin:0.9.0 = 0x27C7B9756A9C691EE87696FB5C9BD9F4EAAD7054
# QBiC Linksmith library to craft weblinks
-life.qbic:linksmith:1.0.0-alpha.2 = 0x701E55A5314556757C7D3ABCC5566B352DF7E74D
+life.qbic:linksmith:1.0.0-alpha.3 = 0x701E55A5314556757C7D3ABCC5566B352DF7E74D
+
+# Jackson core
+tools.jackson.core:jackson-core:3.0.3 = 0x28118C070CB22A0175A2E8D43D12CA2AC19F3181
+
+# Jackson databind
+tools.jackson.core:jackson-databind:3.0.3 = 0x28118C070CB22A0175A2E8D43D12CA2AC19F3181
diff --git a/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java
index 60ce3cc..57167eb 100644
--- a/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java
@@ -1,9 +1,16 @@
package life.qbic.compass.validation;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
import life.qbic.linksmith.model.WebLink;
+import life.qbic.linksmith.spi.WebLinkValidator.Issue;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
*
@@ -12,7 +19,13 @@
*/
public class Level2ContentResourceValidator implements SignPostingValidator {
- private Level2ContentResourceValidator() {}
+ public static final String CITE_AS = "cite-as";
+ public static final String COLLECTION = "collection";
+ public static final String LICENSE = "license";
+ public static final String TYPE = "type";
+
+ private Level2ContentResourceValidator() {
+ }
public static SignPostingValidator create() {
return new Level2ContentResourceValidator();
@@ -20,7 +33,105 @@ public static SignPostingValidator create() {
@Override
public SignPostingResult validate(List webLinks) {
- // TODO implement
- throw new RuntimeException("Not yet implemented");
+ Objects.requireNonNull(webLinks);
+ var issues = new ArrayList();
+ validateForContentResource(webLinks, issues);
+
+ return new SignPostingResult(
+ new SignPostingView(
+ webLinks.stream()
+ .filter(Objects::nonNull)
+ .toList()),
+ new IssueReport(issues));
+ }
+
+ private void validateForContentResource(List webLinks, ArrayList issues) {
+ var linksWithoutAnchor = new ArrayList();
+ var recordedRelations = new HashMap();
+
+ var isContextUnique = Level2Util.validateForSingleAnchor(webLinks, issues, linksWithoutAnchor,
+ recordedRelations);
+
+ if (!isContextUnique) {
+ // Early abort: context ambiguity (e.g. multiple distinct anchors) prevents reliable recipe validation.
+ return;
+ }
+
+ // Validate and record issues for links without anchors
+ // Missing anchors violate Level 2 FAIR Signposting recipes, since the origin of the link
+ // cannot be determined
+ if (!linksWithoutAnchor.isEmpty()) {
+ linksWithoutAnchor.forEach(link -> issues.add(Issue.error(
+ "Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
+ link.target()))));
+ // Early abort without further cardinality evaluation
+ return;
+ }
+
+ // Validate for correct cardinality
+ // relation 'cite-as' cardinality is expected to be 0 or 1
+ validateCiteAs(recordedRelations, issues);
+ // relation 'collection' cardinality is expected to be exactly 1
+ validateCollection(recordedRelations, issues);
+ validateLicense(recordedRelations, issues);
+ validateType(recordedRelations, issues);
+ }
+
+ private void validateType(HashMap recordedRelations, ArrayList issues) {
+ var count = recordedRelations.getOrDefault(TYPE, 0);
+ if (count > 1) {
+ issues.add(Issue.error(
+ "Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (0..1)".formatted(TYPE,
+ count)));
+ }
+ }
+
+ private void validateLicense(HashMap recordedRelations,
+ ArrayList issues) {
+ var count = recordedRelations.getOrDefault(LICENSE, 0);
+ if (count > 1) {
+ issues.add(Issue.error(
+ "Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (0..1)".formatted(LICENSE,
+ count)));
+ }
+ }
+
+ private void validateCollection(HashMap recordedRelations,
+ ArrayList issues) {
+ validatePresenceOfMandatoryRelation(recordedRelations, issues, COLLECTION);
+ var count = recordedRelations.getOrDefault(COLLECTION, 0);
+ if (count > 1) {
+ issues.add(Issue.error(
+ "Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (1)".formatted(COLLECTION,
+ count)));
+ }
+ }
+
+ private static void validateCiteAs(Map recordedRelations, List issues) {
+ var count = recordedRelations.getOrDefault(CITE_AS, 0);
+ if (count > 1) {
+ issues.add(Issue.error(
+ "Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (0..1)".formatted(CITE_AS,
+ count)));
+ }
+ }
+
+ /**
+ * Records an error if a mandatory relation is missing.
+ *
+ *
+ * Presence is defined by the existence of the relation key in {@code recordedRelations}.
+ *
+ *
+ * @param recordedRelations relation counts collected for the selected anchor context
+ * @param issues the list used to record validation issues
+ * @param relation the relation type to validate
+ */
+ private static void validatePresenceOfMandatoryRelation(
+ Map recordedRelations,
+ List issues, String relation) {
+ if (!recordedRelations.containsKey(relation)) {
+ issues.add(Issue.error("Missing mandatory relation type '%s'".formatted(relation)));
+ }
}
}
diff --git a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
index b506c73..75aa357 100644
--- a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java
@@ -121,7 +121,12 @@ public SignPostingResult validate(List webLinks) {
var issues = new ArrayList();
validateForLandingPage(webLinks, issues);
- return new SignPostingResult(new SignPostingView(webLinks), new IssueReport(issues));
+ return new SignPostingResult(
+ new SignPostingView(
+ webLinks.stream()
+ .filter(Objects::nonNull)
+ .toList()),
+ new IssueReport(issues));
}
/**
diff --git a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
index b62091f..c1b8966 100644
--- a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java
@@ -129,7 +129,12 @@ public SignPostingResult validate(List webLinks) {
var issues = new ArrayList();
validateForMetadataResource(webLinks, issues);
- return new SignPostingResult(new SignPostingView(webLinks), new IssueReport(issues));
+ return new SignPostingResult(
+ new SignPostingView(
+ webLinks.stream()
+ .filter(Objects::nonNull)
+ .toList()),
+ new IssueReport(issues));
}
/**
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy
index 229ae9d..0c3f409 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy
@@ -1,49 +1,288 @@
package life.qbic.compass.validation
-import life.qbic.compass.parsing.LinkSetJsonParser
-import life.qbic.compass.spi.SignPostingValidator
+import life.qbic.compass.spi.SignPostingResult
+import life.qbic.linksmith.model.WebLink
+import life.qbic.linksmith.model.WebLinkParameter
import spock.lang.Specification
+import spock.lang.Unroll
-class Level2ContentResourceValidatorSpec extends Specification implements OfficialSignpostingLevel2Fixture {
+class Level2ContentResourceValidatorSpec extends Specification {
- def parser = LinkSetJsonParser.create()
+ static final String COLLECTION = "collection"
+ static final String TYPE = "type"
- def "happy path: official example passes content resource validation with no errors"() {
+ // Landing page used as "collection" target in examples
+ static final URI LANDING = URI.create("https://example.org/page/7507")
+
+ // Content resource anchor (origin)
+ static final String CONTENT_ANCHOR = "https://example.org/file/7507/2"
+
+ def validator = Level2ContentResourceValidator.create()
+
+ // ----------------------------------------------------------------------
+ // Helpers
+ // ----------------------------------------------------------------------
+
+ private static WebLink link(String target,
+ String anchor = CONTENT_ANCHOR,
+ List rels = [],
+ Map extraParams = [:]) {
+
+ def params = []
+
+ // anchor is a parameter of the WebLink model (string)
+ if (anchor != null) {
+ params << WebLinkParameter.create("anchor", anchor)
+ } else {
+ // omit anchor param completely
+ }
+
+ // Each rel is encoded as its own rel parameter for convenience.
+ // Your WebLink.rel() splits on whitespace anyway.
+ rels.each { r ->
+ params << WebLinkParameter.create("rel", r)
+ }
+
+ extraParams.each { k, v ->
+ if (v == null) {
+ params << WebLinkParameter.withoutValue(k)
+ } else {
+ params << WebLinkParameter.create(k, v)
+ }
+ }
+
+ return WebLink.create(URI.create(target), params)
+ }
+
+ private static List minimalValidContentRecipe() {
+ // Content resource recipe at Level 2:
+ // anchor = content resource
+ // rel=collection -> landing page
+ // rel=type -> semantic type URI (schema.org etc.)
+ [
+ // collection link: target is landing page, anchor is content resource, rel=collection
+ link(LANDING.toString(), CONTENT_ANCHOR, [COLLECTION], ["type": "text/html"]),
+ // type link: target is semantic type, anchor is content resource, rel=type
+ link("https://schema.org/Dataset", CONTENT_ANCHOR, [TYPE])
+ ]
+ }
+
+ private static boolean hasError(SignPostingResult result) {
+ result.issueReport() != null && result.issueReport().hasErrors()
+ }
+
+ private static boolean hasWarning(SignPostingResult result) {
+ result.issueReport() != null && result.issueReport().hasWarnings()
+ }
+
+ private static List messages(SignPostingResult result) {
+ result.issueReport()?.issues()?.collect { it.message() } ?: []
+ }
+
+ // ----------------------------------------------------------------------
+ // Happy paths
+ // ----------------------------------------------------------------------
+
+ def "happy path: minimal valid content resource recipe passes without errors"() {
given:
- def weblinks = parser.parse(officialJsonStream())
- SignPostingValidator validator = Level2ContentResourceValidator.create() // adjust
+ def weblinks = minimalValidContentRecipe()
when:
def result = validator.validate(weblinks)
then:
- result != null
- !result.issueReport().hasErrors()
+ !hasError(result)
+ // Depending on your policy, might still contain warnings; typically none:
+ !hasWarning(result)
+
+ and: "view is non-destructive and contains the same links (defensive copied by view)"
+ result.signPostingView().webLinks().size() == weblinks.size()
+ result.signPostingView().webLinks()*.target().containsAll(weblinks*.target())
+ }
+
+ def "happy path: additional unrelated relations are allowed as long as content recipe still holds"() {
+ given:
+ def weblinks = new ArrayList<>(minimalValidContentRecipe())
+ weblinks << link("https://spdx.org/licenses/CC-BY-4.0", CONTENT_ANCHOR, ["license"])
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ !hasError(result)
+ }
+
+ // ----------------------------------------------------------------------
+ // Unhappy paths: context / anchor problems
+ // ----------------------------------------------------------------------
+
+ def "unhappy path: missing anchors cause errors and abort further cardinality checks"() {
+ given:
+ def weblinks = minimalValidContentRecipe()
+ // Remove anchors by creating links without anchor param
+ def noAnchorLinks = weblinks.collect { WebLink l ->
+ // recreate with same target + rel but without anchor param
+ def rels = l.rel()
+ link(l.target().toString(), null, rels, [:])
+ }
+
+ when:
+ def result = validator.validate(noAnchorLinks)
+
+ then:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("missing value for 'anchor'") }
+
+ and: "should NOT additionally report missing mandatory relations if you early-abort"
+ // Adjust if your validator still continues; your Level2 validators typically early-return
+ !messages(result).any { it.toLowerCase().contains("missing mandatory relation") }
+ }
+
+ def "unhappy path: multiple anchors are ambiguous and must fail context validation"() {
+ given:
+ def weblinks = new ArrayList<>(minimalValidContentRecipe())
+ // Add a foreign link with different anchor
+ weblinks << link("https://example.org/foreign", "https://example.org/file/OTHER", [TYPE])
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("multiple anchors") || it.toLowerCase().contains("ambiguous") }
+
+ and: "optional: should not continue with recipe checks after anchor ambiguity"
+ !messages(result).any { it.toLowerCase().contains("missing mandatory relation") }
+ }
- and: "collection relations exist and point to landing page"
- def collectionLinks = result.signPostingView().withRelationType("collection")
- collectionLinks.size() >= 3
+ def "unhappy path: null element in list is reported as error (contract) and does not crash"() {
+ given:
+ def weblinks = new ArrayList<>(minimalValidContentRecipe())
+ weblinks.add(1, null)
+
+ when:
+ def result = validator.validate(weblinks)
- collectionLinks*.target().contains(URI.create("https://example.org/page/7507"))
+ then:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("null") }
}
- def "happy path: official content anchors are present and each has collection -> landing"() {
+ // ----------------------------------------------------------------------
+ // Unhappy paths: missing mandatory relations
+ // ----------------------------------------------------------------------
+
+ def "unhappy path: missing rel=collection is an error"() {
given:
- def weblinks = parser.parse(officialJsonStream())
- def validator = Level2ContentResourceValidator.create() // adjust
+ def weblinks = minimalValidContentRecipe().findAll { !it.rel().contains(COLLECTION) }
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("missing") && it.toLowerCase().contains(COLLECTION) }
+ }
+
+ // ----------------------------------------------------------------------
+ // Unhappy paths: cardinality violations
+ // ----------------------------------------------------------------------
+
+ def "unhappy path: multiple rel=collection violate cardinality (expected exactly 1)"() {
+ given:
+ def weblinks = new ArrayList<>(minimalValidContentRecipe())
+ weblinks << link("https://example.org/page/7507-alt", CONTENT_ANCHOR, [COLLECTION], ["type": "text/html"])
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("multiple") && it.toLowerCase().contains(COLLECTION) }
+ }
+
+ def "unhappy path: multiple rel=type violate cardinality (expected exactly 1)"() {
+ given:
+ def weblinks = new ArrayList<>(minimalValidContentRecipe())
+ weblinks << link("https://schema.org/SoftwareSourceCode", CONTENT_ANCHOR, [TYPE])
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("multiple") && it.toLowerCase().contains(TYPE) }
+ }
+
+ // ----------------------------------------------------------------------
+ // Edge cases: relation representation (whitespace splitting)
+ // ----------------------------------------------------------------------
+
+ def "edge: one rel parameter with whitespace-separated values counts as multiple relations"() {
+ given:
+ // Build a single link whose rel value contains both relation types.
+ // Your WebLink.rel() splits by \\s+.
+ def combinedRelLink = WebLink.create(
+ URI.create(LANDING.toString()),
+ [
+ WebLinkParameter.create("anchor", CONTENT_ANCHOR),
+ WebLinkParameter.create("rel", "${COLLECTION} ${TYPE}"),
+ WebLinkParameter.create("type", "text/html")
+ ]
+ )
+
+ // This makes recordedRelations count:
+ // collection:1, type:1 (from a single WebLink)
+ def weblinks = [
+ combinedRelLink
+ ]
+
+ when:
+ def result = validator.validate(weblinks)
+
+ then:
+ // Depending on your recipe, this is likely still INVALID because you'd also
+ // need a dedicated type-link whose target is the semantic type URI.
+ // If your validator checks only cardinality by relation, then it might pass.
+ // So here we assert only that it doesn't crash and produces a deterministic report.
+ result != null
+ result.issueReport() != null
+ }
+
+ // ----------------------------------------------------------------------
+ // Defensive-copy invariants
+ // ----------------------------------------------------------------------
+
+ def "invariant: SignPostingView performs defensive copy of passed list"() {
+ given:
+ def input = new ArrayList(minimalValidContentRecipe())
+
+ when:
+ def result = validator.validate(input)
+ input.clear() // mutate after validate()
+
+ then:
+ // view should not change (it defensively copies)
+ result.signPostingView().webLinks().size() == 2
+ }
+
+ // ----------------------------------------------------------------------
+ // Table-driven invalid inputs (optional, but nice for coverage)
+ // ----------------------------------------------------------------------
+ @Unroll
+ def "unhappy path: #caseName"() {
when:
def result = validator.validate(weblinks)
then:
- def view = result.signPostingView()
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains(expectedMessageFragment.toLowerCase()) }
- // anchors aren't directly exposed by SignPostingView in your snippet,
- // so we assert at least that the content link targets appear somewhere as WebLink targets:
- view.withRelationType("item")*.target().containsAll([
- URI.create("https://example.org/file/7507/1"),
- URI.create("https://example.org/file/7507/2"),
- URI.create("https://gitmodo.io/johnd/ct.zip")
- ])
+ where:
+ caseName | weblinks | expectedMessageFragment
+ "empty list -> missing mandatory relations" | [] | "missing"
+ "single unrelated link -> missing mandatory relations" | [link("https://example.org/x", CONTENT_ANCHOR, ["license"])] | "missing"
+ "no anchor but has relations -> anchor error" | [link(LANDING.toString(), null, [COLLECTION], ["type": "text/html"])] | "anchor"
}
}
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy
index 1d224ba..d2b8203 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy
@@ -9,7 +9,7 @@ import spock.lang.Unroll
import java.nio.charset.StandardCharsets
import java.util.regex.Pattern
-class Level2LandingPageValidatorSpec extends Specification implements OfficialSignpostingLevel2Fixture {
+class Level2LandingPageValidatorSpec extends Specification {
def parser = LinkSetJsonParser.create()
// --- Official fixture (inline here to keep the spec self-contained) ---
diff --git a/src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy b/src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy
deleted file mode 100644
index 76d5909..0000000
--- a/src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy
+++ /dev/null
@@ -1,128 +0,0 @@
-package life.qbic.compass.validation
-
-import java.nio.charset.StandardCharsets
-
-trait OfficialSignpostingLevel2Fixture {
- static String OFFICIAL_LEVEL2_LINKSET_JSON = '''
-{
- "linkset": [
- {
- "anchor": "https://example.org/page/7507",
- "cite-as": [
- {
- "href": "https://doi.org/10.5061/dryad.5d23f"
- }
- ],
- "type": [
- {
- "href": "https://schema.org/ScholarlyArticle"
- },
- {
- "href": "https://schema.org/AboutPage"
- }
- ],
- "author": [
- {
- "href": "https://orcid.org/0000-0002-1825-0097"
- },
- {
- "href": "https://isni.org/isni/0000002251201436"
- }
- ],
- "item": [
- {
- "href": "https://example.org/file/7507/1",
- "type": "application/pdf"
- },
- {
- "href": "https://example.org/file/7507/2",
- "type": "text/csv"
- },
- {
- "href": "https://gitmodo.io/johnd/ct.zip",
- "type": "application/zip"
- }
- ],
- "describedby": [
- {
- "href": "https://example.org/meta/7507/bibtex",
- "type": "application/x-bibtex"
- },
- {
- "href": "https://doi.org/10.5061/dryad.5d23f",
- "type": "application/vnd.datacite.datacite+json"
- },
- {
- "href": "https://example.org/meta/7507/citeproc",
- "type": "application/vnd.citationstyles.csl+json"
- }
- ],
- "license": [
- {
- "href": "https://spdx.org/licenses/CC-BY-4.0"
- }
- ]
- },
- {
- "anchor": "https://example.org/file/7507/1",
- "collection": [
- {
- "href": "https://example.org/page/7507",
- "type": "text/html"
- }
- ]
- },
- {
- "anchor": "https://example.org/file/7507/2",
- "collection": [
- {
- "href": "https://example.org/page/7507",
- "type": "text/html"
- }
- ],
- "type": [
- {
- "href": "https://schema.org/Dataset"
- }
- ]
- },
- {
- "anchor": "https://gitmodo.io/johnd/ct.zip",
- "collection": [
- {
- "href": "https://example.org/page/7507",
- "type": "text/html"
- }
- ],
- "type": [
- {
- "href": "https://schema.org/SoftwareSourceCode"
- }
- ]
- },
- {
- "anchor": "https://doi.org/10.5061/dryad.5d23f",
- "describes": [
- {
- "href": "https://example.org/page/7507",
- "type": "text/html"
- }
- ]
- },
- {
- "anchor": "https://example.org/meta/7507/bibtex",
- "describes": [
- {
- "href": "https://example.org/page/7507",
- "type": "text/html"
- }
- ]
- }
- ]
-}
-'''
-
- static InputStream officialJsonStream() {
- return new ByteArrayInputStream(OFFICIAL_LEVEL2_LINKSET_JSON.getBytes(StandardCharsets.UTF_8))
- }
-}
From 77fd24cb2b83bf02c3914bf00bdc513e014771dc Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Fri, 9 Jan 2026 15:51:19 +0100
Subject: [PATCH 06/10] Provide java docs
---
.../Level2ContentResourceValidator.java | 166 ++++++++++++++++--
1 file changed, 156 insertions(+), 10 deletions(-)
diff --git a/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java
index 57167eb..97284d6 100644
--- a/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java
@@ -13,24 +13,114 @@
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Validates the FAIR Signposting Level 2content resource recipe
+ * for a single content resource context.
*
- * @since
+ *
+ * In FAIR Signposting Level 2, typed links for multiple resources are typically conveyed via a
+ * Link Set (e.g., {@code application/linkset+json}). A content resource is one of the resources
+ * in that graph (e.g., a PDF, CSV, ZIP, etc.). This validator checks whether a given list of
+ * {@link WebLink}s satisfies the expected relation set and cardinalities for the content resource
+ * context.
+ *
+ *
+ *
How this validator determines the context
+ *
+ * This validator does not assume the input is pre-grouped by anchor.
+ * It uses {@link Level2Util#validateForSingleAnchor(List, List, List, Map)} to ensure that all
+ * links in the input belong to exactly one anchor (origin) value.
+ *
+ *
+ *
+ *
If multiple distinct anchors are present, the context is ambiguous and recipe validation is aborted.
+ *
If one or more links are missing an {@code anchor} value, recipe validation is aborted and errors are recorded.
+ *
+ *
+ *
Null handling contract
+ *
+ * The input list itself must not be {@code null}. If {@code null} elements are contained in the
+ * list, they are:
+ *
+ *
passed to {@link Level2Util#validateForSingleAnchor(List, List, List, Map)} which is expected to record issues, and
+ *
filtered out when constructing the {@link SignPostingView} to prevent {@link NullPointerException}
+ * and to keep the result consumable for clients.
+ *
+ *
+ *
Validated relations and cardinalities
+ *
+ * For the content resource recipe, this validator checks the following relations (as expressed in
+ * {@code rel=} parameters):
+ *
+ * This validator does not dereference targets, does not validate media types, and does not validate
+ * URI semantics beyond what the upstream Link parsing/validation already guarantees.
+ *
+ *
+ * @since 1.0.0
+ * @author Sven Fillinger
*/
public class Level2ContentResourceValidator implements SignPostingValidator {
+ /** Relation type {@code cite-as} used by FAIR Signposting. */
public static final String CITE_AS = "cite-as";
+
+ /** Relation type {@code collection} used by FAIR Signposting for content resources. */
public static final String COLLECTION = "collection";
+
+ /** Relation type {@code license} used by FAIR Signposting. */
public static final String LICENSE = "license";
+
+ /** Relation type {@code type} used by FAIR Signposting (semantic typing). */
public static final String TYPE = "type";
+ /**
+ * Private constructor. Use {@link #create()}.
+ */
private Level2ContentResourceValidator() {
}
+ /**
+ * Creates a new validator instance.
+ *
+ *
+ * The validator is stateless; instances can be reused.
+ *
+ *
+ * @return a new {@link SignPostingValidator} validating the Level 2 content resource recipe
+ */
public static SignPostingValidator create() {
return new Level2ContentResourceValidator();
}
+ /**
+ * Validates the provided list of {@link WebLink}s against the FAIR Signposting Level 2 content
+ * resource recipe.
+ *
+ *
+ * The returned {@link SignPostingResult} always contains a {@link SignPostingView}. The view is
+ * built from a filtered copy of the input list with all {@code null} elements removed.
+ *
+ *
+ *
+ * Validation is best-effort and issue-driven:
+ *
+ *
+ *
If the input contains multiple distinct anchor values, validation aborts after recording an error.
+ *
If the input contains links without an {@code anchor} value, validation aborts after recording errors.
+ *
Otherwise, relation cardinalities are evaluated and corresponding issues are recorded.
+ *
+ *
+ * @param webLinks the weblinks to validate; must not be {@code null}
+ * @return the validation result containing a view and an issue report
+ * @throws NullPointerException if {@code webLinks} is {@code null}
+ */
@Override
public SignPostingResult validate(List webLinks) {
Objects.requireNonNull(webLinks);
@@ -45,6 +135,18 @@ public SignPostingResult validate(List webLinks) {
new IssueReport(issues));
}
+ /**
+ * Performs the actual recipe validation for a content resource context.
+ *
+ *
+ * The method first checks whether all links belong to exactly one anchor (origin) context.
+ * If the context is ambiguous (multiple anchors) or incomplete (missing anchors), the method
+ * records issues and returns early without evaluating relation cardinalities.
+ *
+ *
+ * @param webLinks the input list of links (may contain {@code null} elements)
+ * @param issues the mutable list used to record validation issues
+ */
private void validateForContentResource(List webLinks, ArrayList issues) {
var linksWithoutAnchor = new ArrayList();
var recordedRelations = new HashMap();
@@ -70,14 +172,25 @@ private void validateForContentResource(List webLinks, ArrayList
// Validate for correct cardinality
// relation 'cite-as' cardinality is expected to be 0 or 1
- validateCiteAs(recordedRelations, issues);
- // relation 'collection' cardinality is expected to be exactly 1
- validateCollection(recordedRelations, issues);
- validateLicense(recordedRelations, issues);
- validateType(recordedRelations, issues);
+ // Validate relation cardinalities for the content resource recipe.
+ validateCiteAs(recordedRelations, issues); // (0..1)
+ validateCollection(recordedRelations, issues); // (1)
+ validateLicense(recordedRelations, issues); // (0..1)
+ validateType(recordedRelations, issues); // (0..1)
}
- private void validateType(HashMap recordedRelations, ArrayList issues) {
+ /**
+ * Validates the cardinality of relation {@code type}.
+ *
+ *
+ * For the content resource recipe, {@code type} is optional but must not occur more than once
+ * within the selected anchor context.
+ *
+ *
+ * @param recordedRelations map of relation type to occurrence count for the selected anchor context
+ * @param issues list used to record validation issues
+ */
+ private static void validateType(HashMap recordedRelations, ArrayList issues) {
var count = recordedRelations.getOrDefault(TYPE, 0);
if (count > 1) {
issues.add(Issue.error(
@@ -86,7 +199,18 @@ private void validateType(HashMap recordedRelations, ArrayList<
}
}
- private void validateLicense(HashMap recordedRelations,
+ /**
+ * Validates the cardinality of relation {@code license}.
+ *
+ *
+ * For the content resource recipe, {@code license} is optional but must not occur more than once
+ * within the selected anchor context.
+ *
+ *
+ * @param recordedRelations map of relation type to occurrence count for the selected anchor context
+ * @param issues list used to record validation issues
+ */
+ private static void validateLicense(HashMap recordedRelations,
ArrayList issues) {
var count = recordedRelations.getOrDefault(LICENSE, 0);
if (count > 1) {
@@ -96,7 +220,18 @@ private void validateLicense(HashMap recordedRelations,
}
}
- private void validateCollection(HashMap recordedRelations,
+ /**
+ * Validates the presence and cardinality of relation {@code collection}.
+ *
+ *
+ * For the content resource recipe, {@code collection} is mandatory and must occur exactly once
+ * within the selected anchor context.
+ *
+ *
+ * @param recordedRelations map of relation type to occurrence count for the selected anchor context
+ * @param issues list used to record validation issues
+ */
+ private static void validateCollection(HashMap recordedRelations,
ArrayList issues) {
validatePresenceOfMandatoryRelation(recordedRelations, issues, COLLECTION);
var count = recordedRelations.getOrDefault(COLLECTION, 0);
@@ -107,6 +242,17 @@ private void validateCollection(HashMap recordedRelations,
}
}
+ /**
+ * Validates the cardinality of relation {@code cite-as}.
+ *
+ *
+ * For the content resource recipe, {@code cite-as} is optional but must not occur more than once
+ * within the selected anchor context.
+ *
+ *
+ * @param recordedRelations map of relation type to occurrence count for the selected anchor context
+ * @param issues list used to record validation issues
+ */
private static void validateCiteAs(Map recordedRelations, List issues) {
var count = recordedRelations.getOrDefault(CITE_AS, 0);
if (count > 1) {
From 0ed1557c7f30fc58f8a546f5d64d67a89796d39e Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Fri, 9 Jan 2026 16:31:56 +0100
Subject: [PATCH 07/10] Finish up for the weekend
---
...l2SignPostingValidator.java => Level2RecipeValidator.java} | 2 +-
.../validation/Level2RecipeRoutingValidatorSpec.groovy | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
rename src/main/java/life/qbic/compass/validation/{Level2SignPostingValidator.java => Level2RecipeValidator.java} (84%)
create mode 100644 src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
diff --git a/src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java b/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
similarity index 84%
rename from src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java
rename to src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
index 7a7081e..c82a106 100644
--- a/src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
@@ -10,7 +10,7 @@
*
* @since
*/
-public class Level2SignPostingValidator implements SignPostingValidator {
+public class Level2RecipeRoutingValidator implements SignPostingValidator {
@Override
public SignPostingResult validate(List webLinks) {
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
new file mode 100644
index 0000000..77b7de8
--- /dev/null
+++ b/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
@@ -0,0 +1,4 @@
+package life.qbic.compass.validation
+
+class Level2RecipeRoutingValidatorSpec {
+}
From 227751faf9e8b83935cdecc730366b4914a9e736 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Fri, 9 Jan 2026 16:32:17 +0100
Subject: [PATCH 08/10] Rename level 2 entry validator
---
.../validation/Level2RecipeValidator.java | 37 ++-
.../Level2RecipeRoutingValidatorSpec.groovy | 311 +++++++++++++++++-
2 files changed, 346 insertions(+), 2 deletions(-)
diff --git a/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java b/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
index c82a106..ed2136f 100644
--- a/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
@@ -1,6 +1,7 @@
package life.qbic.compass.validation;
import java.util.List;
+import java.util.Objects;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
import life.qbic.linksmith.model.WebLink;
@@ -10,7 +11,41 @@
*
* @since
*/
-public class Level2RecipeRoutingValidator implements SignPostingValidator {
+public class Level2RecipeValidator implements SignPostingValidator {
+
+ private final SignPostingValidator landingPageValidator;
+ private final SignPostingValidator metadataResourceValidator;
+ private final SignPostingValidator contentResourceValidator;
+
+ private Level2RecipeValidator(
+ SignPostingValidator landingPageValidator,
+ SignPostingValidator metadataResourceValidator,
+ SignPostingValidator contentResourceValidator
+ ) {
+ this.landingPageValidator = Objects.requireNonNull(landingPageValidator);
+ this.metadataResourceValidator = Objects.requireNonNull(metadataResourceValidator);
+ this.contentResourceValidator = Objects.requireNonNull(contentResourceValidator);
+ }
+
+ public static Level2RecipeValidator create() {
+ return new Level2RecipeValidator(
+ Level2LandingPageValidator.create(),
+ Level2MetadataResourceValidator.create(),
+ Level2ContentResourceValidator.create()
+ );
+ }
+
+ static Level2RecipeValidator create(
+ SignPostingValidator landingPageValidator,
+ SignPostingValidator metadataResourceValidator,
+ SignPostingValidator contentResourceValidator
+ ) {
+ return new Level2RecipeValidator(
+ landingPageValidator,
+ metadataResourceValidator,
+ contentResourceValidator);
+ }
+
@Override
public SignPostingResult validate(List webLinks) {
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
index 77b7de8..1f4de9e 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
@@ -1,4 +1,313 @@
package life.qbic.compass.validation
-class Level2RecipeRoutingValidatorSpec {
+import life.qbic.compass.model.SignPostingView
+import life.qbic.compass.spi.SignPostingResult
+import life.qbic.linksmith.model.WebLink
+import life.qbic.linksmith.model.WebLinkParameter
+import life.qbic.linksmith.spi.WebLinkValidator
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exhaustive unit tests for the "parent" Level 2 validator that determines the recipe
+ * (Landing Page / Metadata Resource / Content Resource) and delegates to specialized validators.
+ *
+ * This spec avoids mocking final classes/records by returning real SignPostingResult instances.
+ */
+class Level2RecipeRoutingValidatorSpec extends Specification {
+
+ // --- Relations used for recipe detection ---
+ static final String CITE_AS = "cite-as"
+ static final String DESCRIBEDBY = "describedby"
+ static final String ITEM = "item"
+ static final String TYPE = "type"
+ static final String COLLECTION = "collection"
+ static final String DESCRIBES = "describes"
+ static final String LICENSE = "license"
+
+ // anchors
+ static final String LANDING_ANCHOR = "https://example.org/page/7507"
+ static final String CONTENT_ANCHOR = "https://example.org/file/7507/2"
+ static final String META_ANCHOR = "https://example.org/meta/7507/bibtex"
+
+ // ----------------------------------------------------------------------
+ // Helpers
+ // ----------------------------------------------------------------------
+
+ private static WebLink weblink(String target,
+ String anchor,
+ List rels,
+ Map extras = [:]) {
+
+ def params = []
+ if (anchor != null) {
+ params << WebLinkParameter.create("anchor", anchor)
+ }
+
+ rels.each { r ->
+ params << WebLinkParameter.create("rel", r)
+ }
+
+ extras.each { k, v ->
+ if (v == null) params << WebLinkParameter.withoutValue(k)
+ else params << WebLinkParameter.create(k, v)
+ }
+
+ WebLink.create(URI.create(target), params)
+ }
+
+ private static SignPostingResult childResult(List links, List issues) {
+ new SignPostingResult(new SignPostingView(links), new WebLinkValidator.IssueReport(issues))
+ }
+
+ private static boolean hasError(SignPostingResult r) {
+ r.issueReport() != null && r.issueReport().hasErrors()
+ }
+
+ private static List messages(SignPostingResult r) {
+ r.issueReport()?.issues()?.collect { it.message() } ?: []
+ }
+
+ // ----------------------------------------------------------------------
+ // Given: specialized validators as injected stubs
+ // ----------------------------------------------------------------------
+
+ def landingValidator = Stub(Level2LandingPageValidator)
+ def metadataValidator = Stub(Level2MetadataResourceValidator)
+ def contentValidator = Stub(Level2ContentResourceValidator)
+
+ /**
+ * Replace this with your actual parent validator class.
+ *
+ * It should accept specialized validators so tests can verify routing without reflection.
+ *
+ * Example shape:
+ * new Level2RecipeValidator(landingValidator, metadataValidator, contentValidator)
+ */
+ def parent = Level2RecipeValidator.create(landingValidator, metadataValidator, contentValidator)
+
+ // ----------------------------------------------------------------------
+ // Recipe fixtures (minimal “detectors”)
+ // ----------------------------------------------------------------------
+
+ private static List landingRecipeLinks() {
+ [
+ weblink("https://doi.org/10.123/abc", LANDING_ANCHOR, [CITE_AS]),
+ weblink("https://example.org/meta/7507/bibtex", LANDING_ANCHOR, [DESCRIBEDBY], ["type": "application/x-bibtex"]),
+ weblink("https://example.org/file/7507/2", LANDING_ANCHOR, [ITEM], ["type": "text/csv"]),
+ weblink("https://schema.org/ScholarlyArticle", LANDING_ANCHOR, [TYPE])
+ ]
+ }
+
+ private static List metadataRecipeLinks() {
+ [
+ weblink("https://example.org/page/7507", META_ANCHOR, [DESCRIBES], ["type": "text/html"])
+ ]
+ }
+
+ private static List contentRecipeLinks() {
+ [
+ weblink("https://example.org/page/7507", CONTENT_ANCHOR, [COLLECTION], ["type": "text/html"]),
+ weblink("https://schema.org/Dataset", CONTENT_ANCHOR, [TYPE])
+ ]
+ }
+
+ // ----------------------------------------------------------------------
+ // Routing tests
+ // ----------------------------------------------------------------------
+
+ def "routes landing-page recipe to LandingPageValidator and aggregates its issues"() {
+ given:
+ def input = landingRecipeLinks()
+
+ and: "child validator returns a result with an issue"
+ landingValidator.validate(_ as List) >> { List links ->
+ childResult(links, [Issue.warning("landing-warning")])
+ }
+
+ when:
+ def result = parent.validate(input)
+
+ then: "landing validator invoked once"
+ 1 * landingValidator.validate(_ as List)
+ 0 * metadataValidator.validate(_)
+ 0 * contentValidator.validate(_)
+
+ and: "issues propagated"
+ !hasError(result)
+ messages(result).contains("landing-warning")
+
+ and: "final view contains the original (safe) links"
+ result.signPostingView().webLinks().size() == input.size()
+ }
+
+ def "routes metadata recipe to MetadataResourceValidator"() {
+ given:
+ def input = metadataRecipeLinks()
+
+ metadataValidator.validate(_ as List) >> { List links ->
+ childResult(links, [Issue.warning("meta-warning")])
+ }
+
+ when:
+ def result = parent.validate(input)
+
+ then:
+ 0 * landingValidator.validate(_)
+ 1 * metadataValidator.validate(_ as List)
+ 0 * contentValidator.validate(_)
+
+ and:
+ messages(result).contains("meta-warning")
+ }
+
+ def "routes content recipe to ContentResourceValidator"() {
+ given:
+ def input = contentRecipeLinks()
+
+ contentValidator.validate(_ as List) >> { List links ->
+ childResult(links, [Issue.warning("content-warning")])
+ }
+
+ when:
+ def result = parent.validate(input)
+
+ then:
+ 0 * landingValidator.validate(_)
+ 0 * metadataValidator.validate(_)
+ 1 * contentValidator.validate(_ as List)
+
+ and:
+ messages(result).contains("content-warning")
+ }
+
+ // ----------------------------------------------------------------------
+ // No-recipe / ambiguous cases
+ // ----------------------------------------------------------------------
+
+ def "records error when no recipe can be determined (no distinguishing relations present)"() {
+ given:
+ def input = [
+ weblink("https://example.org/x", LANDING_ANCHOR, [LICENSE]),
+ weblink("https://example.org/y", LANDING_ANCHOR, ["author"])
+ ]
+
+ when:
+ def result = parent.validate(input)
+
+ then: "no child validator should be called"
+ 0 * landingValidator.validate(_)
+ 0 * metadataValidator.validate(_)
+ 0 * contentValidator.validate(_)
+
+ and: "error reported"
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("no recipe") || it.toLowerCase().contains("cannot be determined") }
+
+ and: "still returns a view"
+ result.signPostingView().webLinks().size() == input.size()
+ }
+
+ def "records error when multiple anchors are present (context ambiguous) and does not delegate"() {
+ given:
+ def input = new ArrayList()
+ input.addAll(landingRecipeLinks())
+ input << weblink("https://example.org/page/OTHER", "https://example.org/page/OTHER", [CITE_AS])
+
+ when:
+ def result = parent.validate(input)
+
+ then:
+ 0 * landingValidator.validate(_)
+ 0 * metadataValidator.validate(_)
+ 0 * contentValidator.validate(_)
+
+ and:
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("multiple anchors") || it.toLowerCase().contains("ambiguous") }
+ }
+
+ // ----------------------------------------------------------------------
+ // Null-handling and view invariants
+ // ----------------------------------------------------------------------
+
+ def "filters null elements for the final SignPostingView and records an error for null element"() {
+ given:
+ def input = new ArrayList(contentRecipeLinks())
+ input.add(1, null)
+
+ and: "content validator is still called with safe (null-filtered) links"
+ contentValidator.validate(_ as List) >> { List safe ->
+ assert !safe.contains(null)
+ childResult(safe, [])
+ }
+
+ when:
+ def result = parent.validate(input)
+
+ then:
+ 1 * contentValidator.validate(_ as List)
+ hasError(result)
+ messages(result).any { it.toLowerCase().contains("null") }
+
+ and: "view contains only non-null weblinks"
+ result.signPostingView().webLinks().every { it != null }
+ result.signPostingView().webLinks().size() == 2
+ }
+
+ def "defensive copy: mutating input list after validate does not affect the view"() {
+ given:
+ def input = new ArrayList(landingRecipeLinks())
+
+ landingValidator.validate(_ as List) >> { List links ->
+ childResult(links, [])
+ }
+
+ when:
+ def result = parent.validate(input)
+ input.clear()
+
+ then:
+ result.signPostingView().webLinks().size() == landingRecipeLinks().size()
+ }
+
+ // ----------------------------------------------------------------------
+ // Table-driven: recipe determination priority (if multiple signals exist)
+ // ----------------------------------------------------------------------
+ // If your parent supports mixed signals, define the precedence.
+ // Example precedence: Landing > Metadata > Content (or whatever you decide).
+ // These tests force you to encode that contract.
+
+ @Unroll
+ def "recipe determination precedence: #caseName"() {
+ given:
+ def input = links
+
+ and:
+ landingValidator.validate(_ as List) >> { l -> childResult(l, [Issue.warning("landing")]) }
+ metadataValidator.validate(_ as List) >> { l -> childResult(l, [Issue.warning("metadata")]) }
+ contentValidator.validate(_ as List) >> { l -> childResult(l, [Issue.warning("content")]) }
+
+ when:
+ def result = parent.validate(input)
+
+ then:
+ expectedCalls.call()
+
+ and:
+ messages(result).contains(expectedMessage)
+
+ where:
+ caseName | links | expectedMessage | expectedCalls
+ "landing beats content if both signals exist" | landingRecipeLinks() + contentRecipeLinks() | "landing" | { ->
+ 1 * landingValidator.validate(_ as List)
+ 0 * metadataValidator.validate(_)
+ 0 * contentValidator.validate(_)
+ }
+ "metadata beats content if both signals exist" | metadataRecipeLinks() + contentRecipeLinks() | "metadata" | { ->
+ 0 * landingValidator.validate(_)
+ 1 * metadataValidator.validate(_ as List)
+ 0 * contentValidator.validate(_)
+ }
+ }
}
From 784a547b4991eb2487f4009442c1a97bea604074 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Mon, 12 Jan 2026 16:53:09 +0100
Subject: [PATCH 09/10] Provide more JDs
---
.../validation/Level2RecipeValidator.java | 328 +++++++++++++++++-
.../qbic/compass/validation/Level2Util.java | 4 +-
...roovy => Level2RecipeValidatorSpec.groovy} | 192 +++-------
3 files changed, 379 insertions(+), 145 deletions(-)
rename src/test/groovy/life/qbic/compass/validation/{Level2RecipeRoutingValidatorSpec.groovy => Level2RecipeValidatorSpec.groovy} (51%)
diff --git a/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java b/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
index ed2136f..813bfe8 100644
--- a/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level2RecipeValidator.java
@@ -1,18 +1,118 @@
package life.qbic.compass.validation;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
import life.qbic.linksmith.model.WebLink;
+import life.qbic.linksmith.spi.WebLinkValidator.Issue;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Routes Level 2 FAIR Signposting validation to the appropriate recipe validator per anchor context.
*
- * @since
+ *
+ * In FAIR Signposting Level 2, typed links for multiple resources (e.g., landing page, content
+ * resources, metadata resources) are commonly provided together via a Link Set (RFC 9264).
+ * Each resource is represented by a link context object, which is mapped to RFC 8288 style
+ * {@link WebLink}s using the {@code anchor} parameter as the origin (context) of a link.
+ *
+ *
+ *
What this validator does
+ *
+ *
Normalizes input by filtering {@code null} elements and reporting them as errors.
+ *
Groups all non-null links by their {@code anchor} value (one anchor = one resource context).
+ *
Determines the recipe per anchor context using lightweight heuristics and
+ * delegates validation to one of:
+ *
+ *
{@code Level2LandingPageValidator}
+ *
{@code Level2MetadataResourceValidator}
+ *
{@code Level2ContentResourceValidator}
+ *
+ *
+ *
Aggregates issues from delegated validators into one {@link IssueReport}.
+ *
Returns a non-destructive view ({@link SignPostingView}) over all non-null
+ * input links (regardless of validation outcome).
+ *
+ *
+ *
Contract and policy
+ *
+ *
Input requirements
+ *
+ *
{@code webLinks} must not be {@code null}. A {@link NullPointerException} is thrown otherwise.
+ *
{@code webLinks} may contain {@code null} elements.
+ * Such elements are skipped and an {@code ERROR} issue is recorded that
+ * includes the element index.
+ *
+ *
+ *
Anchor handling
+ *
+ *
Only links with a present {@code anchor} value participate in recipe routing and validation.
+ *
Links without {@code anchor} are retained in the returned {@link SignPostingView} (non-destructive),
+ * but they are not validated by this validator, because a Level 2 recipe context
+ * cannot be established without an origin.
+ *
+ *
+ *
Recipe determination heuristics
+ *
+ * Recipe detection is intentionally lightweight and relies on the presence of relation types
+ * that are characteristic for the corresponding recipe context.
+ *
+ *
+ *
Landing page is assumed if any link in the anchor group contains
+ * {@code rel=cite-as} or {@code rel=item}.
+ *
Metadata resource is assumed if any link contains {@code rel=describes}.
+ *
Content resource is assumed if any link contains {@code rel=collection}.
+ *
+ *
+ *
+ * If none of these signals are present in an anchor group, the recipe is treated as unknown and an
+ * {@code ERROR} is recorded for that anchor.
+ *
+ *
+ *
Delegation safety
+ *
+ *
Delegated validators are expected to return a non-null {@link SignPostingResult}.
+ *
If a delegated validator returns {@code null}, this validator records a dedicated {@code ERROR}
+ * (see {@link #NULL_SIGNPOSTING_RESULT_ERROR}) and continues processing other anchors.
+ *
+ *
+ *
Side effects
+ *
+ *
This validator does not mutate the input list.
+ *
This validator does not dereference URIs or perform any network I/O.
+ *
The returned {@link SignPostingView} is created from a filtered list of non-null links, preserving
+ * their original order among non-null elements.
+ *
+ *
+ *
Client responsibilities
+ *
+ *
Clients that require validation of links without {@code anchor} must handle them separately
+ * (e.g., treat them as Level 1 inline links or report them as input violations).
+ *
Recipe detection is heuristic-based. If clients need strict classification, they should either:
+ *
+ *
pre-group and pre-classify anchor contexts before calling this validator, or
+ *
use specialized validators directly where the recipe type is already known.
+ *
+ *
+ *
+ *
+ * @since 1.0.0
+ * @author Sven Fillinger
*/
public class Level2RecipeValidator implements SignPostingValidator {
+ /**
+ * Error message template used when a delegated validator unexpectedly returns {@code null}.
+ *
+ * The {@code %s} placeholder is formatted with the anchor for which validation was attempted.
+ */
+ public static final String NULL_SIGNPOSTING_RESULT_ERROR = "Validator returned null SignPostingResult for anchor '%s'";
private final SignPostingValidator landingPageValidator;
private final SignPostingValidator metadataResourceValidator;
private final SignPostingValidator contentResourceValidator;
@@ -27,6 +127,12 @@ private Level2RecipeValidator(
this.contentResourceValidator = Objects.requireNonNull(contentResourceValidator);
}
+ /**
+ * Creates a recipe validator using the default Level 2 recipe validators for landing page,
+ * metadata resource, and content resource.
+ *
+ * @return a fully configured {@link Level2RecipeValidator}
+ */
public static Level2RecipeValidator create() {
return new Level2RecipeValidator(
Level2LandingPageValidator.create(),
@@ -35,11 +141,26 @@ public static Level2RecipeValidator create() {
);
}
+ /**
+ * Creates a recipe validator with user-provided validators for each recipe type.
+ *
+ *
+ * This factory method is primarily intended for testing and advanced customization.
+ * Provided validators must be non-null and must adhere to the {@link SignPostingValidator}
+ * contract (in particular: returning a non-null {@link SignPostingResult}).
+ *
+ *
+ * @param landingPageValidator validator for the landing page recipe
+ * @param metadataResourceValidator validator for the metadata resource recipe
+ * @param contentResourceValidator validator for the content resource recipe
+ * @return a configured {@link Level2RecipeValidator}
+ * @throws NullPointerException if any validator is {@code null}
+ */
static Level2RecipeValidator create(
SignPostingValidator landingPageValidator,
SignPostingValidator metadataResourceValidator,
SignPostingValidator contentResourceValidator
- ) {
+ ) {
return new Level2RecipeValidator(
landingPageValidator,
metadataResourceValidator,
@@ -47,9 +168,206 @@ static Level2RecipeValidator create(
}
+ /**
+ * Validates a mixed collection of Level 2 Signposting {@link WebLink}s by routing them per
+ * anchor context to recipe-specific validators.
+ *
+ *
+ * The method performs three phases:
+ *
+ *
+ *
Filters {@code null} list elements and records an {@code ERROR} for each skipped element.
+ *
Groups remaining links by {@code anchor} (only links with present anchors are grouped).
+ *
Determines the recipe for each anchor group and delegates validation.
+ *
+ *
+ *
+ * The returned {@link SignPostingResult} always contains a {@link SignPostingView} over all non-null
+ * links and an {@link IssueReport} aggregating all recorded issues.
+ *
+ *
+ * @param webLinks the input list of weblinks to validate (may contain {@code null} elements)
+ * @return the validation result including a non-destructive view over all non-null input links
+ * @throws NullPointerException if {@code webLinks} is {@code null}
+ */
@Override
public SignPostingResult validate(List webLinks) {
- // TODO implement
- throw new RuntimeException("Not yet implemented");
+ Objects.requireNonNull(webLinks);
+ var issues = new ArrayList();
+ var nonNullLinks = new ArrayList();
+ WebLink currentWebLink;
+ // 1. Filter out null elements and report them as issues with the element's index
+ for (int index = 0; index < webLinks.size(); index++) {
+ currentWebLink = webLinks.get(index);
+ if (currentWebLink == null) {
+ issues.add(Issue.error("Input list of weblinks contained null element at index %d".formatted(index)));
+ continue;
+ }
+ nonNullLinks.add(currentWebLink);
+ }
+
+ // 2. Group all weblinks by their anchor value => one recipe per origin
+ var groupedByAnchor = groupByAnchor(nonNullLinks);
+ for (var entrySet : groupedByAnchor.entrySet()) {
+ validateRecipe(entrySet.getKey(), entrySet.getValue(), issues);
+ }
+ return new SignPostingResult(new SignPostingView(nonNullLinks), new IssueReport(issues));
+ }
+
+ /**
+ * Determines the appropriate recipe validator for a single anchor context and records issues
+ * produced by the delegated validator.
+ *
+ *
+ * If the recipe cannot be determined (none of the heuristic signals match), an {@code ERROR}
+ * is recorded for the given anchor.
+ *
+ *
+ * @param anchor the anchor (origin) representing the resource context
+ * @param links the links belonging to the anchor context
+ * @param issues the shared issue sink collecting all validation findings
+ */
+ private void validateRecipe(String anchor, List links, ArrayList issues) {
+ if (looksLikeLandingPage(links)) {
+ validateAndSafelyRecordIssues(() -> landingPageValidator.validate(links),
+ issues,
+ () -> Issue.error(
+ NULL_SIGNPOSTING_RESULT_ERROR.formatted(anchor)));
+ return;
+ }
+ if (looksLikeMetadataResource(links)) {
+ validateAndSafelyRecordIssues(() -> metadataResourceValidator.validate(links),
+ issues,
+ () -> Issue.error(
+ NULL_SIGNPOSTING_RESULT_ERROR.formatted(anchor)));
+ return;
+ }
+ if (looksLikeContentResource(links)) {
+ validateAndSafelyRecordIssues(() -> contentResourceValidator.validate(links),
+ issues,
+ () -> Issue.error(
+ NULL_SIGNPOSTING_RESULT_ERROR.formatted(anchor)));
+ return;
+ }
+ issues.add(Issue.error("Unknown FAIR Signposting recipe for anchor '%s'".formatted(anchor)));
+ }
+
+ /**
+ * Executes a delegated validator call and appends its issues to the shared issue list.
+ *
+ *
+ * This method hardens delegation by handling unexpected {@code null} results:
+ * if the supplied validator returns {@code null}, {@code nullHandler} is used to produce an
+ * {@link Issue} that is appended to {@code issues}.
+ *
+ *
+ *
+ * Note: This method assumes that a non-null {@link SignPostingResult} contains a non-null
+ * {@link IssueReport}. If a delegated validator violates that contract, a {@link NullPointerException}
+ * may still occur and should be considered a bug in the delegated validator.
+ *
+ *
+ * @param validator supplier performing validation and returning a {@link SignPostingResult}
+ * @param issues shared sink to append issues to
+ * @param nullHandler supplier producing an issue if {@code validator.get()} returns {@code null}
+ */
+ private static void validateAndSafelyRecordIssues(
+ Supplier validator,
+ List issues,
+ Supplier nullHandler) {
+ var result = validator.get();
+ if (result == null) {
+ issues.add(nullHandler.get());
+ return;
+ }
+ issues.addAll(result.issueReport().issues());
+ }
+
+ /**
+ * Groups weblinks by their {@code anchor} value.
+ *
+ *
+ * Only links with a present anchor are included in the returned map. Links without anchors are
+ * ignored for grouping because they cannot be assigned to a Level 2 recipe context.
+ *
+ *
+ * @param weblinks input links (expected to be non-null elements)
+ * @return a map of anchor string to list of links sharing that anchor
+ */
+ private static Map> groupByAnchor(List weblinks) {
+ var linksWithAnchor = weblinks.stream()
+ .filter(link -> link.anchor().isPresent()).toList();
+ // TODO report missing anchor values to the client!
+ return linksWithAnchor.stream()
+ .collect(Collectors.groupingBy(link -> link.anchor().orElse("")));
+ }
+
+ /**
+ * Heuristic check for identifying a landing page recipe within an anchor group.
+ *
+ *
+ * Current policy: if any link contains {@code rel=cite-as} or {@code rel=item}, the group is
+ * treated as a landing page recipe.
+ *
+ *
+ * @param recipe the links for one anchor context
+ * @return true if the group is classified as landing page recipe
+ */
+ private static boolean looksLikeLandingPage(List recipe) {
+ for (WebLink link : recipe) {
+ if (link == null) {
+ continue;
+ }
+ if (link.rel().contains("cite-as") || link.rel().contains("item")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Heuristic check for identifying a metadata resource recipe within an anchor group.
+ *
+ *
+ * Current policy: if any link contains {@code rel=describes}, the group is treated as a metadata
+ * resource recipe.
+ *
+ *
+ * @param recipe the links for one anchor context
+ * @return true if the group is classified as metadata resource recipe
+ */
+ private static boolean looksLikeMetadataResource(List recipe) {
+ for (WebLink link : recipe) {
+ if (link == null) {
+ continue;
+ }
+ if (link.rel().contains("describes")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Heuristic check for identifying a content resource recipe within an anchor group.
+ *
+ *
+ * Current policy: if any link contains {@code rel=collection}, the group is treated as a content
+ * resource recipe.
+ *
+ *
+ * @param recipe the links for one anchor context
+ * @return true if the group is classified as content resource recipe
+ */
+ private static boolean looksLikeContentResource(List recipe) {
+ for (WebLink link : recipe) {
+ if (link == null) {
+ continue;
+ }
+ if (link.rel().contains("collection")) {
+ return true;
+ }
+ }
+ return false;
}
}
diff --git a/src/main/java/life/qbic/compass/validation/Level2Util.java b/src/main/java/life/qbic/compass/validation/Level2Util.java
index cd5bae0..afd5a55 100644
--- a/src/main/java/life/qbic/compass/validation/Level2Util.java
+++ b/src/main/java/life/qbic/compass/validation/Level2Util.java
@@ -16,7 +16,7 @@
*
*
Conceptual background
*
- * In FAIR Signposting Level 2 (RFC 9264), links are expressed in a link set and grouped by
+ * In FAIR Signposting Level 2, links are expressed in a link set (RFC 9264) and grouped by
* their {@code anchor}, which represents the origin resource for which a recipe is defined.
*
*
@@ -100,7 +100,7 @@ private Level2Util() {
* @param relationsCount a mutable map that will be populated with relation-type occurrence
* counts
* @return {@code true} if all WebLinks share a single anchor context; {@code false} if multiple
- * iple distinct anchors are detected
+ * distinct anchors are detected
*/
static boolean validateForSingleAnchor(
List webLinks,
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy
similarity index 51%
rename from src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
rename to src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy
index 1f4de9e..9a6fcf1 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2RecipeRoutingValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy
@@ -2,19 +2,17 @@ package life.qbic.compass.validation
import life.qbic.compass.model.SignPostingView
import life.qbic.compass.spi.SignPostingResult
+import life.qbic.compass.spi.SignPostingValidator
import life.qbic.linksmith.model.WebLink
import life.qbic.linksmith.model.WebLinkParameter
-import life.qbic.linksmith.spi.WebLinkValidator
+import life.qbic.linksmith.spi.WebLinkValidator.Issue
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport
import spock.lang.Specification
import spock.lang.Unroll
-/**
- * Exhaustive unit tests for the "parent" Level 2 validator that determines the recipe
- * (Landing Page / Metadata Resource / Content Resource) and delegates to specialized validators.
- *
- * This spec avoids mocking final classes/records by returning real SignPostingResult instances.
- */
-class Level2RecipeRoutingValidatorSpec extends Specification {
+import java.net.URI
+
+class Level2RecipeValidatorSpec extends Specification {
// --- Relations used for recipe detection ---
static final String CITE_AS = "cite-as"
@@ -30,6 +28,26 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
static final String CONTENT_ANCHOR = "https://example.org/file/7507/2"
static final String META_ANCHOR = "https://example.org/meta/7507/bibtex"
+ // validators (fresh per test)
+ SignPostingValidator landingValidator
+ SignPostingValidator metadataValidator
+ SignPostingValidator contentValidator
+
+ Level2RecipeValidator parent
+
+ def setup() {
+ landingValidator = Mock(SignPostingValidator)
+ metadataValidator = Mock(SignPostingValidator)
+ contentValidator = Mock(SignPostingValidator)
+
+ // Safe default: any validate(List) call returns a non-null SignPostingResult
+ landingValidator.validate(_ as List) >> { List links -> childResult(links as List, []) }
+ metadataValidator.validate(_ as List) >> { List links -> childResult(links as List, []) }
+ contentValidator.validate(_ as List) >> { List links -> childResult(links as List, []) }
+
+ parent = Level2RecipeValidator.create(landingValidator, metadataValidator, contentValidator)
+ }
+
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
@@ -56,8 +74,8 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
WebLink.create(URI.create(target), params)
}
- private static SignPostingResult childResult(List links, List issues) {
- new SignPostingResult(new SignPostingView(links), new WebLinkValidator.IssueReport(issues))
+ private static SignPostingResult childResult(List links, List issues) {
+ new SignPostingResult(new SignPostingView(links), new IssueReport(issues))
}
private static boolean hasError(SignPostingResult r) {
@@ -69,25 +87,7 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
}
// ----------------------------------------------------------------------
- // Given: specialized validators as injected stubs
- // ----------------------------------------------------------------------
-
- def landingValidator = Stub(Level2LandingPageValidator)
- def metadataValidator = Stub(Level2MetadataResourceValidator)
- def contentValidator = Stub(Level2ContentResourceValidator)
-
- /**
- * Replace this with your actual parent validator class.
- *
- * It should accept specialized validators so tests can verify routing without reflection.
- *
- * Example shape:
- * new Level2RecipeValidator(landingValidator, metadataValidator, contentValidator)
- */
- def parent = Level2RecipeValidator.create(landingValidator, metadataValidator, contentValidator)
-
- // ----------------------------------------------------------------------
- // Recipe fixtures (minimal “detectors”)
+ // Recipe fixtures
// ----------------------------------------------------------------------
private static List landingRecipeLinks() {
@@ -120,24 +120,17 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
given:
def input = landingRecipeLinks()
- and: "child validator returns a result with an issue"
- landingValidator.validate(_ as List) >> { List links ->
- childResult(links, [Issue.warning("landing-warning")])
- }
-
when:
def result = parent.validate(input)
- then: "landing validator invoked once"
- 1 * landingValidator.validate(_ as List)
+ then:
+ 1 * landingValidator.validate(_) >> childResult(input, [Issue.warning("landing-warning")])
0 * metadataValidator.validate(_)
0 * contentValidator.validate(_)
- and: "issues propagated"
+ and:
!hasError(result)
messages(result).contains("landing-warning")
-
- and: "final view contains the original (safe) links"
result.signPostingView().webLinks().size() == input.size()
}
@@ -145,19 +138,18 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
given:
def input = metadataRecipeLinks()
- metadataValidator.validate(_ as List) >> { List links ->
- childResult(links, [Issue.warning("meta-warning")])
- }
-
when:
def result = parent.validate(input)
then:
0 * landingValidator.validate(_)
- 1 * metadataValidator.validate(_ as List)
+ 1 * metadataValidator.validate(_) >> { List links ->
+ childResult(links as List, [Issue.warning("meta-warning")])
+ }
0 * contentValidator.validate(_)
and:
+ !hasError(result)
messages(result).contains("meta-warning")
}
@@ -165,19 +157,18 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
given:
def input = contentRecipeLinks()
- contentValidator.validate(_ as List) >> { List links ->
- childResult(links, [Issue.warning("content-warning")])
- }
-
when:
def result = parent.validate(input)
then:
0 * landingValidator.validate(_)
0 * metadataValidator.validate(_)
- 1 * contentValidator.validate(_ as List)
+ 1 * contentValidator.validate(_) >> { List links ->
+ childResult(links as List, [Issue.warning("content-warning")])
+ }
and:
+ !hasError(result)
messages(result).contains("content-warning")
}
@@ -185,7 +176,7 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
// No-recipe / ambiguous cases
// ----------------------------------------------------------------------
- def "records error when no recipe can be determined (no distinguishing relations present)"() {
+ def "records error when no recipe can be determined and does not delegate"() {
given:
def input = [
weblink("https://example.org/x", LANDING_ANCHOR, [LICENSE]),
@@ -195,28 +186,6 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
when:
def result = parent.validate(input)
- then: "no child validator should be called"
- 0 * landingValidator.validate(_)
- 0 * metadataValidator.validate(_)
- 0 * contentValidator.validate(_)
-
- and: "error reported"
- hasError(result)
- messages(result).any { it.toLowerCase().contains("no recipe") || it.toLowerCase().contains("cannot be determined") }
-
- and: "still returns a view"
- result.signPostingView().webLinks().size() == input.size()
- }
-
- def "records error when multiple anchors are present (context ambiguous) and does not delegate"() {
- given:
- def input = new ArrayList()
- input.addAll(landingRecipeLinks())
- input << weblink("https://example.org/page/OTHER", "https://example.org/page/OTHER", [CITE_AS])
-
- when:
- def result = parent.validate(input)
-
then:
0 * landingValidator.validate(_)
0 * metadataValidator.validate(_)
@@ -224,11 +193,12 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
and:
hasError(result)
- messages(result).any { it.toLowerCase().contains("multiple anchors") || it.toLowerCase().contains("ambiguous") }
+ messages(result).any { it.toLowerCase().contains("unknown") || it.toLowerCase().contains("recipe") }
+ result.signPostingView().webLinks().size() == input.size()
}
// ----------------------------------------------------------------------
- // Null-handling and view invariants
+ // Null-handling / view invariants
// ----------------------------------------------------------------------
def "filters null elements for the final SignPostingView and records an error for null element"() {
@@ -236,78 +206,24 @@ class Level2RecipeRoutingValidatorSpec extends Specification {
def input = new ArrayList(contentRecipeLinks())
input.add(1, null)
- and: "content validator is still called with safe (null-filtered) links"
- contentValidator.validate(_ as List) >> { List safe ->
- assert !safe.contains(null)
- childResult(safe, [])
- }
-
when:
def result = parent.validate(input)
then:
- 1 * contentValidator.validate(_ as List)
+ 1 * contentValidator.validate(_) >> { List safe ->
+ assert !safe.contains(null)
+ childResult(safe as List, [])
+ }
+ 0 * landingValidator.validate(_)
+ 0 * metadataValidator.validate(_)
+
+ and:
hasError(result)
messages(result).any { it.toLowerCase().contains("null") }
-
- and: "view contains only non-null weblinks"
result.signPostingView().webLinks().every { it != null }
- result.signPostingView().webLinks().size() == 2
}
- def "defensive copy: mutating input list after validate does not affect the view"() {
- given:
- def input = new ArrayList(landingRecipeLinks())
-
- landingValidator.validate(_ as List) >> { List links ->
- childResult(links, [])
- }
-
- when:
- def result = parent.validate(input)
- input.clear()
-
- then:
- result.signPostingView().webLinks().size() == landingRecipeLinks().size()
- }
-
- // ----------------------------------------------------------------------
- // Table-driven: recipe determination priority (if multiple signals exist)
- // ----------------------------------------------------------------------
- // If your parent supports mixed signals, define the precedence.
- // Example precedence: Landing > Metadata > Content (or whatever you decide).
- // These tests force you to encode that contract.
-
- @Unroll
- def "recipe determination precedence: #caseName"() {
- given:
- def input = links
-
- and:
- landingValidator.validate(_ as List) >> { l -> childResult(l, [Issue.warning("landing")]) }
- metadataValidator.validate(_ as List) >> { l -> childResult(l, [Issue.warning("metadata")]) }
- contentValidator.validate(_ as List) >> { l -> childResult(l, [Issue.warning("content")]) }
-
- when:
- def result = parent.validate(input)
-
- then:
- expectedCalls.call()
-
- and:
- messages(result).contains(expectedMessage)
-
- where:
- caseName | links | expectedMessage | expectedCalls
- "landing beats content if both signals exist" | landingRecipeLinks() + contentRecipeLinks() | "landing" | { ->
- 1 * landingValidator.validate(_ as List)
- 0 * metadataValidator.validate(_)
- 0 * contentValidator.validate(_)
- }
- "metadata beats content if both signals exist" | metadataRecipeLinks() + contentRecipeLinks() | "metadata" | { ->
- 0 * landingValidator.validate(_)
- 1 * metadataValidator.validate(_ as List)
- 0 * contentValidator.validate(_)
- }
+ enum ExpectedValidator {
+ LANDING, METADATA, CONTENT
}
}
From e365ca88a37df336d88d320f83e1979d9c98d4a5 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Tue, 13 Jan 2026 14:07:43 +0100
Subject: [PATCH 10/10] Make distinction for Level 1 clear
---
.../Level1SignPostingValidator.java | 65 ++++++++++---------
...Level2MetadataResourceValidatorSpec.groovy | 4 +-
.../Level2RecipeValidatorSpec.groovy | 3 -
3 files changed, 37 insertions(+), 35 deletions(-)
diff --git a/src/main/java/life/qbic/compass/validation/Level1SignPostingValidator.java b/src/main/java/life/qbic/compass/validation/Level1SignPostingValidator.java
index a74d955..158f379 100644
--- a/src/main/java/life/qbic/compass/validation/Level1SignPostingValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Level1SignPostingValidator.java
@@ -10,55 +10,60 @@
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- * Signposting validator for Level 1 of the FAIR Signposting profile.
+ * Validator for the FAIR Signposting Level 1 profile.
+ *
+ *
Scope and normative target
+ *
+ * FAIR Signposting Level 1 defines a minimal set of typed links intended to support
+ * robust machine navigation of scholarly objects. Importantly, in Level 1 only the
+ * landing page recipe is mandatory and therefore
+ * normative.
+ *
+ *
*
- * Level 1 Signposting describes the minimal, inline set of typed links that a
- * scholarly object should expose to enable reliable discovery, citation, attribution,
- * and access to descriptive metadata. These links are typically provided via HTTP
- * {@code Link} headers or HTML {@code } elements and are directly attached to the
- * resource itself.
+ * Typed links exposed on content resources and metadata resources are
+ * recommended in Level 1 but not required by the profile. Consequently, this
+ * validator deliberately does not attempt to infer, validate, or enforce completeness
+ * of Level 1 content/metadata resource recipes. It validates only the Level 1
+ * landing page expectations on the provided links.
*
*
+ *
What this validator checks
*
- * This validator operates on a list of already parsed {@link WebLink}s and performs
- * structural and semantic checks according to the Level 1 FAIR Signposting
- * recommendations:
+ * The validator operates on a list of already parsed {@link WebLink}s (e.g., obtained from HTTP
+ * {@code Link} headers or HTML {@code } elements) and records issues according to the
+ * Level 1 landing page recipe:
*
*
*
*
- * {@code rel="author"} – recommended; a warning is raised if no author
- * relation is present (cardinality 0..n).
+ * {@code rel="cite-as"} – mandatory for the landing page;
+ * exactly one link is expected (error if missing or duplicated).
*
*
- * {@code rel="cite-as"} – mandatory; exactly one occurrence is expected,
- * and an error is raised if missing or duplicated.
+ * {@code rel="describedby"} – mandatory for the landing page;
+ * at least one link is expected (error if missing).
*
*
- * {@code rel="describedby"} – mandatory; at least one occurrence is required
- * to point to metadata describing the resource.
+ * {@code rel="author"} – recommended for the landing page;
+ * a warning is recorded if absent (cardinality 0..n).
*
*
- * Target URI scheme – all link targets are checked for secure transport;
- * non-HTTPS or non-HTTP targets result in warnings.
+ * Transport security – emits warnings for link targets that are not HTTPS.
*
*
*
- *
- * The validator is intentionally non-fatal where possible: it collects
- * errors and warnings for all detected issues instead of aborting on the first violation.
- *
- *
- *
- * This class does not dereference links, verify identifier persistence,
- * or validate the contents of metadata resources. Its responsibility is limited to
- * validating the presence, cardinality, and basic properties of Level 1 Signposting
- * relations as they appear in the provided WebLinks.
- *
+ *
Non-goals
+ *
+ *
No dereferencing of link targets (no network access).
+ *
No validation of metadata payloads or identifier persistence.
+ *
No enforcement of Level 1 recommendations for content/metadata resources.
+ *
*
*
- * The resulting {@link SignPostingView} is a semantic convenience wrapper around the
- * original WebLinks; no links are filtered or modified during validation.
+ * The returned {@link SignPostingView} is a semantic, read-only convenience wrapper around the
+ * original WebLinks. Validation issues are reported via {@link IssueReport}; the view itself does
+ * not modify or filter links.
*
*
* @author Sven Fillinger
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
index 93fc841..1dd16a6 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy
@@ -166,8 +166,8 @@ class Level2MetadataResourceValidatorSpec extends Specification {
def result = validator.validate(webLinks)
then:
- // null values for passed weblink collections are skipped and recorded as warning
- result.issueReport().hasWarnings()
+ // null values for passed weblink collections are skipped and recorded as error
+ result.issueReport().hasErrors()
where:
caseName | webLinks
diff --git a/src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy
index 9a6fcf1..fdb5c47 100644
--- a/src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Level2RecipeValidatorSpec.groovy
@@ -8,9 +8,6 @@ import life.qbic.linksmith.model.WebLinkParameter
import life.qbic.linksmith.spi.WebLinkValidator.Issue
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport
import spock.lang.Specification
-import spock.lang.Unroll
-
-import java.net.URI
class Level2RecipeValidatorSpec extends Specification {