com.spotify.google.cloud.pubsub.client.Pubsub.java Source code

Java tutorial

Introduction

Here is the source code for com.spotify.google.cloud.pubsub.client.Pubsub.java

Source

/*-
 * -\-\-
 * async-google-pubsub-client
 * --
 * Copyright (C) 2016 - 2017 Spotify AB
 * --
 * 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.
 * -/-/-
 */

/*
 * Copyright (c) 2011-2015 Spotify AB
 *
 * 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.spotify.google.cloud.pubsub.client;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.net.HttpHeaders.CONNECTION;
import static com.google.common.net.HttpHeaders.CONTENT_LENGTH;
import static com.google.common.util.concurrent.MoreExecutors.getExitingExecutorService;
import static com.google.common.util.concurrent.MoreExecutors.getExitingScheduledExecutorService;
import static com.spotify.google.cloud.pubsub.client.Message.isEncoded;
import static com.spotify.google.cloud.pubsub.client.Pubsub.ResponseReader.VOID;
import static com.spotify.google.cloud.pubsub.client.Subscription.canonicalSubscription;
import static com.spotify.google.cloud.pubsub.client.Subscription.validateCanonicalSubscription;
import static com.spotify.google.cloud.pubsub.client.Topic.canonicalTopic;
import static com.spotify.google.cloud.pubsub.client.Topic.validateCanonicalTopic;
import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_ENCODING;
import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
import static io.netty.handler.codec.http.HttpHeaderValues.GZIP;
import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE;
import static io.netty.handler.codec.http.HttpMethod.POST;
import static java.util.Arrays.asList;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.asynchttpclient.Dsl.asyncHttpClient;
import static org.asynchttpclient.Dsl.config;

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.util.Utils;
import com.google.api.client.http.ByteArrayContent;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpHeaders;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.repackaged.com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.io.ByteStreams;

import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.function.Function;
import java.util.zip.Deflater;
import java.util.zip.GZIPOutputStream;
import javax.net.ssl.SSLSocketFactory;

import io.netty.handler.codec.http.HttpMethod;
import org.asynchttpclient.AsyncHandler;
import org.asynchttpclient.AsyncHttpClient;
import org.asynchttpclient.AsyncHttpClientConfig;
import org.asynchttpclient.DefaultAsyncHttpClientConfig;
import org.asynchttpclient.HttpResponseBodyPart;
import org.asynchttpclient.HttpResponseStatus;
import org.asynchttpclient.Request;
import org.asynchttpclient.RequestBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * An async low-level Google Cloud Pub/Sub client.
 */
public class Pubsub implements Closeable {

    private static final Logger log = LoggerFactory.getLogger(Pubsub.class);

    private static final String VERSION = "1.0.0";
    private static final String USER_AGENT = "Spotify-Google-Pubsub-Java-Client/" + VERSION + " (gzip)";

    private static final Object NO_PAYLOAD = new Object();

    private static final String CLOUD_PLATFORM = "https://www.googleapis.com/auth/cloud-platform";
    private static final String PUBSUB = "https://www.googleapis.com/auth/pubsub";
    private static final List<String> SCOPES = ImmutableList.of(CLOUD_PLATFORM, PUBSUB);
    private static final String APPLICATION_JSON_UTF8 = "application/json; charset=UTF-8";

    private static final int DEFAULT_PULL_MAX_MESSAGES = 1000;
    private static final boolean DEFAULT_PULL_RETURN_IMMEDIATELY = true;

    private final AsyncHttpClient client;
    private final String baseUri;
    private final Credential credential;
    private final CompletableFuture<Void> closeFuture = new CompletableFuture<>();

    private final ScheduledExecutorService scheduler = getExitingScheduledExecutorService(
            new ScheduledThreadPoolExecutor(1));

    private final ExecutorService executor = getExitingExecutorService(
            (ThreadPoolExecutor) Executors.newCachedThreadPool());

    private volatile String accessToken;
    private final int compressionLevel;
    private NetHttpTransport transport;

