/* 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.client.GDataProtocol; import com.google.gdata.client.GoogleService; import com.google.gdata.client.GoogleService.SessionExpiredException; import com.google.gdata.client.Service.GDataRequest; import com.google.gdata.util.AuthenticationException; import com.google.gdata.util.ContentType; import com.google.gdata.util.RedirectRequiredException; import com.google.gdata.util.ServiceException; import com.google.gdata.util.Version; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.net.CookieHandler; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.text.SimpleDateFormat; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.logging.Logger; /** * The GoogleGDataRequest class provides a basic implementation of an * interface to connect with a Google-GData server. * * */ public class GoogleGDataRequest extends HttpGDataRequest { private static final Logger logger = Logger.getLogger(GoogleGDataRequest.class.getName()); /** * If set, this System property will globally disable interception and * handling of cookies for all GData services. */ public static final String DISABLE_COOKIE_HANDLER_PROPERTY = "com.google.gdata.DisableCookieHandler"; /* * Disables cookie handling when run in AppEngine. This is a no-op if run * outside of AppEngine. */ static { try { Class apiProxyClass = Class.forName( "com.google.apphosting.api.ApiProxy"); if (apiProxyClass.getMethod( "getCurrentEnvironment").invoke(null) != null) { System.setProperty(DISABLE_COOKIE_HANDLER_PROPERTY, "true"); } } catch (ClassNotFoundException e) { } catch (IllegalAccessException e) { } catch (InvocationTargetException e) { } catch (NoSuchMethodException e) { } } /** * The GoogleGDataRequest.Factory class is a factory class for * constructing new GoogleGDataRequest instances. */ public static class Factory extends HttpGDataRequest.Factory { @Override protected GDataRequest createRequest(RequestType type, URL requestUrl, ContentType contentType) throws IOException, ServiceException { return new GoogleGDataRequest(type, requestUrl, contentType, authToken, headerMap, privateHeaderMap, connectionSource); } } /** * Google cookie. */ public static class GoogleCookie { // Cookie state. All fields have public accessors, except for cookie // values which are restricted to package-level access for security. private String domain; public String getDomain() { return domain; } private String path; public String getPath() { return path; } private String name; public String getName() { return name; } private String value; String getValue() { return value; } private Date expires; public Date getExpires() { return (expires != null) ? (Date) expires.clone() : null; } /** * Constructs a new GoogleCookie instance. * * @param uri the original request URI that returned Set-Cookie header * in the response * @param cookieHeader the value of the Set-Cookie header. */ public GoogleCookie(URI uri, String cookieHeader) { // Set default values String attributes[] = cookieHeader.split(";"); String nameValue = attributes[0].trim(); int equals = nameValue.indexOf('='); if (equals < 0) { throw new IllegalArgumentException("Cookie is not a name/value pair"); } this.name = nameValue.substring(0, equals); this.value = nameValue.substring(equals + 1); this.path = "/"; this.domain = uri.getHost(); // Process optional cookie attributes for (int i = 1; i < attributes.length; i++) { nameValue = attributes[i].trim(); equals = nameValue.indexOf('='); if (equals == -1) { continue; } String name = nameValue.substring(0, equals); String value = nameValue.substring(equals + 1); if (name.equalsIgnoreCase("domain")) { if (uri.getPort() > 0) { // ignore port int colon = value.lastIndexOf(':'); if (colon > 0) { value = value.substring(0, colon); } } String uriDomain = uri.getHost(); if (uriDomain.equals(value)) { this.domain = value; } else { if (!matchDomain(uriDomain, value)) { throw new IllegalArgumentException( "Trying to set foreign cookie"); } } this.domain = value; } else if (name.equalsIgnoreCase("path")) { this.path = value; } else if (name.equalsIgnoreCase("expires")) { try { this.expires = new SimpleDateFormat("E, dd-MMM-yyyy k:m:s 'GMT'", Locale.US) .parse(value); } catch (java.text.ParseException e) { try { this.expires = new SimpleDateFormat("E, dd MMM yyyy k:m:s 'GMT'", Locale.US) .parse(value); } catch (java.text.ParseException e2) { throw new IllegalArgumentException( "Bad date format in header: " + value); } } } } } /** * Returns true if the full domain's final segments match * the tail domain. */ private boolean matchDomain(String testDomain, String tailDomain) { // Simple check if (!testDomain.endsWith(tailDomain)) { return false; } // Exact match if (testDomain.length() == tailDomain.length()) { return true; } // Verify that a segment match happened, not a partial match if (tailDomain.charAt(0) == '.') { return true; } return testDomain.charAt(testDomain.length() - tailDomain.length() - 1) == '.'; } /** * Returns {@code true} if the cookie has expired. */ public boolean hasExpired() { if (expires == null) { return false; } Date now = new Date(); return now.after(expires); } /** * Returns {@code true} if the cookie hasn't expired, the * URI domain matches, and the URI path starts with the * cookie path. * * @param uri URI to check against * @return true if match, false otherwise */ public boolean matches(URI uri) { if (hasExpired()) { return false; } String uriDomain = uri.getHost(); if (!matchDomain(uriDomain, domain)) { return false; } String path = uri.getPath(); if (path == null) { path = "/"; } return path.startsWith(this.path); } /** * Returns the actual name/value pair that should be sent in a * Cookie request header. */ String getHeaderValue() { StringBuilder result = new StringBuilder(name); result.append("="); result.append(value); return result.toString(); } /** * Returns {@code true} if the target object is a GoogleCookie that * has the same name as this cookie and that matches the same target * domain and path as this cookie. Cookie expiration and value * <b>are not</b> taken into account when considering equivalence. */ @Override public boolean equals(Object o) { if (o == null || !(o instanceof GoogleCookie)) { return false; } GoogleCookie cookie = (GoogleCookie) o; if (!name.equals(cookie.name) || !domain.equals(cookie.domain)) { return false; } if (path == null) { if (cookie.path != null) { return false; } return true; } return path.equals(cookie.path); } @Override public int hashCode() { int result = 17; result = 37 * result + name.hashCode(); result = 37 * result + domain.hashCode(); result = 37 * result + (path != null ? path.hashCode() : 0); return result; } @Override public String toString() { StringBuilder buf = new StringBuilder("GoogleCookie("); buf.append(domain); buf.append(path); buf.append("["); buf.append(name); buf.append("]"); buf.append(")"); return buf.toString(); } } /** * Implements a scoped cookie handling mechanism for GData services. This * handler is a singleton class that is registered to globally listen and * set cookies using {@link CookieHandler#setDefault(CookieHandler)}. It * will only process HTTP headers and responses associated with GData * services, and will delegate the processing of any other cookie headers * to the previously registered {@link CookieHandler} (if any). When * a Set-Cookie response header is found, it will save any associated * cookie in the cookie cache associated with the {@link GoogleService} * issuing the request. Similarly, when a {@link GoogleService} issues * a request, it will check its cookie cache and add any necessary * Cookie header. */ private static class GoogleCookieHandler extends CookieHandler { private CookieHandler nextHandler; // This is a singleton, only constructed once at class load time. private GoogleCookieHandler() { // Install the global GoogleCookieHandler instance, chaining to any // existing CookieHandler if (!Boolean.getBoolean(DISABLE_COOKIE_HANDLER_PROPERTY)) { logger.fine("Installing GoogleCookieHandler"); nextHandler = CookieHandler.getDefault(); CookieHandler.setDefault(this); } } @Override public Map<String, List<String>> get( URI uri, Map<String, List<String>> requestHeaders) throws IOException { Map<String, List<String>> cookieHeaders = new HashMap<String, List<String>>(); // Only service requests initiated by GData services with cookie // handling enabled. GoogleService service = activeService.get(); if (service != null && service.handlesCookies()) { // Get the list of matching cookies and accumulate a buffer // containing the cookie name/value pairs. Set<GoogleCookie> cookies = service.getCookies(); StringBuilder cookieBuf = new StringBuilder(); for (GoogleCookie cookie : cookies) { if (cookie.matches(uri)) { if (cookieBuf.length() > 0) { cookieBuf.append("; "); } cookieBuf.append(cookie.getHeaderValue()); logger.fine("Setting cookie: " + cookie); } } // If any matching cookies were found, update the request headers. // Note: it's assumed here that nothing else is setting the Cookie // header, which seems reasonable; otherwise we'd have to parse the // existing value and add/merge managed cookies. if (cookieBuf.length() != 0) { cookieHeaders.put("Cookie", Collections.singletonList(cookieBuf.toString())); } } else { if (nextHandler != null) { return nextHandler.get(uri, requestHeaders); } } return Collections.unmodifiableMap(cookieHeaders); } @Override public void put(URI uri, Map<String, List<String>> responseHeaders) throws IOException { // Only service requests initiated by GData services with cookie // handling enabled. GoogleService service = activeService.get(); if (service != null && service.handlesCookies()) { List<String> setCookieList = responseHeaders.get("Set-Cookie"); if (setCookieList != null && setCookieList.size() > 0) { for (String cookieValue : setCookieList) { GoogleCookie cookie = new GoogleCookie(uri, cookieValue); service.addCookie(cookie); logger.fine("Adding cookie:" + cookie); } } } else { if (nextHandler != null) { nextHandler.get(uri, responseHeaders); } } } } /** * Holds the GoogleService that is executing requests for the current * execution thread. */ private static final ThreadLocal<GoogleService> activeService = new ThreadLocal<GoogleService>(); /** * The global CookieHandler instance for GData services. */ @SuppressWarnings("unused") // instance init installs global hooks. private static final GoogleCookieHandler googleCookieHandler; static { if (!Boolean.getBoolean(DISABLE_COOKIE_HANDLER_PROPERTY)) { googleCookieHandler = new GoogleCookieHandler(); } else { googleCookieHandler = null; } } /** * Constructs a new GoogleGDataRequest instance of the specified * RequestType, targeting the specified URL with the specified * authentication token. * * @param type type of GDataRequest * @param requestUrl request target URL * @param authToken token authenticating request to server * @param headerMap map containing additional headers to set * @param privateHeaderMap map containing additional headers to set * that should not be logged (eg. authentication info) * @throws IOException on error initializing service connection */ protected GoogleGDataRequest(RequestType type, URL requestUrl, ContentType contentType, HttpAuthToken authToken, Map<String, String> headerMap, Map<String, String> privateHeaderMap, HttpUrlConnectionSource connectionSource) throws IOException { super(type, requestUrl, contentType, authToken, headerMap, privateHeaderMap, connectionSource); } /** * The GoogleService instance that constructed the request. */ private GoogleService service; /** * Returns the {@link Version} that will be used to execute the request on the * target service or {@code null} if the service is not versioned. * * @return version sent with the request or {@code null}. */ public Version getRequestVersion() { // Always get the request version from the associated service, never from // the version registry. There are aspects of request handling that happen // outside the scope of Service.begin/endVersionScope. return service.getProtocolVersion(); } /** * The version associated with the response. */ private Version responseVersion; /** * Returns the {@link Version} that was used by the target service to execute * the request or {@code null} if the service is not versioned. * * @return version returned with the response or {@code null}. */ public Version getResponseVersion() { if (!executed) { throw new IllegalStateException("Request has not been executed"); } return responseVersion; } /** * Sets the GoogleService associated with the request. */ public void setService(GoogleService service) { this.service = service; // This undocumented system property can be used to disable version headers. // It exists only to support some unit test scenarios for query-parameter // version configuration and back-compat defaulting when no version // information is sent by the client library. if (Boolean.getBoolean("GoogleGDataRequest.disableVersionHeader")) { return; } // Look up the active version for the type of service initiating the // request, and set the version header if found. try { Version requestVersion = service.getProtocolVersion(); if (requestVersion != null) { setHeader(GDataProtocol.Header.VERSION, requestVersion.getVersionString()); } } catch (IllegalStateException iae) { // Service may not be versioned. } } @Override public void execute() throws IOException, ServiceException { // Set the current active service, so cookie handling will be enabled. try { activeService.set(service); // Propagate redirects to our layer to add URL specific data to the // request (like URL dependant authentication headers) httpConn.setInstanceFollowRedirects(false); super.execute(); // Capture the version used to process the request String versionHeader = httpConn.getHeaderField(GDataProtocol.Header.VERSION); if (versionHeader != null) { GoogleService service = activeService.get(); if (service != null) { responseVersion = new Version(service.getClass(), versionHeader); } } } finally { activeService.set(null); } } @Override protected void handleErrorResponse() throws IOException, ServiceException { try { switch (httpConn.getResponseCode()) { case HttpURLConnection.HTTP_MOVED_PERM: case HttpURLConnection.HTTP_MOVED_TEMP: throw new RedirectRequiredException(httpConn); } super.handleErrorResponse(); } catch (AuthenticationException e) { // Throw a more specific exception for session expiration. String msg = e.getMessage(); if (msg != null && msg.contains("Token expired")) { SessionExpiredException se = new SessionExpiredException(e.getMessage()); se.setResponse(e.getResponseContentType(), e.getResponseBody()); throw se; } throw e; } } }