com.tinspx.util.net.Response.java Source code

Java tutorial

Introduction

Here is the source code for com.tinspx.util.net.Response.java

Source

/* Copyright (C) 2013-2014 Ian Teune <ian.teune@gmail.com>
 * 
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
 * "Software"), to deal in the Software without restriction, including
 * without limitation the rights to use, copy, modify, merge, publish,
 * distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to
 * the following conditions:
 * 
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
package com.tinspx.util.net;

import com.google.common.base.MoreObjects;
import com.google.common.base.Objects;
import com.google.common.base.Optional;
import static com.google.common.base.Preconditions.*;
import com.google.common.base.Strings;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Multimaps;
import com.google.common.io.ByteSource;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.tinspx.util.base.BasicError;
import com.tinspx.util.base.Cancellable;
import com.tinspx.util.base.Errors;
import com.tinspx.util.collect.CollectUtils;
import com.tinspx.util.io.ChannelSource;
import com.tinspx.util.io.ExtendedCharSequence;
import com.tinspx.util.io.StringUtils;
import com.tinspx.util.io.charset.DefaultHeaderCharDet;
import com.tinspx.util.io.charset.HeaderCharDet;
import java.io.IOException;
import java.net.CookieHandler;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
import lombok.Getter;
import lombok.NonNull;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;

/**
 * {@code Response} <i>describes</i> the result of accessing a resource using a
 * {@link Request}. A {@link RequestContext} is responsible executing the
 * {@code Request} and generating the {@code Response}.
 * <p>
 * A {@code Response} can only be created from a {@code Request} using one of
 * the {@link Request#response(URI, int, String) response(...)} methods. A
 * {@code Response} is always linked to the {@code Request} from which it was
 * created. This request can be accessed through {@link #request()}.
 * {@link #id()}, {@link #tag()}, {@link #properties()}, {@link #errors()}, and
 * the {@link #isCancelled() cancel} state are all shared with this
 * {@code Request}.
 * 
 * @author Ian
 */
@Accessors(fluent = true)
@Slf4j
@NotThreadSafe
public class Response implements Cancellable, BasicError.Listener {

    @Getter
    final Request request;
    /**
     * The URI of this Response. Typically will be the same as the URI of the
     * Request (but could be different if a {@link RequestContext} hides
     * redirects).
     */
    @Getter
    final URI uri;
    /**
     * The response code of this Response. Only valid on protocols that have a
     * response code. Typically will be -1 if an error occurs or the protocol
     * does not have a response code.
     */
    @Getter
    final int code;
    /**
     * The response message of the response. Only valid on protocols that have a
     * response message. Typically will be the empty String if the protocol does
     * not have a response message and <i>may</i> have an error message if an
     * error occurred.
     */
    @Getter
    @Nonnull
    final String message;
    /**
     * The headers of this {@code Response}.
     */
    @Nullable
    Headers headers;

    @Nullable
    Supplier<? extends ExtendedCharSequence> asCharSequence;
    @Nullable
    Supplier<? extends ChannelSource> asByteSource;
    @Nullable
    Supplier<? extends Object> asClass;

    /**
     * the {@code Charset} used to decode the response, null if not known
     */
    @Nullable
    Charset decodeCharset;
    /**
     * {@code ImmutableList} of redirects that have led up to this
     * {@code Response}. May be empty if there were no redirects (never null) or
     * if the {@link RequestContext} hides redirects.
     */
    @Nonnull
    @Getter
    final ImmutableList<Response> redirects;
    /**
     * The time at which this {@code Response} was constructed obtained from
     * {@link System#currentTimeMillis()}. This can be used to determine how
     * old the response is (for cache control for example).
     */
    @Getter
    final long startMillis = System.currentTimeMillis();
    /**
     * The total time in milliseconds that this {@code Response} took to
     * complete. This value is set in {@link #onContentComplete()} and is
     * therefore not set until this response has completed. Retrieving this
     * value before the response is complete will return 0.
     */
    @Getter
    long elapsedMillis;
    /**
     * Returns the query parameters from the query segment of {@link #uri()}
     * as a {@code ListMultimap}.
     */
    @Getter
    final ListMultimap<String, String> queryParams;