    private Pubsub(final Builder builder) {
        final AsyncHttpClientConfig config = builder.clientConfig.build();

        log.debug("creating new pubsub client with config:");
        log.debug("uri: {}", builder.uri);
        log.debug("connect timeout: {}", config.getConnectTimeout());
        log.debug("read timeout: {}", config.getReadTimeout());
        log.debug("request timeout: {}", config.getRequestTimeout());
        log.debug("max connections: {}", config.getMaxConnections());
        log.debug("max connections per host: {}", config.getMaxConnectionsPerHost());
        log.debug("enabled cipher suites: {}", Arrays.toString(config.getEnabledCipherSuites()));
        log.debug("response compression enforced: {}", config.isCompressionEnforced());
        log.debug("request compression level: {}", builder.compressionLevel);
        log.debug("accept any certificate: {}", config.isUseInsecureTrustManager());
        log.debug("follows redirect: {}", config.isFollowRedirect());
        log.debug("pooled connection TTL: {}", config.getConnectionTtl());
        log.debug("pooled connection idle timeout: {}", config.getPooledConnectionIdleTimeout());
        log.debug("user agent: {}", config.getUserAgent());
        log.debug("max request retry: {}", config.getMaxRequestRetry());

        final SSLSocketFactory sslSocketFactory = new ConfigurableSSLSocketFactory(config.getEnabledCipherSuites(),
                (SSLSocketFactory) SSLSocketFactory.getDefault());

        this.transport = new NetHttpTransport.Builder().setSslSocketFactory(sslSocketFactory).build();

        this.client = asyncHttpClient(config);

        this.compressionLevel = builder.compressionLevel;

        if (builder.credential == null) {
            this.credential = scoped(defaultCredential());
        } else {
            this.credential = scoped(builder.credential);
        }

        this.baseUri = builder.uri.toString();

        // Get initial access token
        refreshAccessToken();
        if (accessToken == null) {
            throw new RuntimeException("Failed to get access token");
        }

        // Wake up every 10 seconds to check if access token has expired
        scheduler.scheduleAtFixedRate(this::refreshAccessToken, 10, 10, SECONDS);
    }

    private Credential scoped(final Credential credential) {
        if (credential instanceof GoogleCredential) {
            return scoped((GoogleCredential) credential);
        }
        return credential;
    }

    private Credential scoped(final GoogleCredential credential) {
        if (credential.createScopedRequired()) {
            return credential.createScoped(SCOPES);
        }
        return credential;
    }

    private static Credential defaultCredential() {
        try {
            return GoogleCredential.getApplicationDefault(Utils.getDefaultTransport(),
                    Utils.getDefaultJsonFactory());
        } catch (IOException e) {
            throw Throwables.propagate(e);
        }
    }

    /**
     * Close this {@link Pubsub} client.
     */
    @Override
    public void close() throws IOException {
        executor.shutdown();
        scheduler.shutdown();
        client.close();
        closeFuture.complete(null);
    }

    /**
     * Get a future that is completed when this {@link Pubsub} client is closed.
     */
    public CompletableFuture<Void> closeFuture() {
        return closeFuture;
    }

    /**
     * Refresh the Google Cloud API access token, if necessary.
     */
    private void refreshAccessToken() {
        final Long expiresIn = credential.getExpiresInSeconds();

        // trigger refresh if token is about to expire
        String accessToken = credential.getAccessToken();
        if (accessToken == null || expiresIn != null && expiresIn <= 60) {
            try {
                credential.refreshToken();
                accessToken = credential.getAccessToken();
            } catch (final IOException e) {
                log.error("Failed to fetch access token", e);
            }
        }
        if (accessToken != null) {
            this.accessToken = accessToken;
        }
    }

    /**
     * List the Pub/Sub topics in a project. This will get the first page of topics. To enumerate all topics you might
     * have to make further calls to {@link #listTopics(String, String)} with the page token in order to get further
     * pages.
     *
     * @param project The Google Cloud project.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<TopicList> listTopics(final String project) {
        final String path = "projects/" + project + "/topics";
        return get("list topics", path, readJson(TopicList.class));
    }

    /**
     * Get a page of Pub/Sub topics in a project using a specified page token.
     *
     * @param project   The Google Cloud project.
     * @param pageToken A token for the page of topics to get.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<TopicList> listTopics(final String project, final String pageToken) {
        final String query = (pageToken == null) ? "" : "?pageToken=" + pageToken;
        final String path = "projects/" + project + "/topics" + query;
        return get("list topics", path, readJson(TopicList.class));
    }

    /**
     * Create a Pub/Sub topic.
     *
     * @param project The Google Cloud project.
     * @param topic   The name of the topic to create.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Topic> createTopic(final String project, final String topic) {
        return createTopic(canonicalTopic(project, topic));
    }

    /**
     * Create a Pub/Sub topic.
     *
     * @param canonicalTopic The canonical (including project) name of the topic to create.
     * @return A future that is completed when this request is completed.
     */
    private PubsubFuture<Topic> createTopic(final String canonicalTopic) {
        validateCanonicalTopic(canonicalTopic);
        return put("create topic", canonicalTopic, NO_PAYLOAD, readJson(Topic.class));
    }

