Browse Source

Support opaque URIs in UriComponentsBuilder

Before this commit, UriComponentsBuilder did not handle opaque URIs at
all. After this commit it does.

Support is introduced by making UriComponents an abstract base class,
and having two concrete subclasses: HierarchicalUriComponents and
OpaqueUriComponents. The former is more or less the same as the old
UriComponents class.

Issue: SPR-9798
pull/274/head
Arjen Poutsma 14 years ago committed by Rossen Stoyanchev
parent
commit
6e45a79ecb
  1. 858
      spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java
  2. 168
      spring-web/src/main/java/org/springframework/web/util/OpaqueUriComponents.java
  3. 950
      spring-web/src/main/java/org/springframework/web/util/UriComponents.java
  4. 159
      spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java
  5. 33
      spring-web/src/main/java/org/springframework/web/util/UriUtils.java
  6. 35
      spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java

858
spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java

@ -0,0 +1,858 @@ @@ -0,0 +1,858 @@
/*
* Copyright 2002-2012 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
*
* http://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.web.util;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Extension of {@link UriComponents} for hierarchical URIs.
*
* @author Arjen Poutsma
* @since 3.2
* @see <a href="http://tools.ietf.org/html/rfc3986#section-1.2.3">Hierarchical URIs</a>
*/
final class HierarchicalUriComponents extends UriComponents {
private static final char PATH_DELIMITER = '/';
private final String userInfo;
private final String host;
private final int port;
private final PathComponent path;
private final MultiValueMap<String, String> queryParams;
private final boolean encoded;
/**
* Package-private constructor. All arguments are optional, and can be {@code null}.
*
* @param scheme the scheme
* @param userInfo the user info
* @param host the host
* @param port the port
* @param path the path
* @param queryParams the query parameters
* @param fragment the fragment
* @param encoded whether the components are already encoded
* @param verify whether the components need to be checked for illegal characters
*/
HierarchicalUriComponents(String scheme, String userInfo, String host, int port,
PathComponent path, MultiValueMap<String, String> queryParams,
String fragment, boolean encoded, boolean verify) {
super(scheme, fragment);
this.userInfo = userInfo;
this.host = host;
this.port = port;
this.path = path != null ? path : NULL_PATH_COMPONENT;
this.queryParams = CollectionUtils.unmodifiableMultiValueMap(
queryParams != null ? queryParams : new LinkedMultiValueMap<String, String>(0));
this.encoded = encoded;
if (verify) {
verify();
}
}
// component getters
@Override
public String getSchemeSpecificPart() {
return null;
}
@Override
public String getUserInfo() {
return this.userInfo;
}
@Override
public String getHost() {
return this.host;
}
@Override
public int getPort() {
return this.port;
}
@Override
public String getPath() {
return this.path.getPath();
}
@Override
public List<String> getPathSegments() {
return this.path.getPathSegments();
}
@Override
public String getQuery() {
if (!this.queryParams.isEmpty()) {
StringBuilder queryBuilder = new StringBuilder();
for (Map.Entry<String, List<String>> entry : this.queryParams.entrySet()) {
String name = entry.getKey();
List<String> values = entry.getValue();
if (CollectionUtils.isEmpty(values)) {
if (queryBuilder.length() != 0) {
queryBuilder.append('&');
}
queryBuilder.append(name);
}
else {
for (Object value : values) {
if (queryBuilder.length() != 0) {
queryBuilder.append('&');
}
queryBuilder.append(name);
if (value != null) {
queryBuilder.append('=');
queryBuilder.append(value.toString());
}
}
}
}
return queryBuilder.toString();
}
else {
return null;
}
}
/**
* Returns the map of query parameters.
*
* @return the query parameters. Empty if no query has been set.
*/
@Override
public MultiValueMap<String, String> getQueryParams() {
return this.queryParams;
}
// encoding
/**
* Encodes all URI components using their specific encoding rules, and returns the result as a new
* {@code UriComponents} instance.
*
* @param encoding the encoding of the values contained in this map
* @return the encoded uri components
* @throws UnsupportedEncodingException if the given encoding is not supported
*/
@Override
public HierarchicalUriComponents encode(String encoding) throws UnsupportedEncodingException {
Assert.hasLength(encoding, "'encoding' must not be empty");
if (this.encoded) {
return this;
}
String encodedScheme = encodeUriComponent(this.getScheme(), encoding, Type.SCHEME);
String encodedUserInfo = encodeUriComponent(this.userInfo, encoding, Type.USER_INFO);
String encodedHost = encodeUriComponent(this.host, encoding, Type.HOST);
PathComponent encodedPath = this.path.encode(encoding);
MultiValueMap<String, String> encodedQueryParams =
new LinkedMultiValueMap<String, String>(this.queryParams.size());
for (Map.Entry<String, List<String>> entry : this.queryParams.entrySet()) {
String encodedName = encodeUriComponent(entry.getKey(), encoding, Type.QUERY_PARAM);
List<String> encodedValues = new ArrayList<String>(entry.getValue().size());
for (String value : entry.getValue()) {
String encodedValue = encodeUriComponent(value, encoding, Type.QUERY_PARAM);
encodedValues.add(encodedValue);
}
encodedQueryParams.put(encodedName, encodedValues);
}
String encodedFragment = encodeUriComponent(this.getFragment(), encoding, Type.FRAGMENT);
return new HierarchicalUriComponents(encodedScheme, encodedUserInfo, encodedHost, this.port, encodedPath,
encodedQueryParams, encodedFragment, true, false);
}
/**
* Encodes the given source into an encoded String using the rules specified
* by the given component and with the given options.
*
* @param source the source string
* @param encoding the encoding of the source string
* @param type the URI component for the source
* @return the encoded URI
* @throws IllegalArgumentException when the given uri parameter is not a
* valid URI
*/
static String encodeUriComponent(String source, String encoding, Type type)
throws UnsupportedEncodingException {
if (source == null) {
return null;
}
Assert.hasLength(encoding, "'encoding' must not be empty");
byte[] bytes = encodeBytes(source.getBytes(encoding), type);
return new String(bytes, "US-ASCII");
}
private static byte[] encodeBytes(byte[] source, Type type) {
Assert.notNull(source, "'source' must not be null");
Assert.notNull(type, "'type' must not be null");
ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length);
for (int i = 0; i < source.length; i++) {
int b = source[i];
if (b < 0) {
b += 256;
}
if (type.isAllowed(b)) {
bos.write(b);
}
else {
bos.write('%');
char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16));
char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16));
bos.write(hex1);
bos.write(hex2);
}
}
return bos.toByteArray();
}
// verifying
/**
* Verifies all URI components to determine whether they contain any illegal
* characters, throwing an {@code IllegalArgumentException} if so.
*
* @throws IllegalArgumentException if any component has illegal characters
*/
private void verify() {
if (!this.encoded) {
return;
}
verifyUriComponent(getScheme(), Type.SCHEME);
verifyUriComponent(userInfo, Type.USER_INFO);
verifyUriComponent(host, Type.HOST);
this.path.verify();
for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) {
verifyUriComponent(entry.getKey(), Type.QUERY_PARAM);
for (String value : entry.getValue()) {
verifyUriComponent(value, Type.QUERY_PARAM);
}
}
verifyUriComponent(getFragment(), Type.FRAGMENT);
}
private static void verifyUriComponent(String source, Type type) {
if (source == null) {
return;
}
int length = source.length();
for (int i=0; i < length; i++) {
char ch = source.charAt(i);
if (ch == '%') {
if ((i + 2) < length) {
char hex1 = source.charAt(i + 1);
char hex2 = source.charAt(i + 2);
int u = Character.digit(hex1, 16);
int l = Character.digit(hex2, 16);
if (u == -1 || l == -1) {
throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
}
i += 2;
}
else {
throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
}
}
else if (!type.isAllowed(ch)) {
throw new IllegalArgumentException(
"Invalid character '" + ch + "' for " + type.name() + " in \"" + source + "\"");
}
}
}
// expanding
@Override
protected HierarchicalUriComponents expandInternal(UriTemplateVariables uriVariables) {
Assert.state(!encoded, "Cannot expand an already encoded UriComponents object");
String expandedScheme = expandUriComponent(this.getScheme(), uriVariables);
String expandedUserInfo = expandUriComponent(this.userInfo, uriVariables);
String expandedHost = expandUriComponent(this.host, uriVariables);
PathComponent expandedPath = this.path.expand(uriVariables);
MultiValueMap<String, String> expandedQueryParams =
new LinkedMultiValueMap<String, String>(this.queryParams.size());
for (Map.Entry<String, List<String>> entry : this.queryParams.entrySet()) {
String expandedName = expandUriComponent(entry.getKey(), uriVariables);
List<String> expandedValues = new ArrayList<String>(entry.getValue().size());
for (String value : entry.getValue()) {
String expandedValue = expandUriComponent(value, uriVariables);
expandedValues.add(expandedValue);
}
expandedQueryParams.put(expandedName, expandedValues);
}
String expandedFragment = expandUriComponent(this.getFragment(), uriVariables);
return new HierarchicalUriComponents(expandedScheme, expandedUserInfo, expandedHost, this.port, expandedPath,
expandedQueryParams, expandedFragment, false, false);
}
/**
* Normalize the path removing sequences like "path/..".
* @see StringUtils#cleanPath(String)
*/
@Override
public UriComponents normalize() {
String normalizedPath = StringUtils.cleanPath(getPath());
return new HierarchicalUriComponents(getScheme(), this.userInfo, this.host,
this.port, new FullPathComponent(normalizedPath), this.queryParams,
getFragment(), this.encoded, false);
}
// other functionality
/**
* Returns a URI string from this {@code UriComponents} instance.
*
* @return the URI string
*/
@Override
public String toUriString() {
StringBuilder uriBuilder = new StringBuilder();
if (getScheme() != null) {
uriBuilder.append(getScheme());
uriBuilder.append(':');
}
if (this.userInfo != null || this.host != null) {
uriBuilder.append("//");
if (this.userInfo != null) {
uriBuilder.append(this.userInfo);
uriBuilder.append('@');
}
if (this.host != null) {
uriBuilder.append(host);
}
if (this.port != -1) {
uriBuilder.append(':');
uriBuilder.append(port);
}
}
String path = getPath();
if (StringUtils.hasLength(path)) {
if (uriBuilder.length() != 0 && path.charAt(0) != PATH_DELIMITER) {
uriBuilder.append(PATH_DELIMITER);
}
uriBuilder.append(path);
}
String query = getQuery();
if (query != null) {
uriBuilder.append('?');
uriBuilder.append(query);
}
if (getFragment() != null) {
uriBuilder.append('#');
uriBuilder.append(getFragment());
}
return uriBuilder.toString();
}
/**
* Returns a {@code URI} from this {@code UriComponents} instance.
*
* @return the URI
*/
@Override
public URI toUri() {
try {
if (this.encoded) {
return new URI(toString());
}
else {
String path = getPath();
if (StringUtils.hasLength(path) && path.charAt(0) != PATH_DELIMITER) {
path = PATH_DELIMITER + path;
}
return new URI(getScheme(), getUserInfo(), getHost(), getPort(), path, getQuery(),
getFragment());
}
}
catch (URISyntaxException ex) {
throw new IllegalStateException("Could not create URI object: " + ex.getMessage(), ex);
}
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof OpaqueUriComponents)) {
return false;
}
HierarchicalUriComponents other = (HierarchicalUriComponents) o;
if (ObjectUtils.nullSafeEquals(getScheme(), other.getScheme())) {
return false;
}
if (ObjectUtils.nullSafeEquals(getUserInfo(), other.getUserInfo())) {
return false;
}
if (ObjectUtils.nullSafeEquals(getHost(), other.getHost())) {
return false;
}
if (this.port != other.port) {
return false;
}
if (!this.path.equals(other.path)) {
return false;
}
if (!this.queryParams.equals(other.queryParams)) {
return false;
}
if (ObjectUtils.nullSafeEquals(getFragment(), other.getFragment())) {
return false;
}
return true;
}
@Override
public int hashCode() {
int result = ObjectUtils.nullSafeHashCode(getScheme());
result = 31 * result + ObjectUtils.nullSafeHashCode(this.userInfo);
result = 31 * result + ObjectUtils.nullSafeHashCode(this.host);
result = 31 * result + this.port;
result = 31 * result + this.path.hashCode();
result = 31 * result + this.queryParams.hashCode();
result = 31 * result + ObjectUtils.nullSafeHashCode(getFragment());
return result;
}
// inner types
/**
* Enumeration used to identify the parts of a URI.
* <p/>
* Contains methods to indicate whether a given character is valid in a specific URI component.
*
* @author Arjen Poutsma
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986</a>
*/
static enum Type {
SCHEME {
@Override
public boolean isAllowed(int c) {
return isAlpha(c) || isDigit(c) || '+' == c || '-' == c || '.' == c;
}
},
AUTHORITY {
@Override
public boolean isAllowed(int c) {
return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c;
}
},
USER_INFO {
@Override
public boolean isAllowed(int c) {
return isUnreserved(c) || isSubDelimiter(c) || ':' == c;
}
},
HOST {
@Override
public boolean isAllowed(int c) {
return isUnreserved(c) || isSubDelimiter(c);
}
},
PORT {
@Override
public boolean isAllowed(int c) {
return isDigit(c);
}
},
PATH {
@Override
public boolean isAllowed(int c) {
return isPchar(c) || '/' == c;
}
},
PATH_SEGMENT {
@Override
public boolean isAllowed(int c) {
return isPchar(c);
}
},
QUERY {
@Override
public boolean isAllowed(int c) {
return isPchar(c) || '/' == c || '?' == c;
}
},
QUERY_PARAM {
@Override
public boolean isAllowed(int c) {
if ('=' == c || '+' == c || '&' == c) {
return false;
}
else {
return isPchar(c) || '/' == c || '?' == c;
}
}
},
FRAGMENT {
@Override
public boolean isAllowed(int c) {
return isPchar(c) || '/' == c || '?' == c;
}
};
/**
* Indicates whether the given character is allowed in this URI component.
*
* @param c the character
* @return {@code true} if the character is allowed; {@code false} otherwise
*/
public abstract boolean isAllowed(int c);
/**
* Indicates whether the given character is in the {@code ALPHA} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isAlpha(int c) {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
}
/**
* Indicates whether the given character is in the {@code DIGIT} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isDigit(int c) {
return c >= '0' && c <= '9';
}
/**
* Indicates whether the given character is in the {@code gen-delims} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isGenericDelimiter(int c) {
return ':' == c || '/' == c || '?' == c || '#' == c || '[' == c || ']' == c || '@' == c;
}
/**
* Indicates whether the given character is in the {@code sub-delims} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isSubDelimiter(int c) {
return '!' == c || '$' == c || '&' == c || '\'' == c || '(' == c || ')' == c || '*' == c || '+' == c ||
',' == c || ';' == c || '=' == c;
}
/**
* Indicates whether the given character is in the {@code reserved} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isReserved(char c) {
return isGenericDelimiter(c) || isReserved(c);
}
/**
* Indicates whether the given character is in the {@code unreserved} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isUnreserved(int c) {
return isAlpha(c) || isDigit(c) || '-' == c || '.' == c || '_' == c || '~' == c;
}
/**
* Indicates whether the given character is in the {@code pchar} set.
*
* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>
*/
protected boolean isPchar(int c) {
return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c;
}
}
/**
* Defines the contract for path (segments).
*/
interface PathComponent {
String getPath();
List<String> getPathSegments();
PathComponent encode(String encoding) throws UnsupportedEncodingException;
void verify();
PathComponent expand(UriTemplateVariables uriVariables);
}
/**
* Represents a path backed by a string.
*/
final static class FullPathComponent implements PathComponent {
private final String path;
FullPathComponent(String path) {
this.path = path;
}
public String getPath() {
return path;
}
public List<String> getPathSegments() {
String delimiter = new String(new char[]{PATH_DELIMITER});
String[] pathSegments = StringUtils.tokenizeToStringArray(path, delimiter);
return Collections.unmodifiableList(Arrays.asList(pathSegments));
}
public PathComponent encode(String encoding) throws UnsupportedEncodingException {
String encodedPath = encodeUriComponent(getPath(),encoding, Type.PATH);
return new FullPathComponent(encodedPath);
}
public void verify() {
verifyUriComponent(this.path, Type.PATH);
}
public PathComponent expand(UriTemplateVariables uriVariables) {
String expandedPath = expandUriComponent(getPath(), uriVariables);
return new FullPathComponent(expandedPath);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
} else if (o instanceof FullPathComponent) {
FullPathComponent other = (FullPathComponent) o;
return this.getPath().equals(other.getPath());
}
return false;
}
@Override
public int hashCode() {
return getPath().hashCode();
}
}
/**
* Represents a path backed by a string list (i.e. path segments).
*/
final static class PathSegmentComponent implements PathComponent {
private final List<String> pathSegments;
PathSegmentComponent(List<String> pathSegments) {
this.pathSegments = Collections.unmodifiableList(pathSegments);
}
public String getPath() {
StringBuilder pathBuilder = new StringBuilder();
pathBuilder.append(PATH_DELIMITER);
for (Iterator<String> iterator = this.pathSegments.iterator(); iterator.hasNext(); ) {
String pathSegment = iterator.next();
pathBuilder.append(pathSegment);
if (iterator.hasNext()) {
pathBuilder.append(PATH_DELIMITER);
}
}
return pathBuilder.toString();
}
public List<String> getPathSegments() {
return this.pathSegments;
}
public PathComponent encode(String encoding) throws UnsupportedEncodingException {
List<String> pathSegments = getPathSegments();
List<String> encodedPathSegments = new ArrayList<String>(pathSegments.size());
for (String pathSegment : pathSegments) {
String encodedPathSegment = encodeUriComponent(pathSegment, encoding, Type.PATH_SEGMENT);
encodedPathSegments.add(encodedPathSegment);
}
return new PathSegmentComponent(encodedPathSegments);
}
public void verify() {
for (String pathSegment : getPathSegments()) {
verifyUriComponent(pathSegment, Type.PATH_SEGMENT);
}
}
public PathComponent expand(UriTemplateVariables uriVariables) {
List<String> pathSegments = getPathSegments();
List<String> expandedPathSegments = new ArrayList<String>(pathSegments.size());
for (String pathSegment : pathSegments) {
String expandedPathSegment = expandUriComponent(pathSegment, uriVariables);
expandedPathSegments.add(expandedPathSegment);
}
return new PathSegmentComponent(expandedPathSegments);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
} else if (o instanceof PathSegmentComponent) {
PathSegmentComponent other = (PathSegmentComponent) o;
return this.getPathSegments().equals(other.getPathSegments());
}
return false;
}
@Override
public int hashCode() {
return getPathSegments().hashCode();
}
}
/**
* Represents a collection of PathComponents.
*/
final static class PathComponentComposite implements PathComponent {
private final List<PathComponent> pathComponents;
PathComponentComposite(List<PathComponent> pathComponents) {
this.pathComponents = pathComponents;
}
public String getPath() {
StringBuilder pathBuilder = new StringBuilder();
for (PathComponent pathComponent : this.pathComponents) {
pathBuilder.append(pathComponent.getPath());
}
return pathBuilder.toString();
}
public List<String> getPathSegments() {
List<String> result = new ArrayList<String>();
for (PathComponent pathComponent : this.pathComponents) {
result.addAll(pathComponent.getPathSegments());
}
return result;
}
public PathComponent encode(String encoding) throws UnsupportedEncodingException {
List<PathComponent> encodedComponents = new ArrayList<PathComponent>(pathComponents.size());
for (PathComponent pathComponent : pathComponents) {
encodedComponents.add(pathComponent.encode(encoding));
}
return new PathComponentComposite(encodedComponents);
}
public void verify() {
for (PathComponent pathComponent : pathComponents) {
pathComponent.verify();
}
}
public PathComponent expand(UriTemplateVariables uriVariables) {
List<PathComponent> expandedComponents = new ArrayList<PathComponent>(this.pathComponents.size());
for (PathComponent pathComponent : this.pathComponents) {
expandedComponents.add(pathComponent.expand(uriVariables));
}
return new PathComponentComposite(expandedComponents);
}
}
/**
* Represents an empty path.
*/
final static PathComponent NULL_PATH_COMPONENT = new PathComponent() {
public String getPath() {
return null;
}
public List<String> getPathSegments() {
return Collections.emptyList();
}
public PathComponent encode(String encoding) throws UnsupportedEncodingException {
return this;
}
public void verify() {
}
public PathComponent expand(UriTemplateVariables uriVariables) {
return this;
}
@Override
public boolean equals(Object o) {
return this == o;
}
@Override
public int hashCode() {
return 42;
}
};
}

