diff --git a/README.md b/README.md
index 0d1e7f7..fed86d9 100644
--- a/README.md
+++ b/README.md
@@ -716,6 +716,23 @@ To check if the test container is running, call the `isRunning` method:
var isRunning = container.isRunning();
```
+To get a client with options of your own, e.g. the data mapper of your application, hand them to `getClient`:
+
+```java
+var client = container.getClient(new ClientOptions()
+ .withDataMapper(jsonMapper));
+```
+
+`Container` is a Testcontainers `GenericContainer`, so you can use it wherever Testcontainers containers work, e.g. in a `try`-with-resources statement:
+
+```java
+try (var container = new Container()) {
+ container.start();
+
+ // ...
+}
+```
+
#### Configuring the Container Instance
By default, `Container` uses the `latest` tag of the official EventSourcingDB Docker image. To change that, call the `withImageTag` method:
diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java
index 65148e0..4a35748 100644
--- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java
+++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java
@@ -3,6 +3,7 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import io.thenativeweb.eventsourcingdb.Client;
+import io.thenativeweb.eventsourcingdb.ClientOptions;
import java.net.URI;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@@ -35,29 +36,33 @@
* container.stop();
* }
* }
+ *
+ *
It is a Testcontainers {@link GenericContainer}, so it works wherever Testcontainers containers do, e.g. in a
+ * {@code try}-with-resources statement, or with the JUnit extension of Testcontainers.
*/
-public final class Container {
+public final class Container extends GenericContainer {
private static final String IMAGE_NAME = "thenativeweb/eventsourcingdb";
private static final String SIGNING_KEY_PATH = "/etc/esdb/signing-key.pem";
- // How many containers start() starts at most, as it replaces each
+ // How many times start() starts the container at most, as it replaces each
// container whose database does not become reachable.
private static final int MAX_START_ATTEMPTS = 3;
- private String imageTag = "latest";
private int internalPort = 3000;
private String apiToken = "secret";
private @Nullable KeyPair signingKeyPair;
- private @Nullable GenericContainer> container;
- // Starts a container. It is a field only so that tests can replace it.
- Consumer> starter = GenericContainer::start;
+ // Starts the container once. It is a field only so that tests can replace
+ // it.
+ Consumer starter = Container::startOnce;
/**
* Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the
* container, and the API token {@code secret}, and does not sign events.
*/
- public Container() {}
+ public Container() {
+ super(DockerImageName.parse(IMAGE_NAME + ":latest"));
+ }
/**
* Sets the tag of the image to use, e.g. a specific version of EventSourcingDB.
@@ -66,7 +71,7 @@ public Container() {}
* @return this container
*/
public Container withImageTag(String tag) {
- imageTag = tag;
+ setDockerImageName(IMAGE_NAME + ":" + tag);
return this;
}
@@ -104,6 +109,40 @@ public Container withSigningKey() {
return this;
}
+ // Applies the configuration right before the container starts, so that the
+ // with methods may be called in any order.
+ @Override
+ protected void configure() {
+ var command = new ArrayList<>(List.of(
+ "run",
+ "--api-token",
+ apiToken,
+ "--data-directory-temporary",
+ "--http-enabled",
+ "--https-enabled=false",
+ "--http-port",
+ String.valueOf(internalPort)));
+
+ withExposedPorts(internalPort);
+ waitingFor(Wait.forHttp("/api/v1/ping").forPort(internalPort).withStartupTimeout(Duration.ofSeconds(10)));
+
+ if (signingKeyPair != null) {
+ withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH);
+ command.addAll(List.of("--signing-key-file", SIGNING_KEY_PATH));
+ }
+
+ withCommand(command.toArray(String[]::new));
+ }
+
+ private static byte[] pem(PrivateKey privateKey) {
+ var encoder = Base64.getMimeEncoder(64, new byte[] {'\n'});
+
+ return ("-----BEGIN PRIVATE KEY-----\n"
+ + encoder.encodeToString(privateKey.getEncoded())
+ + "\n-----END PRIVATE KEY-----\n")
+ .getBytes(UTF_8);
+ }
+
/**
* Starts the container, and waits until the instance answers pings.
*
@@ -115,23 +154,20 @@ public Container withSigningKey() {
* @throws RuntimeException if the container does not start for another reason, e.g. because the image does not
* exist
*/
+ @Override
public void start() {
for (var attempt = 1; ; attempt++) {
- var container = newContainer();
-
try {
- starter.accept(container);
- this.container = container;
+ starter.accept(this);
return;
} catch (RuntimeException ex) {
- // Docker Desktop sometimes does not make the port it maps a
- // container to reachable from the host. The container keeps
- // running, but the database in it can never be reached, so it
- // is replaced by a new one. Every other failure is thrown at
- // once. Either way, the container that failed to start is
- // removed, so that it is not left behind.
- var isRunning = container.isRunning();
- container.stop();
+ // A container that keeps running although starting it failed
+ // is one whose database can not be reached, so it is replaced
+ // by a new one. Every other failure is thrown at once. Either
+ // way, the container that failed to start is removed, so that
+ // it is not left behind.
+ var isRunning = isRunning();
+ stop();
if (!isRunning) {
throw ex;
@@ -147,39 +183,8 @@ public void start() {
}
}
- private GenericContainer> newContainer() {
- var command = new ArrayList<>(List.of(
- "run",
- "--api-token",
- apiToken,
- "--data-directory-temporary",
- "--http-enabled",
- "--https-enabled=false",
- "--http-port",
- String.valueOf(internalPort)));
-
- GenericContainer> container = new GenericContainer<>(DockerImageName.parse(IMAGE_NAME + ":" + imageTag))
- .withExposedPorts(internalPort)
- .waitingFor(
- Wait.forHttp("/api/v1/ping").forPort(internalPort).withStartupTimeout(Duration.ofSeconds(10)));
-
- if (signingKeyPair != null) {
- container.withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH);
- command.addAll(List.of("--signing-key-file", SIGNING_KEY_PATH));
- }
-
- container.withCommand(command.toArray(String[]::new));
-
- return container;
- }
-
- private static byte[] pem(PrivateKey privateKey) {
- var encoder = Base64.getMimeEncoder(64, new byte[] {'\n'});
-
- return ("-----BEGIN PRIVATE KEY-----\n"
- + encoder.encodeToString(privateKey.getEncoded())
- + "\n-----END PRIVATE KEY-----\n")
- .getBytes(UTF_8);
+ void startOnce() {
+ super.start();
}
/**
@@ -187,8 +192,10 @@ private static byte[] pem(PrivateKey privateKey) {
*
* @throws IllegalStateException if the container is not running
*/
+ @Override
public String getHost() {
- return running().getHost();
+ throwIfNotRunning();
+ return super.getHost();
}
/**
@@ -197,7 +204,8 @@ public String getHost() {
* @throws IllegalStateException if the container is not running
*/
public int getMappedPort() {
- return running().getMappedPort(internalPort);
+ throwIfNotRunning();
+ return getMappedPort(internalPort);
}
/**
@@ -234,38 +242,31 @@ public PublicKey getVerificationKey() {
return signingKeyPair().getPublic();
}
- /** {@return whether the container is running} */
- public boolean isRunning() {
- return container != null;
- }
-
/**
- * Stops and removes the container. If it is not running, this does nothing.
+ * {@return a client for the instance}
+ *
+ * @throws IllegalStateException if the container is not running
*/
- public void stop() {
- if (container == null) {
- return;
- }
-
- container.stop();
- container = null;
+ public Client getClient() {
+ return getClient(new ClientOptions());
}
/**
- * {@return a client for the instance}
+ * {@return a client for the instance, with the given options}
+ *
+ * Use this to test with the same options as in production, e.g. the data mapper of your application.
*
+ * @param options the options of the client
* @throws IllegalStateException if the container is not running
*/
- public Client getClient() {
- return new Client(getBaseUrl(), apiToken);
+ public Client getClient(ClientOptions options) {
+ return new Client(getBaseUrl(), apiToken, options);
}
- private GenericContainer> running() {
- if (container == null) {
+ private void throwIfNotRunning() {
+ if (!isRunning()) {
throw new IllegalStateException("container must be running");
}
-
- return container;
}
private KeyPair signingKeyPair() {
diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java
index 2b2a6f7..a188a79 100644
--- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java
+++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java
@@ -2,9 +2,11 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import io.thenativeweb.eventsourcingdb.ClientOptions;
import io.thenativeweb.eventsourcingdb.EventCandidate;
import java.net.URI;
import java.util.ArrayList;
@@ -13,6 +15,9 @@
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.ContainerLaunchException;
+import org.testcontainers.containers.GenericContainer;
+import tools.jackson.databind.PropertyNamingStrategies;
+import tools.jackson.databind.json.JsonMapper;
class ContainerTest {
@Test
@@ -76,6 +81,39 @@ void signsEventsWithItsSigningKey() {
}
}
+ // Being a Testcontainers container is what frameworks such as Spring Boot
+ // build on, e.g. to connect a client to it.
+ @Test
+ void isATestcontainersContainer() {
+ assertInstanceOf(GenericContainer.class, new Container());
+ }
+
+ record BookAcquired(String bookTitle) {}
+
+ @Test
+ void handsOutAClientWithTheGivenOptions() {
+ var container = new Container().withImageTag(ImageTag.fromDockerfile());
+
+ container.start();
+ try {
+ var client = container.getClient(new ClientOptions()
+ .withDataMapper(JsonMapper.builder()
+ .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
+ .build()));
+
+ var event = client.writeEvents(List.of(new EventCandidate(
+ "https://www.eventsourcingdb.io",
+ "/test",
+ "io.eventsourcingdb.test",
+ new BookAcquired("2001"))))
+ .getFirst();
+
+ assertEquals("2001", event.data().path("book_title").asString());
+ } finally {
+ container.stop();
+ }
+ }
+
@Test
void failsToStartWithAnImageThatDoesNotExist() {
var container = new Container().withImageTag("does-not-exist");
@@ -135,9 +173,9 @@ void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() {
// Docker Desktop sometimes does not make the port of a container
// reachable from the host. The container keeps running, but starting
// it fails, as it does here twice.
- container.starter = genericContainer -> {
- genericContainer.start();
- containerIds.add(genericContainer.getContainerId());
+ container.starter = self -> {
+ self.startOnce();
+ containerIds.add(self.getContainerId());
if (containerIds.size() < 3) {
throw new ContainerLaunchException("the database did not become reachable");
}
@@ -159,9 +197,9 @@ void givesUpAfterThreeAttemptsAndSaysWhy() {
var failures = new ArrayList();
var container = new Container().withImageTag(ImageTag.fromDockerfile());
- container.starter = genericContainer -> {
- genericContainer.start();
- containerIds.add(genericContainer.getContainerId());
+ container.starter = self -> {
+ self.startOnce();
+ containerIds.add(self.getContainerId());
var failure = new ContainerLaunchException("the database did not become reachable");
failures.add(failure);
throw failure;
@@ -184,11 +222,11 @@ void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() {
// Without an API token, EventSourcingDB exits right away.
var container = new Container().withImageTag(ImageTag.fromDockerfile()).withApiToken("");
- container.starter = genericContainer -> {
+ container.starter = self -> {
try {
- genericContainer.start();
+ self.startOnce();
} finally {
- containerIds.add(genericContainer.getContainerId());
+ containerIds.add(self.getContainerId());
}
};