validators) {
this.validators.addAll(validators);
return this;
}
+ /**
+ * Adds a validator for the semantic weblink model.
+ *
+ * If no model validator is provided, it defaults the library's RFC 8288 validation
+ * implementation.
+ *
+ * @param modelValidator a weblink model validator
+ * @return this builder for fluent chaining
+ */
+ public Builder withModelValidator(WebLinkModelValidator modelValidator) {
+ this.modelValidator = modelValidator;
+ return this;
+ }
+
/**
* Builds a {@link SignPostingProcessor} instance.
*
@@ -185,12 +238,17 @@ Builder withValidators(List validators) {
*
* @return a configured {@link SignPostingProcessor}
*/
- SignPostingProcessor build() {
+ public SignPostingProcessor build() {
if (validators.isEmpty()) {
- return new SignPostingProcessor(List.of(Level1SignPostingValidator.create()));
+ return new SignPostingProcessor(List.of(Level1SignPostingValidator.create()),
+ modelValidator);
}
- return new SignPostingProcessor(validators);
+ return new SignPostingProcessor(validators, modelValidator);
}
}
+
+
+
+
}
diff --git a/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
new file mode 100644
index 0000000..a45b30b
--- /dev/null
+++ b/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
@@ -0,0 +1,19 @@
+package life.qbic.compass.processing;
+
+import java.util.List;
+import life.qbic.compass.LinkSetViewAggregationStrategy;
+import life.qbic.compass.model.SignPostingResult;
+
+/**
+ *
+ *
+ * @since
+ */
+public class FailOnMultipleLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
+
+ @Override
+ public SignPostingResult apply(List results)
+ throws AggregationStrategyException {
+ return null;
+ }
+}
diff --git a/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
new file mode 100644
index 0000000..89360e6
--- /dev/null
+++ b/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
@@ -0,0 +1,19 @@
+package life.qbic.compass.processing;
+
+import java.util.List;
+import life.qbic.compass.LinkSetViewAggregationStrategy;
+import life.qbic.compass.model.SignPostingResult;
+
+/**
+ *
+ *
+ * @since
+ */
+public class MergeLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
+
+ @Override
+ public SignPostingResult apply(List results)
+ throws AggregationStrategyException {
+ return null;
+ }
+}
diff --git a/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java
new file mode 100644
index 0000000..87e639b
--- /dev/null
+++ b/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java
@@ -0,0 +1,35 @@
+package life.qbic.compass.processing;
+
+import java.util.List;
+import java.util.Objects;
+import life.qbic.compass.LinkSetViewAggregationStrategy;
+import life.qbic.compass.model.SignPostingResult;
+import life.qbic.compass.model.SignPostingView;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
+
+/**
+ *
+ *
+ * @since
+ */
+public class NoLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
+
+ @Override
+ public SignPostingResult apply(List results)
+ throws AggregationStrategyException {
+ Objects.requireNonNull(results);
+
+ if (results.isEmpty()) {
+ throw new AggregationStrategyException("Signposting result list must not be empty");
+ }
+
+ var aggregatedIssues = results.stream()
+ .map(SignPostingResult::issueReport)
+ .flatMap(issueReport -> issueReport.issues().stream())
+ .toList();
+
+ return new SignPostingResult(
+ new SignPostingView(results.getFirst().signPostingView().webLinks()),
+ new IssueReport(aggregatedIssues), null);
+ }
+}
diff --git a/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
new file mode 100644
index 0000000..2f26b75
--- /dev/null
+++ b/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
@@ -0,0 +1,19 @@
+package life.qbic.compass.processing;
+
+import java.util.List;
+import life.qbic.compass.LinkSetViewAggregationStrategy;
+import life.qbic.compass.model.SignPostingResult;
+
+/**
+ *
+ *
+ * @since
+ */
+public class TakeFirstLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
+
+ @Override
+ public SignPostingResult apply(List results)
+ throws AggregationStrategyException {
+ return null;
+ }
+}
diff --git a/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java b/src/main/java/life/qbic/compass/spi/WebLinkModelValidator.java
similarity index 98%
rename from src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java
rename to src/main/java/life/qbic/compass/spi/WebLinkModelValidator.java
index 2b15d8d..da4e114 100644
--- a/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java
+++ b/src/main/java/life/qbic/compass/spi/WebLinkModelValidator.java
@@ -1,4 +1,4 @@
-package life.qbic.compass.validation;
+package life.qbic.compass.spi;
import java.util.Arrays;
import java.util.List;
@@ -77,7 +77,7 @@
* @author Sven Fillinger
* @since 1.0.0
*/
-interface WebLinkModelValidator {
+public interface WebLinkModelValidator {
/**
* Validates a list of {@link WebLink} objects against model-level constraints.
diff --git a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java
index 69edcca..3fcfc4c 100644
--- a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java
+++ b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java
@@ -6,6 +6,7 @@
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import life.qbic.compass.spi.WebLinkModelValidator;
import life.qbic.linksmith.model.WebLink;
import life.qbic.linksmith.model.WebLinkParameter;
import life.qbic.linksmith.spi.WebLinkValidator.Issue;
diff --git a/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java b/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java
new file mode 100644
index 0000000..dd75fc7
--- /dev/null
+++ b/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java
@@ -0,0 +1,18 @@
+package life.qbic.compass.validation;
+
+import life.qbic.compass.spi.WebLinkModelValidator;
+
+/**
+ *
+ *
+ * @since
+ */
+public final class WebLinkModelValidators {
+
+ private WebLinkModelValidators() {}
+
+ public static WebLinkModelValidator rfc8288() {
+ return Rfc8288ModelValidator.create();
+ }
+
+}
diff --git a/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy b/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy
new file mode 100644
index 0000000..bded186
--- /dev/null
+++ b/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy
@@ -0,0 +1,251 @@
+package life.qbic.compass.processing
+
+import life.qbic.compass.LinkSetViewAggregationStrategy
+import life.qbic.compass.model.*
+import life.qbic.linksmith.model.WebLink
+import life.qbic.linksmith.spi.WebLinkValidator.Issue
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport
+import spock.lang.Specification
+
+class LinkSetViewAggregationStrategiesSpec extends Specification {
+
+ // --------------------------------------------------------------------------
+ // Helpers
+ // --------------------------------------------------------------------------
+
+ private static SignPostingView viewWith(int marker) {
+ // We only need a stable instance; content is irrelevant for these tests.
+ new SignPostingView([] as List)
+ }
+
+ private static IssueReport issues(int n, String prefix = "i") {
+ def list = (1..n).collect { Issue.warning("${prefix}${it}") }
+ new IssueReport(list)
+ }
+
+ private static SignPostingResult result(SignPostingView view, IssueReport report, Level2LinksetView linksetView) {
+ new SignPostingResult(view, report, linksetView)
+ }
+
+ private static Level2LinksetView linkset(int landing, int content, int metadata, int missing) {
+ def mkLinks = { [] as List }
+ def mkLanding = { int i -> new LandingPageView(URI.create("https://example.org/landing/$i"), mkLinks()) }
+ def mkContent = { int i -> new ContentResourceView(URI.create("https://example.org/content/$i"), mkLinks()) }
+ def mkMeta = { int i -> new MetadataResourceView(URI.create("https://example.org/meta/$i"), mkLinks()) }
+
+ // We avoid constructing MissingOriginLink with fields we don't know.
+ // If your MissingOriginLink record has a public ctor, feel free to populate this list.
+ def missingLinks = [] as List
+
+ new Level2LinksetView(
+ (1..landing).collect { mkLanding(it) },
+ (1..content).collect { mkContent(it) },
+ (1..metadata).collect { mkMeta(it) },
+ missingLinks
+ )
+ }
+
+ private static int issueCount(SignPostingResult r) {
+ r.issueReport()?.issues()?.size() ?: 0
+ }
+
+ // --------------------------------------------------------------------------
+ // NoLinkSetViewAggregation
+ // --------------------------------------------------------------------------
+
+ def "NoLinkSetViewAggregation drops any Level2LinksetView but preserves aggregated issues"() {
+ given:
+ def s = new NoLinkSetViewAggregation()
+ def sv = viewWith(1)
+ def v1 = linkset(1, 0, 0, 0)
+
+ def results = [
+ result(sv, issues(1, "a"), v1),
+ result(sv, issues(2, "b"), null)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ out != null
+ out.level2LinksetView() == null
+ issueCount(out) == 3
+ }
+
+ def "NoLinkSetViewAggregation throws on empty input"() {
+ given:
+ def s = new NoLinkSetViewAggregation()
+
+ when:
+ s.apply([])
+
+ then:
+ thrown(LinkSetViewAggregationStrategy.AggregationStrategyException)
+ }
+
+ // --------------------------------------------------------------------------
+ // TakeFirstLinkSetViewAggregation
+ // --------------------------------------------------------------------------
+
+ def "TakeFirstLinkSetViewAggregation takes the first non-null Level2LinksetView"() {
+ given:
+ def s = new TakeFirstLinkSetViewAggregation()
+ def sv = viewWith(1)
+ def first = linkset(1, 0, 0, 0)
+ def second = linkset(0, 2, 0, 0)
+
+ def results = [
+ result(sv, issues(1, "a"), null),
+ result(sv, issues(1, "b"), first),
+ result(sv, issues(1, "c"), second)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ out.level2LinksetView() == first
+ issueCount(out) == 3
+ }
+
+ def "TakeFirstLinkSetViewAggregation returns null linkset view if none are present"() {
+ given:
+ def s = new TakeFirstLinkSetViewAggregation()
+ def sv = viewWith(1)
+
+ def results = [
+ result(sv, issues(1, "a"), null),
+ result(sv, issues(2, "b"), null)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ out.level2LinksetView() == null
+ issueCount(out) == 3
+ }
+
+ // --------------------------------------------------------------------------
+ // FailOnMultipleLinkSetViewAggregation
+ // --------------------------------------------------------------------------
+
+ def "FailOnMultipleLinkSetViewAggregation returns the only non-null Level2LinksetView"() {
+ given:
+ def s = new FailOnMultipleLinkSetViewAggregation()
+ def sv = viewWith(1)
+ def only = linkset(0, 1, 0, 0)
+
+ def results = [
+ result(sv, issues(1, "a"), null),
+ result(sv, issues(1, "b"), only),
+ result(sv, issues(1, "c"), null)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ out.level2LinksetView() == only
+ issueCount(out) == 3
+ }
+
+ def "FailOnMultipleLinkSetViewAggregation throws if multiple non-null Level2LinksetViews exist"() {
+ given:
+ def s = new FailOnMultipleLinkSetViewAggregation()
+ def sv = viewWith(1)
+
+ def results = [
+ result(sv, issues(1, "a"), linkset(1, 0, 0, 0)),
+ result(sv, issues(1, "b"), linkset(0, 1, 0, 0))
+ ]
+
+ when:
+ s.apply(results)
+
+ then:
+ thrown(LinkSetViewAggregationStrategy.AggregationStrategyException)
+ }
+
+ def "FailOnMultipleLinkSetViewAggregation returns null if none are present"() {
+ given:
+ def s = new FailOnMultipleLinkSetViewAggregation()
+ def sv = viewWith(1)
+
+ def results = [
+ result(sv, issues(1, "a"), null),
+ result(sv, issues(1, "b"), null)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ out.level2LinksetView() == null
+ issueCount(out) == 2
+ }
+
+ // --------------------------------------------------------------------------
+ // MergeLinkSetViewAggregation
+ // --------------------------------------------------------------------------
+
+ def "MergeLinkSetViewAggregation merges all non-null Level2LinksetViews by concatenating lists in order"() {
+ given:
+ def s = new MergeLinkSetViewAggregation()
+ def sv = viewWith(1)
+
+ def v1 = linkset(1, 0, 2, 0) // landing=1, content=0, meta=2
+ def v2 = linkset(0, 3, 0, 0) // landing=0, content=3, meta=0
+
+ def results = [
+ result(sv, issues(1, "a"), v1),
+ result(sv, issues(2, "b"), null),
+ result(sv, issues(3, "c"), v2)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ issueCount(out) == 6
+
+ and:
+ out.level2LinksetView() != null
+ out.level2LinksetView().landingPages().size() == 1
+ out.level2LinksetView().contentResources().size() == 3
+ out.level2LinksetView().metadataResources().size() == 2
+
+ and: "missing-origin list is merged too (here empty in both fixtures)"
+ out.level2LinksetView().missingOriginLinks().isEmpty()
+ }
+
+ def "MergeLinkSetViewAggregation returns null if no Level2LinksetView exists in any result"() {
+ given:
+ def s = new MergeLinkSetViewAggregation()
+ def sv = viewWith(1)
+
+ def results = [
+ result(sv, issues(1, "a"), null),
+ result(sv, issues(1, "b"), null)
+ ]
+
+ when:
+ def out = s.apply(results)
+
+ then:
+ out.level2LinksetView() == null
+ issueCount(out) == 2
+ }
+
+ def "MergeLinkSetViewAggregation throws on empty input"() {
+ given:
+ def s = new MergeLinkSetViewAggregation()
+
+ when:
+ s.apply([])
+
+ then:
+ thrown(LinkSetViewAggregationStrategy.AggregationStrategyException)
+ }
+}
diff --git a/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy
index 7581916..b64ee5b 100644
--- a/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy
@@ -1,5 +1,6 @@
package life.qbic.compass.validation
+import life.qbic.compass.spi.WebLinkModelValidator
import life.qbic.linksmith.model.WebLink
import life.qbic.linksmith.model.WebLinkParameter
import life.qbic.linksmith.spi.WebLinkValidator.Issue
From 8c016bd4e9cc29754425271a5f06bf2533a58dbf Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Mon, 19 Jan 2026 09:51:15 +0100
Subject: [PATCH 6/9] Implement LinkSet aggregation strategies
---
.../qbic/compass/model/SignPostingResult.java | 4 ++
.../FailOnMultipleLinkSetViewAggregation.java | 31 ++++++++++-
.../MergeLinkSetViewAggregation.java | 52 ++++++++++++++++++-
.../TakeFirstLinkSetViewAggregation.java | 24 ++++++++-
...inkSetViewAggregationStrategiesSpec.groovy | 6 +--
5 files changed, 111 insertions(+), 6 deletions(-)
diff --git a/src/main/java/life/qbic/compass/model/SignPostingResult.java b/src/main/java/life/qbic/compass/model/SignPostingResult.java
index 11b8315..a1fdb1b 100644
--- a/src/main/java/life/qbic/compass/model/SignPostingResult.java
+++ b/src/main/java/life/qbic/compass/model/SignPostingResult.java
@@ -58,4 +58,8 @@ public record SignPostingResult(
IssueReport issueReport,
Level2LinksetView level2LinksetView) {
+ public boolean hasLinkSetView() {
+ return level2LinksetView != null;
+ }
+
}
diff --git a/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
index a45b30b..8a59c01 100644
--- a/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
@@ -1,8 +1,12 @@
package life.qbic.compass.processing;
import java.util.List;
+import java.util.Objects;
import life.qbic.compass.LinkSetViewAggregationStrategy;
+import life.qbic.compass.model.Level2LinksetView;
import life.qbic.compass.model.SignPostingResult;
+import life.qbic.compass.model.SignPostingView;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
*
@@ -14,6 +18,31 @@ public class FailOnMultipleLinkSetViewAggregation implements LinkSetViewAggregat
@Override
public SignPostingResult apply(List results)
throws AggregationStrategyException {
- return null;
+ Objects.requireNonNull(results);
+
+ if (results.isEmpty()) {
+ throw new AggregationStrategyException("Signposting result list must not be empty");
+ }
+
+ var aggregatedIssues = results.stream()
+ .map(SignPostingResult::issueReport)
+ .flatMap(issueReport -> issueReport.issues().stream())
+ .toList();
+
+ var allLinkSetViews = results.stream()
+ .filter(SignPostingResult::hasLinkSetView)
+ .map(SignPostingResult::level2LinksetView)
+ .toList();
+
+ if (allLinkSetViews.size() > 1 ) {
+ throw new AggregationStrategyException("More than one linkset view available");
+ }
+
+ var selectedLinkSetView = allLinkSetViews.isEmpty() ? null : allLinkSetViews.getFirst();
+
+ return new SignPostingResult(
+ new SignPostingView(results.getFirst().signPostingView().webLinks()),
+ new IssueReport(aggregatedIssues), selectedLinkSetView);
+
}
}
diff --git a/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
index 89360e6..a050e21 100644
--- a/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
@@ -1,8 +1,17 @@
package life.qbic.compass.processing;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import life.qbic.compass.LinkSetViewAggregationStrategy;
+import life.qbic.compass.model.ContentResourceView;
+import life.qbic.compass.model.LandingPageView;
+import life.qbic.compass.model.Level2LinksetView;
+import life.qbic.compass.model.MetadataResourceView;
+import life.qbic.compass.model.MissingOriginLink;
import life.qbic.compass.model.SignPostingResult;
+import life.qbic.compass.model.SignPostingView;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
*
@@ -14,6 +23,47 @@ public class MergeLinkSetViewAggregation implements LinkSetViewAggregationStrate
@Override
public SignPostingResult apply(List results)
throws AggregationStrategyException {
- return null;
+ Objects.requireNonNull(results);
+
+ if (results.isEmpty()) {
+ throw new AggregationStrategyException("Signposting result list must not be empty");
+ }
+
+ var aggregatedIssues = results.stream()
+ .map(SignPostingResult::issueReport)
+ .flatMap(issueReport -> issueReport.issues().stream())
+ .toList();
+
+ var linkSetViews = results.stream()
+ .filter(SignPostingResult::hasLinkSetView)
+ .map(SignPostingResult::level2LinksetView)
+ .toList();
+
+ var mergedView = linkSetViews.isEmpty() ? null : mergeViews(linkSetViews);
+
+ return new SignPostingResult(
+ new SignPostingView(results.getFirst().signPostingView().webLinks()),
+ new IssueReport(aggregatedIssues), mergedView);
+
+ }
+
+ private static Level2LinksetView mergeViews(List linkSetViews) {
+ Objects.requireNonNull(linkSetViews);
+ var landingPages = new ArrayList();
+ var contentResources = new ArrayList();
+ var metadataResources = new ArrayList();
+ var missingOriginLinks = new ArrayList();
+ for (var currentLinkSet : linkSetViews) {
+ Objects.requireNonNull(currentLinkSet);
+ landingPages.addAll(currentLinkSet.landingPages());
+ contentResources.addAll(currentLinkSet.contentResources());
+ metadataResources.addAll(currentLinkSet.metadataResources());
+ missingOriginLinks.addAll(currentLinkSet.missingOriginLinks());
+ }
+ return new Level2LinksetView(
+ landingPages,
+ contentResources,
+ metadataResources,
+ missingOriginLinks);
}
}
diff --git a/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
index 2f26b75..13dcc3f 100644
--- a/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
@@ -1,8 +1,11 @@
package life.qbic.compass.processing;
import java.util.List;
+import java.util.Objects;
import life.qbic.compass.LinkSetViewAggregationStrategy;
import life.qbic.compass.model.SignPostingResult;
+import life.qbic.compass.model.SignPostingView;
+import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
*
@@ -14,6 +17,25 @@ public class TakeFirstLinkSetViewAggregation implements LinkSetViewAggregationSt
@Override
public SignPostingResult apply(List results)
throws AggregationStrategyException {
- return null;
+ Objects.requireNonNull(results);
+
+ if (results.isEmpty()) {
+ throw new AggregationStrategyException("Signposting result list must not be empty");
+ }
+
+ var aggregatedIssues = results.stream()
+ .map(SignPostingResult::issueReport)
+ .flatMap(issueReport -> issueReport.issues().stream())
+ .toList();
+
+ var firstLinkSetView = results.stream()
+ .filter(SignPostingResult::hasLinkSetView)
+ .findFirst()
+ .map(SignPostingResult::level2LinksetView);
+
+ return new SignPostingResult(
+ new SignPostingView(results.getFirst().signPostingView().webLinks()),
+ new IssueReport(aggregatedIssues), firstLinkSetView.orElse(null));
+
}
}
diff --git a/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy b/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy
index bded186..1991ffb 100644
--- a/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy
@@ -38,9 +38,9 @@ class LinkSetViewAggregationStrategiesSpec extends Specification {
def missingLinks = [] as List
new Level2LinksetView(
- (1..landing).collect { mkLanding(it) },
- (1..content).collect { mkContent(it) },
- (1..metadata).collect { mkMeta(it) },
+ (0..
Date: Mon, 19 Jan 2026 15:04:18 +0100
Subject: [PATCH 7/9] Provide Java Docs
---
.../LinkSetViewAggregationStrategy.java | 64 +++++-
.../qbic/compass/LinkSetViewAggregations.java | 95 ++++++++
.../qbic/compass/SignPostingProcessor.java | 216 +++++++++++++++---
.../FailOnMultipleLinkSetViewAggregation.java | 24 +-
.../MergeLinkSetViewAggregation.java | 30 ++-
.../processing/NoLinkSetViewAggregation.java | 21 +-
.../TakeFirstLinkSetViewAggregation.java | 23 +-
.../life/qbic/compass/spi/LinkSetParser.java | 1 -
.../compass/SignPostingProcessorSpec.groovy | 32 +--
.../compass/model/SignPostingViewSpec.groovy | 24 +-
10 files changed, 462 insertions(+), 68 deletions(-)
create mode 100644 src/main/java/life/qbic/compass/LinkSetViewAggregations.java
diff --git a/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java b/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java
index 51e1e82..7816ad2 100644
--- a/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java
+++ b/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java
@@ -4,15 +4,73 @@
import life.qbic.compass.model.SignPostingResult;
/**
- *
+ * Strategy interface for aggregating {@link SignPostingResult} instances produced by
+ * multiple {@link life.qbic.compass.spi.SignPostingValidator}s into a single result.
*
- * @since
+ *
+ * Aggregation strategies define how (and whether) multiple
+ * {@link life.qbic.compass.model.Level2LinksetView} instances are combined, selected,
+ * ignored, or rejected when more than one validator produces such a view.
+ *
+ *
+ * Responsibilities
+ *
+ * - Inspect the list of {@link SignPostingResult}s returned by validators.
+ * - Decide how to handle zero, one, or multiple Level 2 Linkset Views.
+ * - Return a single {@link SignPostingResult} that represents the aggregated outcome.
+ *
+ *
+ * Non-responsibilities
+ *
+ * - Strategies must not execute validators.
+ * - Strategies must not modify individual {@link SignPostingResult} instances.
+ * - Strategies must not perform model or profile validation.
+ *
+ *
+ * Error handling
+ *
+ * Implementations may throw {@link AggregationStrategyException} if the aggregation
+ * policy cannot be satisfied (e.g. when multiple Linkset Views are present but the
+ * strategy requires exactly one).
+ *
+ *
+ *
+ * This interface is primarily intended for internal use by
+ * {@link life.qbic.compass.SignPostingProcessor}, but is exposed to allow advanced
+ * clients to supply custom aggregation behavior.
+ *
+ *
+ * @since 1.0.0
*/
public interface LinkSetViewAggregationStrategy {
- SignPostingResult apply(List results) throws AggregationStrategyException;
+ /**
+ * Aggregates the provided validation results into a single {@link SignPostingResult}.
+ *
+ *
+ * The input list represents the results of all configured validators, in execution order.
+ * Implementations may inspect, select, merge, or ignore individual results according to
+ * their aggregation policy.
+ *
+ *
+ * @param results the results produced by all executed validators
+ * @return a single aggregated {@link SignPostingResult}
+ * @throws AggregationStrategyException if aggregation fails according to the strategy rules
+ */
+ SignPostingResult apply(List results)
+ throws AggregationStrategyException;
+ /**
+ * Exception thrown when a {@link LinkSetViewAggregationStrategy} cannot successfully
+ * aggregate the provided results.
+ *
+ *
+ * This is a runtime exception because aggregation failures indicate a configuration
+ * or policy violation rather than a recoverable validation error.
+ *
+ */
class AggregationStrategyException extends RuntimeException {
+
public AggregationStrategyException(String message) {
super(message);
}
diff --git a/src/main/java/life/qbic/compass/LinkSetViewAggregations.java b/src/main/java/life/qbic/compass/LinkSetViewAggregations.java
new file mode 100644
index 0000000..f05ee02
--- /dev/null
+++ b/src/main/java/life/qbic/compass/LinkSetViewAggregations.java
@@ -0,0 +1,95 @@
+package life.qbic.compass;
+
+import life.qbic.compass.SignPostingProcessor.LinkSetViewAggregationMode;
+import life.qbic.compass.processing.FailOnMultipleLinkSetViewAggregation;
+import life.qbic.compass.processing.MergeLinkSetViewAggregation;
+import life.qbic.compass.processing.NoLinkSetViewAggregation;
+import life.qbic.compass.processing.TakeFirstLinkSetViewAggregation;
+
+/**
+ * Factory and registry for {@link LinkSetViewAggregationStrategy} implementations.
+ *
+ *
+ * This class centralizes the mapping between {@link SignPostingProcessor.LinkSetViewAggregationMode}
+ * values and their concrete aggregation strategy implementations. It exists to:
+ *
+ *
+ *
+ * - keep {@link SignPostingProcessor} free of conditional logic,
+ * - encapsulate aggregation policy decisions in one place, and
+ * - provide a stable extension point for future aggregation modes.
+ *
+ *
+ * Design intent
+ *
+ * Aggregation of {@code Level2LinksetView} instances is a policy decision, not a validation
+ * concern. Different clients may want:
+ *
+ *
+ * - to ignore linkset views entirely,
+ * - to accept only the first valid linkset view,
+ * - to merge multiple linkset views into a single composite view, or
+ * - to fail fast if more than one linkset view is produced.
+ *
+ *
+ *
+ * This factory ensures that the processor only needs to work with an enum
+ * ({@link SignPostingProcessor.LinkSetViewAggregationMode}), while the concrete
+ * strategy selection and lifecycle is handled here.
+ *
+ *
+ * Implementation notes for maintainers
+ *
+ * -
+ * Strategies are held as singleton instances.
+ * They must therefore be stateless and thread-safe.
+ *
+ * -
+ * If a future strategy requires configuration or state, this design will
+ * need to be revisited (e.g. per-processor instantiation instead of singletons).
+ *
+ * -
+ * Adding a new aggregation mode requires:
+ *
+ * - adding a new enum constant to {@code LinkSetViewAggregationMode},
+ * - implementing {@link LinkSetViewAggregationStrategy}, and
+ * - registering it in this factory.
+ *
+ *
+ *
+ *
+ * Stability guarantees
+ *
+ * This class is package-private and intended for internal use only. The set of
+ * available aggregation modes is part of the public API via the enum, but the
+ * concrete strategy classes and their internal behavior may evolve.
+ *
+ *
+ * @since 1.0.0
+ * @author Sven Fillinger
+ */
+final class LinkSetViewAggregations {
+
+ private static final LinkSetViewAggregationStrategy NONE =
+ new NoLinkSetViewAggregation();
+ private static final LinkSetViewAggregationStrategy FIRST =
+ new TakeFirstLinkSetViewAggregation();
+ private static final LinkSetViewAggregationStrategy MERGE =
+ new MergeLinkSetViewAggregation();
+ private static final LinkSetViewAggregationStrategy FAIL =
+ new FailOnMultipleLinkSetViewAggregation();
+
+
+ private LinkSetViewAggregations() {
+ // utility class
+ }
+
+ static LinkSetViewAggregationStrategy forMode(LinkSetViewAggregationMode mode) {
+ return switch (mode) {
+ case LinkSetViewAggregationMode.NONE -> NONE;
+ case LinkSetViewAggregationMode.FIRST -> FIRST;
+ case LinkSetViewAggregationMode.MERGE -> MERGE;
+ case LinkSetViewAggregationMode.FAIL_ON_MULTIPLE -> FAIL;
+ };
+ }
+}
diff --git a/src/main/java/life/qbic/compass/SignPostingProcessor.java b/src/main/java/life/qbic/compass/SignPostingProcessor.java
index 2f71c7c..975ded7 100644
--- a/src/main/java/life/qbic/compass/SignPostingProcessor.java
+++ b/src/main/java/life/qbic/compass/SignPostingProcessor.java
@@ -4,6 +4,7 @@
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
+import life.qbic.compass.LinkSetViewAggregationStrategy.AggregationStrategyException;
import life.qbic.compass.model.SignPostingResult;
import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingValidator;
@@ -57,22 +58,104 @@
*/
public final class SignPostingProcessor {
- public enum LinkSetViewAggregation {
+ private final LinkSetViewAggregationStrategy linkSetViewAggregationStrategy;
+
+ /**
+ * Defines how multiple {@link life.qbic.compass.model.Level2LinksetView} instances
+ * produced during Signposting processing are aggregated into the final
+ * {@link life.qbic.compass.model.SignPostingResult}.
+ *
+ *
+ * In complex Signposting workflows (especially Level 2), multiple validators
+ * may independently produce a {@code Level2LinksetView}. This enum represents the
+ * aggregation policy used by the {@link life.qbic.compass.SignPostingProcessor}
+ * to handle such situations.
+ *
+ *
+ *
+ * The chosen mode controls whether linkset views are ignored, merged, selected, or
+ * treated as an error. The concrete behavior is implemented by
+ * {@link life.qbic.compass.LinkSetViewAggregationStrategy} instances and
+ * selected via an internal factory.
+ *
+ *
+ * Mode semantics
+ *
+ * - {@link #NONE} –
+ * No {@code Level2LinksetView} is propagated.
+ * All produced linkset views are discarded and the final
+ * {@code SignPostingResult} will not expose a linkset view.
+ *
+ * - {@link #FIRST} –
+ * The first non-null {@code Level2LinksetView} encountered is used.
+ * Any subsequent linkset views are ignored.
+ *
+ * - {@link #MERGE} –
+ * All produced {@code Level2LinksetView} instances are merged into a single
+ * composite view. This mode assumes that merging is semantically meaningful
+ * and may fail if conflicts occur.
+ *
+ * - {@link #FAIL_ON_MULTIPLE} –
+ * Exactly zero or one {@code Level2LinksetView} is allowed.
+ * If more than one view is produced, processing fails with an aggregation error.
+ *
+ *
+ * Recommended usage
+ *
+ * -
+ * Use {@code NONE} when linkset discovery is out of scope and only
+ * validation issues are relevant.
+ *
+ * -
+ * Use {@code FIRST} for best-effort discovery pipelines where at most
+ * one linkset is expected but strict enforcement is unnecessary.
+ *
+ * -
+ * Use {@code MERGE} when processing heterogeneous or federated linksets
+ * that are expected to describe multiple independent origins.
+ *
+ * -
+ * Use {@code FAIL_ON_MULTIPLE} in strict FAIR validation scenarios where
+ * multiple linkset views indicate an ambiguous or invalid state.
+ *
+ *
+ * Stability notes
+ *
+ * The set of modes is part of the public API. While additional modes may be
+ * introduced in future versions, the semantics of existing modes will not change
+ * in incompatible ways.
+ *
+ *
+ * @since 1.0.0
+ */
+ public enum LinkSetViewAggregationMode {
+ /** Discard all produced {@code Level2LinksetView} instances. */
NONE,
+
+ /** Use the first produced {@code Level2LinksetView} and ignore the rest. */
FIRST,
+
+ /** Merge all produced {@code Level2LinksetView} instances into one. */
MERGE,
- FAIL_ON_MULTIPLE;
+
+ /** Fail if more than one {@code Level2LinksetView} is produced. */
+ FAIL_ON_MULTIPLE
}
private final List validators;
private final WebLinkModelValidator modelValidator;
- private SignPostingProcessor(List validators,
- WebLinkModelValidator modelValidator) {
+ private SignPostingProcessor(
+ List validators,
+ WebLinkModelValidator modelValidator,
+ LinkSetViewAggregationStrategy aggregationStrategy
+ ) {
Objects.requireNonNull(validators);
Objects.requireNonNull(modelValidator);
+ Objects.requireNonNull(aggregationStrategy);
this.validators = List.copyOf(validators);
this.modelValidator = modelValidator;
+ this.linkSetViewAggregationStrategy = aggregationStrategy;
}
@@ -123,29 +206,34 @@ private SignPostingProcessor(List validators,
* @param webLinks the WebLinks to be validated
* @return a {@link SignPostingResult} containing the aggregated issues and a
* {@link SignPostingView} over the input links
- * @throws NullPointerException if {@code webLinks} is {@code null}
+ * @throws NullPointerException if {@code webLinks} is {@code null}
+ * @throws AggregationStrategyException if a policy of the selected linkset view aggregation
+ * strategy has been violated
*/
- public SignPostingResult process(List webLinks) throws NullPointerException {
+ public SignPostingResult process(List webLinks)
+ throws NullPointerException, AggregationStrategyException {
Objects.requireNonNull(webLinks);
+ var issues = new ArrayList();
var safeLinks = webLinks.stream()
.filter(Objects::nonNull)
.toList();
- var issues = new ArrayList();
-
var sanitizedLinks = applyModelValidation(safeLinks, modelValidator, issues);
+ var aggregatedResults = new ArrayList(validators.size());
+ for (var validator : validators) {
+ var result = validator.validate(sanitizedLinks);
+ if (result == null) {
+ throw new IllegalStateException("Validator returned null SignPostingResult: " + validator.getClass().getName());
+ }
+ aggregatedResults.add(result);
+ }
- var recordedIssues = validators.stream()
- .map(validator -> validator.validate(sanitizedLinks))
- .map(SignPostingResult::issueReport)
- .flatMap(report -> report.issues().stream())
- .toList();
-
- issues.addAll(recordedIssues);
+ if (aggregatedResults.isEmpty()) {
+ throw new IllegalStateException("No SignPostingResult available for aggregation.");
+ }
- return new SignPostingResult(new SignPostingView(sanitizedLinks), new IssueReport(issues),
- null);
+ return linkSetViewAggregationStrategy.apply(aggregatedResults);
}
private static List applyModelValidation(List webLinks,
@@ -162,16 +250,38 @@ private static List applyModelValidation(List webLinks,
}
/**
- * Builder for constructing a {@link SignPostingProcessor} with a configurable set of
- * {@link SignPostingValidator}s.
+ * Builder for constructing a {@link SignPostingProcessor} with configurable validation
+ * and aggregation behavior.
*
*
- * Validators are executed in the order they are added to the builder.
+ * The builder follows a sensible-defaults philosophy: if clients do not
+ * explicitly configure certain aspects, well-defined default behavior is applied.
*
*
+ * Defaults
+ *
+ * - Validators:
+ * If no {@link SignPostingValidator}s are configured, a single
+ * {@link Level1SignPostingValidator} is applied.
+ * - WebLink model validation:
+ * Defaults to the library-provided RFC 8288 model validator
+ * ({@link WebLinkModelValidators#rfc8288()}).
+ * - Level 2 Linkset View aggregation:
+ * Defaults to {@link LinkSetViewAggregationMode#FIRST}, meaning that if multiple
+ * {@link life.qbic.compass.model.Level2LinksetView} instances are produced by
+ * validators, only the first one is retained.
+ *
+ *
+ * Execution semantics
+ *
+ * - Validators are executed in the order they are added.
+ * - All configured validators are always executed; validation does not short-circuit.
+ * - Model validation is performed before semantic Signposting validation.
+ *
+ *
*
- * If no validators are explicitly configured, the processor defaults to using
- * {@link Level1SignPostingValidator}.
+ * The builder itself is mutable and not thread-safe. The resulting
+ * {@link SignPostingProcessor} instance is immutable and thread-safe.
*
*/
public static final class Builder {
@@ -183,6 +293,13 @@ public static final class Builder {
*/
private WebLinkModelValidator modelValidator = WebLinkModelValidators.rfc8288();
+ /**
+ * Sensible default aggregation strategy in case more than one linkset views is produced from a
+ * list of validators.
+ */
+ private LinkSetViewAggregationStrategy linkSetViewAggregationStrategy =
+ LinkSetViewAggregations.forMode(LinkSetViewAggregationMode.FIRST);
+
/**
* Adds one or more validators to this processor.
*
@@ -228,6 +345,51 @@ public Builder withModelValidator(WebLinkModelValidator modelValidator) {
return this;
}
+ /**
+ * Configures how multiple {@link life.qbic.compass.model.Level2LinksetView} instances
+ * produced during processing are aggregated, using a predefined aggregation mode.
+ *
+ *
+ * This is the recommended configuration entry point for clients. The provided
+ * {@link LinkSetViewAggregationMode} is resolved to an internal
+ * {@link LinkSetViewAggregationStrategy} via a factory.
+ *
+ *
+ *
+ * Calling this method overrides any previously configured linkset aggregation strategy.
+ *
+ *
+ * @param linkSetViewAggregationMode the aggregation mode to apply
+ * @return this builder for fluent chaining
+ * @throws NullPointerException if {@code linkSetViewAggregationMode} is {@code null}
+ */
+ public Builder withLinkSetViewStrategy(LinkSetViewAggregationMode linkSetViewAggregationMode) {
+ return withLinkSetViewStrategy(LinkSetViewAggregations.forMode(linkSetViewAggregationMode));
+ }
+
+ /**
+ * Configures a custom {@link LinkSetViewAggregationStrategy} to control how
+ * {@link life.qbic.compass.model.Level2LinksetView} instances are aggregated.
+ *
+ *
+ * This method is intended for advanced use cases, such as custom aggregation policies
+ * or testing. Most clients should prefer
+ * {@link #withLinkSetViewStrategy(LinkSetViewAggregationMode)}.
+ *
+ *
+ *
+ * Calling this method overrides any previously configured aggregation strategy.
+ *
+ *
+ * @param aggregationStrategy the aggregation strategy to use
+ * @return this builder for fluent chaining
+ * @throws NullPointerException if {@code aggregationStrategy} is {@code null}
+ */
+ public Builder withLinkSetViewStrategy(LinkSetViewAggregationStrategy aggregationStrategy) {
+ this.linkSetViewAggregationStrategy = Objects.requireNonNull(aggregationStrategy);
+ return this;
+ }
+
/**
* Builds a {@link SignPostingProcessor} instance.
*
@@ -240,15 +402,9 @@ public Builder withModelValidator(WebLinkModelValidator modelValidator) {
*/
public SignPostingProcessor build() {
if (validators.isEmpty()) {
- return new SignPostingProcessor(List.of(Level1SignPostingValidator.create()),
- modelValidator);
+ validators = List.of(Level1SignPostingValidator.create());
}
- return new SignPostingProcessor(validators, modelValidator);
+ return new SignPostingProcessor(validators, modelValidator, linkSetViewAggregationStrategy);
}
}
-
-
-
-
-
}
diff --git a/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
index 8a59c01..8823431 100644
--- a/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java
@@ -3,15 +3,33 @@
import java.util.List;
import java.util.Objects;
import life.qbic.compass.LinkSetViewAggregationStrategy;
-import life.qbic.compass.model.Level2LinksetView;
import life.qbic.compass.model.SignPostingResult;
import life.qbic.compass.model.SignPostingView;
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Strict aggregation strategy that fails if more than one
+ * {@link life.qbic.compass.model.Level2LinksetView} is present.
*
- * @since
+ *
+ * If zero or one linkset view is encountered, aggregation succeeds.
+ * If two or more validators produce a linkset view, aggregation fails
+ * with an {@link LinkSetViewAggregationStrategy.AggregationStrategyException}.
+ *
+ *
+ *
+ * This strategy enforces a strong invariant:
+ * at most one Level 2 linkset view may exist.
+ *
+ *
+ *
+ * It is recommended for:
+ *
+ *
+ * - strict validation pipelines,
+ * - testing and debugging validator composition, or
+ * - environments where multiple Level 2 producers indicate a configuration error.
+ *
*/
public class FailOnMultipleLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
diff --git a/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
index a050e21..73f69ab 100644
--- a/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java
@@ -14,9 +14,33 @@
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Aggregation strategy that merges multiple
+ * {@link life.qbic.compass.model.Level2LinksetView} instances into a single view.
*
- * @since
+ *
+ * All landing pages, content resources, metadata resources, and missing-origin
+ * links from the individual views are combined into a new aggregated view.
+ *
+ *
+ *
+ * This strategy assumes that individual {@code Level2LinksetView}s are
+ * compatible and does not attempt to detect semantic conflicts
+ * (e.g. duplicate origins with differing semantics).
+ *
+ *
+ *
+ * Use this strategy when:
+ *
+ *
+ * - multiple validators contribute complementary Level 2 information, and
+ * - the client is prepared to handle potential overlaps or redundancies.
+ *
+ *
+ *
+ * This strategy may throw {@link LinkSetViewAggregationStrategy.AggregationStrategyException}
+ * if merging is structurally impossible (e.g. unexpected null invariants) or if the provided
+ * result list is empty and no aggregation can be performed.
+ *
*/
public class MergeLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
@@ -26,7 +50,7 @@ public SignPostingResult apply(List results)
Objects.requireNonNull(results);
if (results.isEmpty()) {
- throw new AggregationStrategyException("Signposting result list must not be empty");
+ throw new AggregationStrategyException("Aggregation strategy was invoked without any results to aggregate");
}
var aggregatedIssues = results.stream()
diff --git a/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java
index 87e639b..9d9823c 100644
--- a/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java
@@ -8,9 +8,26 @@
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Aggregation strategy that deliberately ignores all
+ * {@link life.qbic.compass.model.Level2LinksetView} instances.
*
- * @since
+ *
+ * The resulting {@link life.qbic.compass.model.SignPostingResult} will always have
+ * {@code level2LinksetView == null}, regardless of how many validators produced a linkset view.
+ *
+ *
+ *
+ * This strategy is useful when:
+ *
+ *
+ * - clients are only interested in issues and {@link life.qbic.compass.model.SignPostingView}, or
+ * - Level 2 structure is handled externally or in a separate workflow.
+ *
+ *
+ *
+ * This strategy only throws {@link LinkSetViewAggregationStrategy.AggregationStrategyException} in case the provided
+ * result list is empty.
+ *
*/
public class NoLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
diff --git a/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java b/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
index 13dcc3f..73f9431 100644
--- a/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
+++ b/src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java
@@ -8,9 +8,28 @@
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;
/**
- *
+ * Aggregation strategy that selects the first available
+ * {@link life.qbic.compass.model.Level2LinksetView} and ignores all subsequent ones.
*
- * @since
+ *
+ * The first {@link SignPostingResult} in iteration order that contains a
+ * non-null {@code level2LinksetView} wins.
+ *
+ *
+ *
+ * This is the default strategy used by {@link life.qbic.compass.SignPostingProcessor}
+ * because it provides predictable behavior without failing in multi-validator setups.
+ *
+ *
+ *
+ * Important: Later validators producing conflicting or more complete
+ * linkset views are silently ignored.
+ *
+ *
+ *
+ * This strategy only throws {@link LinkSetViewAggregationStrategy.AggregationStrategyException} in case the provided
+ * result list is empty.
+ *
*/
public class TakeFirstLinkSetViewAggregation implements LinkSetViewAggregationStrategy {
diff --git a/src/main/java/life/qbic/compass/spi/LinkSetParser.java b/src/main/java/life/qbic/compass/spi/LinkSetParser.java
index a0a0dd4..20910b9 100644
--- a/src/main/java/life/qbic/compass/spi/LinkSetParser.java
+++ b/src/main/java/life/qbic/compass/spi/LinkSetParser.java
@@ -2,7 +2,6 @@
import java.io.InputStream;
import java.io.Reader;
-import java.nio.charset.Charset;
import java.util.List;
import life.qbic.linksmith.model.WebLink;
diff --git a/src/test/groovy/life/qbic/compass/SignPostingProcessorSpec.groovy b/src/test/groovy/life/qbic/compass/SignPostingProcessorSpec.groovy
index ca4648a..1f80c34 100644
--- a/src/test/groovy/life/qbic/compass/SignPostingProcessorSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/SignPostingProcessorSpec.groovy
@@ -5,6 +5,7 @@ import life.qbic.compass.model.SignPostingResult
import life.qbic.compass.spi.SignPostingValidator
import life.qbic.compass.validation.Level1SignPostingValidator
import life.qbic.linksmith.model.WebLink
+import life.qbic.linksmith.model.WebLinkParameter
import life.qbic.linksmith.spi.WebLinkValidator
import spock.lang.Specification
@@ -25,16 +26,16 @@ class SignPostingProcessorSpec extends Specification {
def "processor calls all configured validators exactly once with the provided WebLinks"() {
given:
def webLinks = [
- weblink("https://example.org/object"),
- weblink("https://example.org/meta")
+ weblink("https://example.org/object", "cite-as"),
+ weblink("https://example.org/meta", "describedby")
]
def v1 = Mock(SignPostingValidator)
def v2 = Mock(SignPostingValidator)
and: "each validator returns some result"
- def r1 = new SignPostingResult(new SignPostingView(webLinks), new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("v1")]))
- def r2 = new SignPostingResult(new SignPostingView(webLinks), new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("v2")]))
+ def r1 = new SignPostingResult(new SignPostingView(webLinks), new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("v1")]), null)
+ def r2 = new SignPostingResult(new SignPostingView(webLinks), new WebLinkValidator.IssueReport([WebLinkValidator.Issue.warning("v2")]), null)
and:
def processor = new SignPostingProcessor.Builder()
@@ -110,10 +111,10 @@ class SignPostingProcessorSpec extends Specification {
// Side effects / invariants
// ------------------------------------------------------------------------
- def "processor does not mutate the provided WebLinks list"() {
+ def "processor does not mutate the provided WebLinks list (except null elements) "() {
given:
def webLinks = new ArrayList([
- weblink("https://example.org/object")
+ weblink("https://example.org/object", "cite-as")
])
def snapshot = new ArrayList<>(webLinks)
@@ -126,7 +127,8 @@ class SignPostingProcessorSpec extends Specification {
and:
v.validate(_ as List) >> new SignPostingResult(
new SignPostingView(webLinks),
- new WebLinkValidator.IssueReport([])
+ new WebLinkValidator.IssueReport([]),
+ null
)
when:
@@ -138,15 +140,13 @@ class SignPostingProcessorSpec extends Specification {
def "SignPostingView performs defensive copy: modifying input list after processing does not affect the view"() {
given:
- def inputLinks = new ArrayList([
- weblink("https://example.org/object")
- ])
+ def inputLinks = new ArrayList()
+ inputLinks.add(weblink("https://example.org/object", "cite-as"))
and: "a validator that returns a real result"
def validator = Stub(SignPostingValidator) {
- validate(_ as List) >> { List passed ->
- // IMPORTANT: return a real SignPostingResult, not a mock
- new SignPostingResult(new SignPostingView(passed), new WebLinkValidator.IssueReport([]))
+ validate(_ as List) >> { passed ->
+ new SignPostingResult(new SignPostingView(passed.get(0)), new WebLinkValidator.IssueReport([]), null)
}
}
@@ -157,7 +157,7 @@ class SignPostingProcessorSpec extends Specification {
when:
def result = processor.process(inputLinks)
- inputLinks.add(weblink("https://example.org/other")) // mutate after processing
+ inputLinks.add(weblink("https://example.org/other", "cite-as")) // mutate after processing
then:
result.signPostingView().webLinks().size() == 1
@@ -212,10 +212,10 @@ class SignPostingProcessorSpec extends Specification {
// Helpers
// ------------------------------------------------------------------------
- private static WebLink weblink(String target) {
+ private static WebLink weblink(String target, String relation) {
// Adjust if your WebLink constructor differs.
// Many implementations model target/reference; here we assume a single URI target is enough for tests.
- new WebLink(URI.create(target), List.of())
+ new WebLink(URI.create(target), List.of(new WebLinkParameter("rel", relation)))
}
private static List readValidators(SignPostingProcessor processor) {
diff --git a/src/test/groovy/life/qbic/compass/model/SignPostingViewSpec.groovy b/src/test/groovy/life/qbic/compass/model/SignPostingViewSpec.groovy
index 78e47c6..29168d8 100644
--- a/src/test/groovy/life/qbic/compass/model/SignPostingViewSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/model/SignPostingViewSpec.groovy
@@ -136,7 +136,7 @@ class SignPostingViewSpec extends Specification {
def citeAsUris = view.citeAs()
then:
- citeAsUris == [URI.create("https://doi.org/10.1234/xyz")]
+ citeAsUris.contains(weblink("https://doi.org/10.1234/xyz", [rel("cite-as")]))
}
def "Level 1: describedBy returns all URIs with rel=describedby"() {
@@ -153,8 +153,8 @@ class SignPostingViewSpec extends Specification {
then:
described as Set == [
- URI.create("https://example.org/meta/datacite.xml"),
- URI.create("https://example.org/meta/schemaorg.jsonld")
+ weblink("https://example.org/meta/datacite.xml", [rel("describedby")]),
+ weblink("https://example.org/meta/schemaorg.jsonld", [rel("describedby")])
] as Set
}
@@ -194,8 +194,14 @@ class SignPostingViewSpec extends Specification {
then:
linksetUris as Set == [
- URI.create("https://example.org/linkset.json"),
- URI.create("https://example.org/linkset-alt")
+ weblink("https://example.org/linkset.json", [
+ rel("linkset"),
+ type("application/linkset+json")
+ ]),
+ weblink("https://example.org/linkset-alt", [
+ rel("linkset"),
+ type("application/linkset")
+ ])
] as Set
}
@@ -228,11 +234,13 @@ class SignPostingViewSpec extends Specification {
])
expect: "Level 1 helpers"
- view.citeAs() == [URI.create("https://doi.org/10.9999/foo")]
- view.describedBy() == [URI.create("https://example.org/meta/datacite.xml")]
+ view.citeAs().contains(weblink("https://doi.org/10.9999/foo", [rel("cite-as")]))
+ view.describedBy().contains(weblink("https://example.org/meta/datacite.xml", [rel("describedby")]))
and: "Level 2 discovery helper"
- view.linksets() == [URI.create("https://example.org/linkset.json")]
+ view.linksets().contains(weblink("https://example.org/linkset.json", [
+ rel("linkset"), type("application/linkset+json")
+ ]))
and: "rel-based helper is consistent"
view.withRelationType("item")*.target() == [URI.create("https://example.org/file1")]
From e6c72f0cefd62fc3d061e4c99664131eaa38d236 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Mon, 19 Jan 2026 15:07:08 +0100
Subject: [PATCH 8/9] Ignore LinkSet inline parser for now
---
.../life/qbic/compass/parsing/LinkSetInlineParser.java | 9 ++++++---
.../qbic/compass/parsing/LinkSetInlineParserSpec.groovy | 7 +++++++
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java b/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java
index e6bfad9..edbd37f 100644
--- a/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java
+++ b/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java
@@ -21,16 +21,19 @@ public static LinkSetInlineParser create() {
@Override
public List parse(String rawLinkSet) throws ParsingException {
- return List.of();
+ // TODO implement
+ throw new RuntimeException("Not yet implemented");
}
@Override
public List parse(InputStream inputStream) throws ParsingException {
- return List.of();
+ // TODO implement
+ throw new RuntimeException("Not yet implemented");
}
@Override
public List parse(Reader reader) throws ParsingException {
- return List.of();
+ // TODO implement
+ throw new RuntimeException("Not yet implemented");
}
}
diff --git a/src/test/groovy/life/qbic/compass/parsing/LinkSetInlineParserSpec.groovy b/src/test/groovy/life/qbic/compass/parsing/LinkSetInlineParserSpec.groovy
index c5ce4cd..eb96355 100644
--- a/src/test/groovy/life/qbic/compass/parsing/LinkSetInlineParserSpec.groovy
+++ b/src/test/groovy/life/qbic/compass/parsing/LinkSetInlineParserSpec.groovy
@@ -1,6 +1,7 @@
package life.qbic.compass.parsing
import life.qbic.compass.spi.LinkSetParser
+import spock.lang.Ignore
import spock.lang.Specification
import spock.lang.Unroll
@@ -8,6 +9,7 @@ class LinkSetInlineParserSpec extends Specification {
def parser = new LinkSetInlineParser()
+ @Ignore
def "happy path: parses one inline link entry with rel + anchor + type"() {
given:
def raw = '; rel="author"; anchor="https://example.org/resource1"; type="application/rdf+xml"'
@@ -29,6 +31,7 @@ class LinkSetInlineParserSpec extends Specification {
// links[0].anchor().orElse(null) == URI.create("https://example.org/resource1")
}
+ @Ignore
def "happy path: parses multiple link entries separated by comma"() {
given:
def raw = [
@@ -51,6 +54,7 @@ class LinkSetInlineParserSpec extends Specification {
links.find { it.target() == URI.create("https://doi.org/10.1234/example") }.rel().contains("cite-as")
}
+ @Ignore
def "happy path: tolerates OWS / extra whitespace around separators"() {
given:
def raw = ' ; rel = "item" ; anchor = "https://example.org/a"'
@@ -64,6 +68,7 @@ class LinkSetInlineParserSpec extends Specification {
links[0].rel().contains("item")
}
+ @Ignore
def "happy path: parameter without value (e.g., ; foo) is preserved as an extension attribute"() {
given:
def raw = '; rel="item"; anchor="https://example.org/a"; foo'
@@ -78,6 +83,7 @@ class LinkSetInlineParserSpec extends Specification {
// links[0].extensionAttributes().containsKey("foo")
}
+ @Ignore
@Unroll
def "invalid: rejects malformed / semantically invalid inline linkset (#caseName)"() {
when:
@@ -99,6 +105,7 @@ class LinkSetInlineParserSpec extends Specification {
"invalid anchor URI" | '; rel="item"; anchor="::::"'
}
+ @Ignore
def "invariant: returned list is immutable or defensively copied"() {
given:
def raw = '; rel="item"'
From 25acd236f135bde36fd39c65419b52458071f2d0 Mon Sep 17 00:00:00 2001
From: Sven Fillinger
Date: Mon, 19 Jan 2026 15:32:01 +0100
Subject: [PATCH 9/9] Fix errors in JavaDoc HTML
---
.../qbic/compass/SignPostingProcessor.java | 7 +-
.../qbic/compass/model/MissingOriginLink.java | 30 +++++++-
.../qbic/compass/model/SignPostingResult.java | 13 +++-
.../compass/parsing/LinkSetInlineParser.java | 5 --
.../life/qbic/compass/spi/LinkSetParser.java | 75 ++++++++++++++++++-
.../validation/WebLinkModelValidators.java | 60 ++++++++++++++-
6 files changed, 174 insertions(+), 16 deletions(-)
diff --git a/src/main/java/life/qbic/compass/SignPostingProcessor.java b/src/main/java/life/qbic/compass/SignPostingProcessor.java
index 975ded7..3aa45d8 100644
--- a/src/main/java/life/qbic/compass/SignPostingProcessor.java
+++ b/src/main/java/life/qbic/compass/SignPostingProcessor.java
@@ -168,7 +168,7 @@ private SignPostingProcessor(
* are not allowed to mutate the input.
*
*
- * Aggregation semantics
+ * Aggregation semantics
*
* - All validators are executed in the order they were configured.
* - All {@link life.qbic.linksmith.spi.WebLinkValidator.Issue}s from all
@@ -177,7 +177,7 @@ private SignPostingProcessor(
* subsequent validators are still executed.
*
*
- * View semantics
+ * View semantics
*
* The processor is non-destructive with respect to non-null links.
* It does not reorder or modify WebLinks. However, {@code null} elements are
@@ -190,6 +190,7 @@ private SignPostingProcessor(
* As a result, the returned {@link SignPostingView} contains all non-null WebLinks
* from the input list, in their original order.
*
+ *
* This processor intentionally does not merge or expose any
* {@link life.qbic.compass.model.Level2LinksetView} instances returned by
* individual validators. If Level 2 structural views are required,
@@ -197,7 +198,7 @@ private SignPostingProcessor(
* (e.g. {@code Level2RecipeValidator}).
*
*
- * Error handling
+ * Error handling
*
* - {@code webLinks} must not be {@code null}.
* - {@code webLinks} may contain {@code null} elements. Null elements are skipped.
diff --git a/src/main/java/life/qbic/compass/model/MissingOriginLink.java b/src/main/java/life/qbic/compass/model/MissingOriginLink.java
index 4a67d41..5994c59 100644
--- a/src/main/java/life/qbic/compass/model/MissingOriginLink.java
+++ b/src/main/java/life/qbic/compass/model/MissingOriginLink.java
@@ -3,9 +3,35 @@
import life.qbic.linksmith.model.WebLink;
/**
- *
+ * Represents a {@link WebLink} that is missing an {@code anchor}/{@code origin} attribute in a Link
+ * Set context.
*
- * @since
+ *
+ * In FAIR Signposting Level 2, links within a Link Set are expected to be scoped to a common
+ * origin (RFC 8288 {@code anchor} parameter). When a link lacks this information, it
+ * cannot be assigned to a specific Landing Page, Content Resource, or Metadata Resource recipe.
+ *
+ *
+ *
+ * Instances of this record are collected during Level 2 validation and exposed via
+ * {@link Level2LinksetView} so that clients can:
+ *
+ *
+ * - report incomplete or malformed Link Sets to users,
+ * - preserve the original order of links for diagnostics, and
+ * - decide whether to ignore, reject, or repair such links.
+ *
+ *
+ *
+ * Missing-origin links are not assigned to any typed recipe view.
+ * They are reported separately and do not contribute to Landing Page,
+ * Content Resource, or Metadata Resource views.
+ *
+ *
+ * @param index the zero-based index of the link in the original Link Set input
+ * @param webLink the {@link WebLink} instance missing an origin
+ * @author Sven Fillinger
+ * @since 1.0.0
*/
public record MissingOriginLink(int index, WebLink webLink) {
diff --git a/src/main/java/life/qbic/compass/model/SignPostingResult.java b/src/main/java/life/qbic/compass/model/SignPostingResult.java
index a1fdb1b..eb98291 100644
--- a/src/main/java/life/qbic/compass/model/SignPostingResult.java
+++ b/src/main/java/life/qbic/compass/model/SignPostingResult.java
@@ -51,15 +51,26 @@
* - compose validation results in higher-level workflows.
*
*
+ * @param signPostingView a read-only view on the validated weblinks
+ * @param issueReport an aggregated report of all recoded issues during validation
+ * @param level2LinksetView a Signposting Level 2 compliant view semantics in case the validator
+ * also performed FAIR Signposting recipe detection (e.g., landing page,
+ * content or metadata resource)
* @author Sven Fillinger
+ * @since 1.0.0
*/
public record SignPostingResult(
SignPostingView signPostingView,
IssueReport issueReport,
Level2LinksetView level2LinksetView) {
+ /**
+ * Convenience method for aggregators or filters to check, if the current SignPosting result
+ * contains a linkset view or not.
+ *
+ * @return true, if the current Signposting result contains a linkset view, else false
+ */
public boolean hasLinkSetView() {
return level2LinksetView != null;
}
-
}
diff --git a/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java b/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java
index edbd37f..c0a402b 100644
--- a/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java
+++ b/src/main/java/life/qbic/compass/parsing/LinkSetInlineParser.java
@@ -6,11 +6,6 @@
import life.qbic.compass.spi.LinkSetParser;
import life.qbic.linksmith.model.WebLink;
-/**
- *
- *
- * @since
- */
public final class LinkSetInlineParser implements LinkSetParser {
private LinkSetInlineParser() {}
diff --git a/src/main/java/life/qbic/compass/spi/LinkSetParser.java b/src/main/java/life/qbic/compass/spi/LinkSetParser.java
index 20910b9..8a2d851 100644
--- a/src/main/java/life/qbic/compass/spi/LinkSetParser.java
+++ b/src/main/java/life/qbic/compass/spi/LinkSetParser.java
@@ -6,21 +6,93 @@
import life.qbic.linksmith.model.WebLink;
/**
- *
+ * Service Provider Interface (SPI) for parsing RFC 9264 Link Sets into
+ * {@link WebLink} model objects.
*
+ *
+ * A {@code LinkSetParser} converts a serialized Link Set representation
+ * (inline, JSON, or other supported media types) into an in-memory list of
+ * {@link WebLink}s that can be processed by Compass validators.
+ *
+ *
+ * Scope and responsibilities
+ *
+ * - Parse a complete Link Set document.
+ * - Return a list of {@link WebLink} objects representing all links found.
+ * - Fail fast if the input cannot be parsed.
+ *
+ *
+ * Non-goals
+ *
+ * - This interface does not perform Signposting validation.
+ * - This interface does not dereference link targets.
+ * - This interface does not validate semantic correctness of relations.
+ *
+ *
+ * Contract
+ *
+ * - All {@code parse} methods must return a non-null list.
+ * - The returned list must not contain {@code null} elements.
+ * - Implementations may assume UTF-8 unless otherwise documented.
+ * - Parsing errors must be reported via {@link ParsingException}.
+ *
+ *
+ *
+ * Implementations are expected to be stateless and reusable.
+ *
+ *
+ * @since 1.0.0
*/
public interface LinkSetParser {
+ /**
+ * Parses a Link Set from its raw textual representation.
+ *
+ * @param rawLinkSet the raw Link Set document
+ * @return a list of parsed {@link WebLink}s
+ * @throws ParsingException if parsing fails
+ */
List parse(String rawLinkSet) throws ParsingException;
+ /**
+ * Parses a Link Set from an {@link InputStream}.
+ *
+ *
+ * Implementations are responsible for consuming the stream fully.
+ * The stream is not closed by this method.
+ *
+ *
+ * @param inputStream the input stream containing the Link Set
+ * @return a list of parsed {@link WebLink}s
+ * @throws ParsingException if parsing fails
+ */
List parse(InputStream inputStream) throws ParsingException;
+ /**
+ * Parses a Link Set from a {@link Reader}.
+ *
+ *
+ * Implementations are responsible for consuming the reader fully.
+ * The reader is not closed by this method.
+ *
+ *
+ * @param reader the reader supplying the Link Set content
+ * @return a list of parsed {@link WebLink}s
+ * @throws ParsingException if parsing fails
+ */
List parse(Reader reader) throws ParsingException;
/**
+ * Signals a failure during Link Set parsing.
*
+ *
+ * This exception indicates syntactic or structural errors in the
+ * Link Set representation. It is intentionally unchecked to simplify
+ * usage in streaming and validation pipelines.
+ *
*/
class ParsingException extends RuntimeException {
+
public ParsingException(String message) {
super(message);
}
@@ -29,5 +101,4 @@ public ParsingException(String message, Throwable cause) {
super(message, cause);
}
}
-
}
diff --git a/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java b/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java
index dd75fc7..81a7b92 100644
--- a/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java
+++ b/src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java
@@ -3,16 +3,70 @@
import life.qbic.compass.spi.WebLinkModelValidator;
/**
- *
+ * Factory and access point for {@link WebLinkModelValidator} implementations
+ * provided by Compass.
*
- * @since
+ *
+ * This class centralizes the creation of model-level validators that operate
+ * on already parsed {@link life.qbic.linksmith.model.WebLink} instances.
+ * It allows the {@link life.qbic.compass.SignPostingProcessor} and client code
+ * to obtain well-defined, versioned validation behavior without depending
+ * directly on concrete validator classes.
+ *
+ *
+ * Design intent
+ *
+ * - Decouple processor and builder code from concrete validator implementations
+ * - Provide sensible, spec-aligned defaults for model validation
+ * - Allow future addition of alternative or stricter model validators
+ * without breaking the public API
+ *
+ *
+ *
+ * Validators returned by this class are expected to:
+ *
+ *
+ * - be stateless and reusable,
+ * - perform in-memory validation only, and
+ * - report all findings via {@link life.qbic.linksmith.spi.WebLinkValidator.IssueReport}
+ * rather than throwing exceptions.
+ *
+ *
+ *
+ * This class is intentionally non-instantiable and exposes only static factory methods.
+ *
+ *
+ * @author Sven Fillinger
+ * @since 1.0.0
*/
public final class WebLinkModelValidators {
private WebLinkModelValidators() {}
+ /**
+ * Returns the default RFC 8288–compliant model validator.
+ *
+ *
+ * The returned validator enforces normative and structural constraints defined
+ * by RFC 8288 ("Web Linking") on the {@link life.qbic.linksmith.model.WebLink}
+ * model, including:
+ *
+ *
+ * - absolute target and anchor URIs,
+ * - presence and validity of relation types,
+ * - parameter name token rules, and
+ * - parameter multiplicity constraints.
+ *
+ *
+ *
+ * This validator is used as the sensible default by
+ * {@link life.qbic.compass.SignPostingProcessor.Builder} unless explicitly
+ * overridden by client code.
+ *
+ *
+ * @return an RFC 8288–compliant {@link WebLinkModelValidator}
+ */
public static WebLinkModelValidator rfc8288() {
return Rfc8288ModelValidator.create();
}
-
}