com.spotify.apollo.test.ServiceHelper Maven / Gradle / Ivy
/*
* -\-\-
* 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;
/**
* 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.
*
* 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.
*
* Requests can be sent to the running application using any of {@link #request} methods.
*
* Example usage for testing a route provider
*
* {@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()
* .registerAutoRoutes(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"));
* }
* }
*
*
* Example usage for system or acceptance tests
*
* {@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"));
* }
* }
*
*
* Faking outgoing request responses
*
* 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()}.
*
* See {@link StubClient} for more docs on how to set up mocked request replies.
*/
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 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_DOMAIN.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> 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:///ping"}.
*
* @param method The method of the call
* @param uri The uri of the call
* @return A future of the response
*/
public CompletionStage> 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:///ping"}.
*
* @param method The method of the call
* @param uri The uri of the call
* @return A future of the response
*/
public CompletionStage> 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:///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> 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:///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> 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;
}
}
}