/* * Copyright (c) 2010 Google Inc. * * 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 com.google.api.client.http; import com.google.api.client.util.GenericData; import com.google.api.client.util.Key; import com.google.api.client.util.Preconditions; import com.google.api.client.util.escape.CharEscapers; import com.google.api.client.util.escape.Escaper; import com.google.api.client.util.escape.PercentEscaper; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; /** * URL builder in which the query parameters are specified as generic data key/value pairs, based on * the specification <a href="http://tools.ietf.org/html/rfc3986">RFC 3986: Uniform Resource * Identifier (URI)</a>. * * <p> * The query parameters are specified with the data key name as the parameter name, and the data * value as the parameter value. Subclasses can declare fields for known query parameters using the * {@link Key} annotation. {@code null} parameter names are not allowed, but {@code null} query * values are allowed. * </p> * * <p> * Query parameter values are parsed using {@link UrlEncodedParser#parse(String, Object)}. * </p> * * <p> * Implementation is not thread-safe. * </p> * * @since 1.0 * @author Yaniv Inbar */ public class GenericUrl extends GenericData { private static final Escaper URI_FRAGMENT_ESCAPER = new PercentEscaper("=&-_.!~*'()@:$,;/?:", false); /** Scheme (lowercase), for example {@code "https"}. */ private String scheme; /** Host, for example {@code "www.google.com"}. */ private String host; /** User info or {@code null} for none, for example {@code "username:password"}. */ private String userInfo; /** Port number or {@code -1} if undefined, for example {@code 443}. */ private int port = -1; /** * Decoded path component by parts with each part separated by a {@code '/'} or {@code null} for * none, for example {@code "/m8/feeds/contacts/default/full"} is represented by {@code "", "m8", *"feeds", "contacts", "default", "full"}. * <p> * Use {@link #appendRawPath(String)} to append to the path, which ensures that no extra slash is * added. * </p> */ private List<String> pathParts; /** Fragment component or {@code null} for none. */ private String fragment; public GenericUrl() { } /** * Constructs from an encoded URL. * * <p> * Any known query parameters with pre-defined fields as data keys will be parsed based on their * data type. Any unrecognized query parameter will always be parsed as a string. * </p> * * <p> * Any {@link MalformedURLException} is wrapped in an {@link IllegalArgumentException}. * </p> * * <p>Upgrade warning: starting in version 1.18 this parses the encodedUrl using * new URL(encodedUrl). In previous versions it used new URI(encodedUrl). * In particular, this means that only a limited set of schemes are allowed such as "http" and * "https", but that parsing is compliant with, at least, RFC 3986.</p> * * @param encodedUrl encoded URL, including any existing query parameters that should be parsed * @throws IllegalArgumentException if URL has a syntax error */ public GenericUrl(String encodedUrl) { this(parseURL(encodedUrl)); } /** * Constructs from a URI. * * @param uri URI * * @since 1.14 */ public GenericUrl(URI uri) { this(uri.getScheme(), uri.getHost(), uri.getPort(), uri.getRawPath(), uri.getRawFragment(), uri.getRawQuery(), uri.getRawUserInfo()); } /** * Constructs from a URL. * * @param url URL * * @since 1.14 */ public GenericUrl(URL url) { this(url.getProtocol(), url.getHost(), url.getPort(), url.getPath(), url.getRef(), url.getQuery(), url.getUserInfo()); } private GenericUrl(String scheme, String host, int port, String path, String fragment, String query, String userInfo) { this.scheme = scheme.toLowerCase(); this.host = host; this.port = port; this.pathParts = toPathParts(path); this.fragment = fragment != null ? CharEscapers.decodeUri(fragment) : null; if (query != null) { UrlEncodedParser.parse(query, this); } this.userInfo = userInfo != null ? CharEscapers.decodeUri(userInfo) : null; } @Override public int hashCode() { // TODO(yanivi): optimize? return build().hashCode(); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!super.equals(obj) || !(obj instanceof GenericUrl)) { return false; } GenericUrl other = (GenericUrl) obj; // TODO(yanivi): optimize? return build().equals(other.toString()); } @Override public String toString() { return build(); } @Override public GenericUrl clone() { GenericUrl result = (GenericUrl) super.clone(); if (pathParts != null) { result.pathParts = new ArrayList<String>(pathParts); } return result; } @Override public GenericUrl set(String fieldName, Object value) { return (GenericUrl) super.set(fieldName, value); } /** * Returns the scheme (lowercase), for example {@code "https"}. * * @since 1.5 */ public final String getScheme() { return scheme; } /** * Sets the scheme (lowercase), for example {@code "https"}. * * @since 1.5 */ public final void setScheme(String scheme) { this.scheme = Preconditions.checkNotNull(scheme); } /** * Returns the host, for example {@code "www.google.com"}. * * @since 1.5 */ public String getHost() { return host; } /** * Sets the host, for example {@code "www.google.com"}. * * @since 1.5 */ public final void setHost(String host) { this.host = Preconditions.checkNotNull(host); } /** * Returns the user info or {@code null} for none, for example {@code "username:password"}. * * @since 1.15 */ public final String getUserInfo() { return userInfo; } /** * Sets the user info or {@code null} for none, for example {@code "username:password"}. * * @since 1.15 */ public final void setUserInfo(String userInfo) { this.userInfo = userInfo; } /** * Returns the port number or {@code -1} if undefined, for example {@code 443}. * * @since 1.5 */ public int getPort() { return port; } /** * Sets the port number, for example {@code 443}. * * @since 1.5 */ public final void setPort(int port) { Preconditions.checkArgument(port >= -1, "expected port >= -1"); this.port = port; } /** * Returns the decoded path component by parts with each part separated by a {@code '/'} or * {@code null} for none. * * @since 1.5 */ public List<String> getPathParts() { return pathParts; } /** * Sets the decoded path component by parts with each part separated by a {@code '/'} or * {@code null} for none. * * <p> * For example {@code "/m8/feeds/contacts/default/full"} is represented by {@code "", "m8", *"feeds", "contacts", "default", "full"}. * </p> * * <p> * Use {@link #appendRawPath(String)} to append to the path, which ensures that no extra slash is * added. * </p> * * @since 1.5 */ public void setPathParts(List<String> pathParts) { this.pathParts = pathParts; } /** * Returns the fragment component or {@code null} for none. * * @since 1.5 */ public String getFragment() { return fragment; } /** * Sets the fragment component or {@code null} for none. * * @since 1.5 */ public final void setFragment(String fragment) { this.fragment = fragment; } /** * Constructs the string representation of the URL, including the path specified by * {@link #pathParts} and the query parameters specified by this generic URL. */ public final String build() { return buildAuthority() + buildRelativeUrl(); } /** * Constructs the portion of the URL containing the scheme, host and port. * * <p> * For the URL {@code "http://example.com/something?action=add"} this method would return * {@code "http://example.com"}. * </p> * * @return scheme://[user-info@]host[:port] * @since 1.9 */ public final String buildAuthority() { // scheme, [user info], host, [port] StringBuilder buf = new StringBuilder(); buf.append(Preconditions.checkNotNull(scheme)); buf.append("://"); if (userInfo != null) { buf.append(CharEscapers.escapeUriUserInfo(userInfo)).append('@'); } buf.append(Preconditions.checkNotNull(host)); int port = this.port; if (port != -1) { buf.append(':').append(port); } return buf.toString(); } /** * Constructs the portion of the URL beginning at the rooted path. * * <p> * For the URL {@code "http://example.com/something?action=add"} this method would return * {@code "/something?action=add"}. * </p> * * @return path with with leading '/' if the path is non-empty, query parameters and fragment * @since 1.9 */ public final String buildRelativeUrl() { StringBuilder buf = new StringBuilder(); if (pathParts != null) { appendRawPathFromParts(buf); } addQueryParams(entrySet(), buf); // URL fragment String fragment = this.fragment; if (fragment != null) { buf.append('#').append(URI_FRAGMENT_ESCAPER.escape(fragment)); } return buf.toString(); } /** * Constructs the URI based on the string representation of the URL from {@link #build()}. * * <p> * Any {@link URISyntaxException} is wrapped in an {@link IllegalArgumentException}. * </p> * * @return new URI instance * * @since 1.14 */ public final URI toURI() { return toURI(build()); } /** * Constructs the URL based on the string representation of the URL from {@link #build()}. * * <p> * Any {@link MalformedURLException} is wrapped in an {@link IllegalArgumentException}. * </p> * * @return new URL instance * * @since 1.14 */ public final URL toURL() { return parseURL(build()); } /** * Constructs the URL based on {@link URL#URL(URL, String)} with this URL representation from * {@link #toURL()} and a relative url. * * <p> * Any {@link MalformedURLException} is wrapped in an {@link IllegalArgumentException}. * </p> * * @return new URL instance * * @since 1.14 */ public final URL toURL(String relativeUrl) { try { URL url = toURL(); return new URL(url, relativeUrl); } catch (MalformedURLException e) { throw new IllegalArgumentException(e); } } /** * Returns the first query parameter value for the given query parameter name. * * @param name query parameter name * @return first query parameter value */ public Object getFirst(String name) { Object value = get(name); if (value instanceof Collection<?>) { @SuppressWarnings("unchecked") Collection<Object> collectionValue = (Collection<Object>) value; Iterator<Object> iterator = collectionValue.iterator(); return iterator.hasNext() ? iterator.next() : null; } return value; } /** * Returns all query parameter values for the given query parameter name. * * @param name query parameter name * @return unmodifiable collection of query parameter values (possibly empty) */ public Collection<Object> getAll(String name) { Object value = get(name); if (value == null) { return Collections.emptySet(); } if (value instanceof Collection<?>) { @SuppressWarnings("unchecked") Collection<Object> collectionValue = (Collection<Object>) value; return Collections.unmodifiableCollection(collectionValue); } return Collections.singleton(value); } /** * Returns the raw encoded path computed from the {@link #pathParts}. * * @return raw encoded path computed from the {@link #pathParts} or {@code null} if * {@link #pathParts} is {@code null} */ public String getRawPath() { List<String> pathParts = this.pathParts; if (pathParts == null) { return null; } StringBuilder buf = new StringBuilder(); appendRawPathFromParts(buf); return buf.toString(); } /** * Sets the {@link #pathParts} from the given raw encoded path. * * @param encodedPath raw encoded path or {@code null} to set {@link #pathParts} to {@code null} */ public void setRawPath(String encodedPath) { pathParts = toPathParts(encodedPath); } /** * Appends the given raw encoded path to the current {@link #pathParts}, setting field only if it * is {@code null} or empty. * <p> * The last part of the {@link #pathParts} is merged with the first part of the path parts * computed from the given encoded path. Thus, if the current raw encoded path is {@code "a"}, and * the given encoded path is {@code "b"}, then the resulting raw encoded path is {@code "ab"}. * </p> * * @param encodedPath raw encoded path or {@code null} to ignore */ public void appendRawPath(String encodedPath) { if (encodedPath != null && encodedPath.length() != 0) { List<String> appendedPathParts = toPathParts(encodedPath); if (pathParts == null || pathParts.isEmpty()) { this.pathParts = appendedPathParts; } else { int size = pathParts.size(); pathParts.set(size - 1, pathParts.get(size - 1) + appendedPathParts.get(0)); pathParts.addAll(appendedPathParts.subList(1, appendedPathParts.size())); } } } /** * Returns the decoded path parts for the given encoded path. * * @param encodedPath slash-prefixed encoded path, for example * {@code "/m8/feeds/contacts/default/full"} * @return decoded path parts, with each part assumed to be preceded by a {@code '/'}, for example * {@code "", "m8", "feeds", "contacts", "default", "full"}, or {@code null} for * {@code null} or {@code ""} input */ public static List<String> toPathParts(String encodedPath) { if (encodedPath == null || encodedPath.length() == 0) { return null; } List<String> result = new ArrayList<String>(); int cur = 0; boolean notDone = true; while (notDone) { int slash = encodedPath.indexOf('/', cur); notDone = slash != -1; String sub; if (notDone) { sub = encodedPath.substring(cur, slash); } else { sub = encodedPath.substring(cur); } result.add(CharEscapers.decodeUri(sub)); cur = slash + 1; } return result; } private void appendRawPathFromParts(StringBuilder buf) { int size = pathParts.size(); for (int i = 0; i < size; i++) { String pathPart = pathParts.get(i); if (i != 0) { buf.append('/'); } if (pathPart.length() != 0) { buf.append(CharEscapers.escapeUriPath(pathPart)); } } } /** * Adds query parameters from the provided entrySet into the buffer. */ static void addQueryParams(Set<Entry<String, Object>> entrySet, StringBuilder buf) { // (similar to UrlEncodedContent) boolean first = true; for (Map.Entry<String, Object> nameValueEntry : entrySet) { Object value = nameValueEntry.getValue(); if (value != null) { String name = CharEscapers.escapeUriQuery(nameValueEntry.getKey()); if (value instanceof Collection<?>) { Collection<?> collectionValue = (Collection<?>) value; for (Object repeatedValue : collectionValue) { first = appendParam(first, buf, name, repeatedValue); } } else { first = appendParam(first, buf, name, value); } } } } private static boolean appendParam(boolean first, StringBuilder buf, String name, Object value) { if (first) { first = false; buf.append('?'); } else { buf.append('&'); } buf.append(name); String stringValue = CharEscapers.escapeUriQuery(value.toString()); if (stringValue.length() != 0) { buf.append('=').append(stringValue); } return first; } /** * Returns the URI for the given encoded URL. * * <p> * Any {@link URISyntaxException} is wrapped in an {@link IllegalArgumentException}. * </p> * * @param encodedUrl encoded URL * @return URI */ private static URI toURI(String encodedUrl) { try { return new URI(encodedUrl); } catch (URISyntaxException e) { throw new IllegalArgumentException(e); } } /** * Returns the URI for the given encoded URL. * * <p> * Any {@link MalformedURLException} is wrapped in an {@link IllegalArgumentException}. * </p> * * @param encodedUrl encoded URL * @return URL */ private static URL parseURL(String encodedUrl) { try { return new URL(encodedUrl); } catch (MalformedURLException e) { throw new IllegalArgumentException(e); } } }