    /**
     * Get a Pub/Sub topic.
     *
     * @param project The Google Cloud project.
     * @param topic   The name of the topic to get.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Topic> getTopic(final String project, final String topic) {
        return getTopic(canonicalTopic(project, topic));
    }

    /**
     * Get a Pub/Sub topic.
     *
     * @param canonicalTopic The canonical (including project) name of the topic to get.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Topic> getTopic(final String canonicalTopic) {
        validateCanonicalTopic(canonicalTopic);
        return get("get topic", canonicalTopic, readJson(Topic.class));
    }

    /**
     * Delete a Pub/Sub topic.
     *
     * @param project The Google Cloud project.
     * @param topic   The name of the topic to delete.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Void> deleteTopic(final String project, final String topic) {
        return deleteTopic(canonicalTopic(project, topic));
    }

    /**
     * Delete a Pub/Sub topic.
     *
     * @param canonicalTopic The canonical (including project) name of the topic to delete.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Void> deleteTopic(final String canonicalTopic) {
        validateCanonicalTopic(canonicalTopic);
        return delete("delete topic", canonicalTopic, VOID);
    }

    /**
     * Create a Pub/Sub subscription.
     *
     * @param project          The Google Cloud project.
     * @param subscriptionName The name of the subscription to create.
     * @param topic            The name of the topic to subscribe to.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Subscription> createSubscription(final String project, final String subscriptionName,
            final String topic) {
        return createSubscription(canonicalSubscription(project, subscriptionName), canonicalTopic(project, topic));
    }

    /**
     * List the Pub/Sub subscriptions in a project. This will get the first page of subscriptions. To enumerate all
     * subscriptions you might have to make further calls to {@link #listTopics(String, String)} with the page token in
     * order to get further pages.
     *
     * @param project The Google Cloud project.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<SubscriptionList> listSubscriptions(final String project) {
        final String path = "projects/" + project + "/subscriptions";
        return get("list subscriptions", path, readJson(SubscriptionList.class));
    }

    /**
     * Get a page of Pub/Sub subscriptions in a project using a specified page token.
     *
     * @param project   The Google Cloud project.
     * @param pageToken A token for the page of subscriptions to get.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<SubscriptionList> listSubscriptions(final String project, final String pageToken) {
        final String query = (pageToken == null) ? "" : "?pageToken=" + pageToken;
        final String path = "projects/" + project + "/subscriptions" + query;
        return get("list subscriptions", path, readJson(SubscriptionList.class));
    }

    /**
     * Create a Pub/Sub subscription.
     *
     * @param canonicalSubscriptionName The canonical (including project) name of the scubscription to create.
     * @param canonicalTopic            The canonical (including project) name of the topic to subscribe to.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Subscription> createSubscription(final String canonicalSubscriptionName,
            final String canonicalTopic) {
        return createSubscription(Subscription.of(canonicalSubscriptionName, canonicalTopic));
    }

    /**
     * Create a Pub/Sub subscription.
     *
     * @param subscription The subscription to create.
     * @return A future that is completed when this request is completed.
     */
    private PubsubFuture<Subscription> createSubscription(final Subscription subscription) {
        return createSubscription(subscription.name(), subscription);
    }

    /**
     * Create a Pub/Sub subscription.
     *
     * @param canonicalSubscriptionName The canonical (including project) name of the subscription to create.
     * @param subscription              The subscription to create.
     * @return A future that is completed when this request is completed.
     */
    private PubsubFuture<Subscription> createSubscription(final String canonicalSubscriptionName,
            final Subscription subscription) {
        validateCanonicalSubscription(canonicalSubscriptionName);
        return put("create subscription", canonicalSubscriptionName, SubscriptionCreateRequest.of(subscription),
                readJson(Subscription.class));
    }