    /**
     * Creates a new {@code Response}.
     * 
     * @param request the Request that initiated this response
     * @param uri  the URI of this Response
     * @param queryParams the query parameters of {@code uri} if already
     * computed; if null, the params will be extracted from uri.
     * @param code the response code, if available
     * @param message the response message, if available. null will be
     * converted to the empty String.
     * @param redirects the list of redirects that have led up to this Response.
     * if null, {@link #redirects()} will return an empty List. A defensive copy
     * of this List is created, so the caller may continue to use and edit this
     * List after the constructor is called.
     */
    Response(Request request, URI uri, @Nullable ListMultimap<String, String> queryParams, int code,
            @Nullable String message, @Nullable Iterable<Response> redirects) {
        this.request = checkNotNull(request);
        this.uri = checkNotNull(uri);
        if (queryParams != null) {
            this.queryParams = ImmutableListMultimap.copyOf(queryParams);
        } else {
            this.queryParams = Multimaps.unmodifiableListMultimap(UriUtil.parseQuery(uri));
        }
        this.code = code;
        this.message = Strings.nullToEmpty(message);
        this.redirects = redirects != null ? ImmutableList.copyOf(redirects) : ImmutableList.<Response>of();
        checkArgument(CollectUtils.allNonNull(this.redirects));
    }

    /**
     * Returns the {@link Request#properties() properties} {@code Map} shared
     * with the {@link #request() request}.
     */
    public Map<Object, Object> properties() {
        return request.properties();
    }

    /**
     * Returns the tag associated with the Request of this Response.
     * 
     * @return non null tag from the Request
     */
    public String tag() {
        return request.tag();
    }

    /**
     * Returns the {@code id} of {@link #request()}.
     * 
     * @see Request#id()
     */
    public long id() {
        return request.id();
    }

    @Override
    public boolean cancel() {
        return request.cancel();
    }

    @Override
    public boolean isCancelled() {
        return request.isCancelled();
    }

    /**
     * Determine if this response or request has encountered any errors.
     */
    public boolean hasErrors() {
        return request.hasErrors();
    }

    /**
     * Returns all errors encountered by the request or response.
     */
    public List<BasicError> errors() {
        return request.errors();
    }

    @Override
    public void onError(BasicError error) {
        if (error == null) {
            error = Errors.create(this);
        }
        request.onError(error);
    }

    /**
     * Returns the headers of this {@code Response}.
     */
    public Headers headers() {
        if (headers == null) {
            headers = new Headers();
        }
        return headers;
    }

    public String header(@Nullable String name) {
        return headers != null ? headers.last(name) : "";
    }

    public List<String> headers(@Nullable String name) {
        return headers != null ? headers.get(name) : Collections.<String>emptyList();
    }

    public boolean hasHeader(@Nullable String name) {
        return headers != null ? headers.contains(name) : false;
    }

    /**
     * Convenience method equivalent to {@code properties().put(key, value)}.
     */
    public Response put(Object key, Object value) {
        properties().put(key, value);
        return this;
    }

    /**
     * Convenience method equivalent to {@code properties().get(key)}.
     */
    public Object get(Object key) {
        return properties().get(key);
    }

    /**
     * Determines if any headers have been added to this {@code Response}
     * through {@link #headers()}.
     */
    public boolean hasHeaders() {
        return headers != null && !headers.isEmpty();
    }

    /**
     * Returns the {@code Charset} <i>actually</i> used to decode the response,
     * which may differ from {@link Request#decodeCharset()} if a different
     * {@code Charset} was detected. May be absent if no decoding occurred.
     * The agent that decoded the response must set the {@code Charset} using
     * {@link #decodeCharset()}.
     */
    public Optional<Charset> decodeCharset() {
        return Optional.fromNullable(decodeCharset);
    }

    /**
     * Sets the {@code Charset} that should be used or was used to decode the
     * {@code Response}. If the response has not been decoded, this
     * {@code Charset} should be used to decode the response. If the
     * response has already been decoded {@link #hasCharSequence()} is true},
     * this indicates the {@code Charset} actually used to decode the response.
     * 
     * @param decodeCharset the {@code Charset} to use in decoding
     * @return this Response instance
     */
    public Response decodeCharset(@NonNull Charset decodeCharset) {
        this.decodeCharset = decodeCharset;
        return this;
    }

