diff --git a/org.springframework.web/src/main/java/org/springframework/web/util/UriBuilder.java b/org.springframework.web/src/main/java/org/springframework/web/util/UriBuilder.java new file mode 100644 index 00000000000..ec620c8ed61 --- /dev/null +++ b/org.springframework.web/src/main/java/org/springframework/web/util/UriBuilder.java @@ -0,0 +1,556 @@ +/* + * Copyright 2002-2011 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.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Builder for {@link URI} objects. + * + *

Typical usage involves: + *

    + *
  1. Create a {@code UriBuilder} with one of the static factory methods (such as {@link #fromPath(String)} or + * {@link #fromUri(URI)})
  2. + *
  3. Set the various URI components through the respective methods ({@link #scheme(String)}, + * {@link #userInfo(String)}, {@link #host(String)}, {@link #port(int)}, {@link #path(String)}, + * {@link #pathSegment(String...)}, {@link #queryParam(String, Object...)}, and {@link #fragment(String)}.
  4. + *
  5. Build the URI with one of the {@link #build} method variants.
  6. + *
+ * + *

Most of the URI component methods accept URI template variables (i.e. {@code "{foo}"}), which are expanded by + * calling {@code build}. + * one , are allowed in most components of a URI but their value is + * restricted to a particular component. E.g. + *

UriBuilder.fromPath("{arg1}").build("foo#bar");
+ * would result in encoding of the '#' such that the resulting URI is + * "foo%23bar". To create a URI "foo#bar" use + *
UriBuilder.fromPath("{arg1}").fragment("{arg2}").build("foo", "bar")
+ * instead. URI template names and delimiters are never encoded but their + * values are encoded when a URI is built. + * Template parameter regular expressions are ignored when building a URI, i.e. + * no validation is performed. + *

Inspired by {@link javax.ws.rs.core.UriBuilder}. + * + * @author Arjen Poutsma + * @since 3.1 + * @see #newInstance() + * @see #fromPath(String) + * @see #fromUri(URI) + */ +public class UriBuilder { + + private String scheme; + + private String userInfo; + + private String host; + + private int port = -1; + + private final List pathSegments = new ArrayList(); + + private final StringBuilder queryBuilder = new StringBuilder(); + + private String fragment; + + /** + * Default constructor. Protected to prevent direct instantiation. + * + * @see #newInstance() + * @see #fromPath(String) + * @see #fromUri(URI) + */ + protected UriBuilder() { + } + + // Factory methods + + /** + * Returns a new, empty URI builder. + * + * @return the new {@code UriBuilder} + */ + public static UriBuilder newInstance() { + return new UriBuilder(); + } + + /** + * Returns a URI builder that is initialized with the given path. + * + * @param path the path to initialize with + * @return the new {@code UriBuilder} + */ + public static UriBuilder fromPath(String path) { + UriBuilder builder = new UriBuilder(); + builder.path(path); + return builder; + } + + /** + * Returns a URI builder that is initialized with the given {@code URI}. + * + * @param uri the URI to initialize with + * @return the new {@code UriBuilder} + */ + public static UriBuilder fromUri(URI uri) { + UriBuilder builder = new UriBuilder(); + builder.uri(uri); + return builder; + } + + // build methods + + /** + * Builds a URI with no URI template variables. Any template variable definitions found will be encoded (i.e. + * {@code "/{foo}"} will result in {@code "/%7Bfoo%7D"}. + * @return the resulting URI + */ + public URI build() { + StringBuilder uriBuilder = new StringBuilder(); + + if (scheme != null) { + uriBuilder.append(scheme).append(':'); + } + + if (userInfo != null || host != null || port != -1) { + uriBuilder.append("//"); + + if (StringUtils.hasLength(userInfo)) { + uriBuilder.append(userInfo); + uriBuilder.append('@'); + } + + if (host != null) { + uriBuilder.append(host); + } + + if (port != -1) { + uriBuilder.append(':'); + uriBuilder.append(port); + } + } + + if (!pathSegments.isEmpty()) { + for (String pathSegment : pathSegments) { + boolean startsWithSlash = pathSegment.charAt(0) == '/'; + boolean endsWithSlash = uriBuilder.length() > 0 && uriBuilder.charAt(uriBuilder.length() - 1) == '/'; + + if (!endsWithSlash && !startsWithSlash) { + uriBuilder.append('/'); + } + else if (endsWithSlash && startsWithSlash) { + pathSegment = pathSegment.substring(1); + } + uriBuilder.append(pathSegment); + } + } + + if (queryBuilder.length() > 0) { + uriBuilder.append('?'); + uriBuilder.append(queryBuilder); + } + + if (StringUtils.hasLength(fragment)) { + uriBuilder.append('#'); + uriBuilder.append(fragment); + } + String uri = uriBuilder.toString(); + + uri = StringUtils.replace(uri, "{", "%7B"); + uri = StringUtils.replace(uri, "}", "%7D"); + + return URI.create(uri); + } + + /** + * Builds a URI with the given URI template variables. Any template variable definitions found will be expanded with + * the given variables map. All variable values will be encoded in accordance with the encoding rules for the URI + * component they occur in. + * + * @param uriVariables the map of URI variables + * @return the resulting URI + */ + public URI build(Map uriVariables) { + return buildFromMap(true, uriVariables); + } + + /** + * Builds a URI with the given URI template variables. Any template variable definitions found will be expanded with the + * given variables map. All variable values will not be encoded. + * + * @param uriVariables the map of URI variables + * @return the resulting URI + */ + public URI buildFromEncoded(Map uriVariables) { + return buildFromMap(false, uriVariables); + } + + private URI buildFromMap(boolean encodeUriVariableValues, Map uriVariables) { + if (CollectionUtils.isEmpty(uriVariables)) { + return build(); + } + + StringBuilder uriBuilder = new StringBuilder(); + + UriTemplate template; + + if (scheme != null) { + template = new UriTemplate(scheme, UriUtils.SCHEME_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariables)); + uriBuilder.append(':'); + } + + if (userInfo != null || host != null || port != -1) { + uriBuilder.append("//"); + + if (StringUtils.hasLength(userInfo)) { + template = new UriTemplate(userInfo, UriUtils.USER_INFO_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariables)); + uriBuilder.append('@'); + } + + if (host != null) { + template = new UriTemplate(host, UriUtils.HOST_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariables)); + } + + if (port != -1) { + uriBuilder.append(':'); + uriBuilder.append(port); + } + } + + if (!pathSegments.isEmpty()) { + for (String pathSegment : pathSegments) { + boolean startsWithSlash = pathSegment.charAt(0) == '/'; + boolean endsWithSlash = uriBuilder.length() > 0 && uriBuilder.charAt(uriBuilder.length() - 1) == '/'; + + if (!endsWithSlash && !startsWithSlash) { + uriBuilder.append('/'); + } + else if (endsWithSlash && startsWithSlash) { + pathSegment = pathSegment.substring(1); + } + template = new UriTemplate(pathSegment, UriUtils.PATH_SEGMENT_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariables)); + } + } + if (queryBuilder.length() > 0) { + uriBuilder.append('?'); + template = new UriTemplate(queryBuilder.toString(), UriUtils.QUERY_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariables)); + } + + if (StringUtils.hasLength(fragment)) { + uriBuilder.append('#'); + template = new UriTemplate(fragment, UriUtils.FRAGMENT_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariables)); + } + + return URI.create(uriBuilder.toString()); + } + + /** + * Builds a URI with the given URI template variable values. Any template variable definitions found will be expanded + * with the given variables. All variable values will be encoded in accordance with the encoding rules for the URI + * component they occur in. + * + * @param uriVariableValues the array of URI variables + * @return the resulting URI + */ + public URI build(Object... uriVariableValues) { + return buildFromVarArg(true, uriVariableValues); + } + + /** + * Builds a URI with the given URI template variable values. Any template variable definitions found will be expanded + * with the given variables. All variable values will not be encoded. + * + * @param uriVariableValues the array of URI variables + * @return the resulting URI + */ + public URI buildFromEncoded(Object... uriVariableValues) { + return buildFromVarArg(false, uriVariableValues); + } + + private URI buildFromVarArg(boolean encodeUriVariableValues, Object... uriVariableValues) { + if (ObjectUtils.isEmpty(uriVariableValues)) { + return build(); + } + + StringBuilder uriBuilder = new StringBuilder(); + + UriTemplate template; + + if (scheme != null) { + template = new UriTemplate(scheme, UriUtils.SCHEME_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariableValues)); + uriBuilder.append(':'); + } + + if (userInfo != null || host != null || port != -1) { + uriBuilder.append("//"); + + if (StringUtils.hasLength(userInfo)) { + template = new UriTemplate(userInfo, UriUtils.USER_INFO_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariableValues)); + uriBuilder.append('@'); + } + + if (host != null) { + template = new UriTemplate(host, UriUtils.HOST_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariableValues)); + } + + if (port != -1) { + uriBuilder.append(':'); + uriBuilder.append(port); + } + } + + if (!pathSegments.isEmpty()) { + for (String pathSegment : pathSegments) { + boolean startsWithSlash = pathSegment.charAt(0) == '/'; + boolean endsWithSlash = uriBuilder.length() > 0 && uriBuilder.charAt(uriBuilder.length() - 1) == '/'; + + if (!endsWithSlash && !startsWithSlash) { + uriBuilder.append('/'); + } + else if (endsWithSlash && startsWithSlash) { + pathSegment = pathSegment.substring(1); + } + template = new UriTemplate(pathSegment, UriUtils.PATH_SEGMENT_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariableValues)); + } + } + + if (queryBuilder.length() > 0) { + uriBuilder.append('?'); + template = new UriTemplate(queryBuilder.toString(), UriUtils.QUERY_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariableValues)); + } + + if (StringUtils.hasLength(fragment)) { + uriBuilder.append('#'); + template = new UriTemplate(fragment, UriUtils.FRAGMENT_COMPONENT); + uriBuilder.append(template.expandAsString(encodeUriVariableValues, uriVariableValues)); + } + + return URI.create(uriBuilder.toString()); + } + + // URI components methods + + /** + * Initializes all components of this URI builder with the components of the given URI. + * + * @param uri the URI + * @return this UriBuilder + */ + public UriBuilder 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 (StringUtils.hasLength(uri.getRawPath())) { + String[] pathSegments = StringUtils.tokenizeToStringArray(uri.getRawPath(), "/"); + + this.pathSegments.clear(); + Collections.addAll(this.pathSegments, pathSegments); + } + if (StringUtils.hasLength(uri.getRawQuery())) { + this.queryBuilder.setLength(0); + this.queryBuilder.append(uri.getRawQuery()); + } + if (uri.getRawFragment() != null) { + this.fragment = uri.getRawFragment(); + } + return this; + } + + /** + * 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. + * + * @param scheme the URI scheme + * @return this UriBuilder + */ + public UriBuilder scheme(String scheme) { + if (scheme != null) { + Assert.hasLength(scheme, "'scheme' must not be empty"); + this.scheme = UriUtils.encode(scheme, UriUtils.SCHEME_COMPONENT, true); + } + else { + this.scheme = null; + } + 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 + * @return this UriBuilder + */ + public UriBuilder userInfo(String userInfo) { + if (userInfo != null) { + Assert.hasLength(userInfo, "'userInfo' must not be empty"); + this.userInfo = UriUtils.encode(userInfo, UriUtils.USER_INFO_COMPONENT, true); + } + else { + this.userInfo = null; + } + return this; + } + + /** + * 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 + * @return this UriBuilder + */ + public UriBuilder host(String host) { + if (host != null) { + Assert.hasLength(host, "'host' must not be empty"); + this.host = UriUtils.encode(host, UriUtils.HOST_COMPONENT, true); + } + else { + this.host = null; + } + return this; + } + + /** + * Sets the URI port. Passing {@code -1} will clear the port of this builder. + * + * @param port the URI port + * @return this UriBuilder + */ + public UriBuilder port(int port) { + Assert.isTrue(port >= -1, "'port' must not be < -1"); + this.port = port; + return this; + } + + /** + * Appends the given path to the existing path of this builder. The given path may contain URI template variables. + * + * @param path the URI path + * @return this UriBuilder + */ + public UriBuilder path(String path) { + Assert.notNull(path, "path must not be null"); + + String[] pathSegments = StringUtils.tokenizeToStringArray(path, "/"); + return pathSegment(pathSegments); + } + + /** + * Appends the given path segments to the existing path of this builder. Each given path segments may contain URI + * template variables. + * + * @param segments the URI path segments + * @return this UriBuilder + */ + public UriBuilder pathSegment(String... segments) throws IllegalArgumentException { + Assert.notNull(segments, "'segments' must not be null"); + for (String segment : segments) { + this.pathSegments.add(UriUtils.encode(segment, UriUtils.PATH_SEGMENT_COMPONENT, true)); + } + + return this; + } + + /** + * Appends the given query parameter to the existing query parameters. The given name or any of the values may contain + * URI template variables. If no values are given, the resulting URI will contain the query parameter name only (i.e. + * {@code ?foo} instead of {@code ?foo=bar}. + * + * @param name the query parameter name + * @param values the query parameter values + * @return this UriBuilder + */ + public UriBuilder queryParam(String name, Object... values) { + Assert.notNull(name, "'name' must not be null"); + + String encodedName = UriUtils.encode(name, UriUtils.QUERY_PARAM_COMPONENT, true); + + if (ObjectUtils.isEmpty(values)) { + if (queryBuilder.length() != 0) { + queryBuilder.append('&'); + } + queryBuilder.append(encodedName); + } + else { + for (Object value : values) { + if (queryBuilder.length() != 0) { + queryBuilder.append('&'); + } + queryBuilder.append(encodedName); + + String valueAsString = value != null ? value.toString() : ""; + if (valueAsString.length() != 0) { + queryBuilder.append('='); + queryBuilder.append(UriUtils.encode(valueAsString, UriUtils.QUERY_PARAM_COMPONENT, true)); + } + + } + } + return this; + } + + /** + * Sets the URI fragment. The given fragment may contain URI template variables, and may also be {@code null} to clear + * the fragment of this builder. + * + * @param fragment the URI fragment + * @return this UriBuilder + */ + public UriBuilder fragment(String fragment) { + if (fragment != null) { + Assert.hasLength(fragment, "'fragment' must not be empty"); + this.fragment = UriUtils.encode(fragment, UriUtils.FRAGMENT_COMPONENT, true); + } + else { + this.fragment = null; + } + return this; + } + + +} diff --git a/org.springframework.web/src/main/java/org/springframework/web/util/UriTemplate.java b/org.springframework.web/src/main/java/org/springframework/web/util/UriTemplate.java index c9afae78e63..513d2e31dbb 100644 --- a/org.springframework.web/src/main/java/org/springframework/web/util/UriTemplate.java +++ b/org.springframework.web/src/main/java/org/springframework/web/util/UriTemplate.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2010 the original author or authors. + * Copyright 2002-2011 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. @@ -47,7 +47,7 @@ public class UriTemplate implements Serializable { private static final Pattern NAMES_PATTERN = Pattern.compile("\\{([^/]+?)\\}"); /** Replaces template variables in the URI template. */ - private static final String VALUE_REGEX = "(.*)"; + private static final String DEFAULT_VARIABLE_PATTERN = "(.*)"; private final List variableNames; @@ -56,6 +56,8 @@ public class UriTemplate implements Serializable { private final String uriTemplate; + private final UriUtils.UriComponent uriComponent; + /** * Construct a new {@link UriTemplate} with the given URI String. @@ -66,6 +68,19 @@ public class UriTemplate implements Serializable { this.uriTemplate = uriTemplate; this.variableNames = parser.getVariableNames(); this.matchPattern = parser.getMatchPattern(); + this.uriComponent = null; + } + + /** + * Construct a new {@link UriTemplate} with the given URI String. + * @param uriTemplate the URI template string + */ + public UriTemplate(String uriTemplate, UriUtils.UriComponent uriComponent) { + Parser parser = new Parser(uriTemplate); + this.uriTemplate = uriTemplate; + this.variableNames = parser.getVariableNames(); + this.matchPattern = parser.getMatchPattern(); + this.uriComponent = uriComponent; } /** @@ -95,6 +110,28 @@ public class UriTemplate implements Serializable { * or if it does not contain values for all the variable names */ public URI expand(Map uriVariables) { + return encodeUri(expandAsString(true, uriVariables)); + } + + /** + * Given the Map of variables, expands this template into a URI. The Map keys represent variable names, + * the Map values variable values. The order of variables is not significant. + *

Example: + *

+	 * UriTemplate template = new UriTemplate("http://example.com/hotels/{hotel}/bookings/{booking}");
+	 * Map<String, String> uriVariables = new HashMap<String, String>();
+	 * uriVariables.put("booking", "42");
+	 * uriVariables.put("hotel", "1");
+	 * System.out.println(template.expand(uriVariables));
+	 * 
+ * will print:
http://example.com/hotels/1/bookings/42
+ * @param encodeUriVariableValues indicates whether uri template variables should be encoded or not + * @param uriVariables the map of URI variables + * @return the expanded URI + * @throws IllegalArgumentException if uriVariables is null; + * or if it does not contain values for all the variable names + */ + public String expandAsString(boolean encodeUriVariableValues, Map uriVariables) { Assert.notNull(uriVariables, "'uriVariables' must not be null"); Object[] values = new Object[this.variableNames.size()]; for (int i = 0; i < this.variableNames.size(); i++) { @@ -104,7 +141,7 @@ public class UriTemplate implements Serializable { } values[i] = uriVariables.get(name); } - return expand(values); + return expandAsString(encodeUriVariableValues, values); } /** @@ -122,22 +159,45 @@ public class UriTemplate implements Serializable { * or if it does not contain sufficient variables */ public URI expand(Object... uriVariableValues) { + return encodeUri(expandAsString(true, uriVariableValues)); + } + + /** + * Given an array of variables, expand this template into a full URI String. The array represent variable values. + * The order of variables is significant. + *

Example: + *

+	 * UriTemplate template = new UriTemplate("http://example.com/hotels/{hotel}/bookings/{booking}");
+	 * System.out.println(template.expand("1", "42));
+	 * 
+ * will print:
http://example.com/hotels/1/bookings/42
+ * @param encodeVariableValues indicates whether uri template variables should be encoded or not + * @param uriVariableValues the array of URI variables + * @return the expanded URI + * @throws IllegalArgumentException if uriVariables is null + * or if it does not contain sufficient variables + */ + public String expandAsString(boolean encodeVariableValues, Object... uriVariableValues) { Assert.notNull(uriVariableValues, "'uriVariableValues' must not be null"); - if (uriVariableValues.length != this.variableNames.size()) { + if (uriVariableValues.length < this.variableNames.size()) { throw new IllegalArgumentException( - "Invalid amount of variables values in [" + this.uriTemplate + "]: expected " + + "Not enough of variables values in [" + this.uriTemplate + "]: expected at least " + this.variableNames.size() + "; got " + uriVariableValues.length); } Matcher matcher = NAMES_PATTERN.matcher(this.uriTemplate); - StringBuffer buffer = new StringBuffer(); + StringBuffer uriBuffer = new StringBuffer(); int i = 0; while (matcher.find()) { Object uriVariable = uriVariableValues[i++]; - String replacement = Matcher.quoteReplacement(uriVariable != null ? uriVariable.toString() : ""); - matcher.appendReplacement(buffer, replacement); + String uriVariableString = uriVariable != null ? uriVariable.toString() : ""; + if (encodeVariableValues && uriComponent != null) { + uriVariableString = UriUtils.encode(uriVariableString, uriComponent, false); + } + String replacement = Matcher.quoteReplacement(uriVariableString); + matcher.appendReplacement(uriBuffer, replacement); } - matcher.appendTail(buffer); - return encodeUri(buffer.toString()); + matcher.appendTail(uriBuffer); + return uriBuffer.toString(); } /** @@ -220,8 +280,23 @@ public class UriTemplate implements Serializable { int end = 0; while (m.find()) { this.patternBuilder.append(quote(uriTemplate, end, m.start())); - this.patternBuilder.append(VALUE_REGEX); - this.variableNames.add(m.group(1)); + String match = m.group(1); + int colonIdx = match.indexOf(':'); + if (colonIdx == -1) { + this.patternBuilder.append(DEFAULT_VARIABLE_PATTERN); + this.variableNames.add(match); + } + else { + if (colonIdx + 1 == match.length()) { + throw new IllegalArgumentException("No custom regular expression specified after ':' in \"" + match + "\""); + } + String variablePattern = match.substring(colonIdx + 1, match.length()); + this.patternBuilder.append('('); + this.patternBuilder.append(variablePattern); + this.patternBuilder.append(')'); + String variableName = match.substring(0, colonIdx); + this.variableNames.add(variableName); + } end = m.end(); } this.patternBuilder.append(quote(uriTemplate, end, uriTemplate.length())); diff --git a/org.springframework.web/src/main/java/org/springframework/web/util/UriUtils.java b/org.springframework.web/src/main/java/org/springframework/web/util/UriUtils.java index 9053e790f9e..b48f9c516d0 100644 --- a/org.springframework.web/src/main/java/org/springframework/web/util/UriUtils.java +++ b/org.springframework.web/src/main/java/org/springframework/web/util/UriUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2010 the original author or authors. + * Copyright 2002-2011 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. @@ -18,7 +18,6 @@ package org.springframework.web.util; import java.io.ByteArrayOutputStream; import java.io.UnsupportedEncodingException; -import java.util.BitSet; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -41,23 +40,7 @@ import org.springframework.util.Assert; */ public abstract class UriUtils { - private static final BitSet SCHEME; - - private static final BitSet USER_INFO; - - private static final BitSet HOST; - - private static final BitSet PORT; - - private static final BitSet PATH; - - private static final BitSet SEGMENT; - - private static final BitSet QUERY; - - private static final BitSet QUERY_PARAM; - - private static final BitSet FRAGMENT; + private static final String DEFAULT_ENCODING = "UTF-8"; private static final String SCHEME_PATTERN = "([^:/?#]+):"; @@ -84,107 +67,7 @@ public abstract class UriUtils { "^" + HTTP_PATTERN + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN + ")?" + ")?" + PATH_PATTERN + "(\\?" + LAST_PATTERN + ")?"); - - static { - // variable names refer to RFC 3986, appendix A - BitSet alpha = new BitSet(256); - for (int i = 'a'; i <= 'z'; i++) { - alpha.set(i); - } - for (int i = 'A'; i <= 'Z'; i++) { - alpha.set(i); - } - BitSet digit = new BitSet(256); - for (int i = '0'; i <= '9'; i++) { - digit.set(i); - } - - BitSet gendelims = new BitSet(256); - gendelims.set(':'); - gendelims.set('/'); - gendelims.set('?'); - gendelims.set('#'); - gendelims.set('['); - gendelims.set(']'); - gendelims.set('@'); - - BitSet subdelims = new BitSet(256); - subdelims.set('!'); - subdelims.set('$'); - subdelims.set('&'); - subdelims.set('\''); - subdelims.set('('); - subdelims.set(')'); - subdelims.set('*'); - subdelims.set('+'); - subdelims.set(','); - subdelims.set(';'); - subdelims.set('='); - - BitSet reserved = new BitSet(256); - reserved.or(gendelims); - reserved.or(subdelims); - - BitSet unreserved = new BitSet(256); - unreserved.or(alpha); - unreserved.or(digit); - unreserved.set('-'); - unreserved.set('.'); - unreserved.set('_'); - unreserved.set('~'); - - SCHEME = new BitSet(256); - SCHEME.or(alpha); - SCHEME.or(digit); - SCHEME.set('+'); - SCHEME.set('-'); - SCHEME.set('.'); - - USER_INFO = new BitSet(256); - USER_INFO.or(unreserved); - USER_INFO.or(subdelims); - USER_INFO.set(':'); - - HOST = new BitSet(256); - HOST.or(unreserved); - HOST.or(subdelims); - - PORT = new BitSet(256); - PORT.or(digit); - - BitSet pchar = new BitSet(256); - pchar.or(unreserved); - pchar.or(subdelims); - pchar.set(':'); - pchar.set('@'); - - SEGMENT = new BitSet(256); - SEGMENT.or(pchar); - - PATH = new BitSet(256); - PATH.or(SEGMENT); - PATH.set('/'); - - QUERY = new BitSet(256); - QUERY.or(pchar); - QUERY.set('/'); - QUERY.set('?'); - - QUERY_PARAM = new BitSet(256); - QUERY_PARAM.or(pchar); - QUERY_PARAM.set('/'); - QUERY_PARAM.set('?'); - QUERY_PARAM.clear('='); - QUERY_PARAM.clear('+'); - QUERY_PARAM.clear('&'); - - FRAGMENT = new BitSet(256); - FRAGMENT.or(pchar); - FRAGMENT.set('/'); - FRAGMENT.set('?'); - } - - + /** * Encodes the given source URI into an encoded String. All various URI components * are encoded according to their respective valid character sets. @@ -246,6 +129,38 @@ public abstract class UriUtils { } } + /** + * Encodes the given source URI components into an encoded String. + * All various URI components are optional, but encoded according + * to their respective valid character sets. + * @param scheme the scheme + * @param authority the authority + * @param userinfo the user info + * @param host the host + * @param port the port + * @param path the path + * @param query the query + * @param fragment the fragment + * @return the encoded URI + * @throws IllegalArgumentException when the given uri parameter is not a valid URI + */ + public static String encodeUriComponents(String scheme, + String authority, + String userinfo, + String host, + String port, + String path, + String query, + String fragment) { + try { + return encodeUriComponents(scheme, authority, userinfo, host, port, path, query, fragment, + DEFAULT_ENCODING); + } + catch (UnsupportedEncodingException e) { + throw new InternalError("'UTF-8' encoding not supported"); + } + } + /** * Encodes the given source URI components into an encoded String. * All various URI components are optional, but encoded according @@ -263,10 +178,16 @@ public abstract class UriUtils { * @throws IllegalArgumentException when the given uri parameter is not a valid URI * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ - public static String encodeUriComponents(String scheme, String authority, String userinfo, - String host, String port, String path, String query, String fragment, String encoding) - throws UnsupportedEncodingException { - + public static String encodeUriComponents(String scheme, + String authority, + String userinfo, + String host, + String port, + String path, + String query, + String fragment, + String encoding) throws UnsupportedEncodingException { + Assert.hasLength(encoding, "'encoding' must not be empty"); StringBuilder sb = new StringBuilder(); @@ -275,7 +196,7 @@ public abstract class UriUtils { sb.append(':'); } - if (authority != null) { + if (userinfo != null || host != null || port != null) { sb.append("//"); if (userinfo != null) { sb.append(encodeUserInfo(userinfo, encoding)); @@ -288,9 +209,14 @@ public abstract class UriUtils { sb.append(':'); sb.append(encodePort(port, encoding)); } + } else if (authority != null) { + sb.append("//"); + sb.append(encodeAuthority(authority, encoding)); } - sb.append(encodePath(path, encoding)); + if (path != null) { + sb.append(encodePath(path, encoding)); + } if (query != null) { sb.append('?'); @@ -306,129 +232,194 @@ public abstract class UriUtils { } /** - * Encodes the given URI scheme. + * Encodes the given URI scheme with the given encoding. * @param scheme the scheme to be encoded * @param encoding the character encoding to encode to * @return the encoded scheme * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodeScheme(String scheme, String encoding) throws UnsupportedEncodingException { - return encode(scheme, encoding, SCHEME); + return encode(scheme, encoding, SCHEME_COMPONENT, false); } /** - * Encodes the given URI user info. + * Encodes the given URI authority with the given encoding. + * @param authority the authority to be encoded + * @param encoding the character encoding to encode to + * @return the encoded authority + * @throws UnsupportedEncodingException when the given encoding parameter is not supported + */ + public static String encodeAuthority(String authority, String encoding) throws UnsupportedEncodingException { + return encode(authority, encoding, AUTHORITY_COMPONENT, false); + } + + /** + * Encodes the given URI user info with the given encoding. * @param userInfo the user info to be encoded * @param encoding the character encoding to encode to * @return the encoded user info * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodeUserInfo(String userInfo, String encoding) throws UnsupportedEncodingException { - return encode(userInfo, encoding, USER_INFO); + return encode(userInfo, encoding, USER_INFO_COMPONENT, false); } /** - * Encodes the given URI host. + * Encodes the given URI host with the given encoding. * @param host the host to be encoded * @param encoding the character encoding to encode to * @return the encoded host * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodeHost(String host, String encoding) throws UnsupportedEncodingException { - return encode(host, encoding, HOST); + return encode(host, encoding, HOST_COMPONENT, false); } /** - * Encodes the given URI port. + * Encodes the given URI port with the given encoding. * @param port the port to be encoded * @param encoding the character encoding to encode to * @return the encoded port * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodePort(String port, String encoding) throws UnsupportedEncodingException { - return encode(port, encoding, PORT); + return encode(port, encoding, PORT_COMPONENT, false); } /** - * Encodes the given URI path. + * Encodes the given URI path with the given encoding. * @param path the path to be encoded * @param encoding the character encoding to encode to * @return the encoded path * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodePath(String path, String encoding) throws UnsupportedEncodingException { - return encode(path, encoding, PATH); + return encode(path, encoding, PATH_COMPONENT, false); } /** - * Encodes the given URI path segment. + * Encodes the given URI path segment with the given encoding. * @param segment the segment to be encoded * @param encoding the character encoding to encode to * @return the encoded segment * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodePathSegment(String segment, String encoding) throws UnsupportedEncodingException { - return encode(segment, encoding, SEGMENT); + return encode(segment, encoding, PATH_SEGMENT_COMPONENT, false); } /** - * Encodes the given URI query. + * Encodes the given URI query with the given encoding. * @param query the query to be encoded * @param encoding the character encoding to encode to * @return the encoded query * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodeQuery(String query, String encoding) throws UnsupportedEncodingException { - return encode(query, encoding, QUERY); + return encode(query, encoding, QUERY_COMPONENT, false); } /** - * Encodes the given URI query parameter. + * Encodes the given URI query parameter with the given encoding. * @param queryParam the query parameter to be encoded * @param encoding the character encoding to encode to * @return the encoded query parameter * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodeQueryParam(String queryParam, String encoding) throws UnsupportedEncodingException { - return encode(queryParam, encoding, QUERY_PARAM); + return encode(queryParam, encoding, QUERY_PARAM_COMPONENT, false); } /** - * Encodes the given URI fragment. + * Encodes the given URI fragment with the given encoding. * @param fragment the fragment to be encoded * @param encoding the character encoding to encode to * @return the encoded fragment * @throws UnsupportedEncodingException when the given encoding parameter is not supported */ public static String encodeFragment(String fragment, String encoding) throws UnsupportedEncodingException { - return encode(fragment, encoding, FRAGMENT); + return encode(fragment, encoding, FRAGMENT_COMPONENT, false); } - private static String encode(String source, String encoding, BitSet notEncoded) - throws UnsupportedEncodingException { + /** + * Encodes the given source into an encoded String using the rules specified by the given component. This method + * encodes with the default encoding (i.e. UTF-8). + * @param source the source string + * @param uriComponent the URI component for the source + * @param allowTemplateVars whether URI template variables are allowed. If {@code true}, '{' and '}' characters + * are not encoded, even though they might not be valid for the component + * @return the encoded URI + * @throws IllegalArgumentException when the given uri parameter is not a valid URI + * @see #SCHEME_COMPONENT + * @see #AUTHORITY_COMPONENT + * @see #USER_INFO_COMPONENT + * @see #HOST_COMPONENT + * @see #PORT_COMPONENT + * @see #PATH_COMPONENT + * @see #PATH_SEGMENT_COMPONENT + * @see #QUERY_COMPONENT + * @see #QUERY_PARAM_COMPONENT + * @see #FRAGMENT_COMPONENT + */ + public static String encode(String source, UriComponent uriComponent, boolean allowTemplateVars) { + try { + return encode(source, DEFAULT_ENCODING, uriComponent, allowTemplateVars); + } + catch (UnsupportedEncodingException e) { + throw new InternalError("'" + DEFAULT_ENCODING + "' encoding not supported"); + } + } - Assert.notNull(source, "'source' must not be null"); + /** + * Encodes the given source into an encoded String using the rules specified by the given component. + * @param source the source string + * @param encoding the encoding of the source string + * @param uriComponent the URI component for the source + * @param allowTemplateVars whether URI template variables are allowed. If {@code true}, '{' and '}' characters + * are not encoded, even though they might not be valid for the component + * @return the encoded URI + * @throws IllegalArgumentException when the given uri parameter is not a valid URI + * @see #SCHEME_COMPONENT + * @see #AUTHORITY_COMPONENT + * @see #USER_INFO_COMPONENT + * @see #HOST_COMPONENT + * @see #PORT_COMPONENT + * @see #PATH_COMPONENT + * @see #PATH_SEGMENT_COMPONENT + * @see #QUERY_COMPONENT + * @see #QUERY_PARAM_COMPONENT + * @see #FRAGMENT_COMPONENT + */ + public static String encode(String source, String encoding, UriComponent uriComponent, boolean allowTemplateVars) + throws UnsupportedEncodingException { Assert.hasLength(encoding, "'encoding' must not be empty"); - byte[] bytes = encode(source.getBytes(encoding), notEncoded); + byte[] bytes = encodeInternal(source.getBytes(encoding), uriComponent, allowTemplateVars); return new String(bytes, "US-ASCII"); } - private static byte[] encode(byte[] source, BitSet notEncoded) { + private static byte[] encodeInternal(byte[] source, UriComponent uriComponent, boolean allowTemplateVars) { Assert.notNull(source, "'source' must not be null"); - ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length * 2); + Assert.notNull(uriComponent, "'uriComponent' 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 (notEncoded.get(b)) { + if (uriComponent.isAllowed(b)) { + bos.write(b); + } + else if (allowTemplateVars && (b == '{' || 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); } @@ -436,6 +427,7 @@ public abstract class UriUtils { return bos.toByteArray(); } + /** * Decodes the given encoded source String into an URI. Based on the following * rules: @@ -486,4 +478,126 @@ public abstract class UriUtils { return changed ? new String(bos.toByteArray(), encoding) : source; } + /** + * Defines the contract for an URI component, i.e. scheme, host, path, etc. + */ + public interface UriComponent { + + /** + * Specifies 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 + */ + boolean isAllowed(int c); + + } + + private static abstract class AbstractUriComponent implements UriComponent { + + protected boolean isAlpha(int c) { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'; + } + + protected boolean isDigit(int c) { + return c >= '0' && c <= '9'; + } + + protected boolean isGenericDelimiter(int c) { + return ':' == c || '/' == c || '?' == c || '#' == c || '[' == c || ']' == c || '@' == c; + } + + protected boolean isSubDelimiter(int c) { + return '!' == c || '$' == c || '&' == c || '\'' == c || '(' == c || ')' == c || '*' == c || '+' == c || + ',' == c || ';' == c || '=' == c; + } + + protected boolean isReserved(char c) { + return isGenericDelimiter(c) || isReserved(c); + } + + protected boolean isUnreserved(int c) { + return isAlpha(c) || isDigit(c) || '-' == c || '.' == c || '_' == c || '~' == c; + } + + protected boolean isPchar(int c) { + return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c; + } + + } + + /** The scheme URI component. */ + public static final UriComponent SCHEME_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isAlpha(c) || isDigit(c) || '+' == c || '-' == c || '.' == c; + } + }; + + /** The authority URI component. */ + public static final UriComponent AUTHORITY_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c; + } + }; + + /** The user info URI component. */ + public static final UriComponent USER_INFO_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isUnreserved(c) || isSubDelimiter(c) || ':' == c; + } + }; + + /** The host URI component. */ + public static final UriComponent HOST_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isUnreserved(c) || isSubDelimiter(c); + } + }; + + /** The port URI component. */ + public static final UriComponent PORT_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isDigit(c); + } + }; + + /** The path URI component. */ + public static final UriComponent PATH_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isPchar(c) || '/' == c; + } + }; + + /** The path segment URI component. */ + public static final UriComponent PATH_SEGMENT_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isPchar(c); + } + }; + + /** The query URI component. */ + public static final UriComponent QUERY_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isPchar(c) || '/' == c || '?' == c; + } + }; + + /** The query parameter URI component. */ + public static final UriComponent QUERY_PARAM_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + if ('=' == c || '+' == c || '&' == c) { + return false; + } + else { + return isPchar(c) || '/' == c || '?' == c; + } + } + }; + + /** The fragment URI component. */ + public static final UriComponent FRAGMENT_COMPONENT = new AbstractUriComponent() { + public boolean isAllowed(int c) { + return isPchar(c) || '/' == c || '?' == c; + } + }; + } diff --git a/org.springframework.web/src/test/java/org/springframework/web/util/UriBuilderTests.java b/org.springframework.web/src/test/java/org/springframework/web/util/UriBuilderTests.java new file mode 100644 index 00000000000..65c83db1bde --- /dev/null +++ b/org.springframework.web/src/test/java/org/springframework/web/util/UriBuilderTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2002-2011 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.Map; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** @author Arjen Poutsma */ +public class UriBuilderTests { + + @Test + public void plain() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.scheme("http").host("example.com").path("foo").queryParam("bar").fragment("baz").build(); + + URI expected = new URI("http://example.com/foo?bar#baz"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void fromUri() throws URISyntaxException { + URI uri = new URI("http://example.com/foo?bar#baz"); + + URI result = UriBuilder.fromUri(uri).build(); + assertEquals("Invalid result URI", uri, result); + } + + @Test + public void templateVarsVarArgs() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.scheme("http").host("example.com").path("{foo}").build("bar"); + + URI expected = new URI("http://example.com/bar"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void templateVarsEncoded() throws URISyntaxException, UnsupportedEncodingException { + URI result = UriBuilder.fromPath("{foo}").build("bar baz"); + + URI expected = new URI("/bar%20baz"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void templateVarsNotEncoded() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.scheme("http").host("example.com").path("{foo}").buildFromEncoded("bar%20baz"); + + URI expected = new URI("http://example.com/bar%20baz"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void templateVarsMap() throws URISyntaxException { + Map vars = Collections.singletonMap("foo", "bar"); + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.scheme("http").host("example.com").path("{foo}").build(vars); + + URI expected = new URI("http://example.com/bar"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void unusedTemplateVars() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.scheme("http").host("example.com").path("{foo}").build(); + + URI expected = new URI("http://example.com/%7Bfoo%7D"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void pathSegments() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.pathSegment("foo").pathSegment("bar").build(); + + URI expected = new URI("/foo/bar"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void queryParam() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.queryParam("baz", "qux", 42).build(); + + URI expected = new URI("?baz=qux&baz=42"); + assertEquals("Invalid result URI", expected, result); + } + + @Test + public void emptyQueryParam() throws URISyntaxException { + UriBuilder builder = UriBuilder.newInstance(); + URI result = builder.queryParam("baz").build(); + + URI expected = new URI("?baz"); + assertEquals("Invalid result URI", expected, result); + } + + +} diff --git a/org.springframework.web/src/test/java/org/springframework/web/util/UriTemplateTests.java b/org.springframework.web/src/test/java/org/springframework/web/util/UriTemplateTests.java index 6eb3c534b61..7323cdcf39b 100644 --- a/org.springframework.web/src/test/java/org/springframework/web/util/UriTemplateTests.java +++ b/org.springframework.web/src/test/java/org/springframework/web/util/UriTemplateTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2010 the original author or authors. + * Copyright 2002-2011 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. @@ -23,9 +23,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import static org.junit.Assert.*; import org.junit.Test; +import static org.junit.Assert.*; + /** * @author Arjen Poutsma * @author Juergen Hoeller @@ -47,9 +48,9 @@ public class UriTemplateTests { } @Test(expected = IllegalArgumentException.class) - public void expandVarArgsInvalidAmountVariables() throws Exception { + public void expandVarArgsNotEnoughVariables() throws Exception { UriTemplate template = new UriTemplate("http://example.com/hotels/{hotel}/bookings/{booking}"); - template.expand("1", "42", "100"); + template.expand("1"); } @Test @@ -110,6 +111,13 @@ public class UriTemplateTests { assertFalse("UriTemplate matches", template.matches("")); assertFalse("UriTemplate matches", template.matches(null)); } + + @Test + public void matchesCustomRegex() throws Exception { + UriTemplate template = new UriTemplate("http://example.com/hotels/{hotel:\\d+}"); + assertTrue("UriTemplate does not match", template.matches("http://example.com/hotels/42")); + assertFalse("UriTemplate matches", template.matches("http://example.com/hotels/foo")); + } @Test public void match() throws Exception { @@ -122,6 +130,17 @@ public class UriTemplateTests { assertEquals("Invalid match", expected, result); } + @Test + public void matchCustomRegex() throws Exception { + Map expected = new HashMap(2); + expected.put("booking", "42"); + expected.put("hotel", "1"); + + UriTemplate template = new UriTemplate("http://example.com/hotels/{hotel:\\d}/bookings/{booking:\\d+}"); + Map result = template.match("http://example.com/hotels/1/bookings/42"); + assertEquals("Invalid match", expected, result); + } + @Test public void matchDuplicate() throws Exception { UriTemplate template = new UriTemplate("/order/{c}/{c}/{c}");