/*
* Copyright (c) 2012 - 2016 Jadler contributors
* This program is made available under the terms of the MIT License.
*/
package net.jadler;
import net.jadler.stubbing.Stubber;
import net.jadler.stubbing.server.StubHttpServerManager;
import java.nio.charset.Charset;
import java.util.ArrayList;
import net.jadler.stubbing.RequestStubbing;
import net.jadler.stubbing.StubbingFactory;
import net.jadler.stubbing.Stubbing;
import net.jadler.stubbing.StubResponse;
import net.jadler.stubbing.HttpStub;
import net.jadler.exception.JadlerException;
import net.jadler.stubbing.server.StubHttpServer;
import java.util.Collection;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import net.jadler.mocking.Mocker;
import net.jadler.mocking.VerificationException;
import net.jadler.mocking.Verifying;
import org.apache.commons.collections.MultiMap;
import org.apache.commons.collections.map.MultiValueMap;
import org.apache.commons.lang.Validate;
import org.hamcrest.Description;
import org.hamcrest.StringDescription;
import org.hamcrest.Matcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.hamcrest.Matchers.allOf;
/**
* <p>This class represents the very hearth of the Jadler library. It acts as a great {@link Stubber} providing
* a way to create new http stubs, {@link StubHttpServerManager} allowing the client to manage the state
* of the underlying stub http server and {@link RequestManager} providing stub response definitions
* according to a given http request.</p>
*
* <p>An underlying stub http server instance is registered to an instance of this class during the instantiation.</p>
*
* <p>Normally you shouldn't create instances of this on your own, use the {@link Jadler} facade instead.
* However, if more http stub servers are needed in one execution thread (for example two http stub servers
* listening on different ports) have no fear, go ahead and create two or more instances directly.</p>
*
* <p>This class is stateful and thread-safe.</p>
*/
public class JadlerMocker implements StubHttpServerManager, Stubber, RequestManager, Mocker {
private final StubHttpServer server;
private final StubbingFactory stubbingFactory;
private final List<Stubbing> stubbings;
private Deque<HttpStub> httpStubs;
private final List<Request> receivedRequests;
private MultiMap defaultHeaders;
private int defaultStatus;
private Charset defaultEncoding;
private boolean recordRequests = true;
private boolean started = false;
private boolean configurable = true;
private static final StubResponse NO_RULE_FOUND_RESPONSE;
static {
NO_RULE_FOUND_RESPONSE = StubResponse.builder()
.status(404)
.body("No stub response found for the incoming request", Charset.forName("UTF-8"))
.header("Content-Type", "text/plain; charset=utf-8")
.build();
}
private static final Logger logger = LoggerFactory.getLogger(JadlerMocker.class);
/**
* Creates new JadlerMocker instance bound to the given http stub server.
*
* @param server stub http server instance this mocker should use
*/
public JadlerMocker(final StubHttpServer server) {
this(server, new StubbingFactory());
}
/**
* Package private constructor, for testing purposes only! Allows to define a {@link StubbingFactory} instance
* as well.
*
* @param server stub http server instance this mocker should use
* @param stubbingFactory a factory to create stubbing instances
*/
JadlerMocker(final StubHttpServer server, final StubbingFactory stubbingFactory) {
Validate.notNull(server, "server cannot be null");
this.server = server;
this.stubbings = new LinkedList<Stubbing>();
this.defaultHeaders = new MultiValueMap();
this.defaultStatus = 200;
this.defaultEncoding = Charset.forName("UTF-8");
Validate.notNull(stubbingFactory, "stubbingFactory cannot be null");
this.stubbingFactory = stubbingFactory;
this.httpStubs = new LinkedList<HttpStub>();
this.receivedRequests = new ArrayList<Request>();
}
/**
* {@inheritDoc}
*/
@Override
public void start() {
if (this.started) {
throw new IllegalStateException("The stub server has been started already.");
}
logger.debug("starting the underlying stub server...");
this.server.registerRequestManager(this);
try {
server.start();
} catch (final Exception ex) {
throw new JadlerException("Stub http server start failure", ex);
}
this.started = true;
}
/**
* {@inheritDoc}
*/
@Override
public void close() {
if (!this.started) {
throw new IllegalStateException("The stub server hasn't been started yet.");
}
logger.debug("stopping the underlying stub server...");
try {
server.stop();
} catch (final Exception ex) {
throw new JadlerException("Stub http server shutdown failure", ex);
}
this.started = false;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isStarted() {
return this.started;
}
/**
* {@inheritDoc}
*/
@Override
public int getStubHttpServerPort() {
if (!this.started) {
throw new IllegalStateException("The stub http server hasn't been started yet.");
}
return server.getPort();
}
/**
* Adds a default header to be added to every stub http response.
* @param name header name (cannot be empty)
* @param value header value (cannot be <tt>null</tt>)
*/
public void addDefaultHeader(final String name, final String value) {
Validate.notEmpty(name, "header name cannot be empty");
Validate.notNull(value, "header value cannot be null, use an empty string instead");
this.checkConfigurable();
this.defaultHeaders.put(name, value);
}
/**
* Defines a default status to be returned in every stub http response (if not redefined in the
* particular stub rule)
* @param defaultStatus status to be returned in every stub http response. Must be at least 0.
*/
public void setDefaultStatus(final int defaultStatus) {
Validate.isTrue(defaultStatus >= 0, "defaultStatus mustn't be negative");
this.checkConfigurable();
this.defaultStatus = defaultStatus;
}
/**
* Defines default charset of every stub http response (if not redefined in the particular stub)
* @param defaultEncoding default encoding of every stub http response
*/
public void setDefaultEncoding(final Charset defaultEncoding) {
Validate.notNull(defaultEncoding, "defaultEncoding cannot be null");
this.checkConfigurable();
this.defaultEncoding = defaultEncoding;
}
/**
* {@inheritDoc}
*/
@Override
public RequestStubbing onRequest() {
logger.debug("adding new stubbing...");
this.checkConfigurable();
final Stubbing stubbing = this.stubbingFactory.createStubbing(defaultEncoding, defaultStatus, defaultHeaders);
stubbings.add(stubbing);
return stubbing;
}
/**
* {@inheritDoc}
*/
@Override
public StubResponse provideStubResponseFor(final Request request) {
synchronized(this) {
if (this.configurable) {
this.configurable = false;
this.httpStubs = this.createHttpStubs();
}
if (this.recordRequests) {
this.receivedRequests.add(request);
}
}
for (final Iterator<HttpStub> it = this.httpStubs.descendingIterator(); it.hasNext(); ) {
final HttpStub rule = it.next();
if (rule.matches(request)) {
final StringBuilder sb = new StringBuilder();
sb.append("Following rule will be applied:\n");
sb.append(rule);
logger.debug(sb.toString());
return rule.nextResponse(request);
}
}
final StringBuilder sb = new StringBuilder();
sb.append("No suitable rule found. Reason:\n");
for (final HttpStub rule: this.httpStubs) {
sb.append("The rule '");
sb.append(rule);
sb.append("' cannot be applied. Mismatch:\n");
sb.append(rule.describeMismatch(request));
sb.append("\n");
}
logger.info(sb.toString());
return NO_RULE_FOUND_RESPONSE;
}
/**
* {@inheritDoc}
*/
@Override
public Verifying verifyThatRequest() {
checkRequestRecording();
return new Verifying(this);
}
/**
* {@inheritDoc}
*/
@Deprecated
@Override
public int numberOfRequestsMatching(final Collection<Matcher<? super Request>> predicates) {
Validate.notNull(predicates, "predicates cannot be null");
checkRequestRecording();
final Matcher<Request> all = allOf(predicates);
int cnt = 0;
synchronized(this) {
for (final Request req: this.receivedRequests) {
if (all.matches(req)) {
cnt++;
}
}
}
return cnt;
}
@Override
public void evaluateVerification(final Collection<Matcher<? super Request>> requestPredicates,
final Matcher<Integer> nrRequestsPredicate) {
Validate.notNull(requestPredicates, "requestPredicates cannot be null");
Validate.notNull(nrRequestsPredicate, "nrRequestsPredicate cannot be null");
this.checkRequestRecording();
synchronized(this) {
final int cnt = this.numberOfRequestsMatching(requestPredicates);
if (!nrRequestsPredicate.matches(cnt)) {
this.logReceivedRequests(requestPredicates);
throw new VerificationException(this.mismatchDescription(cnt, requestPredicates, nrRequestsPredicate));
}
}
}
/**
* <p>Resets this mocker instance so it can be reused. This method clears all previously created stubs as well as
* stored received requests (for mocking purpose,
* see {@link RequestManager#numberOfRequestsMatching(java.util.Collection)}). Once this method has been called
* new stubs can be created again using {@link #onRequest()}.</p>
*
* <p>Please note that calling this method in a test body <strong>always</strong> signalizes a poorly written test
* with a problem with the granularity. In this case consider writing more fine grained tests instead of using this
* method.</p>
*
* <p>While the standard Jadler lifecycle consists of creating new instance of this class and starting the
* underlying stub server (using {@link #start()}) in the <em>before</em> section of a test and stopping
* the server (using {@link #close()}) in the <em>after</em> section, in some specific scenarios it could be useful
* to reuse one instance of this class in all tests instead.</p>
*
* <p>When more than just one instance of this class is used in a test suite (for mocking more http servers) it
* could take some time to start all underlying stub servers before and stop these after every test method. This is
* a typical use case this method might come to help.</p>
*
* <p>Here's an example code using jUnit which demonstrates usage of this method in a test lifecycle:</p>
*
* <pre>
* public class JadlerResetIntegrationTest {
* private static final JadlerMocker mocker = new JadlerMocker(new JettyStubHttpServer());
*
* {@literal @}BeforeClass
* public static void beforeTests() {
* mocker.start();
* }
*
* {@literal @}AfterClass
* public static void afterTests() {
* mocker.close();
* }
*
* {@literal @}After
* public void reset() {
* mocker.reset();
* }
*
* {@literal @}Test
* public void test1() {
* mocker.onRequest().respond().withStatus(201);
*
* //do an http request here, 201 should be returned from the stub server
*
* verifyThatRequest().receivedOnce();
* }
*
* {@literal @}Test
* public void test2() {
* mocker.onRequest().respond().withStatus(400);
*
* //do an http request here, 400 should be returned from the stub server
*
* verifyThatRequest().receivedOnce();
* }
* }
* </pre>
*/
public void reset() {
synchronized(this) {
this.stubbings.clear();
this.httpStubs.clear();
this.receivedRequests.clear();
this.configurable = true;
}
}
/**
* <p>By default Jadler records all incoming requests (including their bodies) so it can provide mocking
* (verification) features defined in {@link net.jadler.mocking.Mocker}.</p>
*
* <p>In some very specific corner cases this implementation of mocking can cause troubles. For example imagine
* a long running performance test using Jadler for stubbing some remote http service. Since such a test can issue
* thousands or even millions of requests the memory consumption probably would affect the test results (either
* by a performance slowdown or even crashes). In this specific scenarios you should consider disabling
* the incoming requests recording using this method.</p>
*
* <p>When disabled calling {@link net.jadler.mocking.Mocker#verifyThatRequest()} will result in
* {@link java.lang.IllegalStateException}</p>
*
* <p>Please note you should ignore this option almost every time you use Jadler unless you are really
* convinced about it. Because premature optimization is the root of all evil, you know.</p>
*
* @param recordRequests {@code true} for enabling http requests recording, {@code false} for disabling it
*/
public void setRecordRequests(final boolean recordRequests) {
this.checkConfigurable();
this.recordRequests = recordRequests;
}
private Deque<HttpStub> createHttpStubs() {
final Deque<HttpStub> stubs = new LinkedList<HttpStub>();
for (final Stubbing stub : stubbings) {
stubs.add(stub.createRule());
}
return stubs;
}
private void logReceivedRequests(final Collection<Matcher<? super Request>> requestPredicates) {
final StringBuilder sb = new StringBuilder("Verification failed, here is a list of requests received so far:");
this.appendNoneIfEmpty(this.receivedRequests, sb);
int pos = 1;
for (final Request req: this.receivedRequests) {
sb.append("\n");
final Collection<Matcher<? super Request>> matching = new ArrayList<Matcher<? super Request>>();
final Collection<Matcher<? super Request>> clashing = new ArrayList<Matcher<? super Request>>();
for (final Matcher<? super Request> pred: requestPredicates) {
if (pred.matches(req)) {
matching.add(pred);
}
else {
clashing.add(pred);
}
}
this.appendReason(sb, req, pos, matching, clashing);
pos++;
}
logger.info(sb.toString());
}
private void appendReason(final StringBuilder sb, final Request req, final int position,
final Collection<Matcher<? super Request>> matching,
final Collection<Matcher<? super Request>> clashing) {
sb.append("Request #");
sb.append(position);
sb.append(": ");
sb.append(req);
sb.append("\n");
sb.append(" matching predicates:");
this.appendNoneIfEmpty(matching, sb);
sb.append('\n');
for (final Matcher<? super Request> pred: matching) {
sb.append(" ");
final Description desc = new StringDescription(sb);
pred.describeTo(desc);
sb.append('\n');
}
sb.append(" clashing predicates:");
this.appendNoneIfEmpty(clashing, sb);
for (final Matcher<? super Request> pred: clashing) {
sb.append("\n ");
final Description desc = new StringDescription(sb);
pred.describeMismatch(req, desc);
}
}
private void appendNoneIfEmpty(final Collection<?> coll, final StringBuilder sb) {
if (coll.isEmpty()) {
sb.append(" <none>");
}
}
private String mismatchDescription(final int cnt, final Collection<Matcher<? super Request>> predicates,
final Matcher<Integer> nrRequestsMatcher) {
final Description desc = new StringDescription();
desc.appendText("The number of http requests");
if (!predicates.isEmpty()) {
desc.appendText(" having");
}
desc.appendText(" ");
for (final Iterator<Matcher<? super Request>> it = predicates.iterator(); it.hasNext();) {
desc.appendDescriptionOf(it.next());
if (it.hasNext()) {
desc.appendText(" AND");
}
desc.appendText(" ");
}
desc.appendText("was expected to be ");
desc.appendDescriptionOf(nrRequestsMatcher);
desc.appendText(", but ");
nrRequestsMatcher.describeMismatch(cnt, desc);
return desc.toString();
}
private synchronized void checkConfigurable() {
if (!this.configurable) {
throw new IllegalStateException("Once first http request has been served, "
+ "you can't do any stubbing anymore.");
}
}
private synchronized void checkRequestRecording() {
if (!this.recordRequests) {
throw new IllegalStateException("Request recording is switched off, cannot do any request verification");
}
}
}