    /**
     * Determines if a decode {@code Charset} set with
     * {@link #decodeCharset(Charset)}. If false, then {@link #decodeCharset()}
     * will be absent.
     */
    public boolean hasDecodeCharset() {
        return decodeCharset != null;
    }

    /**
     * Sends the {@link RequestCallback#onContentStart(Response) onContentStart}
     * event to all callbacks registered in {@link #request()}.
     * 
     * @param isLastResponse true if there are no additional redirects that
     * will occur after this response. false if there is a pending redirect
     * and this response will not be the last.
     * @return true if this Response is canceled
     */
    public boolean onContentStart(boolean isLastResponse) {
        if (request.hasCallbacks()) {
            for (RequestCallback rc : request.callbacks()) {
                if (isLastResponse || !rc.ignoreRedirects()) {
                    rc.onContentStart(this);
                }
            }
        }
        return request.isCancelled();
    }

    /**
     * Sends the
     * {@link RequestCallback#onContent(Response, ByteBuffer) onContent} event
     * to all callbacks registered in {@link #request()}. {@code content}
     * position and limit will not be unaltered when this method completes.
     * 
     * @param isLastResponse true if there are no additional redirects that
     * will occur after this response. false if there is a pending redirect
     * and this response will not be the last.
     * @return true if this Response is canceled
     */
    public boolean onContent(@NonNull ByteBuffer content, boolean isLastResponse) {
        if (request.hasCallbacks()) {
            final int pos = content.position(), limit = content.limit();
            for (RequestCallback rc : request.callbacks()) {
                if (isLastResponse || !rc.ignoreRedirects()) {
                    rc.onContent(this, content);
                    content.limit(limit).position(pos);
                }
            }
        }
        return request.isCancelled();
    }

    private void setElapsedMillis() {
        if (elapsedMillis == 0) {
            elapsedMillis = System.currentTimeMillis() - startMillis;
        }
    }

    /**
     * Sends the
     * {@link RequestCallback#onContentComplete(Response) onContentComplete} event
     * to all callbacks registered in {@link #request()}. Also sets the
     * {@link #elapsedMillis() elapsed milliseconds}.
     * 
     * @param isLastResponse true if there are no additional redirects that
     * will occur after this response. false if there is a pending redirect
     * and this response will not be the last.
     * @return true if this Response is canceled
     */
    public boolean onContentComplete(boolean isLastResponse) {
        setElapsedMillis();
        if (request.hasCallbacks()) {
            for (RequestCallback rc : request.callbacks()) {
                if (isLastResponse || !rc.ignoreRedirects()) {
                    rc.onContentComplete(this);
                }
            }
        }
        return request.isCancelled();
    }

    /**
     * Sends the
     * {@link RequestCallback#onFailure(Response, Throwable) onFailure} event to
     * all callbacks registered in {@link #request()}
     * 
     * @param propagate true if any uncaught exception thrown by a callback
     * should be propagated
     */
    public void onFailure(Throwable exception, boolean propagate) {
        setElapsedMillis();
        if (request.hasCallbacks()) {
            Throwable error = null;
            for (RequestCallback rc : request.callbacks()) {
                try {
                    rc.onFailure(this, exception);
                } catch (Throwable t) {
                    log.error("onFailure: {}", this, t);
                    error = t;
                }
            }
            if (error != null && propagate) {
                throw Throwables.propagate(error);
            }
        }
    }

    /**
     * Sends the {@link RequestCallback#onSuccess(Response) onSuccess} event to
     * all callbacks registered in {@link #request()}.
     * 
     * @param propagate true if any uncaught exception thrown by a callback
     * should be propagated
     */
    public void onSuccess(boolean propagate) {
        setElapsedMillis();
        if (request.hasCallbacks()) {
            Throwable error = null;
            for (RequestCallback rc : request.callbacks()) {
                try {
                    rc.onSuccess(this);
                } catch (Throwable t) {
                    log.error("onSuccess: {}", this, t);
                    error = t;
                }
            }
            if (error != null && propagate) {
                throw Throwables.propagate(error);
            }
        }
    }

