blob: 5c45faad76ed6ec89c6902fa6b7cc57be6419cd3 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.jclouds.rest.internal;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
import static com.google.inject.name.Names.named;
import static org.jclouds.Constants.PROPERTY_IDEMPOTENT_METHODS;
import static org.jclouds.Constants.PROPERTY_MAX_RETRIES;
import static org.jclouds.Constants.PROPERTY_USER_THREADS;
import static org.testng.Assert.assertEquals;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.custommonkey.xmlunit.Diff;
import org.custommonkey.xmlunit.Difference;
import org.custommonkey.xmlunit.DifferenceConstants;
import org.custommonkey.xmlunit.DifferenceListener;
import org.custommonkey.xmlunit.NodeDetail;
import org.custommonkey.xmlunit.XMLUnit;
import org.jclouds.ContextBuilder;
import org.jclouds.apis.ApiMetadata;
import org.jclouds.concurrent.SingleThreaded;
import org.jclouds.concurrent.config.ConfiguresExecutorService;
import org.jclouds.date.internal.DateServiceDateCodecFactory;
import org.jclouds.date.internal.SimpleDateFormatDateService;
import org.jclouds.http.HttpCommandExecutorService;
import org.jclouds.http.HttpRequest;
import org.jclouds.http.HttpResponse;
import org.jclouds.http.HttpUtils;
import org.jclouds.http.IOExceptionRetryHandler;
import org.jclouds.http.config.ConfiguresHttpCommandExecutorService;
import org.jclouds.http.handlers.DelegatingErrorHandler;
import org.jclouds.http.handlers.DelegatingRetryHandler;
import org.jclouds.http.internal.BaseHttpCommandExecutorService;
import org.jclouds.http.internal.HttpWire;
import org.jclouds.io.ContentMetadataCodec;
import org.jclouds.io.ContentMetadataCodec.DefaultContentMetadataCodec;
import org.jclouds.io.Payload;
import org.jclouds.io.Payloads;
import org.jclouds.logging.config.NullLoggingModule;
import org.jclouds.providers.ProviderMetadata;
import org.jclouds.rest.HttpApiMetadata;
import org.jclouds.rest.config.CredentialStoreModule;
import org.jclouds.util.Strings2;
import org.w3c.dom.Node;
import com.google.common.annotations.Beta;
import com.google.common.base.Function;
import com.google.common.base.Objects;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteSource;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.SimpleTimeLimiter;
import com.google.common.util.concurrent.TimeLimiter;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.inject.AbstractModule;
import com.google.inject.Binder;
import com.google.inject.Injector;
import com.google.inject.Module;
import com.google.inject.Provides;
import com.google.inject.TypeLiteral;
/**
*
* Allows us to test a client via its side effects.
*
* <p/>
* Example usage:
*
* <pre>
*
* HttpRequest bucketFooExists = HttpRequest.builder().method(&quot;HEAD&quot;).endpoint(
* URI.create(&quot;https://s3.amazonaws.com/foo?max-keys=0&quot;)).headers(
* ImmutableMultimap.&lt;String, String&gt; builder().put(&quot;Date&quot;, CONSTANT_DATE)
* .put(&quot;Authorization&quot;, &quot;AWS identity:86P4BBb7xT+gBqq7jxM8Tc28ktY=&quot;).build()).build();
*
* S3Client clientWhenBucketExists = requestSendsResponse(bucketFooExists, HttpResponse.builder().statusCode(200).build());
* assert clientWhenBucketExists.bucketExists(&quot;foo&quot;);
*
* S3Client clientWhenBucketDoesntExist = requestSendsResponse(bucketFooExists, HttpResponse.builder().statusCode(404)
* .build());
* assert !clientWhenBucketDoesntExist.bucketExists(&quot;foo&quot;);
* </pre>
*/
@Beta
public abstract class BaseRestApiExpectTest<S> {
protected String provider = "mock";
protected ContentMetadataCodec contentMetadataCodec = new DefaultContentMetadataCodec(
new DateServiceDateCodecFactory(new SimpleDateFormatDateService()));
/**
* Override this to supply alternative bindings for use in the test. This is commonly used to
* override suppliers of dates so that the test results are predicatable.
*
* @return optional guice module which can override bindings
*/
protected Module createModule() {
return new Module() {
@Override
public void configure(Binder binder) {
}
};
}
/**
* Convenience method used when creating a response that includes an http payload.
*
* <p/>
* ex.
*
* <pre>
* HttpResponse.builder().statusCode(200).payload(payloadFromResource(&quot;/ip_get_details.json&quot;)).build()
* </pre>
*
* @param resource
* resource file such as {@code /serverlist.json}
* @return payload for use in http responses.
*/
public Payload payloadFromResource(String resource) {
try {
return payloadFromString(Strings2.toStringAndClose(getClass().getResourceAsStream(resource)));
} catch (IOException e) {
throw Throwables.propagate(e);
}
}
public Payload payloadFromResourceWithContentType(String resource, String contentType) {
try {
return payloadFromStringWithContentType(Strings2.toStringAndClose(getClass().getResourceAsStream(resource)),
contentType);
} catch (IOException e) {
throw Throwables.propagate(e);
}
}
public static Payload payloadFromString(String payload) {
return Payloads.newStringPayload(payload);
}
public static Payload payloadFromStringWithContentType(String payload, String contentType) {
Payload p = Payloads.newStringPayload(payload);
p.getContentMetadata().setContentType(contentType);
return p;
}
/**
* Mock executor service which uses the supplied function to return http responses.
*/
@SingleThreaded
@Singleton
public static class ExpectHttpCommandExecutorService extends BaseHttpCommandExecutorService<HttpRequest> {
private final Function<HttpRequest, HttpResponse> fn;
@Inject
public ExpectHttpCommandExecutorService(Function<HttpRequest, HttpResponse> fn, HttpUtils utils,
ContentMetadataCodec contentMetadataCodec, IOExceptionRetryHandler ioRetryHandler,
DelegatingRetryHandler retryHandler, DelegatingErrorHandler errorHandler, HttpWire wire,
@Named(PROPERTY_IDEMPOTENT_METHODS) String idempotentMethods) {
super(utils, contentMetadataCodec, retryHandler, ioRetryHandler, errorHandler, wire, idempotentMethods);
this.fn = checkNotNull(fn, "fn");
}
@Override
public void cleanup(HttpRequest nativeResponse) {
if (nativeResponse != null && nativeResponse.getPayload() != null)
nativeResponse.getPayload().release();
}
@Override
public HttpRequest convert(HttpRequest request) throws IOException, InterruptedException {
return request;
}
@Override
public HttpResponse invoke(HttpRequest nativeRequest) throws IOException, InterruptedException {
return fn.apply(nativeRequest);
}
}
@ConfiguresHttpCommandExecutorService
@ConfiguresExecutorService
public static class ExpectModule extends AbstractModule {
private final Function<HttpRequest, HttpResponse> fn;
public ExpectModule(Function<HttpRequest, HttpResponse> fn) {
this.fn = checkNotNull(fn, "fn");
}
@Override
public void configure() {
bind(ListeningExecutorService.class).annotatedWith(named(PROPERTY_USER_THREADS)).toInstance(newDirectExecutorService());
bind(new TypeLiteral<Function<HttpRequest, HttpResponse>>() {
}).toInstance(fn);
bind(HttpCommandExecutorService.class).to(ExpectHttpCommandExecutorService.class);
}
@Provides
@Singleton
TimeLimiter timeLimiter(@Named(PROPERTY_USER_THREADS) ListeningExecutorService userExecutor) {
return SimpleTimeLimiter.create(userExecutor);
}
}
/**
* creates a client for a mock server which only responds to a single http request
*
* @param request
* the http request the mock server responds to
* @param response
* the response the mock server returns for the request
* @return a client configured with this behavior
*/
public S requestSendsResponse(HttpRequest request, HttpResponse response) {
return requestSendsResponse(request, response, createModule());
}
public S requestSendsResponse(HttpRequest request, HttpResponse response, Module module) {
return requestsSendResponses(ImmutableMap.of(request, response), module);
}
/**
* creates a client for a mock server which only responds to two types of requests
*
* @param requestA
* an http request the mock server responds to
* @param responseA
* the response for {@code requestA}
* @param requestB
* another http request the mock server responds to
* @param responseB
* the response for {@code requestB}
* @return a client configured with this behavior
*/
public S requestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB) {
return requestsSendResponses(requestA, responseA, requestB, responseB, createModule());
}
public S requestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB, Module module) {
return requestsSendResponses(ImmutableMap.of(requestA, responseA, requestB, responseB), module);
}
/**
* creates a client for a mock server which only responds to three types of requests
*
* @param requestA
* an http request the mock server responds to
* @param responseA
* the response for {@code requestA}
* @param requestB
* another http request the mock server responds to
* @param responseB
* the response for {@code requestB}
* @param requestC
* another http request the mock server responds to
* @param responseC
* the response for {@code requestC}
* @return a client configured with this behavior
*/
public S requestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB, HttpRequest requestC, HttpResponse responseC) {
return requestsSendResponses(requestA, responseA, requestB, responseB, requestC, responseC, createModule());
}
public S requestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB, HttpRequest requestC, HttpResponse responseC, Module module) {
return requestsSendResponses(ImmutableMap.of(requestA, responseA, requestB, responseB, requestC, responseC),
module);
}
public S orderedRequestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB) {
return orderedRequestsSendResponses(ImmutableList.of(requestA, requestB), ImmutableList.of(responseA, responseB));
}
public S orderedRequestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB, HttpRequest requestC, HttpResponse responseC) {
return orderedRequestsSendResponses(ImmutableList.of(requestA, requestB, requestC), ImmutableList.of(responseA,
responseB, responseC));
}
public S orderedRequestsSendResponses(HttpRequest requestA, HttpResponse responseA, HttpRequest requestB,
HttpResponse responseB, HttpRequest requestC, HttpResponse responseC, HttpRequest requestD,
HttpResponse responseD) {
return orderedRequestsSendResponses(ImmutableList.of(requestA, requestB, requestC, requestD), ImmutableList.of(
responseA, responseB, responseC, responseD));
}
public S orderedRequestsSendResponses(final List<HttpRequest> requests, final List<HttpResponse> responses) {
final AtomicInteger counter = new AtomicInteger(0);
return createClient(new Function<HttpRequest, HttpResponse>() {
@Override
public HttpResponse apply(HttpRequest input) {
int index = counter.getAndIncrement();
if (index >= requests.size())
return HttpResponse.builder().statusCode(500).message(
String.format("request %s is out of range (%s)", index, requests.size())).payload(
Payloads.newStringPayload(renderRequest(input))).build();
if (!httpRequestsAreEqual(input, requests.get(index))) {
assertEquals(renderRequest(input), renderRequest(requests.get(index)),
"Actual request did not match expected request " + index);
}
return responses.get(index);
}
});
}
/**
* creates a client for a mock server which returns responses for requests based on the supplied
* Map parameter.
*
* @param requestToResponse
* valid requests and responses for the mock to respond to
* @return a client configured with this behavior
*/
public S requestsSendResponses(Map<HttpRequest, HttpResponse> requestToResponse) {
return requestsSendResponses(requestToResponse, createModule());
}
protected enum HttpRequestComparisonType {
XML, JSON, DEFAULT;
}
/**
* How should this HttpRequest be compared with others?
*/
protected HttpRequestComparisonType compareHttpRequestAsType(HttpRequest input) {
return HttpRequestComparisonType.DEFAULT;
}
/**
* Compare two requests as instructed by {@link #compareHttpRequestAsType(HttpRequest)} - default
* is to compare using Objects.equal
*/
public boolean httpRequestsAreEqual(HttpRequest a, HttpRequest b) {
try {
if (a == null || b == null || !Objects.equal(a.getRequestLine(), b.getRequestLine())
|| !Objects.equal(a.getHeaders(), b.getHeaders())) {
return false;
}
if (a.getPayload() == null || b.getPayload() == null) {
return Objects.equal(a, b);
}
switch (compareHttpRequestAsType(a)) {
case XML: {
Diff diff = XMLUnit.compareXML(Strings2.toStringAndClose(a.getPayload().openStream()),
Strings2.toStringAndClose(b.getPayload().openStream()));
// Ignoring whitespace in elements that have other children, xsi:schemaLocation and
// differences in namespace prefixes
diff.overrideDifferenceListener(new DifferenceListener() {
@Override
public int differenceFound(Difference diff) {
if (diff.getId() == DifferenceConstants.SCHEMA_LOCATION_ID
|| diff.getId() == DifferenceConstants.NAMESPACE_PREFIX_ID) {
return RETURN_IGNORE_DIFFERENCE_NODES_IDENTICAL;
}
if (diff.getId() == DifferenceConstants.TEXT_VALUE_ID) {
for (NodeDetail detail : ImmutableSet.of(diff.getControlNodeDetail(), diff.getTestNodeDetail())) {
if (detail.getNode().getParentNode().getChildNodes().getLength() < 2
|| !detail.getValue().trim().isEmpty()) {
return RETURN_ACCEPT_DIFFERENCE;
}
}
return RETURN_IGNORE_DIFFERENCE_NODES_IDENTICAL;
}
return RETURN_ACCEPT_DIFFERENCE;
}
@Override
public void skippedComparison(Node node, Node node1) {
}
});
return diff.identical();
}
case JSON: {
JsonParser parser = new JsonParser();
JsonElement payloadA = parser.parse(Strings2.toStringAndClose(a.getPayload().openStream()));
JsonElement payloadB = parser.parse(Strings2.toStringAndClose(b.getPayload().openStream()));
return Objects.equal(payloadA, payloadB);
}
default: {
return Objects.equal(a, b);
}
}
} catch (Exception e) {
throw Throwables.propagate(e);
}
}
public S requestsSendResponses(final Map<HttpRequest, HttpResponse> requestToResponse, Module module) {
return requestsSendResponses(requestToResponse, module, setupProperties());
}
public S requestsSendResponses(final Map<HttpRequest, HttpResponse> requestToResponse, Module module,
Properties props) {
return createClient(new Function<HttpRequest, HttpResponse>() {
@Override
public HttpResponse apply(HttpRequest input) {
HttpRequest matchedRequest = null;
HttpResponse response = null;
for (Map.Entry<HttpRequest, HttpResponse> entry : requestToResponse.entrySet()) {
HttpRequest request = entry.getKey();
if (httpRequestsAreEqual(input, request)) {
matchedRequest = request;
response = entry.getValue();
}
}
if (response == null) {
StringBuilder payload = new StringBuilder("\n");
payload.append("\n----------------------------------------\n");
payload.append("The following request is not configured:\n");
payload.append("----------------------------------------\n");
payload.append(renderRequest(input));
payload.append("\n----------------------------------------\n");
payload.append("Configured requests:\n");
for (HttpRequest request : requestToResponse.keySet()) {
payload.append("\n----------------------------------------\n");
payload.append(renderRequest(request));
}
response = HttpResponse.builder().statusCode(500).message("no response configured for request").payload(
Payloads.newStringPayload(payload.toString())).build();
} else if (compareHttpRequestAsType(input) == HttpRequestComparisonType.DEFAULT) {
// in case hashCode/equals doesn't do a full content check
assertEquals(renderRequest(input), renderRequest(matchedRequest));
}
return response;
}
}, module, props);
}
public String renderRequest(HttpRequest request) {
StringBuilder builder = new StringBuilder().append(request.getRequestLine()).append('\n');
for (Entry<String, String> header : request.getHeaders().entries()) {
builder.append(header.getKey()).append(": ").append(header.getValue()).append('\n');
}
if (request.getPayload() != null) {
for (Entry<String, String> header : contentMetadataCodec.toHeaders(
request.getPayload().getContentMetadata()).entries()) {
builder.append(header.getKey()).append(": ").append(header.getValue()).append('\n');
}
try {
builder.append('\n').append(Strings2.toStringAndClose(request.getPayload().openStream()));
} catch (IOException e) {
throw Throwables.propagate(e);
}
} else {
builder.append('\n');
}
return builder.toString();
}
public S createClient(Function<HttpRequest, HttpResponse> fn) {
return createClient(fn, createModule(), setupProperties());
}
public S createClient(Function<HttpRequest, HttpResponse> fn, Module module) {
return createClient(fn, module, setupProperties());
}
public S createClient(Function<HttpRequest, HttpResponse> fn, Properties props) {
return createClient(fn, createModule(), props);
}
@SuppressWarnings("unchecked")
public S createClient(Function<HttpRequest, HttpResponse> fn, Module module, Properties props) {
return (S) createInjector(fn, module, props).getInstance(api);
}
protected String identity = "identity";
protected String credential = "credential";
protected Class<?> api;
/**
* @see org.jclouds.providers.Providers#withId
*/
protected ProviderMetadata createProviderMetadata() {
return null;
}
/**
* @see org.jclouds.apis.Apis#withId
*/
protected ApiMetadata createApiMetadata() {
return null;
}
protected Injector createInjector(Function<HttpRequest, HttpResponse> fn, Module module, Properties props) {
ContextBuilder builder = null;
if (provider != null)
try {
builder = ContextBuilder.newBuilder(provider).credentials(identity, credential);
} catch (NoSuchElementException e) {
Logger
.getAnonymousLogger()
.warning(
"provider ["
+ provider
+ "] is not setup as META-INF/services/org.jclouds.apis.ApiMetadata or META-INF/services/org.jclouds.providers.ProviderMetadata");
}
if (builder == null) {
ProviderMetadata pm = createProviderMetadata();
ApiMetadata am = (pm != null) ? pm.getApiMetadata() : checkNotNull(createApiMetadata(),
"either createApiMetadata or createProviderMetadata must be overridden");
builder = pm != null ? ContextBuilder.newBuilder(pm) : ContextBuilder.newBuilder(am);
}
ApiMetadata am = builder.getApiMetadata();
if (am instanceof HttpApiMetadata) {
this.api = HttpApiMetadata.class.cast(am).getApi();
} else {
throw new UnsupportedOperationException("unsupported base type: " + am);
}
// isolate tests from eachother, as default credentialStore is static
return builder.credentials(identity, credential)
.modules(ImmutableSet.of(new ExpectModule(fn), new NullLoggingModule(), new CredentialStoreModule(new ConcurrentHashMap<String, ByteSource>()), module))
.overrides(props)
.buildInjector();
}
/**
* override this to supply context-specific parameters during tests.
*/
protected Properties setupProperties() {
Properties props = new Properties();
props.put(PROPERTY_MAX_RETRIES, 1);
return props;
}
}