GH-2161 - Polishing.
This commit is contained in:
@@ -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('"');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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=\"\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user