    /**
     * Sends the {@link RequestCallback#onCancel(Response) onCancel}
     * event to all callbacks registered in {@link #request()}.
     * 
     * @param propagate true if any uncaught exception thrown by a callback
     * should be propagated
     */
    public void onCancel(boolean propagate) {
        setElapsedMillis();
        if (request.hasCallbacks()) {
            Throwable error = null;
            for (RequestCallback rc : request.callbacks()) {
                try {
                    rc.onCancel(this);
                } catch (Throwable t) {
                    log.error("onCancel: {}", this, t);
                    error = t;
                }
            }
            if (error != null && propagate) {
                throw Throwables.propagate(error);
            }
        }
    }

    /**
     * Sets the decoded content that will be made available in
     * {@link #asCharSequence()}.
     */
    public Response asCharSequence(@NonNull CharSequence response) {
        return asCharSequence(Suppliers.ofInstance(response));
    }

    /**
     * Sets the decoded content that will be made available in
     * {@link #asCharSequence()}.
     */
    public Response asCharSequence(@NonNull Supplier<? extends CharSequence> response) {
        this.asCharSequence = Suppliers.compose(StringUtils.asExtendedCharSequence(), response);
        return this;
    }

    /**
     * Sets the response content that will be made available in
     * {@link #asByteBuffer()}.
     */
    public Response asByteSource(@NonNull ByteSource response) {
        return asByteSource(Suppliers.ofInstance(ChannelSource.of(response)));
    }

    /**
     * Sets the response content that will be made available in
     * {@link #asByteBuffer()}.
     */
    public Response asByteSource(@NonNull Supplier<? extends ChannelSource> response) {
        this.asByteSource = response;
        return this;
    }

    /**
     * Sets the response converted to an instance of {@link Request#asClass()}
     * that will be made available in {@link #asClass(Class)}.
     */
    public Response asClass(@NonNull Supplier<? extends Object> response) {
        this.asClass = response;
        return this;
    }

    /**
     * Same as {@link #asCharSequence() asCharSequence().toString()}.
     */
    public String asString() {
        return asCharSequence().toString();
    }

    /**
     * Same as {@link #asCharSequence() asCharSequence().toCharBuffer()}.
     */
    public CharBuffer asCharBuffer() {
        return asCharSequence().toCharBuffer();
    }

    /**
     * Returns the decoded content of this {@code Response} as an
     * {@code ExtendedCharSequence}. Ensure that the {@link #request() request}
     * was configured to produce a {@code CharSequence} through
     * {@link Request#asCharSequence(boolean)} and that
     * {@link #hasCharSequence()} is true before invoking this method.
     *
     * @return the decoded {@code Response} content as a CharSequence
     * @throws IllegalStateException if {@link #hasCharSequence()} is false
     */
    public ExtendedCharSequence asCharSequence() {
        if (asCharSequence == null) {
            throw new IllegalStateException();
        }
        return asCharSequence.get();
    }

    /**
     * Returns the content of this {@code Response} as an
     * {@link ByteSource}. Ensure that the {@link #request() request}
     * was configured to produce a {@code ByteBuffer} through
     * {@link Request#asByteSource(boolean)} and that
     * {@link #hasByteSource()} is true before invoking this method.
     *
     * @return the {@code Response} content as a {@code ChannelSource}
     * @throws IllegalStateException if {@link #hasByteSource()} is false
     */
    public ChannelSource asByteSource() {
        if (asByteSource == null) {
            throw new IllegalStateException();
        }
        return asByteSource.get();
    }

    /**
     * Returns the content of this {@code Response} as an instance of
     * {@code cls}. Ensure that the {@link #request() request} was configured to
     * produce an instance of {@code cls} through {@link Request#asClass(Class)}
     * and that {@link #hasClass(Class) hasClass(cls)} is true before invoking
     * this method.
     *
     * @return the {@code Response} content as an instance of {@code cls}
     * @throws IllegalStateException if {@link #hasClass(Class) hasClass(cls)}
     * is false
     * @throws ClassCastException if the response is not an instance of
     * {@code T}
     */
    public <T> T asClass(@NonNull Class<T> cls) {
        if (asClass == null) {
            throw new IllegalStateException();
        }
        return cls.cast(asClass.get());
    }

