From 69cc9e999746c04054a03206246977f5001faef8 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Thu, 8 Jan 2026 10:57:49 +0100 Subject: [PATCH 01/10] Start scaffold for Level 2 validation --- .../Level2ContentResourceValidator.java | 26 +++ .../Level2LandingPageValidator.java | 27 +++ .../Level2MetadataResourceValidator.java | 26 +++ .../Level2SignPostingValidator.java | 20 +++ .../Level2ContentResourceValidatorSpec.groovy | 49 ++++++ .../Level2LandingPageValidatorSpec.groovy | 164 ++++++++++++++++++ ...Level2MetadataResourceValidatorSpec.groovy | 39 +++++ .../OfficialSignpostingLevel2Fixture.groovy | 128 ++++++++++++++ 8 files changed, 479 insertions(+) create mode 100644 src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java create mode 100644 src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java create mode 100644 src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java create mode 100644 src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java create mode 100644 src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy create mode 100644 src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy create mode 100644 src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy create mode 100644 src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy diff --git a/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java new file mode 100644 index 0000000..60ce3cc --- /dev/null +++ b/src/main/java/life/qbic/compass/validation/Level2ContentResourceValidator.java @@ -0,0 +1,26 @@ +package life.qbic.compass.validation; + +import java.util.List; +import life.qbic.compass.spi.SignPostingResult; +import life.qbic.compass.spi.SignPostingValidator; +import life.qbic.linksmith.model.WebLink; + +/** + * + * + * @since + */ +public class Level2ContentResourceValidator implements SignPostingValidator { + + private Level2ContentResourceValidator() {} + + public static SignPostingValidator create() { + return new Level2ContentResourceValidator(); + } + + @Override + public SignPostingResult validate(List webLinks) { + // TODO implement + throw new RuntimeException("Not yet implemented"); + } +} diff --git a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java new file mode 100644 index 0000000..1586441 --- /dev/null +++ b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java @@ -0,0 +1,27 @@ +package life.qbic.compass.validation; + +import java.util.List; +import life.qbic.compass.spi.SignPostingResult; +import life.qbic.compass.spi.SignPostingValidator; +import life.qbic.linksmith.model.WebLink; + +/** + * + * + * @since + */ +public class Level2LandingPageValidator implements SignPostingValidator { + + private Level2LandingPageValidator() { + } + + public static SignPostingValidator create() { + return new Level2LandingPageValidator(); + } + + @Override + public SignPostingResult validate(List webLinks) { + // TODO implement + throw new RuntimeException("Not yet implemented"); + } +} diff --git a/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java new file mode 100644 index 0000000..c7be9ce --- /dev/null +++ b/src/main/java/life/qbic/compass/validation/Level2MetadataResourceValidator.java @@ -0,0 +1,26 @@ +package life.qbic.compass.validation; + +import java.util.List; +import life.qbic.compass.spi.SignPostingResult; +import life.qbic.compass.spi.SignPostingValidator; +import life.qbic.linksmith.model.WebLink; + +/** + * + * + * @since + */ +public class Level2MetadataResourceValidator implements SignPostingValidator { + + private Level2MetadataResourceValidator() {} + + public static SignPostingValidator create() { + return new Level2MetadataResourceValidator(); + } + + @Override + public SignPostingResult validate(List webLinks) { + // TODO implement + throw new RuntimeException("Not yet implemented"); + } +} diff --git a/src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java b/src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java new file mode 100644 index 0000000..7a7081e --- /dev/null +++ b/src/main/java/life/qbic/compass/validation/Level2SignPostingValidator.java @@ -0,0 +1,20 @@ +package life.qbic.compass.validation; + +import java.util.List; +import life.qbic.compass.spi.SignPostingResult; +import life.qbic.compass.spi.SignPostingValidator; +import life.qbic.linksmith.model.WebLink; + +/** + * + * + * @since + */ +public class Level2SignPostingValidator implements SignPostingValidator { + + @Override + public SignPostingResult validate(List webLinks) { + // TODO implement + throw new RuntimeException("Not yet implemented"); + } +} diff --git a/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy new file mode 100644 index 0000000..229ae9d --- /dev/null +++ b/src/test/groovy/life/qbic/compass/validation/Level2ContentResourceValidatorSpec.groovy @@ -0,0 +1,49 @@ +package life.qbic.compass.validation + +import life.qbic.compass.parsing.LinkSetJsonParser +import life.qbic.compass.spi.SignPostingValidator +import spock.lang.Specification + +class Level2ContentResourceValidatorSpec extends Specification implements OfficialSignpostingLevel2Fixture { + + def parser = LinkSetJsonParser.create() + + def "happy path: official example passes content resource validation with no errors"() { + given: + def weblinks = parser.parse(officialJsonStream()) + SignPostingValidator validator = Level2ContentResourceValidator.create() // adjust + + when: + def result = validator.validate(weblinks) + + then: + result != null + !result.issueReport().hasErrors() + + and: "collection relations exist and point to landing page" + def collectionLinks = result.signPostingView().withRelationType("collection") + collectionLinks.size() >= 3 + + collectionLinks*.target().contains(URI.create("https://example.org/page/7507")) + } + + def "happy path: official content anchors are present and each has collection -> landing"() { + given: + def weblinks = parser.parse(officialJsonStream()) + def validator = Level2ContentResourceValidator.create() // adjust + + when: + def result = validator.validate(weblinks) + + then: + def view = result.signPostingView() + + // 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") + ]) + } +} diff --git a/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy new file mode 100644 index 0000000..552747a --- /dev/null +++ b/src/test/groovy/life/qbic/compass/validation/Level2LandingPageValidatorSpec.groovy @@ -0,0 +1,164 @@ +package life.qbic.compass.validation + +import life.qbic.compass.parsing.LinkSetJsonParser +import spock.lang.Specification +import spock.lang.Unroll + +import java.nio.charset.StandardCharsets +import java.util.regex.Pattern + +class Level2LandingPageValidatorSpec extends Specification implements OfficialSignpostingLevel2Fixture { + + def parser = LinkSetJsonParser.create() + // --- Official fixture (inline here to keep the spec self-contained) --- + static String OFFICIAL = ''' +{ + "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" } ] + } + ] +} +''' + + private static InputStream asStream(String json) { + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)) + } + + def "happy path: official example has no errors for landing page validation"() { + given: + def weblinks = parser.parse(asStream(OFFICIAL)) + def validator = Level2LandingPageValidator.create() + + when: + def result = validator.validate(weblinks) + + then: + result != null + result.issueReport() != null + !result.issueReport().hasErrors() + } + + // ------------------------- + // Unhappy paths + // ------------------------- + + @Unroll + def "unhappy path: missing mandatory landing relation '#missingRel' should raise an error"() { + given: + def brokenJson = removeLandingRelation(OFFICIAL, missingRel) + def weblinks = parser.parse(asStream(brokenJson)) + def validator = Level2LandingPageValidator.create() + + when: + def result = validator.validate(weblinks) + + then: + result.issueReport().hasErrors() + result.issueReport().issues()*.message().any { it.toLowerCase().contains(missingRel.toLowerCase()) } + + where: + missingRel << [ + "cite-as", + "describedby", + "item", + "type" + ] + } + + def "unhappy path: landing page has duplicate cite-as entries should raise an error (if cardinality=1 enforced)"() { + given: "duplicate cite-as in landing context" + def brokenJson = OFFICIAL.replaceFirst( + /"cite-as"\s*:\s*\[\s*\{\s*"href"\s*:\s*"https:\/\/doi\.org\/10\.5061\/dryad\.5d23f"\s*\}\s*\]/, + '"cite-as": [ { "href": "https://doi.org/10.5061/dryad.5d23f" }, { "href": "https://doi.org/10.5061/dryad.5d23f" } ]' + ) + def weblinks = parser.parse(asStream(brokenJson)) + def validator = Level2LandingPageValidator.create() + + when: + def result = validator.validate(weblinks) + + then: + result.issueReport().hasErrors() + // keep message assertion weak, so you can change wording: + 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 weblinks = parser.parse(asStream(brokenJson)) + def validator = Level2LandingPageValidator.create() + + when: + def result = validator.validate(weblinks) + + then: + result.issueReport().hasWarnings() + result.issueReport().issues()*.message().any { it.toLowerCase().contains("http") || it.toLowerCase().contains("https") } + } + + 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() + + when: + def result = validator.validate(weblinks) + + then: + result.issueReport().hasErrors() + result.issueReport().issues()*.message().any { it.toLowerCase().contains("anchor") } + } + + // ------------------------- + // Helper: remove one landing relation array entirely + // Works by removing a "": [ ... ] block in the first link context object. + // ------------------------- + private static String removeLandingRelation(String json, String relation) { + // remove e.g. ,"cite-as":[{...}] + // Keep it intentionally broad; your JSON formatting may differ. + def pattern = ~/(?s),\s*"${Pattern.quote(relation)}"\s*:\s*\[\s*.*?\s*\]/ + return json.replaceFirst(pattern, '') + } +} diff --git a/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy new file mode 100644 index 0000000..13fc573 --- /dev/null +++ b/src/test/groovy/life/qbic/compass/validation/Level2MetadataResourceValidatorSpec.groovy @@ -0,0 +1,39 @@ +package life.qbic.compass.validation + +import life.qbic.compass.parsing.LinkSetJsonParser +import life.qbic.compass.spi.SignPostingValidator +import spock.lang.Specification + +class Level2MetadataResourceValidatorSpec extends Specification implements OfficialSignpostingLevel2Fixture { + + def parser = LinkSetJsonParser.create() + + def "happy path: official example passes metadata resource validation with no errors"() { + given: + def weblinks = parser.parse(officialJsonStream()) + SignPostingValidator validator = Level2MetadataResourceValidator.create() // adjust + + when: + def result = validator.validate(weblinks) + + then: + result != null + !result.issueReport().hasErrors() + + and: + def describesTargets = result.signPostingView().withRelationType("describes")*.target + describesTargets.contains(URI.create("https://example.org/page/7507")) + } + + def "happy path: at least two metadata resources describe the landing page in official example"() { + given: + def weblinks = parser.parse(officialJsonStream()) + def validator = Level2MetadataResourceValidator.create() // adjust + + when: + def result = validator.validate(weblinks) + + then: + result.signPostingView().withRelationType("describes").size() >= 2 + } +} diff --git a/src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy b/src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy new file mode 100644 index 0000000..76d5909 --- /dev/null +++ b/src/test/groovy/life/qbic/compass/validation/OfficialSignpostingLevel2Fixture.groovy @@ -0,0 +1,128 @@ +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 85aee9dbf93c08ca7b11e58090c3dfa148c7f1d4 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Thu, 8 Jan 2026 15:56:12 +0100 Subject: [PATCH 02/10] Finish Landing Page validator --- pom.xml | 2 +- .../Level2LandingPageValidator.java | 284 +++++++++++++++++- .../Level2LandingPageValidatorSpec.groovy | 86 +++--- ...Level2MetadataResourceValidatorSpec.groovy | 2 +- 4 files changed, 333 insertions(+), 41 deletions(-) diff --git a/pom.xml b/pom.xml index 2571f8d..39b80ad 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 21 21 UTF-8 - 1.0.0-alpha.2 + 1.0.0-alpha.3 3.0.3 5.0.0-alpha-12 2.4-M7-groovy-5.0 diff --git a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java index 1586441..7fbe871 100644 --- a/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java +++ b/src/main/java/life/qbic/compass/validation/Level2LandingPageValidator.java @@ -1,27 +1,303 @@ 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.Issue; +import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; +import life.qbic.linksmith.spi.WebLinkValidator.IssueType; /** - * + * Validates a FAIR Signposting Level 2 Landing Page recipe against a list of + * {@link WebLink}s. * - * @since + *

+ * 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. *
* *

Relations validated

@@ -125,19 +125,21 @@ public SignPostingResult validate(List webLinks) { * 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}. + * 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: + *

+ *
    + *
  1. All {@link WebLink}s that declare an {@code anchor} must declare the same anchor value.
  2. + *
  3. Any WebLink missing an {@code anchor} is recorded but does not immediately fail validation.
  4. + *
  5. Relation types ({@code rel}) are counted only for links with the selected anchor.
  6. + *
+ * + *

+ * 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: + *

+ *
    + *
  1. checks that the input can be interpreted as a single anchor context + * (no missing anchors, no mixed anchors),
  2. + *
  3. records issues for ambiguous/invalid context,
  4. + *
  5. and only if the context is valid, validates mandatory relations and cardinalities.
  6. + *
+ * + *

+ * 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 2 content 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): + *

    + *
      + *
    • {@code collection}: mandatory, cardinality {@code (1)}
    • + *
    • {@code cite-as}: optional, cardinality {@code (0..1)}
    • + *
    • {@code license}: optional, cardinality {@code (0..1)}
    • + *
    • {@code type}: optional, cardinality {@code (0..1)}
    • + *
    + * + *

    + * 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: + *

    + *
      + *
    1. Filters {@code null} list elements and records an {@code ERROR} for each skipped element.
    2. + *
    3. Groups remaining links by {@code anchor} (only links with present anchors are grouped).
    4. + *
    5. Determines the recipe for each anchor group and delegates validation.
    6. + *
    + * + *

    + * 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 {