    /**
     * Get a Pub/Sub subscription.
     *
     * @param project      The Google Cloud project.
     * @param subscription The name of the subscription to get.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Subscription> getSubscription(final String project, final String subscription) {
        return getSubscription(canonicalSubscription(project, subscription));
    }

    /**
     * Get a Pub/Sub subscription.
     *
     * @param canonicalSubscriptionName The canonical (including project) name of the subscription to get.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Subscription> getSubscription(final String canonicalSubscriptionName) {
        validateCanonicalSubscription(canonicalSubscriptionName);
        return get("get subscription", canonicalSubscriptionName, readJson(Subscription.class));
    }

    /**
     * Delete a Pub/Sub subscription.
     *
     * @param project      The Google Cloud project.
     * @param subscription The name of the subscription to delete.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Void> deleteSubscription(final String project, final String subscription) {
        return deleteSubscription(canonicalSubscription(project, subscription));
    }

    /**
     * Delete a Pub/Sub subscription.
     *
     * @param canonicalSubscriptionName The canonical (including project) name of the subscription to delete.
     * @return A future that is completed when this request is completed. The future will be completed with {@code null}
     * if the response is 404.
     */
    public PubsubFuture<Void> deleteSubscription(final String canonicalSubscriptionName) {
        validateCanonicalSubscription(canonicalSubscriptionName);
        return delete("delete subscription", canonicalSubscriptionName, VOID);
    }

    /**
     * Publish a batch of messages.
     *
     * @param project  The Google Cloud project.
     * @param topic    The topic to publish on.
     * @param messages The batch of messages.
     * @return a future that is completed with a list of message ID's for the published messages.
     */
    public PubsubFuture<List<String>> publish(final String project, final String topic, final Message... messages) {
        return publish(project, topic, asList(messages));
    }

    /**
     * Publish a batch of messages.
     *
     * @param project  The Google Cloud project.
     * @param topic    The topic to publish on.
     * @param messages The batch of messages.
     * @return a future that is completed with a list of message ID's for the published messages.
     */
    public PubsubFuture<List<String>> publish(final String project, final String topic,
            final List<Message> messages) {
        return publish0(messages, Topic.canonicalTopic(project, topic));
    }

    /**
     * Publish a batch of messages.
     *
     * @param messages       The batch of messages.
     * @param canonicalTopic The canonical topic to publish on.
     * @return a future that is completed with a list of message ID's for the published messages.
     */
    public PubsubFuture<List<String>> publish(final List<Message> messages, final String canonicalTopic) {
        Topic.validateCanonicalTopic(canonicalTopic);
        return publish0(messages, canonicalTopic);
    }

    /**
     * Publish a batch of messages.
     *
     * @param messages       The batch of messages.
     * @param canonicalTopic The canonical topic to publish on.
     * @return a future that is completed with a list of message ID's for the published messages.
     */
    private PubsubFuture<List<String>> publish0(final List<Message> messages, final String canonicalTopic) {
        final String path = canonicalTopic + ":publish";
        for (final Message message : messages) {
            if (!isEncoded(message)) {
                throw new IllegalArgumentException("Message data must be Base64 encoded: " + message);
            }
        }
        return post("publish", path, PublishRequest.of(messages),
                readJson(PublishResponse.class).andThen(PublishResponse::messageIds));
    }