    public boolean hasClass(@NonNull Class<?> cls) {
        return asClass != null && cls.isInstance(asClass.get());
    }

    public boolean hasByteSource() {
        return asByteSource != null;
    }

    public boolean hasCharSequence() {
        return asCharSequence != null;
    }

    /**
     * Determines if this {@code Response} was caused by a redirect. If true,
     * {@link #redirects()} will be non-empty. Note that if the
     * {@link RequestContext} hides redirects, this method may be inaccurate. If
     * the {@code RequestContext} hides redirects, the only way to detect
     * redirects is to compare the response {@link #uri() URI} to the request
     * {@link Request#uri() URI}.
     */
    public boolean isRedirected() {
        return !redirects.isEmpty();
    }

    /**
     * Releases all of the {@code Response} content. This clears all of content
     * that would be available from {@link #asCharSequence()},
     * {@link #asByteSource()}, or {@link #asClass(Class)} so that it be
     * reclaimed by the gc. This allows the memory of a large response to be
     * freed while keeping the {@code Response} meta-data such as the list of
     * redirects and headers.
     * <p>
     * This releases <i>all</i> redirects available from {@link #redirects()} as
     * well.
     * <p>
     * The {@link #properties() properties} of this Response are <i>not</i>
     * cleared. Make sure to remove any properties with large memory footprints
     * a well. The callbacks registered with the {@code Request} of this
     * {@code Response} are <i>not</i> cleared either. These callbacks may be be
     * keeping references to large amounts of memory; therefore, consider
     * clearing the callbacks as well.
     *
     * @return this Response
     */
    public Response release() {
        asCharSequence = null;
        asByteSource = null;
        asClass = null;
        if (!redirects.isEmpty()) {
            redirects.get(redirects.size() - 1).release();
        }
        return this;
    }

    /**
     * Returns the original {@code Request} used to initiate this
     * {@code Response}. This will differ from {@link #request()} if there are
     * any redirects. If there are redirects, the {@code Request} from the first
     * redirect is returned.
     *
     * @return the original {@code Request} that initiated this {@code Response}
     * (ie the Request from the first redirect)
     */
    public Request originalRequest() {
        return redirects.isEmpty() ? request : redirects.get(0).request();
    }

    /**
     * true if the Content-Type header contains the String "xml" or if
     * {@link Request#isXml()} is true.
     * 
     * @return true if the response content is xml
     */
    public boolean isXml() {
        if (headers().contentType().toLowerCase(Locale.US).contains("xml")) {
            return true;
        }
        return request.isXml();
    }

    /**
     * true if the Content-Type header contains the String "html" or if
     * {@link Request#isHtml()} is true.
     * 
     * @return true if the response content is html
     */
    public boolean isHtml() {
        if (headers().contentType().toLowerCase(Locale.US).contains("html")) {
            return true;
        }
        return request.isHtml();
    }

    /**
     * true if the Content-Type header contains the String "text", or if
     * {@link #isHtml()} or {@link #isXml()} is true.
     * 
     * @return if the response content is text
     */
    public boolean isText() {
        if (headers().contentType().toLowerCase(Locale.US).contains("text")) {
            return true;
        }
        return isHtml() || isXml();
    }

    /**
     * Determines if this response requires a redirect. Calls
     * {@link RedirectHandler#shouldRedirect(Response)} on the
     * {@code RedirectHandler} of the {@link #request() request}.
     *
     * @return if this Response requires a redirect
     */
    public boolean shouldRedirect() {
        return request.redirectHandler().shouldRedirect(this);
    }

    /**
     * Returns a new {@code Request} identical to {@link #request()} that can be
     * used as a redirect. {@link Request#isRedirect()} will be true and
     * {@link Request#cause()} will be set to this {@code Response} for the
     * returned {@code Request}. The returned {@code Request} shares the
     * {@link #properties() properties}, {@link Request#callbacks() callbacks},
     * and {@link #isCancelled() cancel} state of this {@code Response}.
     * <p>
     * The returned {@code Request} is initially identical to
     * {@link #request()}. Typically, changes should be made to the returned
     * {@code Request}, such as setting the {@link Request#uri(URI) URI}, before
     * submitting the redirect. This is usually done by using the
     * {@link Request#redirectHandler() RedirectHandler} of {@link #request()}.
     * <p>
     * Note that this method <i>always</i> creates the redirect request.
     * {@link #shouldRedirect()} should be called beforehand to check if a
     * redirect is necessary in first place. The {@link RedirectHandler} of
     * {@link #request()} must be applied to the returned {@link Request} as
     * well (which could also cancel the redirect).
     * 
     * @return a new redirect {@code Request} initially identical to
     * {@link #request()}
     */
    public Request redirect() throws IOException {
        return request.redirect(this);
    }

