From e110faacca0bbfee4e3e24cb4cb8229f71757436 Mon Sep 17 00:00:00 2001 From: Viliam Durina Date: Wed, 20 Mar 2024 12:14:07 +0100 Subject: [PATCH] GH-2099 - Support for advanced Link header expressions. The previous implementation used naive parsing suffering from many issues, especially when special characters were part of values. It also didn't escape the special characters when serializing a link to string. --- .../org/springframework/hateoas/Link.java | 138 +++++------- .../springframework/hateoas/LinkParser.java | 197 ++++++++++++++++++ .../org/springframework/hateoas/Links.java | 22 +- .../hateoas/LinksUnitTest.java | 196 +++++++++++++++++ 4 files changed, 453 insertions(+), 100 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/LinkParser.java 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;