From 1c26efb88c6e5cf8d5976a5508360c8b667ac0ef Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Tue, 2 Jul 2024 14:39:50 +0200 Subject: [PATCH] GH-2099 - Polishing. --- .../org/springframework/hateoas/Link.java | 29 ++- .../springframework/hateoas/LinkParser.java | 174 ++++++++++--- .../org/springframework/hateoas/Links.java | 7 +- .../hateoas/LinkParserUnitTests.java | 238 ++++++++++++++++++ .../springframework/hateoas/LinkUnitTest.java | 9 + .../hateoas/LinksUnitTest.java | 183 -------------- 6 files changed, 403 insertions(+), 237 deletions(-) create mode 100644 src/test/java/org/springframework/hateoas/LinkParserUnitTests.java diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index c0399ade..c277599a 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -104,8 +104,8 @@ public class Link implements Serializable { } 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) { + @Nullable String type, @Nullable String deprecation, @Nullable String profile, @Nullable String name, + @Nullable UriTemplate template, List affordances) { this.rel = rel; this.href = href; @@ -383,15 +383,13 @@ public class Link implements Serializable { * Factory method to easily create {@link Link} instances from RFC-8288 compatible {@link String} representations of a * link. * - * @param element an RFC-8288 compatible representation of a link. + * @param source 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 The parsed link - * @deprecated Use {@link Links#parse(String)} instead. This method parses only the first link from a list of links. + * @return will never be {@literal null}. */ - @Deprecated - public static Link valueOf(String element) { - return LinkParser.parseLink(element, new int[]{0}); + public static Link valueOf(String source) { + return LinkParser.parseLink(source, new int[] { 0 }); } /** @@ -596,12 +594,15 @@ public class Link implements Serializable { */ @Override public String toString() { - StringBuilder result = new StringBuilder(64); + + var 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) { @@ -646,20 +647,26 @@ public class Link implements Serializable { * 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 s Text to quote * @param target StringBuilder to append to */ - private void quoteParamValue(String s, StringBuilder target) { + private static 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('"'); } diff --git a/src/main/java/org/springframework/hateoas/LinkParser.java b/src/main/java/org/springframework/hateoas/LinkParser.java index 5829727d..8b7d3512 100644 --- a/src/main/java/org/springframework/hateoas/LinkParser.java +++ b/src/main/java/org/springframework/hateoas/LinkParser.java @@ -1,43 +1,92 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.springframework.hateoas; -import org.springframework.lang.NonNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import java.util.*; - +/** + * A parser to turn RFC-8288 compliant strings into {@link Link} instances. + * + * @author Viliam Durina + * @author Oliver Drotbohm + */ class LinkParser { + + /** + * Parses the given source link string into a {@link Link}s. + * + * @param source must not be {@literal null}. + * @return will never be {@literal null}. + */ 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 + + // single-element array used as a mutable integer + int[] pos = { 0 }; + + // true if we're expecting to find a link; false if we're expecting end of input, or a comma + boolean inLink = true; + 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); + var 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"); + var 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]++; @@ -45,6 +94,7 @@ class LinkParser { 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 @@ -55,46 +105,65 @@ class LinkParser { return links; } + /** + * Parses the given source into a {@link Link}. + * + * @param input must not be {@literal null}. + * @return + */ + public static Link parseLink(String input) { + return parseLink(input, new int[] { 0 }); + } + /** * 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). + * @param input must not be {@literal null}. + * @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; + static Link parseLink(String input, int[] pos) { + + Assert.notNull(input, "Input string must not be null!"); + Assert.isTrue(pos.length == 1, "Array length must be one!"); + 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); + + var 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(); + var params = new HashMap(); + var state = State.INITIAL; + var key = new StringBuilder(); + var value = new StringBuilder(); + + outer: while (pos[0] <= l) { - 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 == ';') { @@ -104,94 +173,121 @@ class LinkParser { // 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(); + break; } } - String sRel = params.get("rel"); + var 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"); + + var rel = LinkRelation.of(sRel); + var hrefLang = params.get("hreflang"); + var media = params.get("media"); + var title = params.get("title"); + var type = params.get("type"); + var deprecation = params.get("deprecation"); + var profile = params.get("profile"); + var 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. + * Consume a quoted string from @code input}, adding its contents to {@code target}. The starting position should be + * at starting quote. After consuming, the ending position will be just after the last final quote. + * + * @param input must not be {@literal null}. + * @param target must not be {@literal null}. + * @param pos single-element array. */ private static void consumeQuotedString(String input, StringBuilder target, int[] pos) { - int l = input.length(); - char quotingChar = input.charAt(pos[0]); - assert quotingChar == '"' || quotingChar == '\''; + + var l = input.length(); + var quotingChar = input.charAt(pos[0]); + + Assert.isTrue(quotingChar == '"' || quotingChar == '\'', "Expected to find a quoting character (\' or \")!"); + // skip quoting char pos[0]++; for (; pos[0] < l; pos[0]++) { - char ch = input.charAt(pos[0]); + + var 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]); } + + private enum State { + INITIAL, IN_KEY, BEFORE_VALUE, IN_VALUE + } } diff --git a/src/main/java/org/springframework/hateoas/Links.java b/src/main/java/org/springframework/hateoas/Links.java index 494c6f06..548dd0de 100644 --- a/src/main/java/org/springframework/hateoas/Links.java +++ b/src/main/java/org/springframework/hateoas/Links.java @@ -84,15 +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 (source == null) { return NONE; } List links = LinkParser.parseLinks(source); - if (links.isEmpty()) { - return NONE; - } - return new Links(links); + + return links.isEmpty() ? NONE : new Links(links); } /** diff --git a/src/test/java/org/springframework/hateoas/LinkParserUnitTests.java b/src/test/java/org/springframework/hateoas/LinkParserUnitTests.java new file mode 100644 index 00000000..415d9c24 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/LinkParserUnitTests.java @@ -0,0 +1,238 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.hateoas.LinkParser.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link LinkParser}. + * + * @author Viliam Durina + * @author Oliver Drotbohm + */ +class LinkParserUnitTests { + + @Test // GH-2099 + void parsingUnexpectedData() { + + // two URLs without a comma - the second URL is an unexpected text + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks(";rel=\"foo\";rel= \"bar\"")) + .withMessage("Unexpected data at the end of Link header at index 16"); + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks("; rel=\"foo\" ;rel= \"bar\"")) + .withMessage("Unexpected data at the end of Link header at index 18"); + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks(" ; rel= \"foo\" ;rel= \"bar\"")) + .withMessage("Unexpected data at the end of Link header at index 20"); + + // unexpected text after a quoted string + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks(";rel=\"foo\"#")) + .withMessage("Unexpected data at the end of Link header at index 16"); + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks(";rel=\"foo\" foo bar")) + .withMessage("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(parseLink(";rel=foo#")) // + .isEqualTo(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 + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks(";rel=foo,bar")) + .withMessage("Unexpected data at the end of Link header at index 15"); + + // a trailing comma is ignored + assertThat(parseLink(";rel=foo,")) // + .isEqualTo(Link.of("url1", "foo")); + + // a trailing semicolon is also ignored + assertThat(parseLink(";rel=foo;")) // + .isEqualTo(Link.of("url1", "foo")); + + // unexpected text at the beginning + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLinks("foo bar ;rel=\"next\"")) // + .withMessage("Unexpected data at the end of Link header at index 0"); + } + + @Test // GH-2099 + void parsingMissingData() { + + // missing trailing bracket + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLink("' at index 30"); + + // missing end quote + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLink(";rel=\"next")) + .withMessage("Missing final quote at index 32"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLink(";rel='next")) + .withMessage("Missing final quote at index 32"); + } + + @Test // GH-2099 + void parsingGreedyCapture() { + + // no greedy capture until `>` + assertThat(parseLink(";title=foo>;rel=\"next\"")) // + .isEqualTo(Link.of("url", "next").withTitle("foo>")); + + // no greedy capture until `;` + assertThat(parseLink(";title=\"foo;bar\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo;bar")); + + // no greedy capture until `,` + assertThat(parseLink(";title=\"foo,bar\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo,bar")); + } + + @Test // GH-2099 + void parsingQuotedText() { + + // unquoting of double quotes + assertThat(parseLink(";title=\"\\\"bar\\\"\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("\"bar\"")); + + // unquoting of single quotes + assertThat(parseLink(";title='\\'bar\\'';rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("'bar'")); + + // single quote is literal in double-quoted string + assertThat(parseLink(";title=\"'bar'\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("'bar'")); + + // double quote is literal in single-quoted string + assertThat(parseLink(";title='\"bar\"';rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("\"bar\"")); + + // backslash unquoting + assertThat(parseLink(";title=\"foo\\\\bar\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo\\bar")); + assertThat(parseLink(";title='foo\\\\bar';rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo\\bar")); + + // unquoting of unnecessarily quoted text + assertThat(parseLink(";title=\"\\f\\o\\o\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo")); + assertThat(parseLink(";title='\\f\\o\\o';rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo")); + + // no java-style special characters + assertThat(parseLink(";title=\"\\r\\n\\t\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("rnt")); + assertThat(parseLink(";title='\\r\\n\\t';rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("rnt")); + + // quote within a token value - the quote, if it's not the first character, is literal + assertThat(parseLink(";title=foo\"bar\";rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo\"bar\"")); + assertThat(parseLink(";title=foo'bar';rel=next")) // + .isEqualTo(Link.of("url", "next").withTitle("foo'bar'")); + } + + @Test // GH-2099 + void parsingEmptyString() { + + // at the end + Link expected = Link.of("url", "next").withTitle(""); + + // value missing + assertThat(parseLink(";rel=next;title")).isEqualTo(expected); + // empty token-style value + assertThat(parseLink(";rel=next;title=")).isEqualTo(expected); + // empty double-quoted string + assertThat(parseLink(";rel=next;title=\"\"")).isEqualTo(expected); + // empty single-quoted string + assertThat(parseLink(";rel=next;title=''")).isEqualTo(expected); + + // not at the end + expected = Link.of("url", "next").withTitle("").withName("a"); + + assertThat(parseLink(";rel=next;title;name=a")).isEqualTo(expected); + assertThat(parseLink(";rel=next;title=;name=a")).isEqualTo(expected); + assertThat(parseLink(";rel=next;title=\"\";name=a")).isEqualTo(expected); + assertThat(parseLink(";rel=next;title='';name=a")).isEqualTo(expected); + } + + @Test // GH-2099 + void parsingMultipleRels() { + + assertThat(parseLinks(";rel=next last")) // + .containsExactly(Link.of("url", "next"), Link.of("url", "last")); + + assertThat(parseLinks(";rel=\"next last\"")) // + .containsExactly(Link.of("url", "next"), Link.of("url", "last")); + + assertThat(parseLinks(";rel=prev first,;rel=next last")) // + .containsExactly( // + Link.of("/prev", "prev"), // + Link.of("/prev", "first"), // + Link.of("/next", "next"), // + Link.of("/next", "last")); + } + + @Test // GH-2099 + void parsingSpecialChars() { + + // within the href, `,` and `;` aren't special + assertThat(parseLink(";rel=next")) // + .isEqualTo(Link.of("http://example.com/?param=foo,bar;baz", "next")); + } + + @Test // GH-2099 + void parsingWhitespaceOtherThanSpace() { + + var source = "\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 "; + + assertThat(parseLinks(source)) + .containsExactly(Link.of("url1", "next"), Link.of("url2", "foo").withTitle("\r\n\t bar\r\n\t ")); + } + + @Test // GH-2099 + void parsingEmptyRel() { + + // rel is empty string + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLink(";rel=''")) + .withMessage("Missing 'rel' attribute at index 12"); + + // rel is a single space - if we split by whitespace, there's no value + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLink(";rel=' '")) + .withMessage("Missing 'rel' attribute at index 13"); + } + + @Test // GH-2099 + void directLinkParsing() { + + // leading whitespace + assertThat(parseLink(" ;rel=next")).isEqualTo(Link.of("url", "next")); + + // unexpected data at the beginning + assertThatIllegalArgumentException() + .isThrownBy(() -> parseLink("foo ;rel=next")) + .withMessage("Expecting '<' at index 0"); + } +} diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 671032f3..2a1657b5 100755 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -325,4 +325,13 @@ class LinkUnitTest { void createsUriForTemplateWithOptionalParameters() { assertThat(Link.of("/something{?parameter}").toUri()).isEqualTo(URI.create("/something")); } + + @Test // GH-2099 + void toStringEscaping() { + + assertThat(Link.of("/path?formula=a>b", "next").withTitle("foo\"bar\\baz").toString()) // + .isEqualTo(";rel=\"next\";title=\"foo\\\"bar\\\\baz\""); + assertThat(Link.of("/path?formula=a>b", "next").withTitle("").toString()) // + .isEqualTo(";rel=\"next\";title=\"\""); + } } diff --git a/src/test/java/org/springframework/hateoas/LinksUnitTest.java b/src/test/java/org/springframework/hateoas/LinksUnitTest.java index 7d21ed77..b21d5ffb 100755 --- a/src/test/java/org/springframework/hateoas/LinksUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinksUnitTest.java @@ -291,189 +291,6 @@ 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;