    /**
     * Sets the CookieHandler of the Request to the CookieHandler of this
     * Response.
     */
    private Request setupRequest(Request request) {
        if (this.request.hasCookieHandler()) {
            request.cookieHandler(this.request.cookieHandler);
        }
        return request;
    }

    /**
     * Creates a new {@code Request} with the specified {@code URI} that shares
     * the same {@link CookieHandler} as this {@code Response}.
     * 
     * @param uri the URI of the new {@code Request}
     * @return the new {@code Request}
     */
    public Request newRequest(URI uri) {
        return setupRequest(new Request(uri));
    }

    /**
     * Creates a new {@code Request} with the specified {@code URI} that shares
     * the same {@link CookieHandler} as this {@code Response}.
     * 
     * @param uri the URI of the new {@code Request}
     * @return the new {@code Request}
     */
    public Request newRequest(String uri) throws URISyntaxException {
        return setupRequest(new Request(uri));
    }

    /**
     * Creates a new {@code Request} that shares the same {@code URI} and
     * {@link CookieHandler} as this {@code Response}.
     *
     * @return the new {@code Request}
     */
    public Request newRequest() {
        return setupRequest(new Request(uri()));
    }

    /**
     * Returns a basic string representation of this {@code Response} that
     * includes the response {@link #code() code}, {@link #message() message},
     * and {@link #uri() uri}. For more detailed information, use
     * {@link #toDetailString()}.
     */
    @Override
    public String toString() {
        return String.format("[%d] [Response] %s%s %s %d %s", id(),
                Strings.isNullOrEmpty(tag()) ? "" : "[" + tag() + "] ", request.method(), uri(), code(), message());
    }

    /**
     * Returns a String containing <i>all</i> information of this
     * {@link Response}. This String will be very long and span multiple lines.
     */
    public String toDetailString() {
        StringBuilder s = new StringBuilder(1024);
        s.append(toString());
        s.append("\nrequest: ").append(request);
        s.append("\nstart: ").append(startMillis);
        s.append(" elapsed: ").append(elapsedMillis);

        s.append("\ndecodeCharset: ").append(decodeCharset);
        s.append("\nCharSequence: ").append(hasCharSequence());
        s.append(" ByteBuffer: ").append(hasByteSource());
        s.append(" Class: ").append(hasClass(Object.class));

        s.append(" cancel: ").append(isCancelled());
        s.append("\ncookieHandler: ").append(request.cookieHandler);
        s.append("\nproperties: ").append(properties());
        s.append("\nheaders: ");
        if (hasHeaders()) {
            for (Map.Entry<String, String> e : headers.asMultimap().entries()) {
                s.append("\n  ").append(e.getKey()).append(": ").append(e.getValue());
            }
        } else {
            s.append("none");
        }

        s.append("\nredirects: ");
        if (isRedirected()) {
            for (Response r : redirects) {
                s.append("\n  ").append(r);
            }
        } else {
            s.append("none");
        }

        s.append("\nerrors: ");
        if (hasErrors()) {
            for (BasicError t : errors()) {
                s.append("\n  ").append(t);
            }
        } else {
            s.append("none");
        }
        return s.toString();
    }

    private static final class CharsetRef {
        private Charset value;

        public boolean isPresent() {
            return value != null;
        }

        public void set(Charset value) {
            this.value = checkNotNull(value);
        }

        public Charset get() {
            return value;
        }
    }

    /**
     * Attempts to detect the {@code Charset} from the
     * {@link #headers() headers} using {@link DefaultHeaderCharDet} in addition
     * to any provided detectors.
     */
    public Optional<Charset> detectDecodeCharset(HeaderCharDet... headerCharDets) {
        return detectDecodeCharset(Arrays.asList(headerCharDets));
    }