168
spring-web/src/main/java/org/springframework/web/util/OpaqueUriComponents.java

@ -0,0 +1,168 @@ @@ -0,0 +1,168 @@
/*
* Copyright 2002-2012 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
*
* http://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.web.util;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.List;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ObjectUtils;
/**
* Extension of {@link UriComponents} for opaque URIs.
*
* @author Arjen Poutsma
* @since 3.2
* @see <a href="http://tools.ietf.org/html/rfc3986#section-1.2.3">Hierarchical vs Opaque URIs</a>
*/
final class OpaqueUriComponents extends UriComponents {
private static final MultiValueMap<String, String> QUERY_PARAMS_NONE = new LinkedMultiValueMap<String, String>(0);
private final String ssp;
OpaqueUriComponents(String scheme, String schemeSpecificPart, String fragment) {
super(scheme, fragment);
this.ssp = schemeSpecificPart;
}
@Override
public String getSchemeSpecificPart() {
return this.ssp;
}
@Override
public String getUserInfo() {
return null;
}
@Override
public String getHost() {
return null;
}
@Override
public int getPort() {
return -1;
}
@Override
public String getPath() {
return null;
}
@Override
public List<String> getPathSegments() {
return Collections.emptyList();
}
@Override
public String getQuery() {
return null;
}
@Override
public MultiValueMap<String, String> getQueryParams() {
return QUERY_PARAMS_NONE;
}
@Override
public UriComponents encode(String encoding) throws UnsupportedEncodingException {
return this;
}
@Override
protected UriComponents expandInternal(UriTemplateVariables uriVariables) {
String expandedScheme = expandUriComponent(this.getScheme(), uriVariables);
String expandedSSp = expandUriComponent(this.ssp, uriVariables);
String expandedFragment = expandUriComponent(this.getFragment(), uriVariables);
return new OpaqueUriComponents(expandedScheme, expandedSSp, expandedFragment);
}
@Override
public String toUriString() {
StringBuilder uriBuilder = new StringBuilder();
if (getScheme() != null) {
uriBuilder.append(getScheme());
uriBuilder.append(':');
}
if (this.ssp != null) {
uriBuilder.append(this.ssp);
}
if (getFragment() != null) {
uriBuilder.append('#');
uriBuilder.append(getFragment());
}
return uriBuilder.toString();
}
@Override
public URI toUri() {
try {
return new URI(getScheme(), this.ssp, getFragment());
}
catch (URISyntaxException ex) {
throw new IllegalStateException("Could not create URI object: " + ex.getMessage(), ex);
}
}
@Override
public UriComponents normalize() {
return this;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof OpaqueUriComponents)) {
return false;
}
OpaqueUriComponents other = (OpaqueUriComponents) o;
if (ObjectUtils.nullSafeEquals(getScheme(), other.getScheme())) {
return false;
}
if (ObjectUtils.nullSafeEquals(this.ssp, other.ssp)) {
return false;
}
if (ObjectUtils.nullSafeEquals(getFragment(), other.getFragment())) {
return false;
}
return true;
}
@Override
public int hashCode() {
int result = ObjectUtils.nullSafeHashCode(getScheme());
result = 31 * result + ObjectUtils.nullSafeHashCode(this.ssp);
result = 31 * result + ObjectUtils.nullSafeHashCode(getFragment());
return result;
}
}

