From 6e45a79ecbfc632467298a1a6e73085c1478f35d Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Fri, 14 Sep 2012 14:42:51 +0200 Subject: [PATCH] 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 --- .../web/util/HierarchicalUriComponents.java | 858 ++++++++++++++++ .../web/util/OpaqueUriComponents.java | 168 ++++ .../web/util/UriComponents.java | 950 +++--------------- .../web/util/UriComponentsBuilder.java | 159 ++- .../springframework/web/util/UriUtils.java | 33 +- .../web/util/UriComponentsBuilderTests.java | 35 +- 6 files changed, 1308 insertions(+), 895 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java create mode 100644 spring-web/src/main/java/org/springframework/web/util/OpaqueUriComponents.java diff --git a/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java b/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java new file mode 100644 index 00000000000..a4a46ba2c0f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/HierarchicalUriComponents.java @@ -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 Hierarchical URIs + */ +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 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 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(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 getPathSegments() { + return this.path.getPathSegments(); + } + + @Override + public String getQuery() { + if (!this.queryParams.isEmpty()) { + StringBuilder queryBuilder = new StringBuilder(); + for (Map.Entry> entry : this.queryParams.entrySet()) { + String name = entry.getKey(); + List 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 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 encodedQueryParams = + new LinkedMultiValueMap(this.queryParams.size()); + for (Map.Entry> entry : this.queryParams.entrySet()) { + String encodedName = encodeUriComponent(entry.getKey(), encoding, Type.QUERY_PARAM); + List encodedValues = new ArrayList(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> 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 expandedQueryParams = + new LinkedMultiValueMap(this.queryParams.size()); + for (Map.Entry> entry : this.queryParams.entrySet()) { + String expandedName = expandUriComponent(entry.getKey(), uriVariables); + List expandedValues = new ArrayList(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. + *

+ * Contains methods to indicate whether a given character is valid in a specific URI component. + * + * @author Arjen Poutsma + * @see RFC 3986 + */ + 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 RFC 3986, appendix 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 RFC 3986, appendix A + */ + protected boolean isDigit(int c) { + return c >= '0' && c <= '9'; + } + + /** + * Indicates whether the given character is in the {@code gen-delims} set. + * + * @see RFC 3986, appendix 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 RFC 3986, appendix 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 RFC 3986, appendix A + */ + protected boolean isReserved(char c) { + return isGenericDelimiter(c) || isReserved(c); + } + + /** + * Indicates whether the given character is in the {@code unreserved} set. + * + * @see RFC 3986, appendix 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 RFC 3986, appendix A + */ + protected boolean isPchar(int c) { + return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c; + } + + } + + /** + * Defines the contract for path (segments). + */ + interface PathComponent { + + String getPath(); + + List 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 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 pathSegments; + + PathSegmentComponent(List pathSegments) { + this.pathSegments = Collections.unmodifiableList(pathSegments); + } + + public String getPath() { + StringBuilder pathBuilder = new StringBuilder(); + pathBuilder.append(PATH_DELIMITER); + for (Iterator 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 getPathSegments() { + return this.pathSegments; + } + + public PathComponent encode(String encoding) throws UnsupportedEncodingException { + List pathSegments = getPathSegments(); + List encodedPathSegments = new ArrayList(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 pathSegments = getPathSegments(); + List expandedPathSegments = new ArrayList(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 pathComponents; + + PathComponentComposite(List 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 getPathSegments() { + List result = new ArrayList(); + for (PathComponent pathComponent : this.pathComponents) { + result.addAll(pathComponent.getPathSegments()); + } + return result; + } + + public PathComponent encode(String encoding) throws UnsupportedEncodingException { + List encodedComponents = new ArrayList(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 expandedComponents = new ArrayList(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 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; + } + + }; + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/OpaqueUriComponents.java b/spring-web/src/main/java/org/springframework/web/util/OpaqueUriComponents.java new file mode 100644 index 00000000000..fb07addf8ff --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/OpaqueUriComponents.java @@ -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 Hierarchical vs Opaque URIs + */ +final class OpaqueUriComponents extends UriComponents { + + private static final MultiValueMap QUERY_PARAMS_NONE = new LinkedMultiValueMap(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 getPathSegments() { + return Collections.emptyList(); + } + + @Override + public String getQuery() { + return null; + } + + @Override + public MultiValueMap 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; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/UriComponents.java b/spring-web/src/main/java/org/springframework/web/util/UriComponents.java index 54cbcef437e..85605c972c0 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriComponents.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriComponents.java @@ -16,13 +16,9 @@ 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; @@ -30,405 +26,176 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; -import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.util.StringUtils; /** - * Represents an immutable collection of URI components, mapping component type to string values. Contains convenience - * getters for all components. Effectively similar to {@link URI}, but with more powerful encoding options and support - * for URI template variables. + * Represents an immutable collection of URI components, mapping component type to string + * values. Contains convenience getters for all components. Effectively similar to {@link + * java.net.URI}, but with more powerful encoding options and support for URI template + * variables. * * @author Arjen Poutsma - * @since 3.1 * @see UriComponentsBuilder + * @since 3.1 */ -public final class UriComponents { +public abstract class UriComponents { - private static final String DEFAULT_ENCODING = "UTF-8"; - - private static final char PATH_DELIMITER = '/'; + private static final String DEFAULT_ENCODING = "UTF-8"; /** Captures URI template variable names. */ private static final Pattern NAMES_PATTERN = Pattern.compile("\\{([^/]+?)\\}"); private final String scheme; - private final String userInfo; - - private final String host; - - private final int port; - - private final PathComponent path; - - private final MultiValueMap queryParams; - private final String fragment; - private final boolean encoded; - - /** - * Package-friendly constructor that creates a new {@code UriComponents} instance from the given parameters. All - * parameters 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 component - * @param queryParams the query parameters - * @param fragment the fragment - * @param encoded whether the components are encoded - * @param verify whether the components need to be verified to determine whether they contain illegal characters - */ - UriComponents(String scheme, - String userInfo, - String host, - int port, - PathComponent path, - MultiValueMap queryParams, - String fragment, - boolean encoded, - boolean verify) { + protected UriComponents(String scheme, String fragment) { this.scheme = scheme; - 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(0)); this.fragment = fragment; - this.encoded = encoded; - if (verify) { - verify(); - } } // component getters - /** - * Returns the scheme. - * - * @return the scheme. Can be {@code null}. - */ - public String getScheme() { + /** + * Returns the scheme. + * + * @return the scheme. Can be {@code null}. + */ + public final String getScheme() { return scheme; - } + } - /** - * Returns the user info. - * - * @return the user info. Can be {@code null}. - */ - public String getUserInfo() { - return userInfo; - } + /** + * Returns the scheme specific part. + * + * @retur the scheme specific part. Can be {@code null}. + */ + public abstract String getSchemeSpecificPart(); - /** - * Returns the host. - * - * @return the host. Can be {@code null}. - */ - public String getHost() { - return host; - } + /** + * Returns the user info. + * + * @return the user info. Can be {@code null}. + */ + public abstract String getUserInfo(); - /** - * Returns the port. Returns {@code -1} if no port has been set. - * - * @return the port - */ - public int getPort() { - return port; - } + /** + * Returns the host. + * + * @return the host. Can be {@code null}. + */ + public abstract String getHost(); + + /** + * Returns the port. Returns {@code -1} if no port has been set. + * + * @return the port + */ + public abstract int getPort(); /** * Returns the path. * * @return the path. Can be {@code null}. */ - public String getPath() { - return path.getPath(); - } + public abstract String getPath(); - /** - * Returns the list of path segments. - * - * @return the path segments. Empty if no path has been set. - */ - public List getPathSegments() { - return path.getPathSegments(); - } + /** + * Returns the list of path segments. + * + * @return the path segments. Empty if no path has been set. + */ + public abstract List getPathSegments(); /** * Returns the query. * * @return the query. Can be {@code null}. */ - public String getQuery() { - if (!queryParams.isEmpty()) { - StringBuilder queryBuilder = new StringBuilder(); - for (Map.Entry> entry : queryParams.entrySet()) { - String name = entry.getKey(); - List 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; - } - } + public abstract String getQuery(); /** - * Returns the map of query parameters. - * - * @return the query parameters. Empty if no query has been set. - */ - public MultiValueMap getQueryParams() { - return queryParams; - } + * Returns the map of query parameters. + * + * @return the query parameters. Empty if no query has been set. + */ + public abstract MultiValueMap getQueryParams(); - /** - * Returns the fragment. - * - * @return the fragment. Can be {@code null}. - */ - public String getFragment() { + /** + * Returns the fragment. + * + * @return the fragment. Can be {@code null}. + */ + public final String getFragment() { return fragment; - } + } - // encoding + // encoding /** - * Encodes all URI components using their specific encoding rules, and returns the result as a new - * {@code UriComponents} instance. This method uses UTF-8 to encode. + * Encodes all URI components using their specific encoding rules, and returns the result + * as a new {@code UriComponents} instance. This method uses UTF-8 to encode. * * @return the encoded uri components */ - public UriComponents encode() { - try { - return encode(DEFAULT_ENCODING); - } - catch (UnsupportedEncodingException e) { - throw new InternalError("\"" + DEFAULT_ENCODING + "\" not supported"); - } - } - - /** - * 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 - */ - public UriComponents encode(String encoding) throws UnsupportedEncodingException { - Assert.hasLength(encoding, "'encoding' must not be empty"); - - if (encoded) { - return this; - } - - String encodedScheme = encodeUriComponent(this.scheme, encoding, Type.SCHEME); - String encodedUserInfo = encodeUriComponent(this.userInfo, encoding, Type.USER_INFO); - String encodedHost = encodeUriComponent(this.host, encoding, Type.HOST); - PathComponent encodedPath = path.encode(encoding); - MultiValueMap encodedQueryParams = - new LinkedMultiValueMap(this.queryParams.size()); - for (Map.Entry> entry : this.queryParams.entrySet()) { - String encodedName = encodeUriComponent(entry.getKey(), encoding, Type.QUERY_PARAM); - List encodedValues = new ArrayList(entry.getValue().size()); - for (String value : entry.getValue()) { - String encodedValue = encodeUriComponent(value, encoding, Type.QUERY_PARAM); - encodedValues.add(encodedValue); - } - encodedQueryParams.put(encodedName, encodedValues); + public final UriComponents encode() { + try { + return encode(DEFAULT_ENCODING); } - String encodedFragment = encodeUriComponent(this.fragment, encoding, Type.FRAGMENT); - - return new UriComponents(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; + catch (UnsupportedEncodingException e) { + throw new InternalError("\"" + DEFAULT_ENCODING + "\" not supported"); } - - 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. + * Encodes all URI components using their specific encoding rules, and + * returns the result as a new {@code UriComponents} instance. * - * @throws IllegalArgumentException if any of the components contain illegal characters + * @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 */ - private void verify() { - if (!encoded) { - return; - } - verifyUriComponent(scheme, Type.SCHEME); - verifyUriComponent(userInfo, Type.USER_INFO); - verifyUriComponent(host, Type.HOST); - path.verify(); - for (Map.Entry> entry : queryParams.entrySet()) { - verifyUriComponent(entry.getKey(), Type.QUERY_PARAM); - for (String value : entry.getValue()) { - verifyUriComponent(value, Type.QUERY_PARAM); - } - } - verifyUriComponent(fragment, 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 + "\""); - } - } - } + public abstract UriComponents encode(String encoding) throws UnsupportedEncodingException; // expanding /** - * Replaces all URI template variables with the values from a given map. The map keys represent - * variable names; the values variable values. The order of variables is not significant. - + * Replaces all URI template variables with the values from a given map. The map keys + * represent variable names; the values variable values. The order of variables is not + * significant. + * * @param uriVariables the map of URI variables * @return the expanded uri components */ - public UriComponents expand(Map uriVariables) { + public final UriComponents expand(Map uriVariables) { Assert.notNull(uriVariables, "'uriVariables' must not be null"); return expandInternal(new MapTemplateVariables(uriVariables)); } /** - * Replaces all URI template variables with the values from a given array. The array represent variable values. - * The order of variables is significant. - + * Replaces all URI template variables with the values from a given array. The array + * represent variable values. The order of variables is significant. + * * @param uriVariableValues URI variable values * @return the expanded uri components */ - public UriComponents expand(Object... uriVariableValues) { + public final UriComponents expand(Object... uriVariableValues) { Assert.notNull(uriVariableValues, "'uriVariableValues' must not be null"); return expandInternal(new VarArgsTemplateVariables(uriVariableValues)); } - private UriComponents expandInternal(UriTemplateVariables uriVariables) { - Assert.state(!encoded, "Cannot expand an already encoded UriComponents object"); - - String expandedScheme = expandUriComponent(this.scheme, uriVariables); - String expandedUserInfo = expandUriComponent(this.userInfo, uriVariables); - String expandedHost = expandUriComponent(this.host, uriVariables); - PathComponent expandedPath = path.expand(uriVariables); - MultiValueMap expandedQueryParams = - new LinkedMultiValueMap(this.queryParams.size()); - for (Map.Entry> entry : this.queryParams.entrySet()) { - String expandedName = expandUriComponent(entry.getKey(), uriVariables); - List expandedValues = new ArrayList(entry.getValue().size()); - for (String value : entry.getValue()) { - String expandedValue = expandUriComponent(value, uriVariables); - expandedValues.add(expandedValue); - } - expandedQueryParams.put(expandedName, expandedValues); - } - String expandedFragment = expandUriComponent(this.fragment, uriVariables); - - return new UriComponents(expandedScheme, expandedUserInfo, expandedHost, this.port, expandedPath, - expandedQueryParams, expandedFragment, false, false); - } + /** + * Replaces all URI template variables with the values from the given {@link + * UriTemplateVariables} + * + * @param uriVariables URI template values + * @return the expanded uri components + */ + abstract UriComponents expandInternal(UriTemplateVariables uriVariables); - private static String expandUriComponent(String source, UriTemplateVariables uriVariables) { + static String expandUriComponent(String source, UriTemplateVariables uriVariables) { if (source == null) { return null; } @@ -458,536 +225,41 @@ public final class UriComponents { return variableValue != null ? variableValue.toString() : ""; } - /** - * Normalize the path removing sequences like "path/..". - * @see StringUtils#cleanPath(String) - */ - public UriComponents normalize() { - String normalizedPath = StringUtils.cleanPath(getPath()); - return new UriComponents(scheme, userInfo, host, this.port, new FullPathComponent(normalizedPath), - queryParams, fragment, encoded, false); - } - - // other functionality - - /** - * Returns a URI string from this {@code UriComponents} instance. - * - * @return the URI string - */ - public String toUriString() { - StringBuilder uriBuilder = new StringBuilder(); - - if (scheme != null) { - uriBuilder.append(scheme); - uriBuilder.append(':'); - } - - if (userInfo != null || host != null) { - uriBuilder.append("//"); - if (userInfo != null) { - uriBuilder.append(userInfo); - uriBuilder.append('@'); - } - if (host != null) { - uriBuilder.append(host); - } - if (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 (fragment != null) { - uriBuilder.append('#'); - uriBuilder.append(fragment); - } - - return uriBuilder.toString(); - } - - /** - * Returns a {@code URI} from this {@code UriComponents} instance. - * - * @return the URI - */ - public URI toUri() { - try { - if (encoded) { - return new URI(toUriString()); - } - 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 UriComponents) { - UriComponents other = (UriComponents) o; - - if (scheme != null ? !scheme.equals(other.scheme) : other.scheme != null) { - return false; - } - if (userInfo != null ? !userInfo.equals(other.userInfo) : other.userInfo != null) { - return false; - } - if (host != null ? !host.equals(other.host) : other.host != null) { - return false; - } - if (port != other.port) { - return false; - } - if (!path.equals(other.path)) { - return false; - } - if (!queryParams.equals(other.queryParams)) { - return false; - } - if (fragment != null ? !fragment.equals(other.fragment) : other.fragment != null) { - return false; - } - return true; - } - else { - return false; - } - } - - @Override - public int hashCode() { - int result = scheme != null ? scheme.hashCode() : 0; - result = 31 * result + (userInfo != null ? userInfo.hashCode() : 0); - result = 31 * result + (host != null ? host.hashCode() : 0); - result = 31 * result + port; - result = 31 * result + path.hashCode(); - result = 31 * result + queryParams.hashCode(); - result = 31 * result + (fragment != null ? fragment.hashCode() : 0); - return result; - } - - @Override - public String toString() { - return toUriString(); - } - - // inner types - - /** - * Enumeration used to identify the parts of a URI. - *

- * Contains methods to indicate whether a given character is valid in a specific URI component. - * - * @author Arjen Poutsma - * @see RFC 3986 - */ - 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 RFC 3986, appendix 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 RFC 3986, appendix A - */ - protected boolean isDigit(int c) { - return c >= '0' && c <= '9'; - } - - /** - * Indicates whether the given character is in the {@code gen-delims} set. - * - * @see RFC 3986, appendix 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 RFC 3986, appendix 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 RFC 3986, appendix A - */ - protected boolean isReserved(char c) { - return isGenericDelimiter(c) || isReserved(c); - } - - /** - * Indicates whether the given character is in the {@code unreserved} set. - * - * @see RFC 3986, appendix 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 RFC 3986, appendix A - */ - protected boolean isPchar(int c) { - return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c; - } - - } - /** - * Defines the contract for path (segments). + * Returns a URI string from this {@code UriComponents} instance. + * + * @return the URI string */ - interface PathComponent { - - String getPath(); - - List getPathSegments(); - - PathComponent encode(String encoding) throws UnsupportedEncodingException; - - void verify(); - - PathComponent expand(UriTemplateVariables uriVariables); - - } + public abstract String toUriString(); /** - * 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 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(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 pathSegments; - - PathSegmentComponent(List pathSegments) { - this.pathSegments = Collections.unmodifiableList(pathSegments); - } - - public String getPath() { - StringBuilder pathBuilder = new StringBuilder(); - pathBuilder.append(PATH_DELIMITER); - for (Iterator iterator = pathSegments.iterator(); iterator.hasNext(); ) { - String pathSegment = iterator.next(); - pathBuilder.append(pathSegment); - if (iterator.hasNext()) { - pathBuilder.append(PATH_DELIMITER); - } - } - return pathBuilder.toString(); - } - - public List getPathSegments() { - return pathSegments; - } - - public PathComponent encode(String encoding) throws UnsupportedEncodingException { - List pathSegments = getPathSegments(); - List encodedPathSegments = new ArrayList(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 pathSegments = getPathSegments(); - List expandedPathSegments = new ArrayList(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. + * Returns a {@code URI} from this {@code UriComponents} instance. + * + * @return the URI */ - final static class PathComponentComposite implements PathComponent { - - private final List pathComponents; - - PathComponentComposite(List pathComponents) { - this.pathComponents = pathComponents; - } - - public String getPath() { - StringBuilder pathBuilder = new StringBuilder(); - for (PathComponent pathComponent : pathComponents) { - pathBuilder.append(pathComponent.getPath()); - } - return pathBuilder.toString(); - } - - public List getPathSegments() { - List result = new ArrayList(); - for (PathComponent pathComponent : pathComponents) { - result.addAll(pathComponent.getPathSegments()); - } - return result; - } - - public PathComponent encode(String encoding) throws UnsupportedEncodingException { - List encodedComponents = new ArrayList(pathComponents.size()); - for (PathComponent pathComponent : pathComponents) { - encodedComponents.add(pathComponent.encode(encoding)); - } - return new PathComponentComposite(encodedComponents); - } + public abstract URI toUri(); - public void verify() { - for (PathComponent pathComponent : pathComponents) { - pathComponent.verify(); - } - } - - public PathComponent expand(UriTemplateVariables uriVariables) { - List expandedComponents = new ArrayList(pathComponents.size()); - for (PathComponent pathComponent : pathComponents) { - expandedComponents.add(pathComponent.expand(uriVariables)); - } - return new PathComponentComposite(expandedComponents); - } + @Override + public final String toString() { + return toUriString(); } - - /** - * Represents an empty path. + * Normalize the path removing sequences like "path/..". + * + * @see org.springframework.util.StringUtils#cleanPath(String) */ - final static PathComponent NULL_PATH_COMPONENT = new PathComponent() { - - public String getPath() { - return null; - } - - public List 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; - } - - }; + public abstract UriComponents normalize(); /** * Defines the contract for URI Template variables * - * @see UriComponents#expand + * @see HierarchicalUriComponents#expand */ - private interface UriTemplateVariables { + interface UriTemplateVariables { Object getValue(String name); - } /** @@ -1022,10 +294,12 @@ public final class UriComponents { public Object getValue(String name) { if (!valueIterator.hasNext()) { - throw new IllegalArgumentException("Not enough variable values available to expand '" + name + "'"); + throw new IllegalArgumentException( + "Not enough variable values available to expand '" + name + "'"); } return valueIterator.next(); } } + } diff --git a/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java b/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java index e54c05dba5d..bddba4c848f 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java @@ -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 { 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 { * @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 { */ 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 { 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 { 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 { * 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 { 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 { * 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 { else { this.pathBuilder = NULL_PATH_COMPONENT_BUILDER; } + resetSchemeSpecificPart(); return this; } @@ -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 { 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 { else { this.queryParams.clear(); } + resetSchemeSpecificPart(); return this; } @@ -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 { else { this.queryParams.add(name, null); } + resetSchemeSpecificPart(); return this; } @@ -490,6 +564,7 @@ public class UriComponentsBuilder { if (!ObjectUtils.isEmpty(values)) { queryParam(name, values); } + resetSchemeSpecificPart(); return this; } @@ -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 { 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 { 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 { pathComponentBuilders.add(builder); } - public UriComponents.PathComponent build() { - List pathComponents = - new ArrayList(pathComponentBuilders.size()); + public HierarchicalUriComponents.PathComponent build() { + List pathComponents = + new ArrayList(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 { */ 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) { diff --git a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java index 4d6c2a44997..9498f91a081 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java @@ -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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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 { * @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); } diff --git a/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java b/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java index 3fefe43ebc7..378c8704fbe 100644 --- a/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java @@ -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 { 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 { 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 { } @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 { 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 values = new HashMap(); + values.put("user", "foo"); + values.put("domain", "example.com"); + UriComponentsBuilder.fromUriString("mailto:{user}@{domain}").buildAndExpand(values); + assertEquals("mailto:foo@example.com", result.toUriString()); + } + }