    /**
     * Pull a batch of messages.
     *
     * @param project      The Google Cloud project.
     * @param subscription The subscription to pull from.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String project, final String subscription) {
        return pull(project, subscription, DEFAULT_PULL_RETURN_IMMEDIATELY, DEFAULT_PULL_MAX_MESSAGES);
    }

    /**
     * Pull a batch of messages.
     *
     * @param project           The Google Cloud project.
     * @param subscription      The subscription to pull from.
     * @param returnImmediately {@code true} to return immediately if the queue is empty. {@code false} to wait for at
     *                          least one message before returning.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String project, final String subscription,
            final boolean returnImmediately) {
        return pull(project, subscription, returnImmediately, DEFAULT_PULL_MAX_MESSAGES);
    }

    /**
     * Pull a batch of messages.
     *
     * @param project           The Google Cloud project.
     * @param subscription      The subscription to pull from.
     * @param returnImmediately {@code true} to return immediately if the queue is empty. {@code false} to wait for at
     *                          least one message before returning.
     * @param maxMessages       Maximum number of messages to return in batch.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String project, final String subscription,
            final boolean returnImmediately, final int maxMessages) {
        return pull(Subscription.canonicalSubscription(project, subscription), returnImmediately, maxMessages);
    }

    /**
     * Pull a batch of messages.
     *
     * @param canonicalSubscriptionName The canonical (including project name) subscription to pull from.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String canonicalSubscriptionName) {
        return pull(canonicalSubscriptionName, DEFAULT_PULL_RETURN_IMMEDIATELY);
    }

    /**
     * Pull a batch of messages.
     *
     * @param canonicalSubscriptionName The canonical (including project name) subscription to pull from.
     * @param returnImmediately         {@code true} to return immediately if the queue is empty. {@code false} to wait
     *                                  for at least one message before returning.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String canonicalSubscriptionName,
            final boolean returnImmediately) {
        return pull(canonicalSubscriptionName, returnImmediately, DEFAULT_PULL_MAX_MESSAGES);
    }

    /**
     * Pull a batch of messages.
     *
     * @param canonicalSubscriptionName The canonical (including project name) subscription to pull from.
     * @param returnImmediately         {@code true} to return immediately if the queue is empty. {@code false} to wait
     *                                  for at least one message before returning.
     * @param maxMessages               Maximum number of messages to return in batch.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String canonicalSubscriptionName,
            final boolean returnImmediately, final int maxMessages) {
        final String path = canonicalSubscriptionName + ":pull";
        final PullRequest req = PullRequest.builder().returnImmediately(returnImmediately).maxMessages(maxMessages)
                .build();
        return pull(path, req);
    }

    /**
     * Pull a batch of messages.
     *
     * @param pullRequest The pull request.
     * @return a future that is completed with a list of received messages.
     */
    public PubsubFuture<List<ReceivedMessage>> pull(final String path, final PullRequest pullRequest) {
        // TODO (dano): use async client when chunked encoding is fixed
        //    return post("pull", path, pullRequest, PullResponse.class)
        //        .thenApply(PullResponse::receivedMessages);
        return requestJavaNet("pull", POST, path, pullRequest,
                readJson(PullResponse.class).andThen(PullResponse::receivedMessages));
    }

    /**
     * Acknowledge a batch of received messages.
     *
     * @param project      The Google Cloud project.
     * @param subscription The subscription to acknowledge messages on.
     * @param ackIds       List of message ID's to acknowledge.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Void> acknowledge(final String project, final String subscription, final String... ackIds) {
        return acknowledge(project, subscription, asList(ackIds));
    }

    /**
     * Acknowledge a batch of received messages.
     *
     * @param project      The Google Cloud project.
     * @param subscription The subscription to acknowledge messages on.
     * @param ackIds       List of message ID's to acknowledge.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Void> acknowledge(final String project, final String subscription,
            final List<String> ackIds) {
        return acknowledge(Subscription.canonicalSubscription(project, subscription), ackIds);
    }

    /**
     * Acknowledge a batch of received messages.
     *
     * @param canonicalSubscriptionName The canonical (including project name) subscription to acknowledge messages on.
     * @param ackIds                    List of message ID's to acknowledge.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Void> acknowledge(final String canonicalSubscriptionName, final List<String> ackIds) {
        final String path = canonicalSubscriptionName + ":acknowledge";
        final AcknowledgeRequest req = AcknowledgeRequest.builder().ackIds(ackIds).build();
        return post("acknowledge", path, req, VOID);
    }

    /**
     * Modify the ack deadline for a list of received messages.
     *
     * @param project            The Google Cloud project.
     * @param subscription       The subscription of the received message to modify the ack deadline on.
     * @param ackDeadlineSeconds The new ack deadline.
     * @param ackIds             List of message ID's to modify the ack deadline on.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Void> modifyAckDeadline(final String project, final String subscription,
            final int ackDeadlineSeconds, final String... ackIds) {
        return modifyAckDeadline(project, subscription, ackDeadlineSeconds, asList(ackIds));
    }

    /**
     * Modify the ack deadline for a list of received messages.
     *
     * @param project            The Google Cloud project.
     * @param subscription       The subscription of the received message to modify the ack deadline on.
     * @param ackDeadlineSeconds The new ack deadline.
     * @param ackIds             List of message ID's to modify the ack deadline on.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Void> modifyAckDeadline(final String project, final String subscription,
            final int ackDeadlineSeconds, final List<String> ackIds) {
        return modifyAckDeadline(Subscription.canonicalSubscription(project, subscription), ackDeadlineSeconds,
                ackIds);
    }

    /**
     * Modify the ack deadline for a list of received messages.
     *
     * @param canonicalSubscriptionName The canonical (including project name) subscription of the received message to
     *                                  modify the ack deadline on.
     * @param ackDeadlineSeconds        The new ack deadline.
     * @param ackIds                    List of message ID's to modify the ack deadline on.
     * @return A future that is completed when this request is completed.
     */
    public PubsubFuture<Void> modifyAckDeadline(final String canonicalSubscriptionName,
            final int ackDeadlineSeconds, final List<String> ackIds) {
        final String path = canonicalSubscriptionName + ":modifyAckDeadline";
        final ModifyAckDeadlineRequest req = ModifyAckDeadlineRequest.builder()
                .ackDeadlineSeconds(ackDeadlineSeconds).ackIds(ackIds).build();
        return post("modify ack deadline", path, req, VOID);
    }

