Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix link parsing for non-trivial cases #2128

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
269 changes: 193 additions & 76 deletions src/main/java/org/springframework/hateoas/Link.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,30 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.util.StringUtils;

/**
* Value object for links.
*
* @author Oliver Gierke
* @author Greg Turnquist
* @author Jens Schauder
* @author Viliam Durina
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(value = { "templated", "template" }, ignoreUnknown = true)
public class Link implements Serializable {

private static final long serialVersionUID = -9037755944661782121L;
private static final Pattern URI_AND_ATTRIBUTES_PATTERN = Pattern.compile("<(.*)>;(.*)");

public static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom";

Expand Down Expand Up @@ -241,7 +240,7 @@ public Link andAffordances(List<Affordance> affordances) {
}

/**
* Creats a new {@link Link} with the given {@link Affordance}s.
* Creates a new {@link Link} with the given {@link Affordance}s.
*
* @param affordances must not be {@literal null}.
* @return will never be {@literal null}.
Expand Down Expand Up @@ -384,77 +383,162 @@ public URI toUri() {
}

/**
* Factory method to easily create {@link Link} instances from RFC-8288 compatible {@link String} representations of a
* link.
* Internal method to parse and consume one link from input string.
*
* @param element an RFC-8288 compatible representation of a link.
* @throws IllegalArgumentException if a {@link String} was given that does not adhere to RFC-8288.
* @throws IllegalArgumentException if no {@code rel} attribute could be found.
* @return
* @param input The input string
* @param pos Position to start from. It must be a 1-element array. The element will be
* mutated to point to the first non-consumed character (either ',' or the end of input).
* @return a non-null Link
*/
public static Link valueOf(String element) {

if (!StringUtils.hasText(element)) {
throw new IllegalArgumentException(String.format("Given link header %s is not RFC-8288 compliant!", element));
@NonNull
static Link valueOfInt(@NonNull String input, @NonNull int[] pos) {
assert pos.length == 1;
int l = input.length();
while (pos[0] < l && Character.isWhitespace(input.charAt(pos[0]))) {
pos[0]++;
}

Matcher matcher = URI_AND_ATTRIBUTES_PATTERN.matcher(element);

if (matcher.find()) {

Map<String, String> attributes = getAttributeMap(matcher.group(2));

if (!attributes.containsKey("rel")) {
throw new IllegalArgumentException("Link does not provide a rel attribute!");
if (input.charAt(pos[0]) != '<') {
throw new IllegalArgumentException("Expecting '<' at index " + pos[0]);
}
pos[0]++;
int urlEnd = input.indexOf('>', pos[0]);
if (urlEnd < 0) {
throw new IllegalArgumentException("Missing closing '>' at index " + input.length());
}
String url = input.substring(pos[0], urlEnd);
pos[0] = urlEnd + 1;

// parse parameters
Map<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();

outer:
while (pos[0] <= l) {
boolean eoi = pos[0] == l; // EOI - end of input
char ch = eoi ? 0 : input.charAt(pos[0]);
switch (state) {
// searching for the initial `;`
case INITIAL:
if (Character.isWhitespace(ch)) {
pos[0]++;
}
else if (ch == ';') {
state = State.IN_KEY;
pos[0]++;
}
else {
// if there's something else, it's the end of this link
break outer;
}
break;

// consuming the key up to `=`
case IN_KEY:
if (ch == '=') {
state = State.BEFORE_VALUE;
}
// value isn't mandatory, so param separator, link separator, or end of input all create a new param
else if (ch == ';' || ch == ',' || eoi) {
if (!key.isEmpty()) {
params.put(key.toString().trim(), "");
key.setLength(0);
}
} else {
key.append(ch);
}
pos[0]++;
break;

case BEFORE_VALUE:
if (Character.isWhitespace(ch)) {
pos[0]++;
}
else if (ch == '"' || ch == '\'') {
consumeQuotedString(input, value, pos);
params.putIfAbsent(key.toString().trim(), value.toString());
key.setLength(0);
value.setLength(0);
state = State.INITIAL;
} else {
state = State.IN_VALUE;
}
break;

case IN_VALUE:
if (ch == ';' || ch == ',' || eoi) {
params.putIfAbsent(key.toString().trim(), value.toString().trim());
key.setLength(0);
value.setLength(0);
state = State.INITIAL;
} else {
value.append(ch);
pos[0]++;
}
break;

default:
throw new AssertionError();
}
}

LinkRelation rel = LinkRelation.of(attributes.get("rel"));
String href = matcher.group(1);
String hrefLang = attributes.get("hreflang");
String media = attributes.get("media");
String title = attributes.get("title");
String type = attributes.get("type");
String deprecation = attributes.get("deprecation");
String profile = attributes.get("profile");
String name = attributes.get("name");

return new Link(rel, href, hrefLang, media, title, type, deprecation, profile, name, templateOrNull(href),
Collections.emptyList());

} else {
throw new IllegalArgumentException(String.format("Given link header %s is not RFC-8288 compliant!", element));
String sRel = params.get("rel");
if (!StringUtils.hasText(sRel)) {
throw new IllegalArgumentException("Missing 'rel' attribute at index " + pos[0]);
}
LinkRelation rel = LinkRelation.of(sRel);
String hrefLang = params.get("hreflang");
String media = params.get("media");
String title = params.get("title");
String type = params.get("type");
String deprecation = params.get("deprecation");
String profile = params.get("profile");
String name = params.get("name");

return new Link(rel, url, hrefLang, media, title, type, deprecation, profile, name, templateOrNull(url),
Collections.emptyList());
}

/**
* Consume a quoted string from `input`, adding its contents to `target`. The starting position should be at
* starting quote. After consuming, the ending position will be just after the last final quote.
*/
private static void consumeQuotedString(String input, StringBuilder target, int[] pos) {
int l = input.length();
char quotingChar = input.charAt(pos[0]);
assert quotingChar == '"' || quotingChar == '\'';
// skip quoting char
pos[0]++;
for (; pos[0] < l; pos[0]++) {
char ch = input.charAt(pos[0]);
if (ch == quotingChar) {
pos[0]++; // consume the final quote
return;
}
if (ch == '\\') {
ch = input.charAt(++pos[0]);
}
target.append(ch);
}
throw new IllegalArgumentException("Missing final quote at index " + pos[0]);
}

/**
* Parses the links attributes from the given source {@link String}.
* Factory method to easily create {@link Link} instances from RFC-8288 compatible {@link String} representations of a
* link.
*
* @param source
* @return
* @param element an RFC-8288 compatible representation of a link.
* @throws IllegalArgumentException if a {@link String} was given that does not adhere to RFC-8288.
* @throws IllegalArgumentException if no {@code rel} attribute could be found.
* @return The parsed link
* @deprecated Use {@link Links#parse(String)} instead. This method parses only the first link from a list of links.
*/
private static Map<String, String> getAttributeMap(String source) {

if (!StringUtils.hasText(source)) {
return Collections.emptyMap();
}

String[] parts = source.split(";");
Map<String, String> attributes = new HashMap<>();

for (String part : parts) {

int delimiter = part.indexOf('=');

String key = part.substring(0, delimiter).trim();
String value = part.substring(delimiter + 1).trim();

// Potentially unquote value
value = value.startsWith("\"") ? value.substring(1, value.length() - 1) : value;

attributes.put(key, value);
}

return attributes;
@Deprecated
public static Link valueOf(String element) {
return valueOfInt(element, new int[]{0});
}

/**
Expand All @@ -471,7 +555,7 @@ public Link withHref(String href) {
}

/**
* Create a new {@link Link} by copying all attributes and applying the new {@literal hrefleng}.
* Create a new {@link Link} by copying all attributes and applying the new {@literal hreflang}.
*
* @param hreflang can be {@literal null}
* @return will never be {@literal null}.
Expand Down Expand Up @@ -659,38 +743,71 @@ public int hashCode() {
*/
@Override
public String toString() {

String linkString = String.format("<%s>;rel=\"%s\"", href, rel.value());
StringBuilder result = new StringBuilder(64);
result.append('<')
// We only url-encode the `>`. We expect other special chars to already be escaped. `;` and `,` need not
// be escaped within the URL
.append(href.replace(">", "%3e"))
.append(">;rel=");
quoteParamValue(rel.value(), result);

if (hreflang != null) {
linkString += ";hreflang=\"" + hreflang + "\"";
result.append(";hreflang=");
quoteParamValue(hreflang, result);
}

if (media != null) {
linkString += ";media=\"" + media + "\"";
result.append(";media=");
quoteParamValue(media, result);
}

if (title != null) {
linkString += ";title=\"" + title + "\"";
result.append(";title=");
quoteParamValue(title, result);
}

if (type != null) {
linkString += ";type=\"" + type + "\"";
result.append(";type=");
quoteParamValue(type, result);
}

if (deprecation != null) {
linkString += ";deprecation=\"" + deprecation + "\"";
result.append(";deprecation=");
quoteParamValue(deprecation, result);
}

if (profile != null) {
linkString += ";profile=\"" + profile + "\"";
result.append(";profile=");
quoteParamValue(profile, result);
}

if (name != null) {
linkString += ";name=\"" + name + "\"";
result.append(";name=");
quoteParamValue(name, result);
}

return linkString;
return result.toString();
}

/**
* Quotes the given string `s` and appends the result to the `target`. This method appends the start quote, the
* escaped text, and the end quote.
*
* @param s Text to quote
* @param target StringBuilder to append to
*/
private void quoteParamValue(String s, StringBuilder target) {
// we reserve extra 6 chars: two for the start and end quote, 2 is a reserve for potential escaped chars
target.ensureCapacity(target.length() + s.length() + 2);
target.append('"');
for (int i = 0, l = s.length(); i < l; i++) {
char ch = s.charAt(i);
if (ch == '"' || ch == '\\') {
target.append('\\');
}
target.append(ch);
}
target.append('"');
}

@Nullable
Expand Down