950
spring-web/src/main/java/org/springframework/web/util/UriComponents.java

File diff suppressed because it is too large Load Diff

159
spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java

@ -83,6 +83,8 @@ public class UriComponentsBuilder { @@ -83,6 +83,8 @@ public class UriComponentsBuilder {
private String scheme;
private String ssp;
private String userInfo;
private String host;
@ -164,16 +166,43 @@ public class UriComponentsBuilder { @@ -164,16 +166,43 @@ public class UriComponentsBuilder {
if (m.matches()) {
UriComponentsBuilder builder = new UriComponentsBuilder();
builder.scheme(m.group(2));
builder.userInfo(m.group(5));
builder.host(m.group(6));
String scheme = m.group(2);
String userInfo = m.group(5);
String host = m.group(6);
String port = m.group(8);
if (StringUtils.hasLength(port)) {
builder.port(Integer.parseInt(port));
String path = m.group(9);
String query = m.group(11);
String fragment = m.group(13);
boolean opaque = false;
if (StringUtils.hasLength(scheme)) {
String s = uri.substring(scheme.length());
if (!s.startsWith(":/")) {
opaque = true;
}
}
builder.scheme(scheme);
if (opaque) {
String ssp = uri.substring(scheme.length()).substring(1);
if (StringUtils.hasLength(fragment)) {
ssp = ssp.substring(0, ssp.length() - (fragment.length() + 1));
}
builder.schemeSpecificPart(ssp);
}
else {
builder.userInfo(userInfo);
builder.host(host);
if (StringUtils.hasLength(port)) {
builder.port(Integer.parseInt(port));
}
builder.path(path);
builder.query(query);
}
builder.path(m.group(9));
builder.query(m.group(11));
builder.fragment(m.group(13));
builder.fragment(fragment);
return builder;
}
@ -244,7 +273,13 @@ public class UriComponentsBuilder { @@ -244,7 +273,13 @@ public class UriComponentsBuilder {
* @return the URI components
*/
public UriComponents build(boolean encoded) {
return new UriComponents(scheme, userInfo, host, port, pathBuilder.build(), queryParams, fragment, encoded, true);
if (ssp != null) {
return new OpaqueUriComponents(scheme, ssp, fragment);
}
else {
return new HierarchicalUriComponents(
scheme, userInfo, host, port, pathBuilder.build(), queryParams, fragment, encoded, true);
}
}
/**
@ -281,25 +316,31 @@ public class UriComponentsBuilder { @@ -281,25 +316,31 @@ public class UriComponentsBuilder {
*/
public UriComponentsBuilder uri(URI uri) {
Assert.notNull(uri, "'uri' must not be null");
Assert.isTrue(!uri.isOpaque(), "Opaque URI [" + uri + "] not supported");
this.scheme = uri.getScheme();
if (uri.getRawUserInfo() != null) {
this.userInfo = uri.getRawUserInfo();
}
if (uri.getHost() != null) {
this.host = uri.getHost();
}
if (uri.getPort() != -1) {
this.port = uri.getPort();
if (uri.isOpaque()) {
this.ssp = uri.getRawSchemeSpecificPart();
resetHierarchicalComponents();
}
if (StringUtils.hasLength(uri.getRawPath())) {
this.pathBuilder = new FullPathComponentBuilder(uri.getRawPath());
}
if (StringUtils.hasLength(uri.getRawQuery())) {
this.queryParams.clear();
query(uri.getRawQuery());
else {
if (uri.getRawUserInfo() != null) {
this.userInfo = uri.getRawUserInfo();
}
if (uri.getHost() != null) {
this.host = uri.getHost();
}
if (uri.getPort() != -1) {
this.port = uri.getPort();
}
if (StringUtils.hasLength(uri.getRawPath())) {
this.pathBuilder = new FullPathComponentBuilder(uri.getRawPath());
}
if (StringUtils.hasLength(uri.getRawQuery())) {
this.queryParams.clear();
query(uri.getRawQuery());
}
resetSchemeSpecificPart();
}
if (uri.getRawFragment() != null) {
this.fragment = uri.getRawFragment();
@ -307,6 +348,18 @@ public class UriComponentsBuilder { @@ -307,6 +348,18 @@ public class UriComponentsBuilder {
return this;
}
private void resetHierarchicalComponents() {
this.userInfo = null;
this.host = null;
this.port = -1;
this.pathBuilder = NULL_PATH_COMPONENT_BUILDER;
this.queryParams.clear();
}
private void resetSchemeSpecificPart() {
this.ssp = null;
}
/**
* Sets the URI scheme. The given scheme may contain URI template variables,
* and may also be {@code null} to clear the scheme of this builder.
@ -320,17 +373,32 @@ public class UriComponentsBuilder { @@ -320,17 +373,32 @@ public class UriComponentsBuilder {
return this;
}
/**
* Set the URI scheme-specific-part. When invoked, this method overwrites
* {@linkplain #userInfo(String) user-info}, {@linkplain #host(String) host},
* {@linkplain #port(int) port}, {@linkplain #path(String) path}, and
* {@link #query(String) query}.
*
* @param ssp the URI scheme-specific-part, may contain URI template parameters
* @return this UriComponentsBuilder
*/
public UriComponentsBuilder schemeSpecificPart(String ssp) {
this.ssp = ssp;
resetHierarchicalComponents();
return this;
}
/**
* Sets the URI user info. The given user info may contain URI template
* variables, and may also be {@code null} to clear the user info of this
* builder.
*
* @param userInfo
* the URI user info
* @param userInfo the URI user info
* @return this UriComponentsBuilder
*/
public UriComponentsBuilder userInfo(String userInfo) {
this.userInfo = userInfo;
resetSchemeSpecificPart();
return this;
}
@ -338,12 +406,12 @@ public class UriComponentsBuilder { @@ -338,12 +406,12 @@ public class UriComponentsBuilder {
* Sets the URI host. The given host may contain URI template variables, and
* may also be {@code null} to clear the host of this builder.
*
* @param host
* the URI host
* @param host the URI host
* @return this UriComponentsBuilder
*/
public UriComponentsBuilder host(String host) {
this.host = host;
resetSchemeSpecificPart();
return this;
}
@ -356,6 +424,7 @@ public class UriComponentsBuilder { @@ -356,6 +424,7 @@ public class UriComponentsBuilder {
public UriComponentsBuilder port(int port) {
Assert.isTrue(port >= -1, "'port' must not be < -1");
this.port = port;
resetSchemeSpecificPart();
return this;
}
@ -363,8 +432,7 @@ public class UriComponentsBuilder { @@ -363,8 +432,7 @@ public class UriComponentsBuilder {
* Appends the given path to the existing path of this builder. The given
* path may contain URI template variables.
*
* @param path
* the URI path
* @param path the URI path
* @return this UriComponentsBuilder
*/
public UriComponentsBuilder path(String path) {
@ -374,6 +442,7 @@ public class UriComponentsBuilder { @@ -374,6 +442,7 @@ public class UriComponentsBuilder {
else {
this.pathBuilder = NULL_PATH_COMPONENT_BUILDER;
}
resetSchemeSpecificPart();
return this;
}
@ -386,6 +455,7 @@ public class UriComponentsBuilder { @@ -386,6 +455,7 @@ public class UriComponentsBuilder {
public UriComponentsBuilder replacePath(String path) {
this.pathBuilder = NULL_PATH_COMPONENT_BUILDER;
path(path);
resetSchemeSpecificPart();
return this;
}
@ -399,6 +469,7 @@ public class UriComponentsBuilder { @@ -399,6 +469,7 @@ public class UriComponentsBuilder {
public UriComponentsBuilder pathSegment(String... pathSegments) throws IllegalArgumentException {
Assert.notNull(pathSegments, "'segments' must not be null");
this.pathBuilder = this.pathBuilder.appendPathSegments(pathSegments);
resetSchemeSpecificPart();
return this;
}
@ -432,6 +503,7 @@ public class UriComponentsBuilder { @@ -432,6 +503,7 @@ public class UriComponentsBuilder {
else {
this.queryParams.clear();
}
resetSchemeSpecificPart();
return this;
}
@ -444,6 +516,7 @@ public class UriComponentsBuilder { @@ -444,6 +516,7 @@ public class UriComponentsBuilder {
public UriComponentsBuilder replaceQuery(String query) {
this.queryParams.clear();
query(query);
resetSchemeSpecificPart();
return this;
}
@ -470,6 +543,7 @@ public class UriComponentsBuilder { @@ -470,6 +543,7 @@ public class UriComponentsBuilder {
else {
this.queryParams.add(name, null);
}
resetSchemeSpecificPart();
return this;
}
@ -490,6 +564,7 @@ public class UriComponentsBuilder { @@ -490,6 +564,7 @@ public class UriComponentsBuilder {
if (!ObjectUtils.isEmpty(values)) {
queryParam(name, values);
}
resetSchemeSpecificPart();
return this;
}
@ -514,11 +589,11 @@ public class UriComponentsBuilder { @@ -514,11 +589,11 @@ public class UriComponentsBuilder {
}
/**
* Represents a builder for {@link org.springframework.web.util.UriComponents.PathComponent}
* Represents a builder for {@link HierarchicalUriComponents.PathComponent}
*/
private interface PathComponentBuilder {
UriComponents.PathComponent build();
HierarchicalUriComponents.PathComponent build();
PathComponentBuilder appendPath(String path);
@ -536,8 +611,8 @@ public class UriComponentsBuilder { @@ -536,8 +611,8 @@ public class UriComponentsBuilder {
this.path = new StringBuilder(path);
}
public UriComponents.PathComponent build() {
return new UriComponents.FullPathComponent(path.toString());
public HierarchicalUriComponents.PathComponent build() {
return new HierarchicalUriComponents.FullPathComponent(path.toString());
}
public PathComponentBuilder appendPath(String path) {
@ -573,8 +648,8 @@ public class UriComponentsBuilder { @@ -573,8 +648,8 @@ public class UriComponentsBuilder {
return result;
}
public UriComponents.PathComponent build() {
return new UriComponents.PathSegmentComponent(pathSegments);
public HierarchicalUriComponents.PathComponent build() {
return new HierarchicalUriComponents.PathSegmentComponent(pathSegments);
}
public PathComponentBuilder appendPath(String path) {
@ -600,14 +675,14 @@ public class UriComponentsBuilder { @@ -600,14 +675,14 @@ public class UriComponentsBuilder {
pathComponentBuilders.add(builder);
}
public UriComponents.PathComponent build() {
List<UriComponents.PathComponent> pathComponents =
new ArrayList<UriComponents.PathComponent>(pathComponentBuilders.size());
public HierarchicalUriComponents.PathComponent build() {
List<HierarchicalUriComponents.PathComponent> pathComponents =
new ArrayList<HierarchicalUriComponents.PathComponent>(pathComponentBuilders.size());
for (PathComponentBuilder pathComponentBuilder : pathComponentBuilders) {
pathComponents.add(pathComponentBuilder.build());
}
return new UriComponents.PathComponentComposite(pathComponents);
return new HierarchicalUriComponents.PathComponentComposite(pathComponents);
}
public PathComponentBuilder appendPath(String path) {
@ -627,8 +702,8 @@ public class UriComponentsBuilder { @@ -627,8 +702,8 @@ public class UriComponentsBuilder {
*/
private static PathComponentBuilder NULL_PATH_COMPONENT_BUILDER = new PathComponentBuilder() {
public UriComponents.PathComponent build() {
return UriComponents.NULL_PATH_COMPONENT;
public HierarchicalUriComponents.PathComponent build() {
return HierarchicalUriComponents.NULL_PATH_COMPONENT;
}
public PathComponentBuilder appendPath(String path) {

33
spring-web/src/main/java/org/springframework/web/util/UriUtils.java

@ -83,6 +83,7 @@ public abstract class UriUtils { @@ -83,6 +83,7 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
* @deprecated in favor of {@link UriComponentsBuilder}; see note about query param encoding
*/
@Deprecated
public static String encodeUri(String uri, String encoding) throws UnsupportedEncodingException {
Assert.notNull(uri, "'uri' must not be null");
Assert.hasLength(encoding, "'encoding' must not be empty");
@ -123,6 +124,7 @@ public abstract class UriUtils { @@ -123,6 +124,7 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
* @deprecated in favor of {@link UriComponentsBuilder}; see note about query param encoding
*/
@Deprecated
public static String encodeHttpUrl(String httpUrl, String encoding) throws UnsupportedEncodingException {
Assert.notNull(httpUrl, "'httpUrl' must not be null");
Assert.hasLength(encoding, "'encoding' must not be empty");
@ -160,6 +162,7 @@ public abstract class UriUtils { @@ -160,6 +162,7 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
* @deprecated in favor of {@link UriComponentsBuilder}
*/
@Deprecated
public static String encodeUriComponents(String scheme, String authority, String userInfo,
String host, String port, String path, String query, String fragment, String encoding)
throws UnsupportedEncodingException {
@ -213,7 +216,8 @@ public abstract class UriUtils { @@ -213,7 +216,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeScheme(String scheme, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(scheme, encoding, UriComponents.Type.SCHEME);
return HierarchicalUriComponents.encodeUriComponent(scheme, encoding,
HierarchicalUriComponents.Type.SCHEME);
}
/**
@ -224,7 +228,8 @@ public abstract class UriUtils { @@ -224,7 +228,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeAuthority(String authority, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(authority, encoding, UriComponents.Type.AUTHORITY);
return HierarchicalUriComponents.encodeUriComponent(authority, encoding,
HierarchicalUriComponents.Type.AUTHORITY);
}
/**
@ -235,7 +240,8 @@ public abstract class UriUtils { @@ -235,7 +240,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeUserInfo(String userInfo, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(userInfo, encoding, UriComponents.Type.USER_INFO);
return HierarchicalUriComponents.encodeUriComponent(userInfo, encoding,
HierarchicalUriComponents.Type.USER_INFO);
}
/**
@ -246,7 +252,8 @@ public abstract class UriUtils { @@ -246,7 +252,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeHost(String host, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(host, encoding, UriComponents.Type.HOST);
return HierarchicalUriComponents
.encodeUriComponent(host, encoding, HierarchicalUriComponents.Type.HOST);
}
/**
@ -257,7 +264,8 @@ public abstract class UriUtils { @@ -257,7 +264,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodePort(String port, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(port, encoding, UriComponents.Type.PORT);
return HierarchicalUriComponents
.encodeUriComponent(port, encoding, HierarchicalUriComponents.Type.PORT);
}
/**
@ -268,7 +276,8 @@ public abstract class UriUtils { @@ -268,7 +276,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodePath(String path, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(path, encoding, UriComponents.Type.PATH);
return HierarchicalUriComponents
.encodeUriComponent(path, encoding, HierarchicalUriComponents.Type.PATH);
}
/**
@ -279,7 +288,8 @@ public abstract class UriUtils { @@ -279,7 +288,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodePathSegment(String segment, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(segment, encoding, UriComponents.Type.PATH_SEGMENT);
return HierarchicalUriComponents.encodeUriComponent(segment, encoding,
HierarchicalUriComponents.Type.PATH_SEGMENT);
}
/**
@ -290,7 +300,8 @@ public abstract class UriUtils { @@ -290,7 +300,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeQuery(String query, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(query, encoding, UriComponents.Type.QUERY);
return HierarchicalUriComponents
.encodeUriComponent(query, encoding, HierarchicalUriComponents.Type.QUERY);
}
/**
@ -301,7 +312,8 @@ public abstract class UriUtils { @@ -301,7 +312,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeQueryParam(String queryParam, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(queryParam, encoding, UriComponents.Type.QUERY_PARAM);
return HierarchicalUriComponents.encodeUriComponent(queryParam, encoding,
HierarchicalUriComponents.Type.QUERY_PARAM);
}
/**
@ -312,7 +324,8 @@ public abstract class UriUtils { @@ -312,7 +324,8 @@ public abstract class UriUtils {
* @throws UnsupportedEncodingException when the given encoding parameter is not supported
*/
public static String encodeFragment(String fragment, String encoding) throws UnsupportedEncodingException {
return UriComponents.encodeUriComponent(fragment, encoding, UriComponents.Type.FRAGMENT);
return HierarchicalUriComponents.encodeUriComponent(fragment, encoding,
HierarchicalUriComponents.Type.FRAGMENT);
}

35
spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java

@ -64,7 +64,7 @@ public class UriComponentsBuilderTests { @@ -64,7 +64,7 @@ public class UriComponentsBuilderTests {
}
@Test
public void fromUri() throws URISyntaxException {
public void fromHierarchicalUri() throws URISyntaxException {
URI uri = new URI("http://example.com/foo?bar#baz");
UriComponents result = UriComponentsBuilder.fromUri(uri).build();
assertEquals("http", result.getScheme());
@ -76,6 +76,17 @@ public class UriComponentsBuilderTests { @@ -76,6 +76,17 @@ public class UriComponentsBuilderTests {
assertEquals("Invalid result URI", uri, result.toUri());
}
@Test
public void fromOpaqueUri() throws URISyntaxException {
URI uri = new URI("mailto:foo@bar.com#baz");
UriComponents result = UriComponentsBuilder.fromUri(uri).build();
assertEquals("mailto", result.getScheme());
assertEquals("foo@bar.com", result.getSchemeSpecificPart());
assertEquals("baz", result.getFragment());
assertEquals("Invalid result URI", uri, result.toUri());
}
// SPR-9317
@Test
@ -113,14 +124,15 @@ public class UriComponentsBuilderTests { @@ -113,14 +124,15 @@ public class UriComponentsBuilderTests {
assertEquals(expectedQueryParams, result.getQueryParams());
assertEquals("and(java.util.BitSet)", result.getFragment());
result = UriComponentsBuilder.fromUriString("mailto:java-net@java.sun.com").build();
result = UriComponentsBuilder.fromUriString("mailto:java-net@java.sun.com#baz").build();
assertEquals("mailto", result.getScheme());
assertNull(result.getUserInfo());
assertNull(result.getHost());
assertEquals(-1, result.getPort());
assertEquals("java-net@java.sun.com", result.getPathSegments().get(0));
assertEquals("java-net@java.sun.com", result.getSchemeSpecificPart());
assertNull(result.getPath());
assertNull(result.getQuery());
assertNull(result.getFragment());
assertEquals("baz", result.getFragment());
result = UriComponentsBuilder.fromUriString("docs/guide/collections/designfaq.html#28").build();
assertNull(result.getScheme());
@ -265,7 +277,7 @@ public class UriComponentsBuilderTests { @@ -265,7 +277,7 @@ public class UriComponentsBuilderTests {
}
@Test
public void buildAndExpand() {
public void buildAndExpandHierarchical() {
UriComponents result = UriComponentsBuilder.fromPath("/{foo}").buildAndExpand("fooValue");
assertEquals("/fooValue", result.toUriString());
@ -275,4 +287,17 @@ public class UriComponentsBuilderTests { @@ -275,4 +287,17 @@ public class UriComponentsBuilderTests {
result = UriComponentsBuilder.fromPath("/{foo}/{bar}").buildAndExpand(values);
assertEquals("/fooValue/barValue", result.toUriString());
}
@Test
public void buildAndExpandOpaque() {
UriComponents result = UriComponentsBuilder.fromUriString("mailto:{user}@{domain}").buildAndExpand("foo", "example.com");
assertEquals("mailto:foo@example.com", result.toUriString());
Map<String, String> values = new HashMap<String, String>();
values.put("user", "foo");
values.put("domain", "example.com");
UriComponentsBuilder.fromUriString("mailto:{user}@{domain}").buildAndExpand(values);
assertEquals("mailto:foo@example.com", result.toUriString());
}
}

Loading…
Cancel
Save