com.spotify.apollo.test.ServiceHelper.java Source code

Java tutorial

Introduction

Here is the source code for com.spotify.apollo.test.ServiceHelper.java

Source

/*
 * -\-\-
 * Spotify Apollo Testing Helpers
 * --
 * Copyright (C) 2013 - 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.apollo.test;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ThreadFactoryBuilder;

import com.spotify.apollo.AppInit;
import com.spotify.apollo.Client;
import com.spotify.apollo.Environment;
import com.spotify.apollo.Request;
import com.spotify.apollo.RequestContext;
import com.spotify.apollo.Response;
import com.spotify.apollo.core.Service;
import com.spotify.apollo.core.Services;
import com.spotify.apollo.environment.ApolloConfig;
import com.spotify.apollo.environment.ApolloEnvironmentModule;
import com.spotify.apollo.http.client.HttpClientModule;
import com.spotify.apollo.module.ApolloModule;
import com.spotify.apollo.request.RequestHandler;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import com.typesafe.config.ConfigValueFactory;

import org.junit.Rule;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Closeable;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

import okio.ByteString;

import static com.google.common.base.Preconditions.checkState;

/**
 * <p>A JUnit {@link TestRule} for running tests against an apollo service. It is built around
 * the {@link AppInit} setup mechanism and can be used to start a service configured in a way
 * appropriate for the test scenario.</p>
 *
 * <p>Typical usage would use {@link #create(AppInit, String)} together with a
 * {@link Rule} annotation. Further configuration like config key overrides, running domain and
 * additional program arguments can be set up using {@link #conf(String, String)},
 * {@link #domain(String)} and {@link #args(String...)} respectively.</p>
 *
 * <p>Requests can be sent to the running application using any of {@link #request} methods.</p>
 *
 * <h2>Example usage for testing a route provider</h2>
 * <pre><code>
 * {@literal @}RunWith(MockitoJUnitRunner.class)
 * class MyServiceTest {
 *
 *   {@literal @}Rule
 *   public ServiceHelper serviceHelper = ServiceHelper.create(this::appInit, "my-service")
 *       .conf("some.key", "some-value")
 *       .args("-v")
 *       .startTimeoutSeconds(30);
 *
 *   {@literal @}Mock
 *   SomeObject someObject;
 *
 *   void appInit(Environment environment) {
 *     // Implements resource "/endpoint" using someObject
 *     RouteProvider endpointResource = new EndpointResource(someObject);
 *     environment.routingEngine()
 *         .registerRoutes(endpointResource);
 *   }
 *
 *   {@literal @}Test
 *   public void testRequest() throws Exception {
 *     when(someObject.thatDoesThings()).thenReturn("a test string");
 *
 *     String response = Futures.getUnchecked(serviceHelper.request("GET", "/endpoint"))
 *         .getPayloads().get(0).toStringUtf8();
 *
 *     assertThat(response, is("a test string"));
 *   }
 * }
 * </code></pre>
 *
 * <h2>Example usage for system or acceptance tests</h2>
 * <pre><code>
 * {@literal @}RunWith(MockitoJUnitRunner.class)
 * class MyServiceTest {
 *
 *   // Implements {@link AppInit}
 *   MyService myService = new MyService();
 *
 *   {@literal @}Rule
 *   public ServiceHelper serviceHelper = ServiceHelper.create(myService, "my-service")
 *       .conf("some.key", "some-value")
 *       .args("-v")
 *       .startTimeoutSeconds(30);
 *
 *   {@literal @}Test
 *   public void testRequest() throws Exception {
 *     String response = Futures.getUnchecked(serviceHelper.request("GET", "/ping"))
 *         .getPayloads().get(0).toStringUtf8();
 *
 *     assertThat(response, is("pong"));
 *   }
 * }
 * </code></pre>
 *
 * <h1>Faking outgoing request responses</h1>
 *
 * <p>The service helper instance will contain a {@link StubClient} that can be accessed
 * through {@link #stubClient()}. This can be used to setup mocked replies on outgoing requests.
 * Requests made by the application will first try to match against requests set up in the
 * {@link StubClient}. But if none is found the request will be delegated to the underlying
 * client that is normally available to the application through {@link Environment#client()} or
 * {@link RequestContext#requestScopedClient()}.</p>
 *
 * <p>See {@link StubClient} for more docs on how to set up mocked request replies.</p>
 */
