From fd8be54e9b7fa4fa8932bbd163f75d1bf542df8c Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Thu, 15 Jan 2026 17:03:09 +0100 Subject: [PATCH 1/9] Add Rfc8288 validator --- .../validation/Rfc8288ModelValidator.java | 142 ++++++++++++++++++ .../validation/WebLinkModelValidator.java | 16 ++ 2 files changed, 158 insertions(+) create mode 100644 src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java create mode 100644 src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java diff --git a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java new file mode 100644 index 0000000..7a040d5 --- /dev/null +++ b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java @@ -0,0 +1,142 @@ +package life.qbic.compass.validation; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import life.qbic.linksmith.model.WebLink; +import life.qbic.linksmith.spi.WebLinkValidator.Issue; +import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; + +/** + * + * + * @since + */ +class Rfc8288ModelValidator implements WebLinkModelValidator { + + /** + * Following the ABNF for regular "rel" parameters' value: + * + *
{@code
+   *     relation-type  = reg-rel-type / ext-rel-type
+   *     reg-rel-type   = LOALPHA *( LOALPHA / DIGIT / "." / "-" )
+   * }
+ *

+ * the constant captures the notation rule as a regular expression. + *

+ * The pattern compiles and matches case-insensitive, since the semantics of the specification + * give away (see section 2.1.1, that for registered link relation types, they MUST be + * compared character by character in a case-insensitive + * fashion. + */ + private static final Pattern REGULAR_RELATION_TYPE_PATTERN = Pattern.compile("^[a-z][a-z0-9.-]*$", + Pattern.CASE_INSENSITIVE); + + // Defined in https://www.rfc-editor.org/rfc/rfc7230, section 3.2.6 + private static final Pattern ALLOWED_TOKEN_CHARS = Pattern.compile( + "^[!#$%&'*+-.^_`|~0-9A-Za-z]+$"); + + static Rfc8288ModelValidator create() { + return new Rfc8288ModelValidator(); + } + + @Override + public IssueReport validate(List webLinks) { + // Throws NPE early + Objects.requireNonNull(webLinks); + + var issues = new ArrayList(); + for (int index = 0; index < webLinks.size(); index++) { + var currentLink = webLinks.get(index); + if (currentLink == null) { + issues.add(Issue.error("Element is null at index %d".formatted(index))); + continue; + } + validate(currentLink, index, issues); + } + return new IssueReport(issues); + } + + private static void validate(WebLink currentLink, int index, List issues) { + validateTargetUri(currentLink.target(), index, issues); + validateRelationPresence(currentLink, index, issues); + validateRelationTypeToken(currentLink.rel(), index, issues); + validateParameterNames(currentLink, index, issues); + } + + private static void validateParameterNames(WebLink currentLink, int index, List issues) { + currentLink.params().forEach(parameter -> validateParameterName(parameter.name(), index, issues)); + } + + private static void validateParameterName(String name, int index, List issues) { + if (!ALLOWED_TOKEN_CHARS.matcher(name).matches()) { + issues.add(Issue.error("Invalid parameter name '%s' for element at index %d".formatted(name, index))); + } + } + + private static void validateRelationPresence(WebLink currentLink, int index, List issues) { + if (currentLink.rel().isEmpty()) { + issues.add( + Issue.error("Missing relation parameter for element at index %d".formatted(index))); + } + } + + private static void validateTargetUri(URI targetUri, int index, List issues) { + // Validates RFC 8288 Section 3.1 Link Target normative requirement for a target value + if (!targetUri.isAbsolute()) { + issues.add( + Issue.error("Link target URI is relative for element at index %d".formatted(index))); + return; + } + var scheme = targetUri.getScheme(); + if (!isHttpOrHttps(scheme)) { + issues.add(Issue.warning( + "Link target URI scheme is non-http for element at index %d: '%s'".formatted(index, + scheme))); + } + } + + private static void validateRelationTypeToken(List relationTypes, int index, + List issues) { + for (var token : relationTypes) { + validateRelationTypeToken(token, index, issues); + } + } + + private static void validateRelationTypeToken(String typeToken, int index, List issues) { + if (typeToken == null) { + issues.add( + Issue.error("Relation type token is null for element at index %d".formatted(index))); + return; + } + if (typeToken.isBlank()) { + issues.add(Issue.error( + "Relation type token is invalid (reason: empty) for element at index %d".formatted( + index))); + return; + } + if (isValidUri(typeToken)) { + return; + } + if (!REGULAR_RELATION_TYPE_PATTERN.matcher(typeToken).matches()) { + issues.add(Issue.error( + "Relation type token contains invalid characters for element at index %d".formatted( + index))); + } + } + + private static boolean isValidUri(String value) { + try { + var uri = URI.create(value); + return uri.isAbsolute(); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private static boolean isHttpOrHttps(String scheme) { + return scheme.equals("http") || scheme.equals("https"); + } +} diff --git a/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java b/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java new file mode 100644 index 0000000..eee7ce9 --- /dev/null +++ b/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java @@ -0,0 +1,16 @@ +package life.qbic.compass.validation; + +import java.util.List; +import life.qbic.linksmith.model.WebLink; +import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; + +/** + * + * + * @since + */ +interface WebLinkModelValidator { + + IssueReport validate(List webLinks); + +} From 082ae7adf08e03a25f97a35f01db293e25a3f742 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 16 Jan 2026 10:12:47 +0100 Subject: [PATCH 2/9] Add unit tests for the RFC 8288 Model Validator --- .../validation/Rfc8288ModelValidator.java | 44 +- .../Rfc8288ModelValidatorSpec.groovy | 459 ++++++++++++++++++ 2 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy diff --git a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java index 7a040d5..4c324ce 100644 --- a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java +++ b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java @@ -5,7 +5,9 @@ import java.util.List; import java.util.Objects; import java.util.regex.Pattern; +import java.util.stream.Collectors; import life.qbic.linksmith.model.WebLink; +import life.qbic.linksmith.model.WebLinkParameter; import life.qbic.linksmith.spi.WebLinkValidator.Issue; import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; @@ -64,15 +66,53 @@ private static void validate(WebLink currentLink, int index, List issues) validateRelationPresence(currentLink, index, issues); validateRelationTypeToken(currentLink.rel(), index, issues); validateParameterNames(currentLink, index, issues); + validateTargetAttributeCardinality(currentLink.params(), index, issues); + currentLink.anchor().ifPresent(anchor -> validateAnchorAttribute(anchor, index, issues)); + } + + private static void validateAnchorAttribute(String anchor, int index, List issues) { + if (anchor.isBlank()) { + issues.add(Issue.error("Anchor attribute value is empty for element at index %d".formatted(index))); + return; + } + try { + var uri = URI.create(anchor); + if (!uri.isAbsolute()) { + issues.add(Issue.error("Invalid anchor value. URI is not absolute for element at index %d".formatted(index))); + } + } catch (IllegalArgumentException ignored) { + issues.add(Issue.error("Invalid anchor attribute value. '%s' is not an URI for element at index %d".formatted(anchor, index))); + } + } + + private static void validateTargetAttributeCardinality(List targetAttributes, + int index, List issues) { + var attributeCounts = targetAttributes.stream().collect( + Collectors.groupingBy(WebLinkParameter::name, Collectors.counting())); + for (var entry : attributeCounts.entrySet()) { + var attributeName = entry.getKey(); + var attributeCount = entry.getValue(); + if (attributeCount > 1 && !multipleOccurrencesAllowed(attributeName)) { + issues.add(Issue.error( + "Multiple attribute definition available. Target attribute '%s' must appear more than once for element at index %d".formatted( + attributeName, index))); + } + } + } + + private static boolean multipleOccurrencesAllowed(String attributeName) { + return attributeName.equals("hreflang"); } private static void validateParameterNames(WebLink currentLink, int index, List issues) { - currentLink.params().forEach(parameter -> validateParameterName(parameter.name(), index, issues)); + currentLink.params() + .forEach(parameter -> validateParameterName(parameter.name(), index, issues)); } private static void validateParameterName(String name, int index, List issues) { if (!ALLOWED_TOKEN_CHARS.matcher(name).matches()) { - issues.add(Issue.error("Invalid parameter name '%s' for element at index %d".formatted(name, index))); + issues.add(Issue.error( + "Invalid parameter name '%s' for element at index %d".formatted(name, index))); } } diff --git a/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy new file mode 100644 index 0000000..8734849 --- /dev/null +++ b/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy @@ -0,0 +1,459 @@ +package life.qbic.compass.validation + +import life.qbic.linksmith.model.WebLink +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 + +/** + * Contract tests for a WebLinkModelValidator that enforces RFC 8288 "Web Linking" + * constraints at the *object model* level (WebLink + parameters), not at the raw + * header serialization/ABNF level. + * + * These tests intentionally encode strict/normative expectations: + * - link target MUST be a valid absolute URI + * - rel parameter MUST be present + * - parameter names MUST be RFC7230 token + * - parameters (except hreflang) MUST NOT occur multiple times + * - rel values MUST be valid relation types (token or absolute URI) + * + * If you prefer warnings instead of errors for some policies (e.g. relative URI), + * adjust assertions accordingly. + */ +class Rfc8288ModelValidatorSpec extends Specification { + + /** + * Provide your concrete validator under test here. + * + * Examples: + * def validator = Rfc8288ModelWebLinkValidator.create() + * or + * def validator = new WebLinkModelSanityValidator() + */ + def validator = Rfc8288ModelValidator.create() + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + private static WebLink link(String target, List params) { + WebLink.create(URI.create(target), params) + } + + private static WebLinkParameter p(String name, String value) { + WebLinkParameter.create(name, value) + } + + private static WebLinkParameter flag(String name) { + WebLinkParameter.withoutValue(name) + } + + private static boolean hasErrors(IssueReport r) { + r != null && r.hasErrors() + } + + private static boolean hasWarnings(IssueReport r) { + r != null && r.hasWarnings() + } + + private static List issues(IssueReport r) { + r?.issues() ?: [] + } + + private static List messages(IssueReport r) { + issues(r)*.message() + } + + private static boolean anyMsg(IssueReport r, String fragment) { + messages(r).any { it.toLowerCase().contains(fragment.toLowerCase()) } + } + + // ---------------------------------------------------------------------- + // Input handling + // ---------------------------------------------------------------------- + + def "null list input throws NPE (contract)"() { + when: + validator.validate(null) + + then: + thrown(NullPointerException) + } + + def "null element in list is reported as ERROR with index and skipped"() { + given: + def links = [ + link("https://example.org/a", [p("rel", "cite-as")]), + null, + link("https://example.org/b", [p("rel", "describedby")]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "null") + anyMsg(report, "index 1") + } + + // ---------------------------------------------------------------------- + // Target URI requirements (model-level strictness) + // ---------------------------------------------------------------------- + + def "target must be absolute URI: relative target is ERROR"() { + given: + def links = [ + // URI.create("/rel") is valid java.net.URI but relative + link("/rel", [p("rel", "cite-as")]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "relative") + anyMsg(report, "target") + anyMsg(report, "index 0") + } + + def "target must have scheme and authority: 'mailto:' is allowed by URI but treated as non-http (WARNING or ERROR) - choose policy"() { + given: + def links = [ + link("mailto:info@example.org", [p("rel", "author")]) + ] + + when: + def report = validator.validate(links) + + then: + // If you want strict RFC8288-header/web policy => ERROR; if you want soft => WARNING. + // Pick one and keep consistent in implementation. + hasWarnings(report) || hasErrors(report) + anyMsg(report, "index 0") + } + + // ---------------------------------------------------------------------- + // rel parameter presence and validity + // ---------------------------------------------------------------------- + + def "rel parameter MUST be present: missing rel is ERROR"() { + given: + def links = [ + link("https://example.org/a", [p("type", "text/html")]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "rel") + anyMsg(report, "missing") + anyMsg(report, "index 0") + } + + @Unroll + def "rel value must be valid relation type token or absolute URI: '#relValue' -> #expected"() { + given: + def links = [ + link("https://example.org/a", [p("rel", relValue)]) + ] + + when: + def report = validator.validate(links) + + then: + expected == "ok" ? !hasErrors(report) : hasErrors(report) + + where: + relValue || expected + "cite-as" || "ok" // registered token + "describedby" || "ok" + "item" || "ok" + "collection" || "ok" + "describes" || "ok" + "CITE-AS" || "ok" // case-insensitive tokens are ok at model level + "good rel" || "ok" // spaces are allowed in a single token value + "" || "error" // empty not allowed + " " || "error" // blank not allowed + "cite_as" || "error" // underscore not allowed in RFC8288 token (token is ALPHA/DIGIT/.-) + "urn:example:relation" || "ok" // URI relation type allowed + "https://example.org/rel/custom" || "ok" // URI rel allowed + "example.org/rel/custom" || "error" // missing scheme, not a valid absolute URI rel + "http://[::1" || "error" // invalid URI + } + + def "multiple rel tokens in one rel parameter (space-separated) are allowed and must be split/recognized"() { + given: + def links = [ + link("https://example.org/a", [p("rel", "cite-as describedby")]) + ] + + when: + def report = validator.validate(links) + + then: + // should be valid: rel can contain multiple relation types separated by SP + !hasErrors(report) + } + + def "rel MUST be present and MUST NOT occur more than once; duplicates are ERROR and must be reported"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + p("rel", "describedby") // second rel parameter => forbidden by RFC8288 + ]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "rel") + anyMsg(report, "must not") || anyMsg(report, "more than once") || anyMsg(report, "ignored") + anyMsg(report, "index 0") + } + + // ---------------------------------------------------------------------- + // Parameter name token validity (RFC7230 token) + // ---------------------------------------------------------------------- + + @Unroll + def "parameter name must be RFC7230 token: '#paramName' -> #expected"() { + given: + def links = [ + link("https://example.org/a", [ + p(paramName, "x"), + p("rel", "cite-as") + ]) + ] + + when: + def report = validator.validate(links) + + then: + expected == "ok" ? !hasErrors(report) : hasErrors(report) + + where: + paramName || expected + "type" || "ok" + "title" || "ok" + "hreflang" || "ok" + "x-custom" || "ok" + "x_custom" || "ok" + "bad name" || "error" // spaces not allowed + "na(me)" || "error" // parentheses not allowed + "" || "error" + } + + // ---------------------------------------------------------------------- + // Parameter multiplicity rules + // ---------------------------------------------------------------------- + + @Unroll + def "duplicate non-repeatable parameter '#paramName' is WARNING or ERROR, but MUST be reported and duplicates ignored"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + p(paramName, firstValue), + p(paramName, secondValue) + ]) + ] + + when: + def report = validator.validate(links) + + then: + // MUST be reported (your strictness decides warning vs error) + hasWarnings(report) || hasErrors(report) + anyMsg(report, paramName) + anyMsg(report, "multiple") || anyMsg(report, "duplicate") || anyMsg(report, "not allowed") + anyMsg(report, "index 0") + + and: + // Optional but highly recommended contract test: + // validator should deterministically keep one value (e.g. first) and ignore the rest. + // If your validator returns a normalized/filtered link list, assert it here. + // Example (adapt to your API): + // def normalized = report.normalizedLinks() + // normalized[0].param(paramName) == firstValue + + where: + paramName | firstValue | secondValue + "type" | "text/html" | "application/json" + "title" | "Landing page" | "Other title" + "title*" | "UTF-8'en'Hello" | "UTF-8'en'World" + "media" | "screen" | "print" + "anchor" | "https://ex.org/a" | "https://ex.org/b" + } + + def "hreflang may occur multiple times (allowed repeatable parameter) and should not produce error or warning"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "describedby"), + p("hreflang", "en"), + p("hreflang", "de") + ]) + ] + + when: + def report = validator.validate(links) + + then: + !hasErrors(report) + !hasWarnings(report) + } + + // ---------------------------------------------------------------------- + // Anchor parameter is extension in RFC8288 (but used in RFC9264/linksets). + // We treat it as allowed parameter name; value is URI-ish but may be string. + // ---------------------------------------------------------------------- + + def "anchor parameter is allowed; anchor value may be absolute URI string"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + p("anchor", "https://example.org/origin/1") + ]) + ] + + when: + def report = validator.validate(links) + + then: + !hasErrors(report) + } + + def "anchor parameter with invalid URI string may be ERROR or WARNING - but MUST be reported"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + p("anchor", "http://[::1") // invalid + ]) + ] + + when: + def report = validator.validate(links) + + then: + (hasWarnings(report) || hasErrors(report)) + anyMsg(report, "anchor") + anyMsg(report, "invalid") + anyMsg(report, "index 0") + } + + def "relative anchor URI-reference is ERROR because parser MUST resolve to absolute in the model (Compass policy)"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + p("anchor", "/relative/origin") // valid URI-reference, but not absolute + ]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "anchor") + anyMsg(report, "absolute") || anyMsg(report, "relative") || anyMsg(report, "resolve") + anyMsg(report, "index 0") + } + + def "relative target URI is ERROR because parser MUST resolve to absolute in the model (Compass policy)"() { + given: + def links = [ + link("/content/file", [ + p("rel", "cite-as") + ]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "target") + anyMsg(report, "absolute") || anyMsg(report, "relative") || anyMsg(report, "resolve") + anyMsg(report, "index 0") + } + + def "fragment-only anchor is ERROR because it is not absolute (Compass policy)"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + p("anchor", "#section") // URI-reference but not absolute + ]) + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + anyMsg(report, "anchor") + anyMsg(report, "absolute") || anyMsg(report, "fragment") || anyMsg(report, "resolve") + anyMsg(report, "index 0") + } + + // ---------------------------------------------------------------------- + // Flag / valueless parameters (allowed in RFC8288) + // ---------------------------------------------------------------------- + + def "valueless parameter is allowed (flag param) as long as name is token"() { + given: + def links = [ + link("https://example.org/a", [ + p("rel", "cite-as"), + flag("templated") // or any extension parameter + ]) + ] + + when: + def report = validator.validate(links) + + then: + !hasErrors(report) + } + + // ---------------------------------------------------------------------- + // Mixed issues: validator should accumulate findings (non-fatal) + // ---------------------------------------------------------------------- + + def "accumulates issues across multiple links (non-fatal) and reports all violations"() { + given: + def links = [ + // Missing rel + link("https://example.org/a", [p("type", "text/html")]), + // Bad param name + link("https://example.org/b", [p("rel", "cite-as"), p("bad name", "x")]), + // Relative target + link("/rel", [p("rel", "describedby")]), + ] + + when: + def report = validator.validate(links) + + then: + hasErrors(report) + issues(report).size() >= 3 + anyMsg(report, "missing") + anyMsg(report, "bad name") + anyMsg(report, "relative") + anyMsg(report, "index 0") + anyMsg(report, "index 1") + anyMsg(report, "index 2") + } +} From 6c631fc7acbf8a581aee03c56028939213d87d72 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 16 Jan 2026 10:33:22 +0100 Subject: [PATCH 3/9] Provide java docs for the rfc 8288 validator --- .../validation/Rfc8288ModelValidator.java | 285 +++++++++++++++++- 1 file changed, 271 insertions(+), 14 deletions(-) diff --git a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java index 4c324ce..f6923f9 100644 --- a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java +++ b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java @@ -12,38 +12,124 @@ import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; /** - * + * Internal, model-level validator for RFC 8288 ("Web Linking") constraints. * - * @since + *

Audience: This class is package-private and intended for maintainers of the + * Compass library. It is not a public API and its exact issue wording is allowed to change. + * Tests should primarily assert on the presence and type of issues (ERROR/WARNING) and on stable + * message fragments.

+ * + *

Scope

+ *

+ * This validator checks the in-memory {@link WebLink} model for normative and structural + * requirements that follow from RFC 8288 and closely related ABNF/token rules (e.g. RFC 7230 + * token production used by RFC 8288 for parameter names). It does not parse header field + * syntax; it assumes parsing already happened upstream (e.g. by Linksmith or other tooling). + *

+ * + *

Why this exists in Compass

+ *

+ * Linksmith's {@link WebLink} model is intentionally permissive. Compass operates on {@link WebLink} + * objects that may originate from different sources and therefore cannot assume that upstream + * parsing/validation has enforced all RFC 8288 requirements. This validator provides a + * deterministic safety net at the model boundary. + *

+ * + *

Policy decisions encoded here

+ *
    + *
  • Null safety: {@code null} list entries are reported as {@code ERROR} and skipped.
  • + *
  • Relative URIs in the model are errors: + * RFC 8288 parsers are expected to resolve URI-references against a base URI. Therefore, + * a relative {@code target} or {@code anchor} in the model is treated as a normative violation + * of the producer of the model and reported as {@code ERROR}.
  • + *
  • Target scheme warnings: Non-http(s) absolute targets are still legal URIs, + * but Compass warns because most signposting usage expects web-resolvable HTTP(S) identifiers.
  • + *
  • Relation type tokens: A relation type token is accepted if it is either a valid + * absolute URI (extension relation type) or matches the registered relation token ABNF.
  • + *
  • Parameter name tokens: Parameter names must match RFC 7230 token rules.
  • + *
  • Parameter multiplicity: Target attributes (parameters) are checked for duplicates, + * allowing repeated {@code hreflang} only. All other repeated parameters are reported.
  • + *
+ * + *

Known limitations / deliberate non-goals

+ *
    + *
  • This class does not attempt to validate full RFC 8288 header field syntax or quoting rules.
  • + *
  • This class does not validate that {@code rel} occurred exactly once as a serialized parameter, + * because the {@link WebLink} API exposes relation values via {@link WebLink#rel()} (a derived view). + * If you need enforcement of "rel MUST NOT appear more than once", it must be done at the + * parameter layer (i.e. by inspecting {@link WebLink#params()} for repeated {@code rel} parameters).
  • + *
  • This class does not enforce constraints of specific relation types (e.g. signposting recipes); + * that is done by Compass recipe validators.
  • + *
+ * + * @since 1.0.0 + * @author Sven Fillinger */ class Rfc8288ModelValidator implements WebLinkModelValidator { /** - * Following the ABNF for regular "rel" parameters' value: + * Pattern for a registered relation type token ("reg-rel-type") as defined by RFC 8288. * *
{@code
-   *     relation-type  = reg-rel-type / ext-rel-type
-   *     reg-rel-type   = LOALPHA *( LOALPHA / DIGIT / "." / "-" )
+   * relation-type  = reg-rel-type / ext-rel-type
+   * reg-rel-type   = LOALPHA *( LOALPHA / DIGIT / "." / "-" )
+   * ext-rel-type   = URI
    * }
+ * *

- * the constant captures the notation rule as a regular expression. - *

- * The pattern compiles and matches case-insensitive, since the semantics of the specification - * give away (see section 2.1.1, that for registered link relation types, they MUST be - * compared character by character in a case-insensitive - * fashion. + * This pattern matches the registered token form and is compiled case-insensitive because RFC 8288 + * specifies case-insensitive comparison for registered relation types. + *

+ * + *

Note for maintainers: Case-insensitive comparison does not mean the token + * grammar accepts arbitrary case; it means comparisons are performed case-insensitively. + * Accepting mixed case here is therefore an interoperability-friendly choice. + *

*/ private static final Pattern REGULAR_RELATION_TYPE_PATTERN = Pattern.compile("^[a-z][a-z0-9.-]*$", Pattern.CASE_INSENSITIVE); - // Defined in https://www.rfc-editor.org/rfc/rfc7230, section 3.2.6 + /** + * RFC 7230 "token" character class used by RFC 8288 for parameter names. + * + *

+ * RFC 8288 uses HTTP header parameter conventions, which rely on RFC 7230 token syntax. + * This pattern is applied to {@link WebLinkParameter#name()}. + *

+ */ private static final Pattern ALLOWED_TOKEN_CHARS = Pattern.compile( "^[!#$%&'*+-.^_`|~0-9A-Za-z]+$"); + /** + * Factory for internal use. + * + *

+ * Kept package-private intentionally. The class is internal; callers should use Compass entry points. + *

+ * + * @return a new {@link Rfc8288ModelValidator} + */ static Rfc8288ModelValidator create() { return new Rfc8288ModelValidator(); } + /** + * Validates a list of {@link WebLink} objects for RFC 8288 model constraints. + * + *

+ * The input list must not be {@code null}. Individual {@code null} elements are reported as + * {@code ERROR} and skipped to keep validation robust and deterministic. + *

+ * + *

+ * The returned {@link IssueReport} contains all findings; validation is intentionally non-fatal and + * does not stop after the first violation. + *

+ * + * @param webLinks list of model links to validate (must not be {@code null}) + * @return an {@link IssueReport} containing all recorded issues + * @throws NullPointerException if {@code webLinks} is {@code null} + */ @Override public IssueReport validate(List webLinks) { // Throws NPE early @@ -61,6 +147,19 @@ public IssueReport validate(List webLinks) { return new IssueReport(issues); } + /** + * Runs all model-level checks for a single {@link WebLink}. + * + *

+ * The checks are intentionally ordered so that "cheap and structural" validations happen early + * (target URI, relation presence) before more detailed validations (relation token grammar, + * parameter name token rules, multiplicity). This ordering improves debugging signal. + *

+ * + * @param currentLink the link to validate (non-null) + * @param index index of the link in the original list (used for error localization) + * @param issues mutable sink for issues (append-only) + */ private static void validate(WebLink currentLink, int index, List issues) { validateTargetUri(currentLink.target(), index, issues); validateRelationPresence(currentLink, index, issues); @@ -70,6 +169,19 @@ private static void validate(WebLink currentLink, int index, List issues) currentLink.anchor().ifPresent(anchor -> validateAnchorAttribute(anchor, index, issues)); } + /** + * Validates the {@code anchor} parameter value, if present. + * + *

+ * Policy: the model must contain an absolute URI for {@code anchor}. While RFC 8288 allows + * URI-references, a parser is expected to resolve them to absolute URIs at model construction time. + * Therefore, a relative anchor indicates a producer/parser contract violation and is an {@code ERROR}. + *

+ * + * @param anchor raw anchor value from {@link WebLink#anchor()} + * @param index index of the link in the original list + * @param issues issue sink + */ private static void validateAnchorAttribute(String anchor, int index, List issues) { if (anchor.isBlank()) { issues.add(Issue.error("Anchor attribute value is empty for element at index %d".formatted(index))); @@ -85,6 +197,27 @@ private static void validateAnchorAttribute(String anchor, int index, List + * RFC 8288 defines multiplicity constraints for many parameters. This validator implements a + * conservative rule: + *

+ *
    + *
  • {@code hreflang} may appear more than once.
  • + *
  • All other parameters appearing more than once are reported.
  • + *
+ * + *

Important: The current implementation reports duplicates as {@code ERROR}. + * RFC 8288 often specifies "occurrences after the first MUST be ignored by parsers". If you want to + * align strictly with that behavior at the model layer, you could downgrade some findings to + * {@code WARNING}. This is a maintainer decision and should be reflected in tests.

+ * + * @param targetAttributes list of parameters on the {@link WebLink} + * @param index index of the link in the original list + * @param issues issue sink + */ private static void validateTargetAttributeCardinality(List targetAttributes, int index, List issues) { var attributeCounts = targetAttributes.stream().collect( @@ -94,21 +227,50 @@ private static void validateTargetAttributeCardinality(List ta var attributeCount = entry.getValue(); if (attributeCount > 1 && !multipleOccurrencesAllowed(attributeName)) { issues.add(Issue.error( - "Multiple attribute definition available. Target attribute '%s' must appear more than once for element at index %d".formatted( + "Multiple attribute definition available. Target attribute '%s' must not appear more than once for element at index %d".formatted( attributeName, index))); } } } + /** + * Returns whether a target attribute is allowed to occur multiple times. + * + *

+ * RFC 8288 allows multiple {@code hreflang}. Most other parameters are single-occurrence + * and are either ignored after the first occurrence or treated as invalid depending on the layer. + *

+ * + * @param attributeName parameter name + * @return true if repeated occurrence is allowed + */ private static boolean multipleOccurrencesAllowed(String attributeName) { return attributeName.equals("hreflang"); } + /** + * Validates all parameter names of the given {@link WebLink}. + * + *

+ * Parameter names must conform to the RFC 7230 token grammar. + *

+ * + * @param currentLink link to inspect + * @param index list index for localization + * @param issues issue sink + */ private static void validateParameterNames(WebLink currentLink, int index, List issues) { currentLink.params() .forEach(parameter -> validateParameterName(parameter.name(), index, issues)); } + /** + * Validates a single parameter name for token compliance. + * + * @param name parameter name + * @param index list index for localization + * @param issues issue sink + */ private static void validateParameterName(String name, int index, List issues) { if (!ALLOWED_TOKEN_CHARS.matcher(name).matches()) { issues.add(Issue.error( @@ -116,6 +278,22 @@ private static void validateParameterName(String name, int index, List is } } + /** + * Ensures that the link has at least one relation type token. + * + *

+ * RFC 8288 requires the {@code rel} parameter to be present in the serialized representation. + * At model level, we approximate this requirement by ensuring {@link WebLink#rel()} is non-empty. + *

+ * + *

Maintainer note: This does not enforce "rel MUST NOT appear more than once" + * because {@link WebLink#rel()} is a derived list of tokens. If strict "single rel parameter" needs + * enforcement, validate based on {@link WebLink#params()} and count {@code rel} parameters.

+ * + * @param currentLink current link + * @param index list index for localization + * @param issues issue sink + */ private static void validateRelationPresence(WebLink currentLink, int index, List issues) { if (currentLink.rel().isEmpty()) { issues.add( @@ -123,6 +301,32 @@ private static void validateRelationPresence(WebLink currentLink, int index, Lis } } + /** + * Validates the link target URI ({@code <...>}) according to RFC 8288 model expectations. + * + *

Normative basis

+ *

+ * RFC 8288 defines the link target as a URI-reference in the serialized syntax. However, + * Compass operates on a parsed model ({@link WebLink}) and applies the policy that any + * URI-references must already be resolved at the parsing boundary. Therefore, a relative target + * at model level is treated as an {@code ERROR}. + *

+ * + *

Compass policy

+ *
    + *
  • If the target URI is not absolute: {@code ERROR}.
  • + *
  • If the target URI scheme is not {@code http} or {@code https}: {@code WARNING}.
  • + *
+ * + *

+ * The non-HTTP(S) case is not an RFC violation. It is a policy choice aligned with typical + * FAIR Signposting deployments, where link targets are expected to be web-resolvable. + *

+ * + * @param targetUri the target URI of the link (from {@link WebLink#target()}) + * @param index index of the link in the original list (used for issue localization) + * @param issues issue sink + */ private static void validateTargetUri(URI targetUri, int index, List issues) { // Validates RFC 8288 Section 3.1 Link Target normative requirement for a target value if (!targetUri.isAbsolute()) { @@ -138,6 +342,21 @@ private static void validateTargetUri(URI targetUri, int index, List issu } } + /** + * Validates a single relation type token. + * + *

+ * A token is valid if it is either: + *

+ *
    + *
  • a valid absolute URI (extension relation type), or
  • + *
  • a registered relation type token matching the RFC 8288 ABNF.
  • + *
+ * + *

+ * {@code null}, empty, or whitespace-only tokens are reported as errors. + *

+ */ private static void validateRelationTypeToken(List relationTypes, int index, List issues) { for (var token : relationTypes) { @@ -145,6 +364,26 @@ private static void validateRelationTypeToken(List relationTypes, int in } } + /** + * Validates a single relation type token extracted from {@link WebLink#rel()}. + * + *

+ * A token is considered valid if it is either: + *

+ *
    + *
  • a valid absolute URI (extension relation type, {@code ext-rel-type}), or
  • + *
  • a registered relation type token matching {@code reg-rel-type} ABNF.
  • + *
+ * + *

+ * This method intentionally does not enforce that the serialized {@code rel} parameter occurred + * exactly once. That check must be performed on {@link WebLink#params()} if desired. + *

+ * + * @param typeToken relation type token (must not be {@code null} / blank) + * @param index index of the owning link in the original list + * @param issues issue sink + */ private static void validateRelationTypeToken(String typeToken, int index, List issues) { if (typeToken == null) { issues.add( @@ -167,6 +406,18 @@ private static void validateRelationTypeToken(String typeToken, int index, List< } } + /** + * Checks whether the provided string is a syntactically valid absolute URI. + * + *

+ * This is used to detect extension relation types ({@code ext-rel-type}) and anchor values. + * Relative URIs are intentionally rejected here because Compass expects parsers to resolve + * URI-references before constructing the model. + *

+ * + * @param value string to test + * @return {@code true} if {@code value} parses as an absolute {@link URI}, otherwise {@code false} + */ private static boolean isValidUri(String value) { try { var uri = URI.create(value); @@ -176,7 +427,13 @@ private static boolean isValidUri(String value) { } } + /** + * Returns whether the provided URI scheme denotes HTTP(S). + * + * @param scheme the URI scheme string (must not be {@code null}) + * @return {@code true} if scheme is {@code "http"} or {@code "https"} + */ private static boolean isHttpOrHttps(String scheme) { - return scheme.equals("http") || scheme.equals("https"); + return scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"); } } From be24d8f0aab3aff9311cc0fd851a8a65a69ceb60 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 16 Jan 2026 13:34:59 +0100 Subject: [PATCH 4/9] Checkin progress --- .../qbic/compass/SignPostingProcessor.java | 2 + .../validation/Rfc8288ModelValidator.java | 228 ++++++++++-------- .../validation/WebLinkModelValidator.java | 118 ++++++++- .../Rfc8288ModelValidatorSpec.groovy | 14 +- 4 files changed, 256 insertions(+), 106 deletions(-) diff --git a/src/main/java/life/qbic/compass/SignPostingProcessor.java b/src/main/java/life/qbic/compass/SignPostingProcessor.java index 9595818..4a40566 100644 --- a/src/main/java/life/qbic/compass/SignPostingProcessor.java +++ b/src/main/java/life/qbic/compass/SignPostingProcessor.java @@ -64,6 +64,8 @@ private SignPostingProcessor(List validators) { public SignPostingResult process(List webLinks) throws NullPointerException { Objects.requireNonNull(webLinks); + + var recordedIssues = validators.stream() .map(validator -> validator.validate(webLinks)) .map(SignPostingResult::issueReport) diff --git a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java index f6923f9..69edcca 100644 --- a/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java +++ b/src/main/java/life/qbic/compass/validation/Rfc8288ModelValidator.java @@ -15,24 +15,24 @@ * Internal, model-level validator for RFC 8288 ("Web Linking") constraints. * *

Audience: This class is package-private and intended for maintainers of the - * Compass library. It is not a public API and its exact issue wording is allowed to change. - * Tests should primarily assert on the presence and type of issues (ERROR/WARNING) and on stable - * message fragments.

+ * Compass library. It is not a public API and its exact issue wording is allowed to + * change. Tests should primarily assert on the presence and type of issues (ERROR/WARNING) and on + * stable message fragments.

* *

Scope

*

* This validator checks the in-memory {@link WebLink} model for normative and structural - * requirements that follow from RFC 8288 and closely related ABNF/token rules (e.g. RFC 7230 - * token production used by RFC 8288 for parameter names). It does not parse header field - * syntax; it assumes parsing already happened upstream (e.g. by Linksmith or other tooling). + * requirements that follow from RFC 8288 and closely related ABNF/token rules (e.g. RFC 7230 token + * production used by RFC 8288 for parameter names). It does not parse header field syntax; it + * assumes parsing already happened upstream (e.g. by Linksmith or other tooling). *

* *

Why this exists in Compass

*

- * Linksmith's {@link WebLink} model is intentionally permissive. Compass operates on {@link WebLink} - * objects that may originate from different sources and therefore cannot assume that upstream - * parsing/validation has enforced all RFC 8288 requirements. This validator provides a - * deterministic safety net at the model boundary. + * Linksmith's {@link WebLink} model is intentionally permissive. Compass operates on + * {@link WebLink} objects that may originate from different sources and therefore cannot assume + * that upstream parsing/validation has enforced all RFC 8288 requirements. This validator provides + * a deterministic safety net at the model boundary. *

* *

Policy decisions encoded here

@@ -62,8 +62,8 @@ * that is done by Compass recipe validators. * * - * @since 1.0.0 * @author Sven Fillinger + * @since 1.0.0 */ class Rfc8288ModelValidator implements WebLinkModelValidator { @@ -77,8 +77,8 @@ class Rfc8288ModelValidator implements WebLinkModelValidator { * } * *

- * This pattern matches the registered token form and is compiled case-insensitive because RFC 8288 - * specifies case-insensitive comparison for registered relation types. + * This pattern matches the registered token form and is compiled case-insensitive because RFC + * 8288 specifies case-insensitive comparison for registered relation types. *

* *

Note for maintainers: Case-insensitive comparison does not mean the token @@ -93,8 +93,8 @@ class Rfc8288ModelValidator implements WebLinkModelValidator { * RFC 7230 "token" character class used by RFC 8288 for parameter names. * *

- * RFC 8288 uses HTTP header parameter conventions, which rely on RFC 7230 token syntax. - * This pattern is applied to {@link WebLinkParameter#name()}. + * RFC 8288 uses HTTP header parameter conventions, which rely on RFC 7230 token syntax. This + * pattern is applied to {@link WebLinkParameter#name()}. *

*/ private static final Pattern ALLOWED_TOKEN_CHARS = Pattern.compile( @@ -104,7 +104,8 @@ class Rfc8288ModelValidator implements WebLinkModelValidator { * Factory for internal use. * *

- * Kept package-private intentionally. The class is internal; callers should use Compass entry points. + * Kept package-private intentionally. The class is internal; callers should use Compass entry + * points. *

* * @return a new {@link Rfc8288ModelValidator} @@ -122,29 +123,30 @@ static Rfc8288ModelValidator create() { *

* *

- * The returned {@link IssueReport} contains all findings; validation is intentionally non-fatal and - * does not stop after the first violation. + * The returned {@link IssueReport} contains all findings; validation is intentionally non-fatal + * and does not stop after the first violation. *

* * @param webLinks list of model links to validate (must not be {@code null}) - * @return an {@link IssueReport} containing all recorded issues + * @return an {@link ModelValidationResult} containing all detected issues and the indices of + * weblinks with recorded ERROR * @throws NullPointerException if {@code webLinks} is {@code null} */ @Override - public IssueReport validate(List webLinks) { + public ModelValidationResult validate(List webLinks) { // Throws NPE early Objects.requireNonNull(webLinks); - - var issues = new ArrayList(); + var issueSink = new IssueSink(new ArrayList<>(), new boolean[webLinks.size()], 0); for (int index = 0; index < webLinks.size(); index++) { + issueSink.currentIndex = index; var currentLink = webLinks.get(index); if (currentLink == null) { - issues.add(Issue.error("Element is null at index %d".formatted(index))); + issueSink.addError("Element is null at index %d".formatted(index)); continue; } - validate(currentLink, index, issues); + validate(currentLink, issueSink); } - return new IssueReport(issues); + return new ModelValidationResult(new IssueReport(issueSink.issues), issueSink.blockingLinkIndices); } /** @@ -157,16 +159,17 @@ public IssueReport validate(List webLinks) { *

* * @param currentLink the link to validate (non-null) - * @param index index of the link in the original list (used for error localization) - * @param issues mutable sink for issues (append-only) + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validate(WebLink currentLink, int index, List issues) { - validateTargetUri(currentLink.target(), index, issues); - validateRelationPresence(currentLink, index, issues); - validateRelationTypeToken(currentLink.rel(), index, issues); - validateParameterNames(currentLink, index, issues); - validateTargetAttributeCardinality(currentLink.params(), index, issues); - currentLink.anchor().ifPresent(anchor -> validateAnchorAttribute(anchor, index, issues)); + private static void validate(WebLink currentLink, IssueSink issueSink) { + validateTargetUri(currentLink.target(), issueSink); + validateRelationPresence(currentLink, issueSink); + validateRelationTypeToken(currentLink.rel(), issueSink); + validateParameterNames(currentLink, issueSink); + validateTargetAttributeCardinality(currentLink.params(), issueSink); + currentLink.anchor().ifPresent(anchor -> validateAnchorAttribute(anchor, issueSink)); + } /** @@ -174,26 +177,32 @@ private static void validate(WebLink currentLink, int index, List issues) * *

* Policy: the model must contain an absolute URI for {@code anchor}. While RFC 8288 allows - * URI-references, a parser is expected to resolve them to absolute URIs at model construction time. - * Therefore, a relative anchor indicates a producer/parser contract violation and is an {@code ERROR}. + * URI-references, a parser is expected to resolve them to absolute URIs at model construction + * time. Therefore, a relative anchor indicates a producer/parser contract violation and is an + * {@code ERROR}. *

* - * @param anchor raw anchor value from {@link WebLink#anchor()} - * @param index index of the link in the original list - * @param issues issue sink + * @param anchor raw anchor value from {@link WebLink#anchor()} + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validateAnchorAttribute(String anchor, int index, List issues) { + private static void validateAnchorAttribute(String anchor, IssueSink issueSink) { if (anchor.isBlank()) { - issues.add(Issue.error("Anchor attribute value is empty for element at index %d".formatted(index))); + issueSink.addError("Anchor attribute value is empty for element at index %d".formatted( + issueSink.currentIndex)); return; } try { var uri = URI.create(anchor); if (!uri.isAbsolute()) { - issues.add(Issue.error("Invalid anchor value. URI is not absolute for element at index %d".formatted(index))); + issueSink.addError( + "Invalid anchor value. URI is not absolute for element at index %d".formatted( + issueSink.currentIndex)); } } catch (IllegalArgumentException ignored) { - issues.add(Issue.error("Invalid anchor attribute value. '%s' is not an URI for element at index %d".formatted(anchor, index))); + issueSink.addError( + "Invalid anchor attribute value. '%s' is not an URI for element at index %d".formatted( + anchor, issueSink.currentIndex)); } } @@ -210,25 +219,24 @@ private static void validateAnchorAttribute(String anchor, int index, List * *

Important: The current implementation reports duplicates as {@code ERROR}. - * RFC 8288 often specifies "occurrences after the first MUST be ignored by parsers". If you want to - * align strictly with that behavior at the model layer, you could downgrade some findings to - * {@code WARNING}. This is a maintainer decision and should be reflected in tests.

+ * RFC 8288 often specifies "occurrences after the first MUST be ignored by parsers". + *

* * @param targetAttributes list of parameters on the {@link WebLink} - * @param index index of the link in the original list - * @param issues issue sink + * @param issueSink a container with the recorded issues, the current index and the + * recorded blocking weblinks */ private static void validateTargetAttributeCardinality(List targetAttributes, - int index, List issues) { + IssueSink issueSink) { var attributeCounts = targetAttributes.stream().collect( Collectors.groupingBy(WebLinkParameter::name, Collectors.counting())); for (var entry : attributeCounts.entrySet()) { var attributeName = entry.getKey(); var attributeCount = entry.getValue(); if (attributeCount > 1 && !multipleOccurrencesAllowed(attributeName)) { - issues.add(Issue.error( + issueSink.addError( "Multiple attribute definition available. Target attribute '%s' must not appear more than once for element at index %d".formatted( - attributeName, index))); + attributeName, issueSink.currentIndex)); } } } @@ -237,8 +245,8 @@ private static void validateTargetAttributeCardinality(List ta * Returns whether a target attribute is allowed to occur multiple times. * *

- * RFC 8288 allows multiple {@code hreflang}. Most other parameters are single-occurrence - * and are either ignored after the first occurrence or treated as invalid depending on the layer. + * RFC 8288 allows multiple {@code hreflang}. Most other parameters are single-occurrence and are + * treated as invalid. *

* * @param attributeName parameter name @@ -256,25 +264,25 @@ private static boolean multipleOccurrencesAllowed(String attributeName) { *

* * @param currentLink link to inspect - * @param index list index for localization - * @param issues issue sink + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validateParameterNames(WebLink currentLink, int index, List issues) { + private static void validateParameterNames(WebLink currentLink, IssueSink issueSink) { currentLink.params() - .forEach(parameter -> validateParameterName(parameter.name(), index, issues)); + .forEach(parameter -> validateParameterName(parameter.name(), issueSink)); } /** * Validates a single parameter name for token compliance. * - * @param name parameter name - * @param index list index for localization - * @param issues issue sink + * @param name parameter name + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validateParameterName(String name, int index, List issues) { + private static void validateParameterName(String name, IssueSink issueSink) { if (!ALLOWED_TOKEN_CHARS.matcher(name).matches()) { - issues.add(Issue.error( - "Invalid parameter name '%s' for element at index %d".formatted(name, index))); + issueSink.addError("Invalid parameter name '%s' for element at index %d".formatted(name, + issueSink.currentIndex)); } } @@ -282,22 +290,23 @@ private static void validateParameterName(String name, int index, List is * Ensures that the link has at least one relation type token. * *

- * RFC 8288 requires the {@code rel} parameter to be present in the serialized representation. - * At model level, we approximate this requirement by ensuring {@link WebLink#rel()} is non-empty. + * RFC 8288 requires the {@code rel} parameter to be present in the serialized representation. At + * model level, we approximate this requirement by ensuring {@link WebLink#rel()} is non-empty. *

* - *

Maintainer note: This does not enforce "rel MUST NOT appear more than once" - * because {@link WebLink#rel()} is a derived list of tokens. If strict "single rel parameter" needs - * enforcement, validate based on {@link WebLink#params()} and count {@code rel} parameters.

+ *

Maintainer note: This does not enforce "rel MUST NOT appear more than + * once" because {@link WebLink#rel()} is a derived list of tokens. If strict "single rel + * parameter" needs enforcement, validate based on {@link WebLink#params()} and count {@code rel} + * parameters.

* * @param currentLink current link - * @param index list index for localization - * @param issues issue sink + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validateRelationPresence(WebLink currentLink, int index, List issues) { + private static void validateRelationPresence(WebLink currentLink, IssueSink issueSink) { if (currentLink.rel().isEmpty()) { - issues.add( - Issue.error("Missing relation parameter for element at index %d".formatted(index))); + issueSink.addError( + "Missing relation parameter for element at index %d".formatted(issueSink.currentIndex)); } } @@ -306,8 +315,8 @@ private static void validateRelationPresence(WebLink currentLink, int index, Lis * *

Normative basis

*

- * RFC 8288 defines the link target as a URI-reference in the serialized syntax. However, - * Compass operates on a parsed model ({@link WebLink}) and applies the policy that any + * RFC 8288 defines the link target as a URI-reference in the serialized syntax. However, Compass + * operates on a parsed model ({@link WebLink}) and applies the policy that any * URI-references must already be resolved at the parsing boundary. Therefore, a relative target * at model level is treated as an {@code ERROR}. *

@@ -324,21 +333,21 @@ private static void validateRelationPresence(WebLink currentLink, int index, Lis *

* * @param targetUri the target URI of the link (from {@link WebLink#target()}) - * @param index index of the link in the original list (used for issue localization) - * @param issues issue sink + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validateTargetUri(URI targetUri, int index, List issues) { + private static void validateTargetUri(URI targetUri, IssueSink issueSink) { // Validates RFC 8288 Section 3.1 Link Target normative requirement for a target value if (!targetUri.isAbsolute()) { - issues.add( - Issue.error("Link target URI is relative for element at index %d".formatted(index))); + issueSink.addError( + "Link target URI is relative for element at index %d".formatted(issueSink.currentIndex)); return; } var scheme = targetUri.getScheme(); if (!isHttpOrHttps(scheme)) { - issues.add(Issue.warning( - "Link target URI scheme is non-http for element at index %d: '%s'".formatted(index, - scheme))); + issueSink.addError( + "Link target URI scheme is non-http for element at index %d: '%s'".formatted( + issueSink.currentIndex, scheme)); } } @@ -357,10 +366,9 @@ private static void validateTargetUri(URI targetUri, int index, List issu * {@code null}, empty, or whitespace-only tokens are reported as errors. *

*/ - private static void validateRelationTypeToken(List relationTypes, int index, - List issues) { + private static void validateRelationTypeToken(List relationTypes, IssueSink issueSink) { for (var token : relationTypes) { - validateRelationTypeToken(token, index, issues); + validateRelationTypeToken(token, issueSink); } } @@ -381,28 +389,28 @@ private static void validateRelationTypeToken(List relationTypes, int in *

* * @param typeToken relation type token (must not be {@code null} / blank) - * @param index index of the owning link in the original list - * @param issues issue sink + * @param issueSink a container with the recorded issues, the current index and the recorded + * blocking weblinks */ - private static void validateRelationTypeToken(String typeToken, int index, List issues) { + private static void validateRelationTypeToken(String typeToken, IssueSink issueSink) { if (typeToken == null) { - issues.add( - Issue.error("Relation type token is null for element at index %d".formatted(index))); + issueSink.addError( + "Relation type token is null for element at index %d".formatted(issueSink.currentIndex)); return; } if (typeToken.isBlank()) { - issues.add(Issue.error( + issueSink.addError( "Relation type token is invalid (reason: empty) for element at index %d".formatted( - index))); + issueSink.currentIndex)); return; } if (isValidUri(typeToken)) { return; } if (!REGULAR_RELATION_TYPE_PATTERN.matcher(typeToken).matches()) { - issues.add(Issue.error( + issueSink.addError( "Relation type token contains invalid characters for element at index %d".formatted( - index))); + issueSink.currentIndex)); } } @@ -416,7 +424,8 @@ private static void validateRelationTypeToken(String typeToken, int index, List< *

* * @param value string to test - * @return {@code true} if {@code value} parses as an absolute {@link URI}, otherwise {@code false} + * @return {@code true} if {@code value} parses as an absolute {@link URI}, otherwise + * {@code false} */ private static boolean isValidUri(String value) { try { @@ -436,4 +445,31 @@ private static boolean isValidUri(String value) { private static boolean isHttpOrHttps(String scheme) { return scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"); } + + static final class IssueSink { + + private final List issues; + private final boolean[] blockingLinkIndices; + private int currentIndex; + + IssueSink(List issues, boolean[] blockingLinkIndices, int currentIndex) { + this.issues = Objects.requireNonNull(issues); + this.blockingLinkIndices = Objects.requireNonNull(blockingLinkIndices); + this.currentIndex = currentIndex; + } + + void addWarning(String message) { + addIssue(Issue.warning(message)); + } + + void addError(String message) { + addIssue(Issue.error(message)); + blockingLinkIndices[currentIndex] = true; + } + + private void addIssue(Issue issue) { + issues.add(issue); + } + + } } diff --git a/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java b/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java index eee7ce9..2b15d8d 100644 --- a/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java +++ b/src/main/java/life/qbic/compass/validation/WebLinkModelValidator.java @@ -1,16 +1,128 @@ package life.qbic.compass.validation; +import java.util.Arrays; import java.util.List; +import java.util.Objects; import life.qbic.linksmith.model.WebLink; import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; /** - * + * Contract for validating {@link WebLink} objects at the model level. * - * @since + *

Audience: Maintainers of the Compass library. + * This interface is internal to the validation layer and is not intended as a public extension + * point for end users.

+ * + *

Purpose

+ *

+ * A {@code WebLinkModelValidator} performs semantic and normative checks on already-parsed + * {@link WebLink} instances. Unlike parsers, it does not deal with serialization syntax (e.g. HTTP + * {@code Link} header grammar), but instead validates invariants that must hold for the in-memory + * model. + *

+ * + *

+ * Typical responsibilities include: + *

+ *
    + *
  • checking RFC-level constraints that are not guaranteed by parsers,
  • + *
  • detecting invalid or inconsistent model states,
  • + *
  • reporting violations in a deterministic and non-fatal manner.
  • + *
+ * + *

Position in the Compass architecture

+ *

+ * Model validators sit below Signposting profile and recipe validators: + *

+ *
    + *
  • Parsers produce {@link WebLink} instances (possibly permissive).
  • + *
  • {@code WebLinkModelValidator}s ensure the model obeys core Web Linking rules + * (e.g. RFC 8288 invariants).
  • + *
  • Signposting validators operate on a trusted model to apply FAIR-specific semantics.
  • + *
+ * + *

+ * This separation keeps profile validation logic free from low-level defensive checks + * and avoids repeating RFC-level validation across multiple validators. + *

+ * + *

Validation contract

+ *
    + *
  • The input list itself must not be {@code null}; implementations may throw + * {@link NullPointerException} otherwise.
  • + *
  • Implementations must be robust against {@code null} elements within the list + * and are expected to report them as validation issues rather than failing.
  • + *
  • Validation must be non-destructive: implementations must not + * modify the supplied list or its {@link WebLink} elements.
  • + *
  • All findings must be reported via the returned {@link IssueReport}; + * implementations must not throw on validation failures.
  • + *
+ * + *

Error severity

+ *

+ * Implementations may distinguish between: + *

+ *
    + *
  • ERROR — the model violates a normative requirement + * (e.g. invalid URI, missing relation type).
  • + *
  • WARNING — the model is technically valid but suspicious + * or problematic for interoperability.
  • + *
+ * + *

+ * The exact severity mapping is a policy decision of the implementing validator + * and should be documented and tested accordingly. + *

+ * + * @author Sven Fillinger + * @since 1.0.0 */ interface WebLinkModelValidator { - IssueReport validate(List webLinks); + /** + * Validates a list of {@link WebLink} objects against model-level constraints. + * + *

+ * Implementations must inspect each element independently and accumulate all detected issues into + * the returned {@link IssueReport}. Validation must not stop after the first failure. + *

+ * + * @param webLinks the list of {@link WebLink} instances to validate (must not be {@code null}) + * @return an {@link ModelValidationResult} containing all detected issues and the indices of + * weblinks with recorded ERROR + * @throws NullPointerException if {@code webLinks} is {@code null} + */ + ModelValidationResult validate(List webLinks); + + + record ModelValidationResult(IssueReport issueReport, boolean[] blockingLinkByIndex) { + + public ModelValidationResult { + issueReport = new IssueReport(List.copyOf(issueReport.issues())); + blockingLinkByIndex = Arrays.copyOf(blockingLinkByIndex, blockingLinkByIndex.length); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + ModelValidationResult that = (ModelValidationResult) o; + return Objects.equals(issueReport, that.issueReport) && Objects.deepEquals( + blockingLinkByIndex, that.blockingLinkByIndex); + } + + @Override + public int hashCode() { + return Objects.hash(issueReport, Arrays.hashCode(blockingLinkByIndex)); + } + @Override + public String toString() { + return "ModelValidationResult{" + + "issueReport=" + issueReport + + ", blockingLinkByIndex=" + Arrays.toString(blockingLinkByIndex) + + '}'; + } + } } diff --git a/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy b/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy index 8734849..7581916 100644 --- a/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy +++ b/src/test/groovy/life/qbic/compass/validation/Rfc8288ModelValidatorSpec.groovy @@ -50,12 +50,12 @@ class Rfc8288ModelValidatorSpec extends Specification { WebLinkParameter.withoutValue(name) } - private static boolean hasErrors(IssueReport r) { - r != null && r.hasErrors() + private static boolean hasErrors(WebLinkModelValidator.ModelValidationResult r) { + r != null && r.issueReport().hasErrors() } - private static boolean hasWarnings(IssueReport r) { - r != null && r.hasWarnings() + private static boolean hasWarnings(WebLinkModelValidator.ModelValidationResult r) { + r != null && r.issueReport().hasWarnings() } private static List issues(IssueReport r) { @@ -66,8 +66,8 @@ class Rfc8288ModelValidatorSpec extends Specification { issues(r)*.message() } - private static boolean anyMsg(IssueReport r, String fragment) { - messages(r).any { it.toLowerCase().contains(fragment.toLowerCase()) } + private static boolean anyMsg(WebLinkModelValidator.ModelValidationResult r, String fragment) { + messages(r.issueReport()).any { it.toLowerCase().contains(fragment.toLowerCase()) } } // ---------------------------------------------------------------------- @@ -448,7 +448,7 @@ class Rfc8288ModelValidatorSpec extends Specification { then: hasErrors(report) - issues(report).size() >= 3 + issues(report.issueReport()).size() >= 3 anyMsg(report, "missing") anyMsg(report, "bad name") anyMsg(report, "relative") From 1ff557377ef094afa8e16520c8b3ebd73fc2649b Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 16 Jan 2026 14:59:24 +0100 Subject: [PATCH 5/9] Draft aggregation strategy --- .../LinkSetViewAggregationStrategy.java | 20 ++ .../qbic/compass/SignPostingProcessor.java | 96 +++++-- .../FailOnMultipleLinkSetViewAggregation.java | 19 ++ .../MergeLinkSetViewAggregation.java | 19 ++ .../processing/NoLinkSetViewAggregation.java | 35 +++ .../TakeFirstLinkSetViewAggregation.java | 19 ++ .../WebLinkModelValidator.java | 4 +- .../validation/Rfc8288ModelValidator.java | 1 + .../validation/WebLinkModelValidators.java | 18 ++ ...inkSetViewAggregationStrategiesSpec.groovy | 251 ++++++++++++++++++ .../Rfc8288ModelValidatorSpec.groovy | 1 + 11 files changed, 462 insertions(+), 21 deletions(-) create mode 100644 src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java create mode 100644 src/main/java/life/qbic/compass/processing/FailOnMultipleLinkSetViewAggregation.java create mode 100644 src/main/java/life/qbic/compass/processing/MergeLinkSetViewAggregation.java create mode 100644 src/main/java/life/qbic/compass/processing/NoLinkSetViewAggregation.java create mode 100644 src/main/java/life/qbic/compass/processing/TakeFirstLinkSetViewAggregation.java rename src/main/java/life/qbic/compass/{validation => spi}/WebLinkModelValidator.java (98%) create mode 100644 src/main/java/life/qbic/compass/validation/WebLinkModelValidators.java create mode 100644 src/test/groovy/life/qbic/compass/processing/LinkSetViewAggregationStrategiesSpec.groovy diff --git a/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java b/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java new file mode 100644 index 0000000..51e1e82 --- /dev/null +++ b/src/main/java/life/qbic/compass/LinkSetViewAggregationStrategy.java @@ -0,0 +1,20 @@ +package life.qbic.compass; + +import java.util.List; +import life.qbic.compass.model.SignPostingResult; + +/** + * + * + * @since + */ +public interface LinkSetViewAggregationStrategy { + + SignPostingResult apply(List results) throws AggregationStrategyException; + + class AggregationStrategyException extends RuntimeException { + public AggregationStrategyException(String message) { + super(message); + } + } +} diff --git a/src/main/java/life/qbic/compass/SignPostingProcessor.java b/src/main/java/life/qbic/compass/SignPostingProcessor.java index c0aa099..2f71c7c 100644 --- a/src/main/java/life/qbic/compass/SignPostingProcessor.java +++ b/src/main/java/life/qbic/compass/SignPostingProcessor.java @@ -4,11 +4,14 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; -import life.qbic.compass.model.SignPostingView; import life.qbic.compass.model.SignPostingResult; +import life.qbic.compass.model.SignPostingView; import life.qbic.compass.spi.SignPostingValidator; +import life.qbic.compass.spi.WebLinkModelValidator; import life.qbic.compass.validation.Level1SignPostingValidator; +import life.qbic.compass.validation.WebLinkModelValidators; import life.qbic.linksmith.model.WebLink; +import life.qbic.linksmith.spi.WebLinkValidator.Issue; import life.qbic.linksmith.spi.WebLinkValidator.IssueReport; /** @@ -54,20 +57,32 @@ */ public final class SignPostingProcessor { + public enum LinkSetViewAggregation { + NONE, + FIRST, + MERGE, + FAIL_ON_MULTIPLE; + } + private final List validators; + private final WebLinkModelValidator modelValidator; - private SignPostingProcessor(List validators) { + private SignPostingProcessor(List validators, + WebLinkModelValidator modelValidator) { Objects.requireNonNull(validators); + Objects.requireNonNull(modelValidator); this.validators = List.copyOf(validators); + this.modelValidator = modelValidator; + } /** - * Applies all configured {@link SignPostingValidator}s to the provided WebLinks - * and aggregates their reported issues into a single {@link SignPostingResult}. + * Applies all configured {@link SignPostingValidator}s to the provided WebLinks and aggregates + * their reported issues into a single {@link SignPostingResult}. * *

- * Each validator is executed independently and receives the same - * input list. Validators are not allowed to mutate the input. + * Each validator is executed independently and receives the same input list. Validators + * are not allowed to mutate the input. *

* *

Aggregation semantics

@@ -106,8 +121,8 @@ 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 + * @return a {@link SignPostingResult} containing the aggregated issues and a + * {@link SignPostingView} over the input links * @throws NullPointerException if {@code webLinks} is {@code null} */ public SignPostingResult process(List webLinks) throws NullPointerException { @@ -116,34 +131,58 @@ public SignPostingResult process(List webLinks) throws NullPointerExcep .filter(Objects::nonNull) .toList(); + var issues = new ArrayList(); + + var sanitizedLinks = applyModelValidation(safeLinks, modelValidator, issues); var recordedIssues = validators.stream() - .map(validator -> validator.validate(safeLinks)) + .map(validator -> validator.validate(sanitizedLinks)) .map(SignPostingResult::issueReport) .flatMap(report -> report.issues().stream()) .toList(); - return new SignPostingResult(new SignPostingView(webLinks), new IssueReport(recordedIssues), null); + issues.addAll(recordedIssues); + + return new SignPostingResult(new SignPostingView(sanitizedLinks), new IssueReport(issues), + null); + } + + private static List applyModelValidation(List webLinks, + WebLinkModelValidator modelValidator, List issues) { + var result = modelValidator.validate(webLinks); + var sanitizedLinks = new ArrayList(); + for (int index = 0; index < webLinks.size(); index++) { + if (!result.blockingLinkByIndex()[index]) { + sanitizedLinks.add(webLinks.get(index)); + } + } + issues.addAll(result.issueReport().issues()); + return sanitizedLinks; } /** - * Builder for constructing a {@link SignPostingProcessor} with a configurable - * set of {@link SignPostingValidator}s. + * Builder for constructing a {@link SignPostingProcessor} with a configurable set of + * {@link SignPostingValidator}s. * *

* Validators are executed in the order they are added to the builder. *

* *

- * If no validators are explicitly configured, the processor defaults to - * using {@link Level1SignPostingValidator}. + * If no validators are explicitly configured, the processor defaults to using + * {@link Level1SignPostingValidator}. *

*/ public static final class Builder { private List validators = new ArrayList<>(); + /** + * Sensible default for the Weblink model validator is the provided RFC 8288 implementation + */ + private WebLinkModelValidator modelValidator = WebLinkModelValidators.rfc8288(); + /** * Adds one or more validators to this processor. * @@ -155,7 +194,7 @@ public static final class Builder { * @return this builder for fluent chaining * @throws NullPointerException if {@code validators} is {@code null} */ - Builder withValidators(SignPostingValidator... validators) { + public Builder withValidators(SignPostingValidator... validators) { return this.withValidators(Arrays.stream(validators).toList()); } @@ -170,11 +209,25 @@ Builder withValidators(SignPostingValidator... validators) { * @return this builder for fluent chaining * @throws NullPointerException if {@code validators} is {@code null} */ - Builder withValidators(List validators) { + public Builder withValidators(List 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: + *
      + *
    1. adding a new enum constant to {@code LinkSetViewAggregationMode},
    2. + *
    3. implementing {@link LinkSetViewAggregationStrategy}, and
    4. + *
    5. registering it in this factory.
    6. + *
    + *
  • + *
+ * + *

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(); } - }