    /**
     * Make a GET request.
     */
    private <T> PubsubFuture<T> get(final String operation, final String path,
            final ResponseReader<T> responseReader) {
        return request(operation, HttpMethod.GET, path, responseReader);
    }

    /**
     * Make a POST request.
     */
    private <T> PubsubFuture<T> post(final String operation, final String path, final Object payload,
            final ResponseReader<T> responseReader) {
        return request(operation, HttpMethod.POST, path, payload, responseReader);
    }

    /**
     * Make a PUT request.
     */
    private <T> PubsubFuture<T> put(final String operation, final String path, final Object payload,
            final ResponseReader<T> responseReader) {
        return request(operation, HttpMethod.PUT, path, payload, responseReader);
    }

    /**
     * Make a DELETE request.
     */
    private <T> PubsubFuture<T> delete(final String operation, final String path,
            final ResponseReader<T> responseReader) {
        return request(operation, HttpMethod.DELETE, path, responseReader);
    }

    /**
     * Make an HTTP request.
     */
    private <T> PubsubFuture<T> request(final String operation, final HttpMethod method, final String path,
            final ResponseReader<T> responseReader) {
        return request(operation, method, path, NO_PAYLOAD, responseReader);
    }

    /**
     * Make an HTTP request.
     */
    private <T> PubsubFuture<T> request(final String operation, final HttpMethod method, final String path,
            final Object payload, final ResponseReader<T> responseReader) {

        final String uri = baseUri + path;
        final RequestBuilder builder = new RequestBuilder().setUrl(uri).setMethod(method.toString())
                .setHeader("Authorization", "Bearer " + accessToken).setHeader(CONNECTION, KEEP_ALIVE)
                .setHeader("User-Agent", USER_AGENT);

        final long payloadSize;
        if (payload != NO_PAYLOAD) {
            final byte[] json = gzipJson(payload);
            payloadSize = json.length;
            builder.setHeader(CONTENT_ENCODING, GZIP);
            builder.setHeader(CONTENT_LENGTH, String.valueOf(json.length));
            builder.setHeader(CONTENT_TYPE, APPLICATION_JSON_UTF8);
            builder.setBody(json);
        } else {
            builder.setHeader(CONTENT_LENGTH, String.valueOf(0));
            payloadSize = 0;
        }

        final Request request = builder.build();

        final RequestInfo requestInfo = RequestInfo.builder().operation(operation).method(method.toString())
                .uri(uri).payloadSize(payloadSize).build();

        final PubsubFuture<T> future = new PubsubFuture<>(requestInfo);
        client.executeRequest(request, new AsyncHandler<Void>() {
            private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();

            @Override
            public void onThrowable(final Throwable t) {
                future.fail(t);
            }

            @Override
            public State onBodyPartReceived(final HttpResponseBodyPart bodyPart) throws Exception {
                bytes.write(bodyPart.getBodyPartBytes());
                return State.CONTINUE;
            }

            @Override
            public State onStatusReceived(final HttpResponseStatus status) {

                // Return null for 404'd GET & DELETE requests
                if (status.getStatusCode() == 404 && method == HttpMethod.GET || method == HttpMethod.DELETE) {
                    future.succeed(null);
                    return State.ABORT;
                }

                // Fail on non-2xx responses
                final int statusCode = status.getStatusCode();
                if (!(statusCode >= 200 && statusCode < 300)) {
                    future.fail(new RequestFailedException(status.getStatusCode(), status.getStatusText()));
                    return State.ABORT;
                }

                if (responseReader == VOID) {
                    future.succeed(null);
                    return State.ABORT;
                }

                return State.CONTINUE;
            }

            @Override
            public State onHeadersReceived(final io.netty.handler.codec.http.HttpHeaders headers) {
                return State.CONTINUE;
            }

            @Override
            public Void onCompleted() throws Exception {
                if (future.isDone()) {
                    return null;
                }
                try {
                    future.succeed(responseReader.read(bytes.toByteArray()));
                } catch (Exception e) {
                    future.fail(e);
                }
                return null;
            }
        });

        return future;
    }

