diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index be753b40..c0399ade 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -19,16 +19,12 @@ import java.io.Serializable; import java.net.URI; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -41,13 +37,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Oliver Gierke * @author Greg Turnquist * @author Jens Schauder + * @author Viliam Durina */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(value = { "templated", "template" }, ignoreUnknown = true) public class Link implements Serializable { private static final long serialVersionUID = -9037755944661782121L; - private static final Pattern URI_AND_ATTRIBUTES_PATTERN = Pattern.compile("<(.*)>;(.*)"); public static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; @@ -107,9 +103,9 @@ public class Link implements Serializable { this.affordances = affordances; } - private Link(LinkRelation rel, String href, @Nullable String hreflang, @Nullable String media, @Nullable String title, - @Nullable String type, @Nullable String deprecation, @Nullable String profile, @Nullable String name, - @Nullable UriTemplate template, List affordances) { + Link(LinkRelation rel, String href, @Nullable String hreflang, @Nullable String media, @Nullable String title, + @Nullable String type, @Nullable String deprecation, @Nullable String profile, @Nullable String name, + @Nullable UriTemplate template, List affordances) { this.rel = rel; this.href = href; @@ -241,7 +237,7 @@ public class Link implements Serializable { } /** - * Creats a new {@link Link} with the given {@link Affordance}s. + * Creates a new {@link Link} with the given {@link Affordance}s. * * @param affordances must not be {@literal null}. * @return will never be {@literal null}. @@ -390,71 +386,12 @@ public class Link implements Serializable { * @param element an RFC-8288 compatible representation of a link. * @throws IllegalArgumentException if a {@link String} was given that does not adhere to RFC-8288. * @throws IllegalArgumentException if no {@code rel} attribute could be found. - * @return + * @return The parsed link + * @deprecated Use {@link Links#parse(String)} instead. This method parses only the first link from a list of links. */ + @Deprecated public static Link valueOf(String element) { - - if (!StringUtils.hasText(element)) { - throw new IllegalArgumentException(String.format("Given link header %s is not RFC-8288 compliant!", element)); - } - - Matcher matcher = URI_AND_ATTRIBUTES_PATTERN.matcher(element); - - if (matcher.find()) { - - Map attributes = getAttributeMap(matcher.group(2)); - - if (!attributes.containsKey("rel")) { - throw new IllegalArgumentException("Link does not provide a rel attribute!"); - } - - LinkRelation rel = LinkRelation.of(attributes.get("rel")); - String href = matcher.group(1); - String hrefLang = attributes.get("hreflang"); - String media = attributes.get("media"); - String title = attributes.get("title"); - String type = attributes.get("type"); - String deprecation = attributes.get("deprecation"); - String profile = attributes.get("profile"); - String name = attributes.get("name"); - - return new Link(rel, href, hrefLang, media, title, type, deprecation, profile, name, templateOrNull(href), - Collections.emptyList()); - - } else { - throw new IllegalArgumentException(String.format("Given link header %s is not RFC-8288 compliant!", element)); - } - } - - /** - * Parses the links attributes from the given source {@link String}. - * - * @param source - * @return - */ - private static Map getAttributeMap(String source) { - - if (!StringUtils.hasText(source)) { - return Collections.emptyMap(); - } - - String[] parts = source.split(";"); - Map attributes = new HashMap<>(); - - for (String part : parts) { - - int delimiter = part.indexOf('='); - - String key = part.substring(0, delimiter).trim(); - String value = part.substring(delimiter + 1).trim(); - - // Potentially unquote value - value = value.startsWith("\"") ? value.substring(1, value.length() - 1) : value; - - attributes.put(key, value); - } - - return attributes; + return LinkParser.parseLink(element, new int[]{0}); } /** @@ -471,7 +408,7 @@ public class Link implements Serializable { } /** - * Create a new {@link Link} by copying all attributes and applying the new {@literal hrefleng}. + * Create a new {@link Link} by copying all attributes and applying the new {@literal hreflang}. * * @param hreflang can be {@literal null} * @return will never be {@literal null}. @@ -659,42 +596,75 @@ public class Link implements Serializable { */ @Override public String toString() { - - String linkString = String.format("<%s>;rel=\"%s\"", href, rel.value()); + StringBuilder result = new StringBuilder(64); + result.append('<') + // We only url-encode the `>`. We expect other special chars to already be escaped. `;` and `,` need not + // be escaped within the URL + .append(href.replace(">", "%3e")) + .append(">;rel="); + quoteParamValue(rel.value(), result); if (hreflang != null) { - linkString += ";hreflang=\"" + hreflang + "\""; + result.append(";hreflang="); + quoteParamValue(hreflang, result); } if (media != null) { - linkString += ";media=\"" + media + "\""; + result.append(";media="); + quoteParamValue(media, result); } if (title != null) { - linkString += ";title=\"" + title + "\""; + result.append(";title="); + quoteParamValue(title, result); } if (type != null) { - linkString += ";type=\"" + type + "\""; + result.append(";type="); + quoteParamValue(type, result); } if (deprecation != null) { - linkString += ";deprecation=\"" + deprecation + "\""; + result.append(";deprecation="); + quoteParamValue(deprecation, result); } if (profile != null) { - linkString += ";profile=\"" + profile + "\""; + result.append(";profile="); + quoteParamValue(profile, result); } if (name != null) { - linkString += ";name=\"" + name + "\""; + result.append(";name="); + quoteParamValue(name, result); } - return linkString; + return result.toString(); + } + + /** + * Quotes the given string `s` and appends the result to the `target`. This method appends the start quote, the + * escaped text, and the end quote. + * + * @param s Text to quote + * @param target StringBuilder to append to + */ + private void quoteParamValue(String s, StringBuilder target) { + // we reserve extra 4 chars: two for the start and end quote, another two are a reserve for potential escaped chars + target.ensureCapacity(target.length() + s.length() + 4); + target.append('"'); + for (int i = 0, l = s.length(); i < l; i++) { + char ch = s.charAt(i); + if (ch == '"' || ch == '\\') { + target.append('\\'); + } + target.append(ch); + } + target.append('"'); } @Nullable - private static UriTemplate templateOrNull(String href) { + static UriTemplate templateOrNull(String href) { Assert.notNull(href, "Href must not be null!"); diff --git a/src/main/java/org/springframework/hateoas/LinkParser.java b/src/main/java/org/springframework/hateoas/LinkParser.java new file mode 100644 index 00000000..5829727d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/LinkParser.java @@ -0,0 +1,197 @@ +package org.springframework.hateoas; + +import org.springframework.lang.NonNull; +import org.springframework.util.StringUtils; + +import java.util.*; + +class LinkParser { + public static List parseLinks(String source) { + var links = new ArrayList(); + int[] pos = { 0 }; // single-element array used as a mutable integer + int l = source.length(); + boolean inLink = true; // true if we're expecting to find a link; false if we're expecting end of input, or a comma + while (pos[0] < l) { + char ch = source.charAt(pos[0]); + if (Character.isWhitespace(ch)) { + pos[0]++; + continue; + } + if (inLink) { + if (ch == '<') { + // start of a link, consume it using the Link class + Link link = parseLink(source, pos); + // In a single link there can be multiple rels separated by whitespace. The Link class doesn't handle this + // because it doesn't have API to handle it. However, at this level, we can split the rels and create a + // separate Link for each rel. + String[] rels = link.getRel().value().split("\\s"); + if (rels.length == 0) { + throw new IllegalArgumentException("A link with missing rel at " + pos[0]); + } + for (String rel : rels) { + links.add(link.withRel(rel)); + } + inLink = false; + continue; + } else if (ch == ',') { + pos[0]++; + continue; + } + } else { + // there must be a comma to move on to another link + if (ch == ',') { + pos[0]++; + inLink = true; + continue; + } + } + // The parsing algorithm in appendix B.2 of RFC-8288 suggests ignoring unexpected content at the end of a link. + // At the same time it specifies that implementations aren't required to support them. We believe that missing + // terminal `>` or unexpected data after the end point to more serious problem, and we throw an exception + // in that case. + throw new IllegalArgumentException("Unexpected data at the end of Link header at index " + pos[0]); + } + + return links; + } + + /** + * Internal method to parse and consume one link from input string. + * + * @param input The input string + * @param pos Position to start from. It must be a 1-element array. The element will be mutated to point to the + * first non-consumed character (either ',' or the end of input). + * @return a non-null Link + */ + @NonNull + static Link parseLink(@NonNull String input, @NonNull int[] pos) { + assert pos.length == 1; + int l = input.length(); + while (pos[0] < l && Character.isWhitespace(input.charAt(pos[0]))) { + pos[0]++; + } + if (input.charAt(pos[0]) != '<') { + throw new IllegalArgumentException("Expecting '<' at index " + pos[0]); + } + pos[0]++; + int urlEnd = input.indexOf('>', pos[0]); + if (urlEnd < 0) { + throw new IllegalArgumentException("Missing closing '>' at index " + input.length()); + } + String url = input.substring(pos[0], urlEnd); + pos[0] = urlEnd + 1; + + // parse parameters + Map params = new HashMap<>(); + enum State {INITIAL, IN_KEY, BEFORE_VALUE, IN_VALUE} + ; + State state = State.INITIAL; + StringBuilder key = new StringBuilder(), value = new StringBuilder(); + + outer: + while (pos[0] <= l) { + boolean eoi = pos[0] == l; // EOI - end of input + char ch = eoi ? 0 : input.charAt(pos[0]); + switch (state) { + // searching for the initial `;` + case INITIAL: + if (Character.isWhitespace(ch)) { + pos[0]++; + } else if (ch == ';') { + state = State.IN_KEY; + pos[0]++; + } else { + // if there's something else, it's the end of this link + break outer; + } + break; + + // consuming the key up to `=` + case IN_KEY: + if (ch == '=') { + state = State.BEFORE_VALUE; + } + // value isn't mandatory, so param separator, link separator, or end of input all create a new param + else if (ch == ';' || ch == ',' || eoi) { + if (!key.isEmpty()) { + params.put(key.toString().trim(), ""); + key.setLength(0); + } + } else { + key.append(ch); + } + pos[0]++; + break; + + case BEFORE_VALUE: + if (Character.isWhitespace(ch)) { + pos[0]++; + } else if (ch == '"' || ch == '\'') { + consumeQuotedString(input, value, pos); + params.putIfAbsent(key.toString().trim(), value.toString()); + key.setLength(0); + value.setLength(0); + state = State.INITIAL; + } else { + state = State.IN_VALUE; + } + break; + + case IN_VALUE: + if (ch == ';' || ch == ',' || eoi) { + params.putIfAbsent(key.toString().trim(), value.toString().trim()); + key.setLength(0); + value.setLength(0); + state = State.INITIAL; + } else { + value.append(ch); + pos[0]++; + } + break; + + default: + throw new AssertionError(); + } + } + + String sRel = params.get("rel"); + if (!StringUtils.hasText(sRel)) { + throw new IllegalArgumentException("Missing 'rel' attribute at index " + pos[0]); + } + LinkRelation rel = LinkRelation.of(sRel); + String hrefLang = params.get("hreflang"); + String media = params.get("media"); + String title = params.get("title"); + String type = params.get("type"); + String deprecation = params.get("deprecation"); + String profile = params.get("profile"); + String name = params.get("name"); + + return new Link(rel, url, hrefLang, media, title, type, deprecation, profile, name, Link.templateOrNull(url), + Collections.emptyList()); + } + + /** + * Consume a quoted string from `input`, adding its contents to `target`. The starting position should be at starting + * quote. After consuming, the ending position will be just after the last final quote. + */ + private static void consumeQuotedString(String input, StringBuilder target, int[] pos) { + int l = input.length(); + char quotingChar = input.charAt(pos[0]); + assert quotingChar == '"' || quotingChar == '\''; + // skip quoting char + pos[0]++; + for (; pos[0] < l; pos[0]++) { + char ch = input.charAt(pos[0]); + if (ch == quotingChar) { + pos[0]++; // consume the final quote + return; + } + if (ch == '\\') { + ch = input.charAt(++pos[0]); + } + target.append(ch); + } + throw new IllegalArgumentException("Missing final quote at index " + pos[0]); + } +} diff --git a/src/main/java/org/springframework/hateoas/Links.java b/src/main/java/org/springframework/hateoas/Links.java index d894010b..494c6f06 100644 --- a/src/main/java/org/springframework/hateoas/Links.java +++ b/src/main/java/org/springframework/hateoas/Links.java @@ -23,7 +23,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Supplier; -import java.util.regex.Pattern; import java.util.stream.Collector; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -40,15 +39,15 @@ import com.fasterxml.jackson.annotation.JsonValue; * * @author Oliver Gierke * @author Greg Turnquist + * @author Viliam Durina */ public class Links implements Iterable { public static final Links NONE = new Links(Collections.emptyList()); - private static final Pattern LINK_HEADER_PATTERN = Pattern.compile("(<[^>]*>(;\\s*\\w+=\"?[^\",]*\"?)+)"); private final List links; - private Links(Iterable links) { + Links(Iterable links) { Assert.notNull(links, "Links must not be null!"); @@ -85,23 +84,14 @@ public class Links implements Iterable { * @return the {@link Links} represented by the given {@link String}. */ public static Links parse(@Nullable String source) { - - if (!StringUtils.hasText(source)) { + if (source == null) { return NONE; } - var links = new ArrayList(); - var matcher = LINK_HEADER_PATTERN.matcher(source); - - while (matcher.find()) { - - var link = Link.valueOf(matcher.group()); - - if (link != null) { - links.add(link); - } + List links = LinkParser.parseLinks(source); + if (links.isEmpty()) { + return NONE; } - return new Links(links); } diff --git a/src/test/java/org/springframework/hateoas/LinksUnitTest.java b/src/test/java/org/springframework/hateoas/LinksUnitTest.java index 1fcf0f03..7d21ed77 100755 --- a/src/test/java/org/springframework/hateoas/LinksUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinksUnitTest.java @@ -35,6 +35,7 @@ import org.springframework.util.StringUtils; * * @author Oliver Gierke * @author Greg Turnquist + * @author Viliam Durina */ class LinksUnitTest { @@ -74,8 +75,20 @@ class LinksUnitTest { @Test void skipsEmptyLinkElements() { + // extra commas at the end assertThat(Links.parse(LINKS + ",,,")).isEqualTo(reference); assertThat(Links.parse(LINKS2 + ",,,")).isEqualTo(reference2); + + // extra commas inside + assertThat(Links.parse(";rel= \"foo\",,;rel= \"bar\"")).isEqualTo( + Links.of(Link.of("url1", "foo"), Link.of("url2", "bar"))); + + // extra commas at the beginning + assertThat(Links.parse(",,;rel= \"foo\"")).isEqualTo(Links.of(Link.of("url1", "foo"))); + + // extra commas everywhere with whitespace + assertThat(Links.parse(" , , ;rel= \"foo\" , , ;rel= \"bar\" , , ")).isEqualTo( + Links.of(Link.of("url1", "foo"), Link.of("url2", "bar"))); } @Test @@ -278,6 +291,189 @@ class LinksUnitTest { assertThat(Links.parse(LINKS3)).isEqualTo(reference3); } + // ### tests added after https://github.com/spring-projects/spring-hateoas/issues/2099 ### + + @Test + void parsingUnexpectedData() { + // two URLs without a comma - the second URL is an unexpected text + assertThatThrownBy(() -> Links.parse(";rel=\"foo\";rel= \"bar\"")).isInstanceOf( + IllegalArgumentException.class).hasMessage("Unexpected data at the end of Link header at index 16"); + assertThatThrownBy(() -> Links.parse("; rel=\"foo\" ;rel= \"bar\"")).isInstanceOf( + IllegalArgumentException.class).hasMessage("Unexpected data at the end of Link header at index 18"); + assertThatThrownBy(() -> Links.parse(" ; rel= \"foo\" ;rel= \"bar\"")).isInstanceOf( + IllegalArgumentException.class).hasMessage("Unexpected data at the end of Link header at index 20"); + + // unexpected text after a quoted string + assertThatThrownBy(() -> Links.parse(";rel=\"foo\"#")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unexpected data at the end of Link header at index 16"); + assertThatThrownBy(() -> Links.parse(";rel=\"foo\" foo bar")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unexpected data at the end of Link header at index 17"); + + // if the value isn't quoted, it can't be unexpected; all is part of the value + assertThat(Links.parse(";rel=foo#")).isEqualTo(Links.of(Link.of("url1", "foo#"))); + + // extra text after a comma - looks like a legit value for rel, but comma is special and starts a new link + assertThatThrownBy(() -> Links.parse(";rel=foo,bar")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unexpected data at the end of Link header at index 15"); + + // a trailing comma is ignored + assertThat(Links.parse(";rel=foo,")).isEqualTo(Links.of(Link.of("url1", "foo"))); + + // a trailing semicolon is also ignored + assertThat(Links.parse(";rel=foo;")).isEqualTo(Links.of(Link.of("url1", "foo"))); + + // unexpected text at the beginning + assertThatThrownBy(() -> Links.parse("foo bar ;rel=\"next\"")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unexpected data at the end of Link header at index 0"); + } + + @Test + void parsingMissingData() { + // missing trailing bracket + assertThatThrownBy(() -> Links.parse("' at index 30"); + + // missing end quote + assertThatThrownBy(() -> Links.parse(";rel=\"next")).isInstanceOf( + IllegalArgumentException.class).hasMessage("Missing final quote at index 32"); + assertThatThrownBy(() -> Links.parse(";rel='next")).isInstanceOf( + IllegalArgumentException.class).hasMessage("Missing final quote at index 32"); + } + + @Test + void parsingGreedyCapture() { + // no greedy capture until `>` + assertThat(Links.parse(";title=foo>;rel=\"next\"")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo>"))); + + // no greedy capture until `;` + assertThat(Links.parse(";title=\"foo;bar\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo;bar"))); + + // no greedy capture until `,` + assertThat(Links.parse(";title=\"foo,bar\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo,bar"))); + } + + @Test + void parsingQuotedText() { + // unquoting of double quotes + assertThat(Links.parse(";title=\"\\\"bar\\\"\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("\"bar\""))); + + // unquoting of single quotes + assertThat(Links.parse(";title='\\'bar\\'';rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("'bar'"))); + + // single quote is literal in double-quoted string + assertThat(Links.parse(";title=\"'bar'\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("'bar'"))); + + // double quote is literal in single-quoted string + assertThat(Links.parse(";title='\"bar\"';rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("\"bar\""))); + + // backslash unquoting + assertThat(Links.parse(";title=\"foo\\\\bar\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo\\bar"))); + assertThat(Links.parse(";title='foo\\\\bar';rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo\\bar"))); + + // unquoting of unnecessarily quoted text + assertThat(Links.parse(";title=\"\\f\\o\\o\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo"))); + assertThat(Links.parse(";title='\\f\\o\\o';rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo"))); + + // no java-style special characters + assertThat(Links.parse(";title=\"\\r\\n\\t\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("rnt"))); + assertThat(Links.parse(";title='\\r\\n\\t';rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("rnt"))); + + // quote within a token value - the quote, if it's not the first character, is literal + assertThat(Links.parse(";title=foo\"bar\";rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo\"bar\""))); + assertThat(Links.parse(";title=foo'bar';rel=next")).isEqualTo( + Links.of(Link.of("url", "next").withTitle("foo'bar'"))); + } + + @Test + void parsingEmptyString() { + // at the end + Links expected = Links.of(Link.of("url", "next").withTitle("")); + // value missing + assertThat(Links.parse(";rel=next;title")).isEqualTo(expected); + // empty token-style value + assertThat(Links.parse(";rel=next;title=")).isEqualTo(expected); + // empty double-quoted string + assertThat(Links.parse(";rel=next;title=\"\"")).isEqualTo(expected); + // empty single-quoted string + assertThat(Links.parse(";rel=next;title=''")).isEqualTo(expected); + + // not at the end + expected = Links.of(Link.of("url", "next").withTitle("").withName("a")); + assertThat(Links.parse(";rel=next;title;name=a")).isEqualTo(expected); + assertThat(Links.parse(";rel=next;title=;name=a")).isEqualTo(expected); + assertThat(Links.parse(";rel=next;title=\"\";name=a")).isEqualTo(expected); + assertThat(Links.parse(";rel=next;title='';name=a")).isEqualTo(expected); + } + + @Test + void parsingMultipleRels() { + assertThat(Links.parse(";rel=next last")).isEqualTo(Links.of(Link.of("url", "next"), Link.of("url", "last"))); + assertThat(Links.parse(";rel=\"next last\"")).isEqualTo( + Links.of(Link.of("url", "next"), Link.of("url", "last"))); + assertThat(Links.parse(";rel=prev first,;rel=next last")).isEqualTo( + Links.of(Link.of("/prev", "prev"), Link.of("/prev", "first"), Link.of("/next", "next"), + Link.of("/next", "last"))); + } + + @Test + void parsingSpecialChars() { + // within the href, `,` and `;` aren't special + assertThat(Links.parse(";rel=next")).isEqualTo( + Links.of(Link.of("http://example.com/?param=foo,bar;baz", "next"))); + } + + @Test + void parsingWhitespaceOtherThanSpace() { + assertThat(Links.parse( + "\n\r\t \n\r\t ;\n\r\t rel\n\r\t =\r\n\t next \r\n\t , \r\n\t ," + " \r\n\t \r\n\t ;\r\n\t rel \r\n\t = \r\n\t \"foo\"\r\n\t ; title=\"\r\n\t bar\r\n\t \"\r\n\t ")).isEqualTo( + Links.of(Link.of("url1", "next"), Link.of("url2", "foo").withTitle("\r\n\t bar\r\n\t "))); + } + + @Test + void parsingEmptyRel() { + // rel is empty string + assertThatThrownBy(() -> Links.parse(";rel=''")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing 'rel' attribute at index 12"); + + // rel is a single space - if we split by whitespace, there's no value + assertThatThrownBy(() -> Links.parse(";rel=' '")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing 'rel' attribute at index 13"); + } + + @Test + void toStringEscaping() { + assertThat(Links.of(Link.of("/path?formula=a>b", "next").withTitle("foo\"bar\\baz")).toString()).isEqualTo( + ";rel=\"next\";title=\"foo\\\"bar\\\\baz\""); + assertThat(Links.of(Link.of("/path?formula=a>b", "next").withTitle("")).toString()).isEqualTo( + ";rel=\"next\";title=\"\""); + } + + @Test + void directLinkParsing() { + // here we test only code that isn't covered by the tests using `Links.parse` + + // leading whitespace + assertThat(Link.valueOf(" ;rel=next")).isEqualTo(Link.of("url", "next")); + + // unexpected data at the beginning + assertThatThrownBy(() -> Link.valueOf("foo ;rel=next")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Expecting '<' at index 0"); + } + @Value(staticConstructor = "of") static class NamedLinks { String name;