/* Copyright (c) 2008 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.gdata.client.http; import com.google.gdata.util.common.xml.XmlWriter; import com.google.gdata.client.AuthTokenFactory; import com.google.gdata.client.GDataProtocol; import com.google.gdata.client.Query; import com.google.gdata.client.GDataProtocol.Header; import com.google.gdata.client.Service.GDataRequest; import com.google.gdata.client.Service.GDataRequestFactory; import com.google.gdata.client.authn.oauthproxy.OAuthProxyProtocol; import com.google.gdata.data.DateTime; import com.google.gdata.data.ParseSource; import com.google.gdata.util.AuthenticationException; import com.google.gdata.util.ContentType; import com.google.gdata.util.EntityTooLargeException; import com.google.gdata.util.InvalidEntryException; import com.google.gdata.util.LoggableInputStream; import com.google.gdata.util.LoggableOutputStream; import com.google.gdata.util.NoLongerAvailableException; import com.google.gdata.util.NotAcceptableException; import com.google.gdata.util.NotImplementedException; import com.google.gdata.util.NotModifiedException; import com.google.gdata.util.OAuthProxyException; import com.google.gdata.util.PreconditionFailedException; import com.google.gdata.util.ResourceNotFoundException; import com.google.gdata.util.ServiceException; import com.google.gdata.util.ServiceForbiddenException; import com.google.gdata.util.VersionConflictException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.URL; import java.net.URLConnection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import java.util.zip.GZIPInputStream; /** * The HttpGDataRequest class provides a basic implementation of the * <code>GDataRequest</code> interface over HTTP. * * @see GDataRequest */ public class HttpGDataRequest implements GDataRequest { static final Logger logger = Logger.getLogger(HttpGDataRequest.class.getName()); /** * If this system property is set to <code>true</code>, the GData HTTP * client library will use POST to send data to the associated GData service * and will specify the actual method using the {@code METHOD_OVERRIDE_HEADER} * HTTP header. This can be used as a workaround for HTTP proxies or gateways * that do not handle PUT or DELETE HTTP methods properly. If the system * property is <code>false</code>, the regular PUT and DELETE HTTP verbs * will be used. */ public static final String METHOD_OVERRIDE_PROPERTY = "com.google.gdata.UseMethodOverride"; /** * Name of HTTP header containing the method name that overrides the normal * HTTP method. * * @deprecated Use {@link GDataProtocol.Header#METHOD_OVERRIDE} instead */ @Deprecated public static final String METHOD_OVERRIDE_HEADER = GDataProtocol.Header.METHOD_OVERRIDE; /** * The HttpGDataRequest.Factory class is a factory class for constructing * new HttpGDataRequest instances. */ public static class Factory implements GDataRequestFactory { protected HttpAuthToken authToken; protected Map<String, String> headerMap = new LinkedHashMap<String, String>(); protected Map<String, String> privateHeaderMap = new LinkedHashMap<String, String>(); protected boolean useSsl = false; protected HttpUrlConnectionSource connectionSource = JdkHttpUrlConnectionSource.INSTANCE; public void setAuthToken(AuthTokenFactory.AuthToken authToken) { if (authToken != null && !(authToken instanceof HttpAuthToken)) { throw new IllegalArgumentException("Invalid token type"); } setAuthToken((HttpAuthToken) authToken); } public void setAuthToken(HttpAuthToken authToken) { this.authToken = authToken; } public void useSsl() { this.useSsl = true; } private void extendHeaderMap(Map<String, String> headerMap, String header, String value) { if (value == null) { headerMap.remove(header); } else { headerMap.put(header, value); } } public void setHeader(String header, String value) { extendHeaderMap(this.headerMap, header, value); } public void setPrivateHeader(String header, String value) { extendHeaderMap(this.privateHeaderMap, header, value); } /** * Sets a specific {@link HttpUrlConnectionSource} instance to create * backing {@link URLConnection} instance. */ public void setConnectionSource(HttpUrlConnectionSource connectionSource) { if (connectionSource == null) { throw new NullPointerException("connectionSource"); } this.connectionSource = connectionSource; } @SuppressWarnings("unused") public GDataRequest getRequest(RequestType type, URL requestUrl, ContentType contentType) throws IOException, ServiceException { if (this.useSsl && !requestUrl.getProtocol().startsWith("https")) { requestUrl = new URL( requestUrl.toString().replaceFirst("http", "https")); } return createRequest(type, requestUrl, contentType); } @SuppressWarnings("unused") public GDataRequest getRequest(Query query, ContentType contentType) throws IOException, ServiceException { return getRequest(RequestType.QUERY, query.getUrl(), contentType); } /** * Creates a {@link GDataRequest} instance. * * <p>This method is called from {@link #getRequest} after any changes to * the parameters have been applied. * * <p>Subclasses should overwrite this method and not {@link #getRequest} */ protected GDataRequest createRequest(RequestType type, URL requestUrl, ContentType contentType) throws IOException, ServiceException { return new HttpGDataRequest(type, requestUrl, contentType, authToken, headerMap, privateHeaderMap, connectionSource); } } /** * Source of {@link HttpURLConnection} instances. */ protected final HttpUrlConnectionSource connectionSource; /** * Underlying HTTP connection to the GData service. */ protected HttpURLConnection httpConn; /** * The request URL provided by the client. */ protected URL requestUrl; /** * The GData request type. */ protected RequestType type; /** * Indicates whether request execution has taken place. Set to * <code>true</code> if executed, <code>false</code> otherwise. */ protected boolean executed = false; /** * True if the request type expects input from the client. */ protected boolean expectsInput; /** * Contains the content type of the request data */ protected ContentType inputType; /** * True if the request type returns output to the client. */ protected boolean hasOutput; /** * The connection timeout for this request. A value of -1 means no value has * been configured (use JDK default timeout behavior). */ protected int connectTimeout = -1; /** * The read timeout for this request. A value of -1 means no value has been * configured (use JDK default timeout behavior). */ protected int readTimeout = -1; /** * The input stream from which HTTP response data may be read or {@code null} * if no response stream or not opened yet via {@link #getResponseStream()}. */ private InputStream inputStream = null; /** * Constructs a new HttpGDataRequest instance of the specified RequestType, * targeting the specified URL. * * @param type type of GDataRequest. * @param requestUrl request target URL. * @param inputType the content type of request data (or {@code null}). * @param headerMap a set of headers to be included in each request * @param privateHeaderMap a set of headers to be included in each request * @param connectionSource source of {@link HttpURLConnection}s * @throws IOException on error initializating service connection. */ protected HttpGDataRequest(RequestType type, URL requestUrl, ContentType inputType, HttpAuthToken authToken, Map<String, String> headerMap, Map<String, String> privateHeaderMap, HttpUrlConnectionSource connectionSource) throws IOException { this.connectionSource = connectionSource; this.type = type; this.inputType = inputType; this.requestUrl = requestUrl; this.httpConn = getRequestConnection(requestUrl); switch (type) { case QUERY: hasOutput = true; break; case INSERT: case BATCH: expectsInput = true; hasOutput = true; setMethod("POST"); setHeader("Content-Type", inputType.toString()); break; case UPDATE: expectsInput = true; hasOutput = true; if (Boolean.getBoolean(METHOD_OVERRIDE_PROPERTY)) { setMethod("POST"); setHeader(Header.METHOD_OVERRIDE, "PUT"); } else { setMethod("PUT"); } setHeader("Content-Type", inputType.toString()); break; case PATCH: expectsInput = true; hasOutput = true; // HTTPUrlConnection does not accept unrecognized methods, so always use // the POST override model for PATCH requests setMethod("POST"); setHeader(Header.METHOD_OVERRIDE, "PATCH"); setHeader("Content-Type", inputType.toString()); break; case DELETE: if (Boolean.getBoolean(METHOD_OVERRIDE_PROPERTY)) { setMethod("POST"); setHeader(Header.METHOD_OVERRIDE, "DELETE"); } else { setMethod("DELETE"); } setHeader("Content-Length", "0"); // no data to POST break; default: throw new UnsupportedOperationException("Unknown request type:" + type); } if (authToken != null) { // NOTE: Do not use setHeader() here, authorization should never be // logged. String authHeader = authToken.getAuthorizationHeader(requestUrl, httpConn .getRequestMethod()); setPrivateHeader("Authorization", authHeader); } if (headerMap != null) { for (Map.Entry<String, String> e : headerMap.entrySet()) { setHeader(e.getKey(), e.getValue()); } } if (privateHeaderMap != null) { for (Map.Entry<String, String> e : privateHeaderMap.entrySet()) { setPrivateHeader(e.getKey(), e.getValue()); } } // Request compressed response format setHeader("Accept-Encoding", "gzip"); httpConn.setDoOutput(expectsInput); } /** * Protected default constructor for testing. */ protected HttpGDataRequest() { connectionSource = JdkHttpUrlConnectionSource.INSTANCE; } public URL getRequestUrl() { return requestUrl; } public ContentType getRequestContentType() { return inputType; } /** * Obtains a connection to the GData service. */ protected HttpURLConnection getRequestConnection(URL requestUrl) throws IOException { HttpURLConnection uc; try { uc = connectionSource.openConnection(requestUrl); } catch (IllegalArgumentException e) { throw new UnsupportedOperationException("Unsupported scheme:" + requestUrl.getProtocol()); } // Should never cache GData requests/responses uc.setUseCaches(false); // Always follow redirects uc.setInstanceFollowRedirects(true); return uc; } public void setConnectTimeout(int timeout) { if (timeout < 0) { throw new IllegalArgumentException("Timeout cannot be negative"); } connectTimeout = timeout; } public void setReadTimeout(int timeout) { if (timeout < 0) { throw new IllegalArgumentException("Timeout cannot be negative"); } readTimeout = timeout; } public void setIfModifiedSince(DateTime conditionDate) { if (conditionDate == null) { return; } if (type == RequestType.QUERY) { setHeader(GDataProtocol.Header.IF_MODIFIED_SINCE, conditionDate.toStringRfc822()); } else { throw new IllegalStateException( "Date conditions not supported for this request type"); } } public void setEtag(String etag) { if (etag == null) { return; } switch (type) { case QUERY: if (etag != null) { setHeader(GDataProtocol.Header.IF_NONE_MATCH, etag); } break; case PATCH: case UPDATE: case DELETE: if (etag != null) { setHeader(GDataProtocol.Header.IF_MATCH, etag); } break; default: throw new IllegalStateException( "Etag conditions not supported for this request type"); } } public OutputStream getRequestStream() throws IOException { if (!expectsInput) { throw new IllegalStateException("Request doesn't accept input"); } if (logger.isLoggable(Level.FINEST)){ return new LoggableOutputStream(logger, httpConn.getOutputStream()); } return httpConn.getOutputStream(); } public XmlWriter getRequestWriter() throws IOException { OutputStream requestStream = getRequestStream(); Writer writer = new OutputStreamWriter(requestStream, "utf-8"); return new XmlWriter(writer); } public void setMethod(String method) throws ProtocolException { httpConn.setRequestMethod(method); if (logger.isLoggable(Level.FINE)) { logger.fine(method + " " + httpConn.getURL().toExternalForm()); } } public void setHeader(String name, String value) { httpConn.setRequestProperty(name, value); logger.finer(name + ": " + value); } public void setPrivateHeader(String name, String value) { httpConn.setRequestProperty(name, value); logger.finer(name + ": <Not Logged>"); } public void execute() throws IOException, ServiceException { if (connectTimeout >= 0) { httpConn.setConnectTimeout(connectTimeout); } if (readTimeout >= 0) { httpConn.setReadTimeout(readTimeout); } // Set the http.strictPostRedirect property to prevent redirected // POST/PUT/DELETE from being mapped to a GET. This // system property was a hack to fix a jdk bug w/out changing back // compat behavior. It's bogus that this is a system (and not a // per-connection) property, so we just change it for the duration // of the connection. // See http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4023866 String httpStrictPostRedirect = System.getProperty("http.strictPostRedirect"); try { System.setProperty("http.strictPostRedirect", "true"); httpConn.connect(); if (logger.isLoggable(Level.FINE)) { // Avoid calling URL.equals() unless an object equivalence test fails, // because URL.equals() requires DNS resolution. This test will // fail on the first check for any URLConnection implementation // that derives from java.net.URLConnection. The 2nd check would // work on an alternate impl that clones the URL. if (httpConn.getURL() != requestUrl && !httpConn.getURL().toExternalForm().equals( requestUrl.toExternalForm())) { logger.fine("Redirected to:" + httpConn.getURL().toExternalForm()); } // Log response information here, if enabled logger.fine(httpConn.getResponseCode() + " " + httpConn.getResponseMessage()); if (logger.isLoggable(Level.FINER)) { for (Map.Entry<String, List<String>> headerField : httpConn .getHeaderFields().entrySet()) { for (String value : headerField.getValue()) { logger.finer(headerField.getKey() + ": " + value); } } } } checkResponse(); // will flush any request data } finally { if (httpStrictPostRedirect == null) { System.clearProperty("http.strictPostRedirect"); } else { System.setProperty("http.strictPostRedirect", httpStrictPostRedirect); } } executed = true; } /** * Called after a request is executed to process the response and generate an * appropriate exception (on failure). */ protected void checkResponse() throws IOException, ServiceException { if (isOAuthProxyErrorResponse()) { handleOAuthProxyErrorResponse(); } else if (httpConn.getResponseCode() >= 300) { handleErrorResponse(); } } /** Whether or not the http response comes from the OAuth Proxy. */ private boolean isOAuthProxyErrorResponse() throws IOException { Set<String> headers = httpConn.getHeaderFields().keySet(); boolean isOAuthRedirectToApproval = headers.contains(OAuthProxyProtocol.Header.X_OAUTH_APPROVAL_URL); boolean isOtherOAuthError = httpConn.getResponseCode() == HttpURLConnection.HTTP_OK && (headers.contains(OAuthProxyProtocol.Header.X_OAUTH_ERROR) || headers.contains(OAuthProxyProtocol.Header.X_OAUTH_ERROR_TEXT)); return isOAuthRedirectToApproval || isOtherOAuthError; } /** * Sets the appropriate parameters used by the OAuth Proxy and throws an * {@link OAuthProxyException}. */ private void handleOAuthProxyErrorResponse() throws IOException, ServiceException { throw new OAuthProxyException(httpConn); } /** * Handles an error response received while executing a GData service request. * Throws a {@link ServiceException} or one of its subclasses, depending on * the failure conditions. * * @throws ServiceException exception describing the failure. * @throws IOException error reading the error response from the GData * service. */ protected void handleErrorResponse() throws ServiceException, IOException { switch (httpConn.getResponseCode()) { case HttpURLConnection.HTTP_NOT_FOUND: throw new ResourceNotFoundException(httpConn); case HttpURLConnection.HTTP_BAD_REQUEST: throw new InvalidEntryException(httpConn); case HttpURLConnection.HTTP_FORBIDDEN: throw new ServiceForbiddenException(httpConn); case HttpURLConnection.HTTP_UNAUTHORIZED: throw new AuthenticationException(httpConn); case HttpURLConnection.HTTP_NOT_MODIFIED: throw new NotModifiedException(httpConn); case HttpURLConnection.HTTP_PRECON_FAILED: throw new PreconditionFailedException(httpConn); case HttpURLConnection.HTTP_NOT_IMPLEMENTED: throw new NotImplementedException(httpConn); case HttpURLConnection.HTTP_CONFLICT: throw new VersionConflictException(httpConn); case HttpURLConnection.HTTP_ENTITY_TOO_LARGE: throw new EntityTooLargeException(httpConn); case HttpURLConnection.HTTP_NOT_ACCEPTABLE: throw new NotAcceptableException(httpConn); case HttpURLConnection.HTTP_GONE: throw new NoLongerAvailableException(httpConn); default: throw new ServiceException(httpConn); } } public ContentType getResponseContentType() { if (!executed) { throw new IllegalStateException( "Must call execute() before attempting to read response"); } String value = httpConn.getHeaderField("Content-Type"); if (value == null) { return null; } return new ContentType(value); } public String getResponseHeader(String headerName) { return httpConn.getHeaderField(headerName); } public DateTime getResponseDateHeader(String headerName) { long dateValue = httpConn.getHeaderFieldDate(headerName, -1); return (dateValue >= 0) ? new DateTime(dateValue) : null; } public InputStream getResponseStream() throws IOException { if (!executed) { throw new IllegalStateException( "Must call execute() before attempting to read response"); } if (!hasOutput) { throw new IllegalStateException("Request doesn't have response data"); } if (inputStream != null) { return inputStream; } inputStream = httpConn.getInputStream(); if ("gzip".equalsIgnoreCase(httpConn.getContentEncoding())) { inputStream = new GZIPInputStream(inputStream); } if (logger.isLoggable(Level.FINEST)){ return new LoggableInputStream(logger, inputStream); } return inputStream; } public ParseSource getParseSource() throws IOException { return new ParseSource(getResponseStream()); } /** * Returns the URLConnection instance that represents the underlying * connection to the GData service that will be used by this request. * * @return connection to GData service. */ public HttpURLConnection getConnection() { return httpConn; } public void end() { try { if (inputStream != null) { inputStream.close(); } } catch (IOException ioe) { logger.log(Level.WARNING, "Error closing response stream", ioe); } } }