public class ServiceHelper implements TestRule, Closeable {

    private static final Logger LOG = LoggerFactory.getLogger(ServiceHelper.class);
    public static final String[] NO_ARGS = new String[0];
    private static final String DEFAULT_SCHEME = "http";

    // https://tools.ietf.org/html/rfc3986#section-3.1 Scheme
    // scheme      = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
    private static final Pattern SCHEME_RE = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*");

    private final CountDownLatch started = new CountDownLatch(1);

    private final AppInit appInit;
    private final String serviceName;

    private final StubClient stubClient;
    private final Client serviceClient;
    private final List<ApolloModule> additionalModules;

    private Config conf;
    private final ExecutorService executor = Executors
            .newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("apollo-servicehelper-%d").build());

    private String[] args = NO_ARGS;

    private Future currentHelperFuture;

    private Service.Instance instance;
    private RequestHandler requestHandler;

    private boolean forwardNonStubbedRequests = true;
    private int timeoutSeconds = 5;
    private String scheme = DEFAULT_SCHEME;

    private ServiceHelper(AppInit appInit, String serviceName, StubClient stubClient) {
        this.appInit = appInit;
        this.serviceName = serviceName;
        this.stubClient = Objects.requireNonNull(stubClient);
        this.serviceClient = this::request;
        this.conf = ConfigFactory.load(serviceName);
        additionalModules = new ArrayList<>();
    }

    /**
     * Creates a {@link ServiceHelper} using the given {@link AppInit} and service name.
     *
     * @param appInit      The init function for the test setup
     * @param serviceName  The service name for looking up config
     * @return A ServiceHelper to be used with a JUnit {@link Rule}
     */
    public static ServiceHelper create(AppInit appInit, String serviceName) {
        return new ServiceHelper(appInit, serviceName, new StubClient());
    }

    /**
     * Creates a {@link ServiceHelper} using the given {@link AppInit}, service name and
     * stub client. Use, for instance, when you want to configure the thread count of the stub client.
     *
     * @param appInit      The init function for the test setup
     * @param serviceName  The service name for looking up config
     * @param stubClient   The stub client to use
     * @return A ServiceHelper to be used with a JUnit {@link Rule}
     */
    public static ServiceHelper create(AppInit appInit, String serviceName, StubClient stubClient) {
        return new ServiceHelper(appInit, serviceName, stubClient);
    }

    /**
     * Run the service in the given domain. This will set the {@code "apollo.backend"} config key
     * which is also available through {@link Environment#domain()}.
     *
     * @param domain  The domain to use
     * @return This ServiceHelper instance
     */
    public ServiceHelper domain(String domain) {
        return conf(Services.CommonConfigKeys.APOLLO_BACKEND.getKey(), domain);
    }

    /**
     * Don't set up {@code /_meta/*} routes for the application.
     *
     * @return This ServiceHelper instance
     */
    public ServiceHelper disableMetaApi() {
        return conf("apollo.metaApi", "false");
    }

    /**
     * Run the service with the given program arguments.
     *
     * @param args  The program arguments to use
     * @return This ServiceHelper instance
     */
    public ServiceHelper args(String... args) {
        this.args = args;
        return this;
    }

    /**
     * Run the service with the key/value pair defined in the loaded configuration. The key/values
     * defined through this method will be overlayed over any existing config loaded through the
     * given service name when creating this ServiceHelper.
     *
     * @param key    The key to define
     * @param value  The value to associate with the key
     * @return This ServiceHelper instance
     */
    public ServiceHelper conf(String key, String value) {
        conf = conf.withValue(key, ConfigValueFactory.fromAnyRef(value, "Overridden var in ServiceHelper: " + key));
        return this;
    }

    /**
     * Run the service with the key/value pair defined in the loaded configuration. The key/values
     * defined through this method will be overlayed over any existing config loaded through the
     * given service name when creating this ServiceHelper.
     *
     * @param key   The key to define
     * @param value The value for the configuration. It can be any accepted type as described by
     *              this method's documentation:
     *              {@link com.typesafe.config.ConfigValueFactory#fromAnyRef(
     *              java.lang.Object, java.lang.String)}
     * @return      This ServiceHelper instance
     */
    public ServiceHelper conf(String key, Object value) {
        conf = conf.withValue(key, ConfigValueFactory.fromAnyRef(value, "Overridden var in ServiceHelper: " + key));
        return this;
    }

    /**
     * Reset a key in the configuration
     *
     * @param key The path to unset
     * @return    This ServiceHelper instance
     */
    public ServiceHelper resetConf(String key) {
        conf = conf.withoutPath(key);
        return this;
    }

    /**
     * Determines whether to forward requests for which nothing has been stubbed. The default is
     * true. If false, requests that don't match stubs will fail.
     *
     * @param forward whether to enable forwarding
     */
    public ServiceHelper forwardingNonStubbedRequests(boolean forward) {
        this.forwardNonStubbedRequests = forward;
        return this;
    }

    /**
     * Set the time to wait for the service to start before giving up. The default value is 5.
     */
    public ServiceHelper startTimeoutSeconds(int timeoutSeconds) {
        this.timeoutSeconds = timeoutSeconds;
        return this;
    }

    public ServiceHelper withModule(ApolloModule module) {
        this.additionalModules.add(module);
        return this;
    }

    /**
     * Set the scheme to be used for relative request uris on this ServiceHelper instance.
     *
     * When request() methods are called with a uri without scheme, scheme://serviceName is prepended
     *
     * @param scheme The scheme to be used for relative request uris (without "://")
     * @return      This ServiceHelper instance
     */
    public ServiceHelper scheme(String scheme) {
        Preconditions.checkArgument(SCHEME_RE.matcher(scheme).matches(),
                "Illegal scheme format in " + scheme + " (no not include ://)");
        this.scheme = scheme;
        return this;
    }

    /**
     * A {@link StubClient} that can be used to mock outgoing application request responses.
     *
     * @return the stub client for this service helper instance
     */
    public StubClient stubClient() {
        return stubClient;
    }

    /**
     * Get a {@link Client} that allows to make requests to the service created by this helper
     * @return A client that can resolve requests to this service
     */
    public Client serviceClient() {
        return serviceClient;
    }

    /**
     * Make a call to the running application and return a {@link CompletionStage} of the response.
     *
     * @param request  The request to send to the application
     * @return A future of the response
     */
    public CompletionStage<Response<ByteString>> request(Request request) {
        if (started.getCount() != 0) {
            throw new IllegalStateException(
                    "ServiceHelper not started. This can be solved setting it up as a JUnit @Rule or calling the start() method.");
        }

        final FakeOngoingRequest ongoingRequest = new FakeOngoingRequest(request);
        requestHandler.handle(ongoingRequest);
        return ongoingRequest.getReply();
    }

    @VisibleForTesting
    String addSchemaAuthForRelative(String uriString) {
        if (uriString.startsWith("/")) { // relative
            return scheme + "://" + serviceName + uriString;
        } else {
            return uriString;
        }
    }

    /**
     * Makes a call on the given uri. The uri can be an application relative path such as
     * {@code "/ping"} or a full path like {@link "http://<service-name>/ping"}.
     *
     * @param method  The method of the call
     * @param uri     The uri of the call
     * @return A future of the response
     */
    public CompletionStage<Response<ByteString>> request(String method, String uri) {
        return request(method, URI.create(uri));
    }

    /**
     * Makes a call on the given uri. The uri can be an application relative path such as
     * {@code "/ping"} or a full path like {@link "http://<service-name>/ping"}.
     *
     * @param method  The method of the call
     * @param uri     The uri of the call
     * @return A future of the response
     */
    public CompletionStage<Response<ByteString>> request(String method, URI uri) {
        final String uriString = addSchemaAuthForRelative(uri.toString());

        return request(Request.forUri(uriString, method));
    }

    /**
     * Makes a call on the given uri. The uri can be an application relative path such as
     * {@code "/ping"} or a full path like {@link "http://<service-name>/ping"}.
     *
     * @param method  The method of the call
     * @param uri     The uri of the call
     * @param payload A payload body
     * @return A future of the response
     */
    public CompletionStage<Response<ByteString>> request(String method, String uri, ByteString payload) {
        return request(method, URI.create(uri), payload);
    }

    /**
     * Makes a call on the given uri. The uri can be an application relative path such as
     * {@code "/ping"} or a full path like {@link "http://<service-name>/ping"}.
     *
     * @param method  The method of the call
     * @param uri     The uri of the call
     * @param payload A payload body
     * @return A future of the response
     */
    public CompletionStage<Response<ByteString>> request(String method, URI uri, ByteString payload) {
        final String uriString = addSchemaAuthForRelative(uri.toString());

        return request(Request.forUri(uriString, method).withPayload(payload));
    }

    @Override
    public Statement apply(final Statement base, final Description description) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                try {
                    start(timeoutSeconds, args);
                    base.evaluate();
                } catch (Exception e) {
                    throw Throwables.propagate(e);
                } finally {
                    ServiceHelper.this.close();
                }
            }
        };
    }

    @Override
    public void close() throws IOException {
        try {
            stubClient.clear();
        } catch (Exception e) {
            LOG.warn("Exception when clearing StubClient", e);
        }
        shutdown();
    }

    /**
     * Starts the service, blocking for maximum {@code timeoutSeconds} until it has come up.
     */
    public void start() throws InterruptedException {
        start(timeoutSeconds, args);
    }

    private void start(int timeoutSeconds, final String... args) throws InterruptedException {
        checkState(currentHelperFuture == null, "currentHelperFuture non-null!");

        currentHelperFuture = executor.submit(() -> {
            try {
                Service.Builder serviceBuilder = Services.usingName(serviceName).usingModuleDiscovery(false)
                        .withModule(ApolloEnvironmentModule.create()).withModule(HttpClientModule.create())
                        .withModule(ForwardingStubClientModule.create(forwardNonStubbedRequests,
                                stubClient.asRequestAwareClient()));

                for (ApolloModule module : additionalModules) {
                    serviceBuilder = serviceBuilder.withModule(module);
                }

                final Service service = serviceBuilder.build();

                LOG.info("Starting with args: {}", Arrays.toString(args));

                try (Service.Instance instance = service.start(args, conf)) {
                    final RequestHandler envRequestHandler = ApolloEnvironmentModule.environment(instance)
                            .initialize(appInit);

                    final ApolloConfig config = instance.resolve(ApolloConfig.class);

                    LOG.info("Started {} with backend domain '{}'", serviceName, config.backend());

                    instanceCreated(instance, envRequestHandler);

                    instance.waitForShutdown();

                    LOG.info("Shutting down {}", serviceName);
                }
            } catch (Throwable e) {
                LOG.error("Failed to start service", e);
                started.countDown();
            }
        });
        if (!started.await(timeoutSeconds, TimeUnit.SECONDS)) {
            currentHelperFuture.cancel(true);
            currentHelperFuture = null;
            throw new IllegalStateException("Service did not start within a reasonable time");
        }
        if (instance == null) {
            throw new IllegalStateException("Service failed during startup");
        }
    }

    private void instanceCreated(Service.Instance instance, RequestHandler requestHandler) {
        try {
            shutdown();
        } catch (Throwable throwable) {
            LOG.warn("failed to shutdown previous instance", throwable);
        }

        LOG.info("Got instance {}", instance);
        this.instance = instance;
        this.requestHandler = requestHandler;
        started.countDown();
    }

    private void shutdown() {
        if (instance != null) {
            instance.getSignaller().signalShutdown();
            requestHandler = null;
            instance = null;
            Futures.getUnchecked(currentHelperFuture);
            currentHelperFuture = null;
        }
    }
}