Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,29 +36,33 @@
* container.stop();
* }
* }</pre>
*
* <p>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<Container> {
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<GenericContainer<?>> starter = GenericContainer::start;
// Starts the container once. It is a field only so that tests can replace
// it.
Consumer<Container> 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.
Expand All @@ -66,7 +71,7 @@ public Container() {}
* @return this container
*/
public Container withImageTag(String tag) {
imageTag = tag;
setDockerImageName(IMAGE_NAME + ":" + tag);
return this;
}

Expand Down Expand Up @@ -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.
*
Expand All @@ -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;
Expand All @@ -147,48 +183,19 @@ 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();
}

/**
* {@return the host name to reach the container at}
*
* @throws IllegalStateException if the container is not running
*/
@Override
public String getHost() {
return running().getHost();
throwIfNotRunning();
return super.getHost();
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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}
*
* <p>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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
Expand All @@ -159,9 +197,9 @@ void givesUpAfterThreeAttemptsAndSaysWhy() {
var failures = new ArrayList<ContainerLaunchException>();
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;
Expand All @@ -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());
}
};

Expand Down
Loading