    /**
     * Attempts to detect the {@code Charset} from the
     * {@link #headers() headers} using {@link DefaultHeaderCharDet} in addition
     * to any provided detectors.
     */
    public Optional<Charset> detectDecodeCharset(Iterable<? extends HeaderCharDet> headerCharDets) {
        if (!hasHeaders()) {
            return Optional.absent();
        }
        final CharsetRef charset = new CharsetRef();

        final FutureCallback<String> fc = new FutureCallback<String>() {
            @Override
            public void onSuccess(String result) {
                if (result != null && !charset.isPresent()) {
                    Charset cs = request.charsetFunction().apply(result);
                    if (cs != null) {
                        charset.set(cs);
                    } else {
                        request.onError(Errors.create(Response.this, "unsupported Charset (%s)", result));
                    }
                } else {
                    request.onError(Errors.create(Response.this, "ignoring Charset (%s)", result));
                }
            }

            @Override
            public void onFailure(Throwable t) {
                //no longer causes an error if charset detection fails
                //                ErrorIOException.send(request, t);
            }
        };

        if (request.hasCharsetDetectors()) {
            headerCharDets = Iterables.concat(headerCharDets,
                    Iterables.filter(request.charsetDetectors(), HeaderCharDet.class));
        }
        boolean useDefaultHeaderCharDet = true;
        for (HeaderCharDet hcd : headerCharDets) {
            Futures.addCallback(hcd.charsetFuture(), fc);
            if (hcd instanceof DefaultHeaderCharDet) {
                useDefaultHeaderCharDet = false;
            }
        }
        if (useDefaultHeaderCharDet) {
            HeaderCharDet hcd = new DefaultHeaderCharDet();
            Futures.addCallback(hcd.charsetFuture(), fc);
            headerCharDets = CollectUtils.concat(headerCharDets, hcd);
        }

        for (HeaderCharDet hcd : headerCharDets) {
            if (charset.isPresent()) {
                break;
            }
            hcd.onHeaders(headers.asMultimap());
        }
        for (HeaderCharDet cdet : headerCharDets) {
            cdet.onHeadersComplete();
        }

        return Optional.fromNullable(charset.get());
    }

    private static boolean p(String m, Object... args) {
        //        System.out.printf("response: " + m, args);
        //        System.out.println();
        return true;
    }

    boolean isEqual(@Nullable Response r) throws IOException {
        return r != null && p("r") && UriUtil.uriEqual(uri(), r.uri()) //tests uri and params
                && p("uri") && byteSourceEquals(r) && p("byte") && charSequenceEquals(r) && p("char")
                && classEquals(r) && p("class") && p("%d, %d", code, r.code) && code == r.code && p("code")
                && Objects.equal(decodeCharset, r.decodeCharset) && p("charset")
                && p("headers: %s, %s", headers, r.headers)
                && Objects.equal(MoreObjects.firstNonNull(headers, new Headers()),
                        MoreObjects.firstNonNull(r.headers, new Headers()))
                && p("headers") && Objects.equal(message, r.message) && p("response") && request.isEqual(r.request);
    }

    @SuppressWarnings("null")
    boolean byteSourceEquals(Response r) throws IOException {
        if (asByteSource == null) {
            return r.asByteSource == null;
        }
        if (r.asByteSource == null) {
            return false;
        }
        return asByteSource.get().contentEquals(r.asByteSource.get());
    }

    @SuppressWarnings("null")
    boolean charSequenceEquals(Response r) {
        //        p("this: %s, other: %s", asCharSequence.get().toCharArray().length, r.asCharSequence.get().toCharArray().length);
        if (asCharSequence == null) {
            return r.asCharSequence == null;
        }
        if (r.asCharSequence == null) {
            return false;
        }
        return Arrays.equals(asCharSequence.get().toCharArray(), r.asCharSequence.get().toCharArray());
    }

    @SuppressWarnings("null")
    boolean classEquals(Response r) {
        if (asClass == null) {
            return r.asClass == null;
        }
        if (r.asClass == null) {
            return false;
        }
        return Objects.equal(asClass.get(), r.asClass.get());
    }
}