From d626a3b3051258c166eff6023e5b21ea04297603 Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 19 Dec 2025 13:51:13 +0100 Subject: [PATCH 1/3] Provide tests for the weblink model --- .../qbic/linksmith/model/WebLinkSpec.groovy | 353 +++++++++++++++++- 1 file changed, 352 insertions(+), 1 deletion(-) diff --git a/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy b/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy index acfb321..a5d1ab5 100644 --- a/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy +++ b/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy @@ -1,6 +1,8 @@ package life.qbic.linksmith.model +import org.junit.platform.engine.discovery.UriSelector import spock.lang.Specification +import spock.lang.Unroll class WebLinkSpec extends Specification { @@ -17,12 +19,355 @@ class WebLinkSpec extends Specification { } + // -------------------------------------------------------------------------- + // rel (RFC 8288 §3.3) + // - rel MUST be present to convey relation type + // - rel MUST NOT appear more than once in a given link-value; occurrences after the first MUST be ignored + // - rel value is a space-separated list of relation-types: relation-type *( 1*SP relation-type ) + // -------------------------------------------------------------------------- + + def "rel: returns empty list when no rel parameter present"() { + given: + def link = weblink(uri("https://example.org/res"), List.of(parameter("type", "application/json"))) + + expect: + link.rel() == [] + } + + def "rel: splits relation-types on one or more SP characters (space), returning individual values"() { + given: + def link = weblink(uri("https://example.org/res"), List.of( + parameter("rel", "self describedby item") + )) + + expect: + link.rel() == ["self", "describedby", "item"] + } + + @Unroll + def "rel: collapses multiple SP runs (#value) into separators (RFC 8288 §3.3: 1*SP)"() { + given: + def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", value))) + + expect: + link.rel() == expected + + where: + value || expected + "self item" || ["self", "item"] + "self describedby item" || ["self", "describedby", "item"] + "self item " || ["self", "item"] + " self item" || ["self", "item"] + } + + def "rel: if rel appears multiple times, only the first occurrence is used; later occurrences are ignored (RFC 8288 §3.3)"() { + given: + def link = weblink(uri("https://example.org/res"), List.of( + parameter("rel", "self"), + parameter("rel", "describedby item") + )) + + expect: + link.rel() == ["self"] + } + + // -------------------------------------------------------------------------- + // rev (RFC 8288 §3.3, historical / reverse relation) + // RFC 8288 defines 'rev' but does not standardize the same MUST-NOT-REPEAT rule as 'rel'. + // Here we test the API semantics: treat 'rev' values like rel (space-separated list). + // -------------------------------------------------------------------------- + + def "rev: returns empty list when no rev parameter present"() { + given: + def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", "self"))) + + expect: + link.rev() == [] + } + + def "rev: splits reverse relation-types on spaces and returns individual values"() { + given: + def link = weblink(uri("https://example.org/res"), List.of( + parameter("rev", "predecessor successor") + )) + + expect: + link.rev() == ["predecessor", "successor"] + } + + def "rev: supports multiple rev occurrences (API returns all, preserving order)"() { + given: + def link = weblink(uri("https://example.org/res"), List.of( + parameter("rev", "predecessor"), + parameter("rev", "successor") + )) + + expect: + link.rev() == ["predecessor", "successor"] + } + + // -------------------------------------------------------------------------- + // type (RFC 8288 §3.4.3) + // - 'type' is a target attribute indicating the media type of the target resource. + // - If multiple occurrences exist, the API should be deterministic (typically: first wins). + // -------------------------------------------------------------------------- + + def "type: returns empty when absent"() { + given: + def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", "self"))) + + expect: + link.type().isEmpty() + } + + def "type: returns the media type string when present"() { + given: + def link = weblink(uri("https://example.org/res"), List.of( + parameter("type", "application/linkset+json") + )) + + expect: + link.type().get() == "application/linkset+json" + } + + def "type: if multiple type parameters exist, returns the first occurrence"() { + given: + def link = weblink(uri("https://example.org/res"), List.of( + parameter("type", "application/json"), + parameter("type", "text/html") + )) + + expect: + link.type().get() == "application/json" + } + + // -------------------------------------------------------------------------- + // anchor (RFC 8288 §3.2) + // - 'anchor' is a link parameter used to specify the link context (origin) explicitly. + // -------------------------------------------------------------------------- + + def "anchor: returns empty when absent (RFC 8288 §3.2)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("rel", "item") + )) + + expect: + link.anchor().isEmpty() + } + + def "anchor: returns value when present (RFC 8288 §3.2)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("anchor", "https://example.org/context"), + parameter("rel", "item") + )) + + expect: + link.anchor().get() == "https://example.org/context" + } + + def "anchor: if multiple anchor parameters exist, returns the first occurrence deterministically"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("anchor", "https://example.org/context1"), + parameter("anchor", "https://example.org/context2"), + parameter("rel", "item") + )) + + expect: + link.anchor().get() == "https://example.org/context1" + } + + // -------------------------------------------------------------------------- + // hreflang (RFC 8288 §3.4.1) + // - 'hreflang' is a target attribute indicating the language of the target resource. + // - It can appear multiple times. + // -------------------------------------------------------------------------- + + def "hreflang: returns empty list when absent (RFC 8288 §3.4.1)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) + + expect: + link.hreflang() == [] + } + + def "hreflang: returns all occurrences in order (RFC 8288 §3.4.1)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("hreflang", "en"), + parameter("hreflang", "de"), + parameter("hreflang", "fr") + )) + + expect: + link.hreflang() == ["en", "de", "fr"] + } + + // -------------------------------------------------------------------------- + // media (RFC 8288 §3.4.2) + // - 'media' is a target attribute describing intended media / device. + // -------------------------------------------------------------------------- + + def "media: returns empty when absent (RFC 8288 §3.4.2)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) + + expect: + link.media().isEmpty() + } + + def "media: returns value when present (RFC 8288 §3.4.2)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("media", "screen"), + parameter("rel", "item") + )) + + expect: + link.media().get() == "screen" + } + + def "media: if multiple media parameters exist, returns the first occurrence deterministically"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("media", "screen"), + parameter("media", "print"), + parameter("rel", "item") + )) + + expect: + link.media().get() == "screen" + } + + // -------------------------------------------------------------------------- + // title and title* (RFC 8288 §3.4.4; title* references RFC 5987) + // -------------------------------------------------------------------------- + + def "title: returns empty when absent (RFC 8288 §3.4.4)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) + + expect: + link.title().isEmpty() + } + + def "title: returns value when present (RFC 8288 §3.4.4)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("title", "Some title"), + parameter("rel", "item") + )) + + expect: + link.title().get() == "Some title" + } + + def "title: if multiple title parameters exist, returns the first occurrence deterministically"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("title", "First"), + parameter("title", "Second") + )) + + expect: + link.title().get() == "First" + } + + def "titleMultiple: returns empty when absent (RFC 8288 §3.4.4 / RFC 5987)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) + + expect: + link.titleMultiple().isEmpty() + } + + def "titleMultiple: returns value when present (RFC 8288 §3.4.4 / RFC 5987)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("title*", "UTF-8''%E2%9C%93"), + parameter("rel", "item") + )) + + expect: + link.titleMultiple().get() == "UTF-8''%E2%9C%93" + } + + def "titleMultiple: if multiple title* parameters exist, returns the first occurrence deterministically"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("title*", "UTF-8''first"), + parameter("title*", "UTF-8''second") + )) + + expect: + link.titleMultiple().get() == "UTF-8''first" + } + + // -------------------------------------------------------------------------- + // Extension attributes (non-RFC parameter names) + // - extensionAttributes(): groups all non-standard parameter names to values + // - extensionAttribute(name): convenience accessor + // -------------------------------------------------------------------------- + + def "extensionAttributes: returns empty map when no extension attributes exist"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("rel", "item"), + parameter("type", "application/json"), + parameter("title", "t") + )) + + expect: + link.extensionAttributes().isEmpty() + link.extensionAttribute("profile") == [] + } + + def "extensionAttributes: groups unknown parameter names and preserves all values"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("profile", "https://example.org/profile/a"), + parameter("profile", "https://example.org/profile/b"), + parameter("foo", "1"), + parameter("foo", "2"), + parameter("rel", "item") + )) + + when: + def ext = link.extensionAttributes() + + then: + ext.keySet() == ["profile", "foo"] as Set + ext.get("profile") == ["https://example.org/profile/a", "https://example.org/profile/b"] + ext.get("foo") == ["1", "2"] + + and: + link.extensionAttribute("profile") == ["https://example.org/profile/a", "https://example.org/profile/b"] + link.extensionAttribute("does-not-exist") == [] + } + + def "extensionAttributes: parameter name comparison is case-sensitive (API invariant)"() { + given: + def link = weblink(uri("https://example.org/target"), List.of( + parameter("Profile", "X"), + parameter("profile", "Y") + )) + + expect: + link.extensionAttributes().keySet() == ["Profile", "profile"] as Set + } + // ------------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------------ private static WebLink weblink(String uri, List params) { - new WebLink(URI.create(uri), List.copyOf(params)) + weblink(URI.create(uri), List.copyOf(params)) + } + + private static WebLink weblink(URI uri, List params) { + new WebLink(uri, List.copyOf(params)) } private static WebLinkParameter rel(String relValue) { @@ -33,4 +378,10 @@ class WebLinkSpec extends Specification { new WebLinkParameter("type", typeValue) } + private static URI uri(String u) { URI.create(u) } + + private static WebLinkParameter parameter(String name, String value) { + new WebLinkParameter(name, value) + } + } From 3eec1bd801c1a3c2011399c3692b145925382ebe Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 19 Dec 2025 15:55:37 +0100 Subject: [PATCH 2/3] Provide more API method implementations --- .../life/qbic/linksmith/model/WebLink.java | 68 ++++- .../qbic/linksmith/model/WebLinkSpec.groovy | 282 ++++++------------ 2 files changed, 145 insertions(+), 205 deletions(-) diff --git a/src/main/java/life/qbic/linksmith/model/WebLink.java b/src/main/java/life/qbic/linksmith/model/WebLink.java index bc8fa80..72f2eba 100644 --- a/src/main/java/life/qbic/linksmith/model/WebLink.java +++ b/src/main/java/life/qbic/linksmith/model/WebLink.java @@ -7,7 +7,9 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.stream.Stream; import life.qbic.linksmith.core.RfcLinkParameter; /** @@ -53,15 +55,21 @@ public static WebLink create(URI reference) throws NullPointerException { public Optional anchor() { - return Optional.empty(); + return findFirstWithFilter(params, WebLink::isAnchorParameter) + .map(WebLinkParameter::value); } + public List hreflang() { - return List.of(); + return params.stream() + .filter(WebLink::isHreflangParameter) + .map(WebLinkParameter::value) + .toList(); } public Optional media() { - return Optional.empty(); + return findFirstWithFilter(params, WebLink::isMediaParameter) + .map(WebLinkParameter::value); } /** @@ -78,10 +86,9 @@ public Optional media() { * @return a list of relation parameter values */ public List rel() { - return this.params.stream() - .filter(param -> param.name().equals("rel")) + return findAllWithFilter(params, WebLink::isRelParameter) .map(WebLinkParameter::value) - .map(value -> value.split("\\s+")) + .map(WebLink::splitByWhitespace) .flatMap(Arrays::stream) .toList(); } @@ -106,13 +113,14 @@ public List rev() { return this.params.stream() .filter(param -> param.name().equals("rev")) .map(WebLinkParameter::value) - .map(value -> value.split("\\s+")) + .map(WebLink::splitByWhitespace) .flatMap(Arrays::stream) .toList(); } public Optional title() { - return Optional.empty(); + return findFirstWithFilter(params, WebLink::isTitleParameter) + .map(WebLinkParameter::value); } public Optional titleMultiple() { @@ -126,21 +134,61 @@ public Optional titleMultiple() { */ public Optional type() { return this.params.stream() - .filter(WebLink::hasTypeParameter) + .filter(WebLink::isTypeParameter) .findFirst() .map(WebLinkParameter::value); } + private static boolean isAnchorParameter(WebLinkParameter param) { + return param.name().equals("anchor"); + } + + private static boolean isHreflangParameter(WebLinkParameter param) { + return param.name().equals("hreflang"); + } + + private static boolean isMediaParameter(WebLinkParameter param) { + return param.name().equals("media"); + } + + private static boolean isRelParameter(WebLinkParameter param) { + return param.name().equals("rel"); + } + + private static boolean isTitleParameter(WebLinkParameter param) { + return param.name().equals("title"); + } + /** * Checks, if a web link parameter is with name {@code type}. * * @param param the web link parameter to validate * @return true, if the parameter name is {@code type}, else returns false */ - private static boolean hasTypeParameter(WebLinkParameter param) { + private static boolean isTypeParameter(WebLinkParameter param) { return param.name().equals("type"); } + private static String[] splitByWhitespace(String value) { + return value.trim().split("\\s+"); + } + + private static Optional findFirstWithFilter( + List params, + Predicate filter) { + return params.stream() + .filter(filter) + .findFirst(); + } + + private static Stream findAllWithFilter( + List params, + Predicate filter + ) { + return params.stream() + .filter(filter); + } + public Map> extensionAttributes() { Set rfcParameterNames = Arrays.stream(RfcLinkParameter.values()) .map(RfcLinkParameter::rfcValue) diff --git a/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy b/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy index a5d1ab5..b3e2518 100644 --- a/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy +++ b/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy @@ -1,6 +1,6 @@ package life.qbic.linksmith.model -import org.junit.platform.engine.discovery.UriSelector +import spock.lang.Ignore import spock.lang.Specification import spock.lang.Unroll @@ -20,13 +20,12 @@ class WebLinkSpec extends Specification { } // -------------------------------------------------------------------------- - // rel (RFC 8288 §3.3) - // - rel MUST be present to convey relation type - // - rel MUST NOT appear more than once in a given link-value; occurrences after the first MUST be ignored - // - rel value is a space-separated list of relation-types: relation-type *( 1*SP relation-type ) + // rel(): view behavior + // - extracts ALL rel parameter occurrences (no RFC multiplicity enforcement here) + // - splits by whitespace (\\s+) as documented in the model // -------------------------------------------------------------------------- - def "rel: returns empty list when no rel parameter present"() { + def "rel: returns empty list when no rel parameter is present"() { given: def link = weblink(uri("https://example.org/res"), List.of(parameter("type", "application/json"))) @@ -34,7 +33,7 @@ class WebLinkSpec extends Specification { link.rel() == [] } - def "rel: splits relation-types on one or more SP characters (space), returning individual values"() { + def "rel: splits a single rel value into multiple relation-types by whitespace"() { given: def link = weblink(uri("https://example.org/res"), List.of( parameter("rel", "self describedby item") @@ -44,40 +43,38 @@ class WebLinkSpec extends Specification { link.rel() == ["self", "describedby", "item"] } - @Unroll - def "rel: collapses multiple SP runs (#value) into separators (RFC 8288 §3.3: 1*SP)"() { + def "rel: flattens multiple rel parameters (view does not ignore later occurrences)"() { given: - def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", value))) + def link = weblink(uri("https://example.org/res"), List.of( + parameter("rel", "self"), + parameter("rel", "describedby item") + )) expect: - link.rel() == expected - - where: - value || expected - "self item" || ["self", "item"] - "self describedby item" || ["self", "describedby", "item"] - "self item " || ["self", "item"] - " self item" || ["self", "item"] + link.rel() == ["self", "describedby", "item"] } - def "rel: if rel appears multiple times, only the first occurrence is used; later occurrences are ignored (RFC 8288 §3.3)"() { + @Unroll + def "rel: treats any whitespace as separator because implementation uses \\\\s+ (#value)"() { given: - def link = weblink(uri("https://example.org/res"), List.of( - parameter("rel", "self"), - parameter("rel", "describedby item") - )) + def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", value))) expect: - link.rel() == ["self"] + link.rel() == expected + + where: + value || expected + "self item" || ["self", "item"] + "self\titem" || ["self", "item"] + "self\nitem" || ["self", "item"] + " self item " || ["self", "item"] } // -------------------------------------------------------------------------- - // rev (RFC 8288 §3.3, historical / reverse relation) - // RFC 8288 defines 'rev' but does not standardize the same MUST-NOT-REPEAT rule as 'rel'. - // Here we test the API semantics: treat 'rev' values like rel (space-separated list). + // rev(): view behavior (same splitting strategy as rel) // -------------------------------------------------------------------------- - def "rev: returns empty list when no rev parameter present"() { + def "rev: returns empty list when no rev parameter is present"() { given: def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", "self"))) @@ -85,34 +82,32 @@ class WebLinkSpec extends Specification { link.rev() == [] } - def "rev: splits reverse relation-types on spaces and returns individual values"() { + def "rev: splits a single rev value by whitespace"() { given: - def link = weblink(uri("https://example.org/res"), List.of( - parameter("rev", "predecessor successor") - )) + def link = weblink(uri("https://example.org/res"), List.of(parameter("rev", "a b c"))) expect: - link.rev() == ["predecessor", "successor"] + link.rev() == ["a", "b", "c"] } - def "rev: supports multiple rev occurrences (API returns all, preserving order)"() { + def "rev: flattens multiple rev parameters"() { given: def link = weblink(uri("https://example.org/res"), List.of( - parameter("rev", "predecessor"), - parameter("rev", "successor") + parameter("rev", "a"), + parameter("rev", "b c") )) expect: - link.rev() == ["predecessor", "successor"] + link.rev() == ["a", "b", "c"] } // -------------------------------------------------------------------------- - // type (RFC 8288 §3.4.3) - // - 'type' is a target attribute indicating the media type of the target resource. - // - If multiple occurrences exist, the API should be deterministic (typically: first wins). + // type(): view behavior + // - returns first matching 'type' value if present + // - does not validate MIME format here // -------------------------------------------------------------------------- - def "type: returns empty when absent"() { + def "type: returns empty when type parameter is absent"() { given: def link = weblink(uri("https://example.org/res"), List.of(parameter("rel", "self"))) @@ -120,80 +115,97 @@ class WebLinkSpec extends Specification { link.type().isEmpty() } - def "type: returns the media type string when present"() { + def "type: returns the first type parameter value if present"() { given: def link = weblink(uri("https://example.org/res"), List.of( - parameter("type", "application/linkset+json") + parameter("type", "application/json"), + parameter("type", "text/html") )) expect: - link.type().get() == "application/linkset+json" + link.type().get() == "application/json" } - def "type: if multiple type parameters exist, returns the first occurrence"() { + def "type: does not validate the media type format (view semantics)"() { given: - def link = weblink(uri("https://example.org/res"), List.of( - parameter("type", "application/json"), - parameter("type", "text/html") - )) + def link = weblink(uri("https://example.org/res"), List.of(parameter("type", "not a mime"))) expect: - link.type().get() == "application/json" + link.type().get() == "not a mime" } // -------------------------------------------------------------------------- - // anchor (RFC 8288 §3.2) - // - 'anchor' is a link parameter used to specify the link context (origin) explicitly. + // extensionAttributes(): view behavior + // - groups parameters that are not in the RFC parameter enum + // - preserves multiplicity and order of values per key (collector keeps encounter order) // -------------------------------------------------------------------------- - def "anchor: returns empty when absent (RFC 8288 §3.2)"() { + def "extensionAttributes: returns empty map if only known RFC parameters are present"() { given: def link = weblink(uri("https://example.org/target"), List.of( - parameter("rel", "item") + parameter("rel", "item"), + parameter("type", "application/json"), + parameter("title", "t"), + parameter("anchor", "https://example.org/context") )) expect: - link.anchor().isEmpty() + link.extensionAttributes().isEmpty() } - def "anchor: returns value when present (RFC 8288 §3.2)"() { + def "extensionAttributes: groups unknown parameters by name and retains all values"() { given: def link = weblink(uri("https://example.org/target"), List.of( - parameter("anchor", "https://example.org/context"), + parameter("profile", "https://example.org/p1"), + parameter("profile", "https://example.org/p2"), + parameter("x-flag", "a"), + parameter("x-flag", "b"), parameter("rel", "item") )) - expect: - link.anchor().get() == "https://example.org/context" + when: + def ext = link.extensionAttributes() + + then: + ext["profile"] == ["https://example.org/p1", "https://example.org/p2"] + ext["x-flag"] == ["a", "b"] + + and: + link.extensionAttribute("profile") == ["https://example.org/p1", "https://example.org/p2"] + link.extensionAttribute("does-not-exist") == [] } - def "anchor: if multiple anchor parameters exist, returns the first occurrence deterministically"() { + def "extensionAttributes: treats names case-sensitively (no normalization in the view)"() { given: def link = weblink(uri("https://example.org/target"), List.of( - parameter("anchor", "https://example.org/context1"), - parameter("anchor", "https://example.org/context2"), - parameter("rel", "item") + parameter("Profile", "X"), + parameter("profile", "Y") )) expect: - link.anchor().get() == "https://example.org/context1" + link.extensionAttributes().keySet() == ["Profile", "profile"] as Set } // -------------------------------------------------------------------------- - // hreflang (RFC 8288 §3.4.1) - // - 'hreflang' is a target attribute indicating the language of the target resource. - // - It can appear multiple times. + // Methods currently returning empty by implementation: + // anchor(), hreflang(), media(), title(), titleMultiple() + // + // These tests document the intended view semantics without enforcing RFC rules. + // They will fail until implemented; keep them as "pending" by ignoring for now. // -------------------------------------------------------------------------- - def "hreflang: returns empty list when absent (RFC 8288 §3.4.1)"() { + def "anchor: returns the first anchor parameter value if present (view semantics)"() { given: - def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) + def link = weblink(uri("https://example.org/target"), List.of( + parameter("anchor", "https://example.org/context1"), + parameter("anchor", "https://example.org/context2") + )) expect: - link.hreflang() == [] + link.anchor().get() == "https://example.org/context1" } - def "hreflang: returns all occurrences in order (RFC 8288 §3.4.1)"() { + def "hreflang: returns all hreflang parameter values in encounter order (view semantics)"() { given: def link = weblink(uri("https://example.org/target"), List.of( parameter("hreflang", "en"), @@ -205,66 +217,18 @@ class WebLinkSpec extends Specification { link.hreflang() == ["en", "de", "fr"] } - // -------------------------------------------------------------------------- - // media (RFC 8288 §3.4.2) - // - 'media' is a target attribute describing intended media / device. - // -------------------------------------------------------------------------- - - def "media: returns empty when absent (RFC 8288 §3.4.2)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) - - expect: - link.media().isEmpty() - } - - def "media: returns value when present (RFC 8288 §3.4.2)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of( - parameter("media", "screen"), - parameter("rel", "item") - )) - - expect: - link.media().get() == "screen" - } - - def "media: if multiple media parameters exist, returns the first occurrence deterministically"() { + def "media: returns the first media parameter value if present (view semantics)"() { given: def link = weblink(uri("https://example.org/target"), List.of( parameter("media", "screen"), - parameter("media", "print"), - parameter("rel", "item") + parameter("media", "print") )) expect: link.media().get() == "screen" } - // -------------------------------------------------------------------------- - // title and title* (RFC 8288 §3.4.4; title* references RFC 5987) - // -------------------------------------------------------------------------- - - def "title: returns empty when absent (RFC 8288 §3.4.4)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) - - expect: - link.title().isEmpty() - } - - def "title: returns value when present (RFC 8288 §3.4.4)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of( - parameter("title", "Some title"), - parameter("rel", "item") - )) - - expect: - link.title().get() == "Some title" - } - - def "title: if multiple title parameters exist, returns the first occurrence deterministically"() { + def "title: returns the first title parameter value if present (view semantics)"() { given: def link = weblink(uri("https://example.org/target"), List.of( parameter("title", "First"), @@ -275,26 +239,7 @@ class WebLinkSpec extends Specification { link.title().get() == "First" } - def "titleMultiple: returns empty when absent (RFC 8288 §3.4.4 / RFC 5987)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of(parameter("rel", "item"))) - - expect: - link.titleMultiple().isEmpty() - } - - def "titleMultiple: returns value when present (RFC 8288 §3.4.4 / RFC 5987)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of( - parameter("title*", "UTF-8''%E2%9C%93"), - parameter("rel", "item") - )) - - expect: - link.titleMultiple().get() == "UTF-8''%E2%9C%93" - } - - def "titleMultiple: if multiple title* parameters exist, returns the first occurrence deterministically"() { + def "titleMultiple: returns the first title* parameter value if present (view semantics)"() { given: def link = weblink(uri("https://example.org/target"), List.of( parameter("title*", "UTF-8''first"), @@ -305,59 +250,6 @@ class WebLinkSpec extends Specification { link.titleMultiple().get() == "UTF-8''first" } - // -------------------------------------------------------------------------- - // Extension attributes (non-RFC parameter names) - // - extensionAttributes(): groups all non-standard parameter names to values - // - extensionAttribute(name): convenience accessor - // -------------------------------------------------------------------------- - - def "extensionAttributes: returns empty map when no extension attributes exist"() { - given: - def link = weblink(uri("https://example.org/target"), List.of( - parameter("rel", "item"), - parameter("type", "application/json"), - parameter("title", "t") - )) - - expect: - link.extensionAttributes().isEmpty() - link.extensionAttribute("profile") == [] - } - - def "extensionAttributes: groups unknown parameter names and preserves all values"() { - given: - def link = weblink(uri("https://example.org/target"), List.of( - parameter("profile", "https://example.org/profile/a"), - parameter("profile", "https://example.org/profile/b"), - parameter("foo", "1"), - parameter("foo", "2"), - parameter("rel", "item") - )) - - when: - def ext = link.extensionAttributes() - - then: - ext.keySet() == ["profile", "foo"] as Set - ext.get("profile") == ["https://example.org/profile/a", "https://example.org/profile/b"] - ext.get("foo") == ["1", "2"] - - and: - link.extensionAttribute("profile") == ["https://example.org/profile/a", "https://example.org/profile/b"] - link.extensionAttribute("does-not-exist") == [] - } - - def "extensionAttributes: parameter name comparison is case-sensitive (API invariant)"() { - given: - def link = weblink(uri("https://example.org/target"), List.of( - parameter("Profile", "X"), - parameter("profile", "Y") - )) - - expect: - link.extensionAttributes().keySet() == ["Profile", "profile"] as Set - } - // ------------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------------ From d3ad0d2a7e76d068cd2f3ffc254dff4d2ad2a73e Mon Sep 17 00:00:00 2001 From: Sven Fillinger Date: Fri, 19 Dec 2025 16:09:01 +0100 Subject: [PATCH 3/3] Provide exhaustive JavaDocs --- .../life/qbic/linksmith/model/WebLink.java | 264 ++++++++++++++---- .../qbic/linksmith/model/WebLinkSpec.groovy | 6 +- 2 files changed, 217 insertions(+), 53 deletions(-) diff --git a/src/main/java/life/qbic/linksmith/model/WebLink.java b/src/main/java/life/qbic/linksmith/model/WebLink.java index 72f2eba..7671a10 100644 --- a/src/main/java/life/qbic/linksmith/model/WebLink.java +++ b/src/main/java/life/qbic/linksmith/model/WebLink.java @@ -13,26 +13,58 @@ import life.qbic.linksmith.core.RfcLinkParameter; /** - * A Java record representing a web link object following the - * RFC 8288 model specification. + * A semantic view of a single Web Linking relation as modeled by the HTTP {@code Link} header field + * in RFC 8288. + *

+ * Scope and intent
+ * This record represents one link consisting of: + *

    + *
  • a mandatory {@linkplain #target() target URI} (the link target), and
  • + *
  • a list of {@linkplain #params() parameters} (link-params / target attributes)
  • + *
+ * The record does not attempt to fully enforce all RFC constraints at construction time. + * It is designed as a semantic accessor layer over raw parameters produced by parsers and/or + * validators. Consumers can use this type to conveniently access commonly used RFC parameters such + * as {@code rel}, {@code anchor}, {@code type}, {@code hreflang}, {@code media}, {@code title}, + * and {@code title*}. + *

+ * Parameter model
+ * In RFC 8288, links can carry parameters, serialized as {@code link-param} entries after the + * target URI. The ABNF for a parameter in HTTP serialization is defined as: + *

{@code
+ * link-param = token BWS [ "=" BWS ( token / quoted-string ) ]
+ * }
+ * This record stores parameters as {@link WebLinkParameter} name/value pairs. Higher-level + * components (e.g. validators) may interpret parameter semantics (cardinality, value formats, + * profile rules such as FAIR Signposting) and emit issues; this record focuses on accessors. + *

+ * Known vs extension attributes
+ * RFC 8288 defines a set of well-known parameters (e.g. {@code rel}, {@code anchor}, {@code type}). + * Parameters not listed in {@link RfcLinkParameter} are treated as extension attributes and + * can be accessed via {@link #extensionAttributes()} and {@link #extensionAttribute(String)}. + *

+ * Multiplicity
+ * Some parameters may occur multiple times (e.g. {@code hreflang}), while others have stricter + * rules in the RFC (e.g. {@code rel} is specified as not appearing more than once in a given + * link-value). This model exposes values as found in {@link #params()} and provides deterministic + * accessors (e.g. {@link #type()} returns the first occurrence). + * + * @param target the target of the link (the URI inside {@code <...>} in the HTTP serialization) + * @param params the list of link parameters / target attributes associated with the link */ public record WebLink(URI target, List params) { /** - * Creates an RFC 8288 compliant web - * link object. + * Creates a new {@link WebLink} instance. *

- * Following RFC8288, the ABNF for a link parameter is: - *

- * {@code link-param = token BWS [ "=" BWS ( token / quoted-string ) ]} - *

- * The parameter key must not be withoutValue, so during construction the {@code params} keys are - * checked for an withoutValue key. The values can be withoutValue though. + * This factory method exists to provide a stable, explicit construction API and to enforce basic + * null checks. The semantic correctness of {@code params} (e.g. allowed characters, parameter + * cardinality, value constraints) is typically handled by validators. * - * @param reference a {@link URI} pointing to the actual resource - * @param params a {@link Map} of parameters as keys and a list of their values - * @return the new Weblink - * @throws NullPointerException if any method argument is {@code null} + * @param reference a {@link URI} pointing to the link target + * @param params the raw link parameters associated with the target + * @return a new {@link WebLink} + * @throws NullPointerException if {@code reference} or {@code params} is {@code null} */ public static WebLink create(URI reference, List params) throws NullPointerException { @@ -42,24 +74,39 @@ public static WebLink create(URI reference, List params) } /** - * Web link constructor that can be used if a web link has no parameters. - *

+ * Convenience factory to create a {@link WebLink} without any parameters. * - * @param reference a {@link URI} pointing to the actual resource - * @return the new Weblink - * @throws NullPointerException if any method argument is {@code null} + * @param reference a {@link URI} pointing to the link target + * @return a new {@link WebLink} without parameters + * @throws NullPointerException if {@code reference} is {@code null} */ public static WebLink create(URI reference) throws NullPointerException { return create(reference, List.of()); } - + /** + * Returns the {@code anchor} parameter value of this link, if present. + *

+ * The {@code anchor} parameter expresses the link context (origin) explicitly as defined in + * RFC 8288 ("Link Context"). If multiple {@code anchor} parameters are present, this method + * returns the first one encountered in {@link #params()}. + * + * @return the first {@code anchor} parameter value, or {@link Optional#empty()} if absent + */ public Optional anchor() { return findFirstWithFilter(params, WebLink::isAnchorParameter) .map(WebLinkParameter::value); } - + /** + * Returns all {@code hreflang} parameter values of this link. + *

+ * The {@code hreflang} target attribute indicates the language of the target resource as defined + * in RFC 8288 ("The hreflang Target Attribute"). The attribute may occur multiple times; this + * method returns values in encounter order. + * + * @return all {@code hreflang} values, or an empty list if none are present + */ public List hreflang() { return params.stream() .filter(WebLink::isHreflangParameter) @@ -67,23 +114,36 @@ public List hreflang() { .toList(); } + /** + * Returns the {@code media} parameter value of this link, if present. + *

+ * The {@code media} target attribute describes the intended media/device of the target resource + * as defined in RFC 8288 ("The media Target Attribute"). If multiple {@code media} parameters are + * present, this method returns the first one encountered. + * + * @return the first {@code media} parameter value, or {@link Optional#empty()} if absent + */ public Optional media() { return findFirstWithFilter(params, WebLink::isMediaParameter) .map(WebLinkParameter::value); } /** - * Returns all "rel" parameter values of the link. - *

- * RFC 8288 section 3.3 states, that the relation parameter MUST NOT appear more than once in a - * given link-value, but one "rel" parameter value can contain multiple relation-types when - * separated by one or more space characters (SP = ASCII 0x20): - *

- * {@code relation-type *( 1*SP relation-type ) }. + * Returns all relation types conveyed by the {@code rel} parameter(s). *

- * The method returns space-separated values as individual values of the "rel" parameter. + * In RFC 8288, the relation type of a link is conveyed via the {@code rel} parameter. + * The value of {@code rel} is a whitespace-separated list of relation types: + *

{@code
+   * relation-type *( 1*SP relation-type )
+   * }
+ * This method: + *
    + *
  • collects all {@code rel} parameters present in {@link #params()},
  • + *
  • splits each value by one or more whitespace characters,
  • + *
  • and returns the flattened list in encounter order.
  • + *
* - * @return a list of relation parameter values + * @return a list of relation types derived from {@code rel} values, or an empty list if absent */ public List rel() { return findAllWithFilter(params, WebLink::isRelParameter) @@ -94,20 +154,14 @@ public List rel() { } /** - * Returns all "rev" parameter values of the link. + * Returns all reverse relation types conveyed by the {@code rev} parameter(s). *

- * RFC 8288 section 3.3 does not specify the multiplicity of occurrence. But given the close - * relation to the "rel" parameter and its definition in the same section, web link will treat the - * "rev" parameter equally. - *

- * As with the "rel" parameter, multiple regular relation types are allowed when they are - * separated by one or more space characters (SP = ASCII 0x20): - *

- * {@code relation-type *( 1*SP relation-type ) }. - *

- * The method returns space-separated values as individual values of the "rel" parameter. + * The {@code rev} parameter is defined in RFC 8288 in relation to {@code rel} and conveys reverse + * relation types. This method mirrors the {@link #rel()} behavior: + * it splits each {@code rev} parameter value by one or more whitespace characters and returns the + * flattened result. * - * @return a list of relation parameter values + * @return a list of reverse relation types derived from {@code rev} values, or an empty list if absent */ public List rev() { return this.params.stream() @@ -117,20 +171,45 @@ public List rev() { .flatMap(Arrays::stream) .toList(); } - + /** + * Returns the {@code title} parameter value of this link, if present. + *

+ * The {@code title} target attribute provides a human-readable label for the link target as + * defined in RFC 8288 ("The title Target Attribute"). If multiple {@code title} parameters are + * present, this method returns the first one encountered. + * + * @return the first {@code title} value, or {@link Optional#empty()} if absent + */ public Optional title() { return findFirstWithFilter(params, WebLink::isTitleParameter) .map(WebLinkParameter::value); } - public Optional titleMultiple() { - return Optional.empty(); + /** + * Returns the {@code title*} parameter value of this link, if present. + *

+ * The {@code title*} target attribute is the extended form of {@code title} and allows character + * set and language encoding as referenced by RFC 8288 (via RFC 5987). + * If multiple {@code title*} parameters are present, this method returns the first one encountered. + *

+ * Note: this method returns the raw serialized value as found in {@link #params()} without + * decoding. + * + * @return the first {@code title*} value, or {@link Optional#empty()} if absent + */ + public Optional titleEncodings() { + return findFirstWithFilter(params, WebLink::isTitleEncodingsParameter) + .map(WebLinkParameter::value); } /** - * Returns the MIME type of the current link, if one is available. + * Returns the {@code type} parameter value of this link, if present. + *

+ * The {@code type} target attribute indicates the media type (MIME type) of the link target as + * defined in RFC 8288 ("The type Target Attribute"). If multiple {@code type} parameters are + * present, this method returns the first one encountered. * - * @return the MIME type of the link, or empty if none was provided + * @return the first {@code type} value, or {@link Optional#empty()} if absent */ public Optional type() { return this.params.stream() @@ -139,40 +218,97 @@ public Optional type() { .map(WebLinkParameter::value); } + /** + * Determines whether a parameter represents {@code anchor}. + * + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "anchor"} + */ private static boolean isAnchorParameter(WebLinkParameter param) { return param.name().equals("anchor"); } + /** + * Determines whether a parameter represents {@code hreflang}. + * + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "hreflang"} + */ private static boolean isHreflangParameter(WebLinkParameter param) { return param.name().equals("hreflang"); } + /** + * Determines whether a parameter represents {@code media}. + * + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "media"} + */ private static boolean isMediaParameter(WebLinkParameter param) { return param.name().equals("media"); } + /** + * Determines whether a parameter represents {@code rel}. + * + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "rel"} + */ private static boolean isRelParameter(WebLinkParameter param) { return param.name().equals("rel"); } + /** + * Determines whether a parameter represents {@code title}. + * + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "title"} + */ private static boolean isTitleParameter(WebLinkParameter param) { return param.name().equals("title"); } /** - * Checks, if a web link parameter is with name {@code type}. + * Determines whether a parameter represents {@code title*}. * - * @param param the web link parameter to validate - * @return true, if the parameter name is {@code type}, else returns false + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "title*"} + */ + private static boolean isTitleEncodingsParameter(WebLinkParameter param) { + return param.name().equals("title*"); + } + + /** + * Determines whether a parameter represents {@code type}. + * + * @param param the parameter to test + * @return {@code true} if {@code param.name()} equals {@code "type"} */ private static boolean isTypeParameter(WebLinkParameter param) { return param.name().equals("type"); } + /** + * Splits a serialized parameter value into parts using one or more whitespace characters. + *

+ * This helper is used for parameters whose syntax is defined as a whitespace-separated list + * (notably {@code rel} and {@code rev}). Leading and trailing whitespace is trimmed prior to + * splitting. + * + * @param value the serialized value to split + * @return an array of parts (never {@code null}) + */ private static String[] splitByWhitespace(String value) { return value.trim().split("\\s+"); } + /** + * Finds the first parameter in the given list that matches the provided predicate. + * + * @param params the parameter list to search + * @param filter predicate selecting the desired parameter(s) + * @return the first matching parameter, or {@link Optional#empty()} if none match + */ private static Optional findFirstWithFilter( List params, Predicate filter) { @@ -181,6 +317,13 @@ private static Optional findFirstWithFilter( .findFirst(); } + /** + * Returns a stream over all parameters in the given list that match the provided predicate. + * + * @param params the parameter list to search + * @param filter predicate selecting the desired parameter(s) + * @return a stream of all matching parameters (possibly empty) + */ private static Stream findAllWithFilter( List params, Predicate filter @@ -189,6 +332,18 @@ private static Stream findAllWithFilter( .filter(filter); } + /** + * Returns a map of all extension attributes (parameters not defined by RFC 8288) grouped by + * parameter name. + *

+ * The set of RFC-defined parameter names is derived from {@link RfcLinkParameter}. All parameters + * whose {@link WebLinkParameter#name()} is not in that set are considered extension attributes. + *

+ * The returned map groups values by name and preserves the encounter order of values within each + * list. + * + * @return a map of extension attribute names to lists of their values (possibly empty) + */ public Map> extensionAttributes() { Set rfcParameterNames = Arrays.stream(RfcLinkParameter.values()) .map(RfcLinkParameter::rfcValue) @@ -199,6 +354,15 @@ public Map> extensionAttributes() { Collectors.mapping(WebLinkParameter::value, Collectors.toList()))); } + /** + * Returns all values for a specific extension attribute name. + *

+ * This is a convenience method on top of {@link #extensionAttributes()} and returns an empty list + * if the attribute is not present. + * + * @param name the extension attribute name + * @return a list of values associated with {@code name}, or an empty list if absent + */ public List extensionAttribute(String name) { return extensionAttributes().getOrDefault(name, List.of()); } diff --git a/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy b/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy index b3e2518..8dcb6b3 100644 --- a/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy +++ b/src/test/groovy/life/qbic/linksmith/model/WebLinkSpec.groovy @@ -1,6 +1,6 @@ package life.qbic.linksmith.model -import spock.lang.Ignore + import spock.lang.Specification import spock.lang.Unroll @@ -188,7 +188,7 @@ class WebLinkSpec extends Specification { // -------------------------------------------------------------------------- // Methods currently returning empty by implementation: - // anchor(), hreflang(), media(), title(), titleMultiple() + // anchor(), hreflang(), media(), title(), titleEncodings() // // These tests document the intended view semantics without enforcing RFC rules. // They will fail until implemented; keep them as "pending" by ignoring for now. @@ -247,7 +247,7 @@ class WebLinkSpec extends Specification { )) expect: - link.titleMultiple().get() == "UTF-8''first" + link.titleEncodings().get() == "UTF-8''first" } // ------------------------------------------------------------------------