    /**
     * Make an HTTP request using {@link java.net.HttpURLConnection}.
     */
    private <T> PubsubFuture<T> requestJavaNet(final String operation, final HttpMethod method, final String path,
            final Object payload, final ResponseReader<T> responseReader) {

        final HttpRequestFactory requestFactory = transport.createRequestFactory();

        final String uri = baseUri + path;

        final HttpHeaders headers = new HttpHeaders();
        final HttpRequest request;
        try {
            request = requestFactory.buildRequest(method.name(), new GenericUrl(URI.create(uri)), null);
        } catch (IOException e) {
            throw Throwables.propagate(e);
        }

        headers.setAuthorization("Bearer " + accessToken);
        headers.setUserAgent("Spotify");

        final long payloadSize;
        if (payload != NO_PAYLOAD) {
            final byte[] json = gzipJson(payload);
            payloadSize = json.length;
            headers.setContentEncoding(GZIP.toString());
            headers.setContentLength((long) json.length);
            headers.setContentType(APPLICATION_JSON_UTF8);
            request.setContent(new ByteArrayContent(APPLICATION_JSON_UTF8, json));
        } else {
            payloadSize = 0;
        }

        request.setHeaders(headers);

        final RequestInfo requestInfo = RequestInfo.builder().operation(operation).method(method.toString())
                .uri(uri).payloadSize(payloadSize).build();

        final PubsubFuture<T> future = new PubsubFuture<>(requestInfo);

        executor.execute(() -> {
            final HttpResponse response;
            try {
                response = request.execute();
            } catch (IOException e) {
                future.fail(e);
                return;
            }

            // Return null for 404'd GET & DELETE requests
            if (response.getStatusCode() == 404 && method == HttpMethod.GET || method == HttpMethod.DELETE) {
                future.succeed(null);
                return;
            }

            // Fail on non-2xx responses
            final int statusCode = response.getStatusCode();
            if (!(statusCode >= 200 && statusCode < 300)) {
                future.fail(new RequestFailedException(response.getStatusCode(), response.getStatusMessage()));
                return;
            }

            if (responseReader == VOID) {
                future.succeed(null);
                return;
            }

            try {

                future.succeed(responseReader.read(ByteStreams.toByteArray(response.getContent())));
            } catch (Exception e) {
                future.fail(e);
            }
        });

        return future;
    }

    private byte[] gzipJson(final Object payload) {
        // TODO (dano): cache and reuse deflater
        try (final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
                final GZIPOutputStream gzip = new GZIPOutputStream(bytes) {
                    {
                        this.def.setLevel(compressionLevel);
                    }
                }) {
            Json.write(gzip, payload);
            return bytes.toByteArray();
        } catch (IOException e) {
            throw Throwables.propagate(e);
        }
    }

    /**
     * Create a new {@link Pubsub} client with default configuration.
     */
    public static Pubsub create() {
        return builder().build();
    }

    /**
     * Create a new {@link Builder} that can be used to build a new {@link Pubsub} client.
     */
    public static Builder builder() {
        return new Builder();
    }

    /**
     * A {@link Builder} that can be used to build a new {@link Pubsub} client.
     */
    public static class Builder {

        private static final URI DEFAULT_URI = URI.create("https://pubsub.googleapis.com/v1/");

        private static final int DEFAULT_REQUEST_TIMEOUT_MS = 30000;

        private final DefaultAsyncHttpClientConfig.Builder clientConfig = config().setKeepAlive(true)
                .setCompressionEnforced(true).setUseProxySelector(true)
                .setRequestTimeout(DEFAULT_REQUEST_TIMEOUT_MS).setReadTimeout(DEFAULT_REQUEST_TIMEOUT_MS);

        private Credential credential;
        private URI uri = DEFAULT_URI;
        private int compressionLevel = Deflater.DEFAULT_COMPRESSION;

        private Builder() {
        }

        /**
         * Creates a new {@code Pubsub}.
         */
        public Pubsub build() {
            return new Pubsub(this);
        }

        /**
         * Set the maximum time in milliseconds the client will can wait when connecting to a remote host.
         *
         * @param connectTimeout the connect timeout in milliseconds.
         * @return this config builder.
         */
        public Builder connectTimeout(final int connectTimeout) {
            clientConfig.setConnectTimeout(connectTimeout);
            return this;
        }

