GH-2161 - Polishing.

This commit is contained in:
Oliver Drotbohm
2024-07-02 14:39:50 +02:00
parent c1da439976
commit 7a4f9e842c
6 changed files with 403 additions and 237 deletions

View File

@@ -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<Affordance> affordances) {
@Nullable String type, @Nullable String deprecation, @Nullable String profile, @Nullable String name,
@Nullable UriTemplate template, List<Affordance> 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('"');
}

View File

@@ -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<Link> parseLinks(String source) {
var links = new ArrayList<Link>();
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<String, String> 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<String, String>();
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
}
}

View File

@@ -84,15 +84,14 @@ public class Links implements Iterable<Link> {
* @return the {@link Links} represented by the given {@link String}.
*/
public static Links parse(@Nullable String source) {
if (source == null) {
return NONE;
}
List<Link> links = LinkParser.parseLinks(source);
if (links.isEmpty()) {
return NONE;
}
return new Links(links);
return links.isEmpty() ? NONE : new Links(links);
}
/**

View File

@@ -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("<url1>;rel=\"foo\"<url2>;rel= \"bar\""))
.withMessage("Unexpected data at the end of Link header at index 16");
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLinks("<url1>; rel=\"foo\" <url2>;rel= \"bar\""))
.withMessage("Unexpected data at the end of Link header at index 18");
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLinks("<url1> ; rel= \"foo\" <url2>;rel= \"bar\""))
.withMessage("Unexpected data at the end of Link header at index 20");
// unexpected text after a quoted string
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLinks("<url1>;rel=\"foo\"#"))
.withMessage("Unexpected data at the end of Link header at index 16");
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLinks("<url1>;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("<url1>;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("<url1>;rel=foo,bar"))
.withMessage("Unexpected data at the end of Link header at index 15");
// a trailing comma is ignored
assertThat(parseLink("<url1>;rel=foo,")) //
.isEqualTo(Link.of("url1", "foo"));
// a trailing semicolon is also ignored
assertThat(parseLink("<url1>;rel=foo;")) //
.isEqualTo(Link.of("url1", "foo"));
// unexpected text at the beginning
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLinks("foo bar <url>;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("<https://example.com/;rel=next"))
.withMessage("Missing closing '>' at index 30");
// missing end quote
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLink("<https://example.com/>;rel=\"next"))
.withMessage("Missing final quote at index 32");
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLink("<https://example.com/>;rel='next"))
.withMessage("Missing final quote at index 32");
}
@Test // GH-2099
void parsingGreedyCapture() {
// no greedy capture until `>`
assertThat(parseLink("<url>;title=foo>;rel=\"next\"")) //
.isEqualTo(Link.of("url", "next").withTitle("foo>"));
// no greedy capture until `;`
assertThat(parseLink("<url>;title=\"foo;bar\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo;bar"));
// no greedy capture until `,`
assertThat(parseLink("<url>;title=\"foo,bar\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo,bar"));
}
@Test // GH-2099
void parsingQuotedText() {
// unquoting of double quotes
assertThat(parseLink("<url>;title=\"\\\"bar\\\"\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("\"bar\""));
// unquoting of single quotes
assertThat(parseLink("<url>;title='\\'bar\\'';rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("'bar'"));
// single quote is literal in double-quoted string
assertThat(parseLink("<url>;title=\"'bar'\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("'bar'"));
// double quote is literal in single-quoted string
assertThat(parseLink("<url>;title='\"bar\"';rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("\"bar\""));
// backslash unquoting
assertThat(parseLink("<url>;title=\"foo\\\\bar\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo\\bar"));
assertThat(parseLink("<url>;title='foo\\\\bar';rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo\\bar"));
// unquoting of unnecessarily quoted text
assertThat(parseLink("<url>;title=\"\\f\\o\\o\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo"));
assertThat(parseLink("<url>;title='\\f\\o\\o';rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo"));
// no java-style special characters
assertThat(parseLink("<url>;title=\"\\r\\n\\t\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("rnt"));
assertThat(parseLink("<url>;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("<url>;title=foo\"bar\";rel=next")) //
.isEqualTo(Link.of("url", "next").withTitle("foo\"bar\""));
assertThat(parseLink("<url>;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("<url>;rel=next;title")).isEqualTo(expected);
// empty token-style value
assertThat(parseLink("<url>;rel=next;title=")).isEqualTo(expected);
// empty double-quoted string
assertThat(parseLink("<url>;rel=next;title=\"\"")).isEqualTo(expected);
// empty single-quoted string
assertThat(parseLink("<url>;rel=next;title=''")).isEqualTo(expected);
// not at the end
expected = Link.of("url", "next").withTitle("").withName("a");
assertThat(parseLink("<url>;rel=next;title;name=a")).isEqualTo(expected);
assertThat(parseLink("<url>;rel=next;title=;name=a")).isEqualTo(expected);
assertThat(parseLink("<url>;rel=next;title=\"\";name=a")).isEqualTo(expected);
assertThat(parseLink("<url>;rel=next;title='';name=a")).isEqualTo(expected);
}
@Test // GH-2099
void parsingMultipleRels() {
assertThat(parseLinks("<url>;rel=next last")) //
.containsExactly(Link.of("url", "next"), Link.of("url", "last"));
assertThat(parseLinks("<url>;rel=\"next last\"")) //
.containsExactly(Link.of("url", "next"), Link.of("url", "last"));
assertThat(parseLinks("</prev>;rel=prev first,</next>;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("<http://example.com/?param=foo,bar;baz>;rel=next")) //
.isEqualTo(Link.of("http://example.com/?param=foo,bar;baz", "next"));
}
@Test // GH-2099
void parsingWhitespaceOtherThanSpace() {
var source = "\n\r\t <url1>\n\r\t ;\n\r\t rel\n\r\t =\r\n\t next \r\n\t , \r\n\t ,"
+ " \r\n\t <url2>\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("<url>;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("<url>;rel=' '"))
.withMessage("Missing 'rel' attribute at index 13");
}
@Test // GH-2099
void directLinkParsing() {
// leading whitespace
assertThat(parseLink(" <url>;rel=next")).isEqualTo(Link.of("url", "next"));
// unexpected data at the beginning
assertThatIllegalArgumentException()
.isThrownBy(() -> parseLink("foo <url>;rel=next"))
.withMessage("Expecting '<' at index 0");
}
}

View File

@@ -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("</path?formula=a%3eb>;rel=\"next\";title=\"foo\\\"bar\\\\baz\"");
assertThat(Link.of("/path?formula=a>b", "next").withTitle("").toString()) //
.isEqualTo("</path?formula=a%3eb>;rel=\"next\";title=\"\"");
}
}

View File

@@ -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("<url1>;rel=\"foo\"<url2>;rel= \"bar\"")).isInstanceOf(
IllegalArgumentException.class).hasMessage("Unexpected data at the end of Link header at index 16");
assertThatThrownBy(() -> Links.parse("<url1>; rel=\"foo\" <url2>;rel= \"bar\"")).isInstanceOf(
IllegalArgumentException.class).hasMessage("Unexpected data at the end of Link header at index 18");
assertThatThrownBy(() -> Links.parse("<url1> ; rel= \"foo\" <url2>;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("<url1>;rel=\"foo\"#")).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unexpected data at the end of Link header at index 16");
assertThatThrownBy(() -> Links.parse("<url1>;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("<url1>;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("<url1>;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("<url1>;rel=foo,")).isEqualTo(Links.of(Link.of("url1", "foo")));
// a trailing semicolon is also ignored
assertThat(Links.parse("<url1>;rel=foo;")).isEqualTo(Links.of(Link.of("url1", "foo")));
// unexpected text at the beginning
assertThatThrownBy(() -> Links.parse("foo bar <url>;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("<https://example.com/;rel=next")).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Missing closing '>' at index 30");
// missing end quote
assertThatThrownBy(() -> Links.parse("<https://example.com/>;rel=\"next")).isInstanceOf(
IllegalArgumentException.class).hasMessage("Missing final quote at index 32");
assertThatThrownBy(() -> Links.parse("<https://example.com/>;rel='next")).isInstanceOf(
IllegalArgumentException.class).hasMessage("Missing final quote at index 32");
}
@Test
void parsingGreedyCapture() {
// no greedy capture until `>`
assertThat(Links.parse("<url>;title=foo>;rel=\"next\"")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo>")));
// no greedy capture until `;`
assertThat(Links.parse("<url>;title=\"foo;bar\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo;bar")));
// no greedy capture until `,`
assertThat(Links.parse("<url>;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("<url>;title=\"\\\"bar\\\"\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("\"bar\"")));
// unquoting of single quotes
assertThat(Links.parse("<url>;title='\\'bar\\'';rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("'bar'")));
// single quote is literal in double-quoted string
assertThat(Links.parse("<url>;title=\"'bar'\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("'bar'")));
// double quote is literal in single-quoted string
assertThat(Links.parse("<url>;title='\"bar\"';rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("\"bar\"")));
// backslash unquoting
assertThat(Links.parse("<url>;title=\"foo\\\\bar\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo\\bar")));
assertThat(Links.parse("<url>;title='foo\\\\bar';rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo\\bar")));
// unquoting of unnecessarily quoted text
assertThat(Links.parse("<url>;title=\"\\f\\o\\o\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo")));
assertThat(Links.parse("<url>;title='\\f\\o\\o';rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo")));
// no java-style special characters
assertThat(Links.parse("<url>;title=\"\\r\\n\\t\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("rnt")));
assertThat(Links.parse("<url>;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("<url>;title=foo\"bar\";rel=next")).isEqualTo(
Links.of(Link.of("url", "next").withTitle("foo\"bar\"")));
assertThat(Links.parse("<url>;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("<url>;rel=next;title")).isEqualTo(expected);
// empty token-style value
assertThat(Links.parse("<url>;rel=next;title=")).isEqualTo(expected);
// empty double-quoted string
assertThat(Links.parse("<url>;rel=next;title=\"\"")).isEqualTo(expected);
// empty single-quoted string
assertThat(Links.parse("<url>;rel=next;title=''")).isEqualTo(expected);
// not at the end
expected = Links.of(Link.of("url", "next").withTitle("").withName("a"));
assertThat(Links.parse("<url>;rel=next;title;name=a")).isEqualTo(expected);
assertThat(Links.parse("<url>;rel=next;title=;name=a")).isEqualTo(expected);
assertThat(Links.parse("<url>;rel=next;title=\"\";name=a")).isEqualTo(expected);
assertThat(Links.parse("<url>;rel=next;title='';name=a")).isEqualTo(expected);
}
@Test
void parsingMultipleRels() {
assertThat(Links.parse("<url>;rel=next last")).isEqualTo(Links.of(Link.of("url", "next"), Link.of("url", "last")));
assertThat(Links.parse("<url>;rel=\"next last\"")).isEqualTo(
Links.of(Link.of("url", "next"), Link.of("url", "last")));
assertThat(Links.parse("</prev>;rel=prev first,</next>;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("<http://example.com/?param=foo,bar;baz>;rel=next")).isEqualTo(
Links.of(Link.of("http://example.com/?param=foo,bar;baz", "next")));
}
@Test
void parsingWhitespaceOtherThanSpace() {
assertThat(Links.parse(
"\n\r\t <url1>\n\r\t ;\n\r\t rel\n\r\t =\r\n\t next \r\n\t , \r\n\t ," + " \r\n\t <url2>\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("<url>;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("<url>;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(
"</path?formula=a%3eb>;rel=\"next\";title=\"foo\\\"bar\\\\baz\"");
assertThat(Links.of(Link.of("/path?formula=a>b", "next").withTitle("")).toString()).isEqualTo(
"</path?formula=a%3eb>;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(" <url>;rel=next")).isEqualTo(Link.of("url", "next"));
// unexpected data at the beginning
assertThatThrownBy(() -> Link.valueOf("foo <url>;rel=next")).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Expecting '<' at index 0");
}
@Value(staticConstructor = "of")
static class NamedLinks {
String name;