        /**
         * Set the Gzip compression level to use, 0-9 or -1 for default.
         *
         * @param compressionLevel The compression level to use.
         * @return this config builder.
         * @see Deflater#setLevel(int)
         * @see Deflater#DEFAULT_COMPRESSION
         * @see Deflater#BEST_COMPRESSION
         * @see Deflater#BEST_SPEED
         */
        public Builder compressionLevel(final int compressionLevel) {
            checkArgument(compressionLevel > -1 && compressionLevel <= 9, "compressionLevel must be -1 or 0-9.");
            this.compressionLevel = compressionLevel;
            return this;
        }

        /**
         * Set the connection read timeout in milliseconds.
         *
         * @param readTimeout the read timeout in milliseconds.
         * @return this config builder.
         */
        public Builder readTimeout(final int readTimeout) {
            clientConfig.setReadTimeout(readTimeout);
            return this;
        }

        /**
         * Set the request timeout in milliseconds.
         *
         * @param requestTimeout the maximum time in milliseconds.
         * @return this config builder.
         */
        public Builder requestTimeout(final int requestTimeout) {
            clientConfig.setRequestTimeout(requestTimeout);
            return this;
        }

        /**
         * Set the maximum number of connections client will open. Default is unlimited (-1).
         *
         * @param maxConnections the maximum number of connections.
         * @return this config builder.
         */
        public Builder maxConnections(final int maxConnections) {
            clientConfig.setMaxConnections(maxConnections);
            return this;
        }

        /**
         * Set the maximum number of milliseconds a pooled connection will be reused. -1 for no limit.
         *
         * @param pooledConnectionTTL the maximum time in milliseconds.
         * @return this config builder.
         */
        public Builder pooledConnectionTTL(final int pooledConnectionTTL) {
            clientConfig.setConnectionTtl(pooledConnectionTTL);
            return this;
        }

        /**
         * Set the maximum number of milliseconds an idle pooled connection will be be kept.
         *
         * @param pooledConnectionIdleTimeout the timeout in milliseconds.
         * @return this config builder.
         */
        public Builder pooledConnectionIdleTimeout(final int pooledConnectionIdleTimeout) {
            clientConfig.setPooledConnectionIdleTimeout(pooledConnectionIdleTimeout);
            return this;
        }

        /**
         * Set Google Cloud API credentials to use. Set to null to use application default credentials.
         *
         * @param credential the credentials used to authenticate.
         */
        public Builder credential(final Credential credential) {
            this.credential = credential;
            return this;
        }

        /**
         * Set cipher suites to enable for SSL/TLS.
         *
         * @param enabledCipherSuites The cipher suites to enable.
         */
        public Builder enabledCipherSuites(final String... enabledCipherSuites) {
            clientConfig.setEnabledCipherSuites(enabledCipherSuites);
            return this;
        }

        /**
         * Set cipher suites to enable for SSL/TLS.
         *
         * @param enabledCipherSuites The cipher suites to enable.
         */
        public Builder enabledCipherSuites(final List<String> enabledCipherSuites) {
            clientConfig
                    .setEnabledCipherSuites(enabledCipherSuites.toArray(new String[enabledCipherSuites.size()]));
            return this;
        }

        /**
         * The Google Cloud Pub/Sub API URI. By default, this is the Google Cloud Pub/Sub provider, however you may run a
         * local Developer Server.
         *
         * @param uri the service to connect to.
         */
        public Builder uri(final URI uri) {
            checkNotNull(uri, "uri");
            checkArgument(uri.getRawQuery() == null, "illegal service uri: %s", uri);
            checkArgument(uri.getRawFragment() == null, "illegal service uri: %s", uri);
            this.uri = uri;
            return this;
        }
    }

    /**
     * A {@link ResponseReader} that parses a response payload as Json into a specified {@link Class}.
     * @param cls The {@link Class} to parse the Json payload as.
     */
    private <T> ResponseReader<T> readJson(Class<T> cls) {
        return payload -> Json.read(payload, cls);
    }

    /**
     * A function that takes a payload byte array and parses it into some object.
     */
    @FunctionalInterface
    interface ResponseReader<T> {

        /**
         * A marker {@link ResponseReader} that completely disables reading of the response payload.
         */
        ResponseReader<Void> VOID = bytes -> null;

        /**
         * Parse a byte array into an object.
         */
        T read(byte[] bytes) throws Exception;

        /**
         * Perform additional transformation on the parsed object.
         */
        default <U> ResponseReader<U> andThen(Function<T, U> f) {
            return bytes -> f.apply(read(bytes));
        }
    }
}