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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ test:
coverage:
@./gradlew test jacocoTestReport
@$(OPEN) ./eventsourcingdb/build/reports/jacoco/test/html/index.html
@$(OPEN) ./eventsourcingdb-spring-boot-starter/build/reports/jacoco/test/html/index.html
@$(OPEN) ./eventsourcingdb-testcontainers/build/reports/jacoco/test/html/index.html

.PHONY: analyze coverage format qa test
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ For more information on EventSourcingDB, see its [official documentation](https:

This client SDK includes support for [Testcontainers](https://testcontainers.com/) to spin up EventSourcingDB instances in integration tests. It lives in an artifact of its own, so applications that only use the client do not depend on Testcontainers. For details, see [Using Testcontainers](#using-testcontainers).

For applications built with [Spring Boot](https://spring.io/projects/spring-boot), there is a starter that sets up the client from the configuration of the application. For details, see [Using Spring Boot](#using-spring-boot).

## Getting Started

The client SDK requires Java 21 or later. Add it to your build, e.g. with Gradle:
Expand Down Expand Up @@ -774,3 +776,88 @@ In case you need to set up the client yourself, use the following methods to get
- `getMappedPort()` returns the port
- `getBaseUrl()` returns the full URL of the container
- `getApiToken()` returns the API token

### Using Spring Boot

The Spring Boot support lives in the `eventsourcingdb-spring-boot-starter` artifact. It requires Spring Boot 4. Add it to your dependencies, e.g. with Gradle:

```kotlin
dependencies {
implementation("io.thenativeweb:eventsourcingdb-spring-boot-starter:<version>")
}
```

Then set the URL of your EventSourcingDB instance and the API token to use, e.g. in `application.properties`:

```properties
eventsourcingdb.base-url=http://localhost:3000
eventsourcingdb.api-token=secret
```

The starter creates a client from them, which you can inject wherever you need it:

```java
@Service
public class BookService {
private final Client client;

public BookService(Client client) {
this.client = client;
}

// ...
}
```

If one of the two properties is not set, the application fails to start.

The client serializes and deserializes the data of events with the `JsonMapper` of Spring Boot, so the data follows the Jackson configuration of your application, e.g. its naming strategy. When the application shuts down, Spring Boot closes the client.

To configure the client in other ways, e.g. with an HTTP client of your own, define a bean of type `Client`. The starter then uses yours instead of creating one. To connect it to the configured instance, inject `EventSourcingDbConnectionDetails`:

```java
@Bean
Client client(EventSourcingDbConnectionDetails connectionDetails, JsonMapper jsonMapper) {
return new Client(
connectionDetails.getBaseUrl(),
connectionDetails.getApiToken(),
new ClientOptions()
.withHttpClient(httpClient)
.withDataMapper(jsonMapper)
);
}
```

#### Connecting to the Test Container

In tests, the starter can take the URL and the API token from the test container instead of from the properties. Add the test container and the Testcontainers support of Spring Boot to the dependencies of your tests, e.g. with Gradle:

```kotlin
dependencies {
testImplementation("io.thenativeweb:eventsourcingdb-testcontainers:<version>")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
}
```

Then define the container as a bean, and annotate it with `@ServiceConnection`:

```java
@TestConfiguration(proxyBeanMethods = false)
class ContainerConfiguration {
@Bean
@ServiceConnection
Container eventSourcingDb() {
return new Container();
}
}
```

Import that configuration into your tests. Spring Boot starts the container, and connects the client to it:

```java
@SpringBootTest
@Import(ContainerConfiguration.class)
class BookServiceTest {
// ...
}
```
44 changes: 44 additions & 0 deletions eventsourcingdb-spring-boot-starter/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
plugins {
id("eventsourcingdb.java-conventions")
}

description = "Spring Boot support for the official Java client SDK for EventSourcingDB."

dependencies {
api(project(":eventsourcingdb"))
api(libs.spring.boot.starter)
annotationProcessor(libs.spring.boot.configuration.processor)

// The support for @ServiceConnection needs the test container and the
// Testcontainers support of Spring Boot. Applications only have them in
// their tests, where they add them anyway to create the container, so the
// starter does not bring them along, and only uses them when they are
// present.
compileOnly(project(":eventsourcingdb-testcontainers"))
compileOnly(libs.spring.boot.testcontainers)

// The test support of Spring Boot builds on AssertJ, which it only depends
// on optionally.
testImplementation(libs.assertj.core)
testImplementation(libs.spring.boot.jackson)
testImplementation(libs.spring.boot.test)
testImplementation(libs.spring.boot.testcontainers)
testImplementation(project(":eventsourcingdb-testcontainers"))
}

// Spring Boot logs with Logback, which the starter brings along, and refuses
// to start next to another SLF4J provider, such as the one that silences
// Testcontainers in the tests of the other modules. Here, Logback does that,
// configured in logback-test.xml.
configurations.testRuntimeClasspath {
exclude(group = "org.slf4j", module = "slf4j-nop")
}

// The configuration processor of Spring Boot reads @ConfigurationProperties
// without claiming it, as do all annotation processors that only collect
// information. javac warns about every annotation no processor claims, which
// fails the build. That warning says nothing about the code, so it is turned
// off here, and here only; all other warnings stay errors.
tasks.compileJava {
options.compilerArgs.add("-Xlint:-processing")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.thenativeweb.eventsourcingdb.springboot;

import io.thenativeweb.eventsourcingdb.Client;
import io.thenativeweb.eventsourcingdb.ClientOptions;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.json.JsonMapper;

/**
* Auto-configures a client for EventSourcingDB.
*
* <p>Exclude it to turn the auto-configuration off, e.g. with {@code
* @SpringBootApplication(exclude = EventSourcingDbAutoConfiguration.class)}.
*/
@AutoConfiguration
@EnableConfigurationProperties(EventSourcingDbProperties.class)
public final class EventSourcingDbAutoConfiguration {
EventSourcingDbAutoConfiguration() {}

@Bean
@ConditionalOnMissingBean(EventSourcingDbConnectionDetails.class)
PropertiesEventSourcingDbConnectionDetails eventSourcingDbConnectionDetails(EventSourcingDbProperties properties) {
return new PropertiesEventSourcingDbConnectionDetails(properties);
}

// Spring closes the client with the context, as it calls close() on every
// bean that is AutoCloseable.
@Bean
@ConditionalOnMissingBean
Client eventSourcingDbClient(
EventSourcingDbConnectionDetails connectionDetails, ObjectProvider<JsonMapper> jsonMapper) {
var options = new ClientOptions();

// The JSON mapper of Spring carries the configuration of the
// application, e.g. its naming strategy, so the data of events is
// serialized like everything else the application serializes. Without
// one, the client uses a JSON mapper of its own.
var dataMapper = jsonMapper.getIfAvailable();
if (dataMapper != null) {
options = options.withDataMapper(dataMapper);
}

return new Client(connectionDetails.getBaseUrl(), connectionDetails.getApiToken(), options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.thenativeweb.eventsourcingdb.springboot;

import java.net.URI;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;

/**
* The details to connect to an EventSourcingDB instance with.
*
* <p>By default, they come from the {@code eventsourcingdb.*} properties. To take them from somewhere else, define a
* bean of this type. With {@code @ServiceConnection}, Spring Boot takes them from a test container.
*/
public interface EventSourcingDbConnectionDetails extends ConnectionDetails {
/** {@return the URL of the instance} */
URI getBaseUrl();

/** {@return the API token to authenticate with} */
String getApiToken();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.thenativeweb.eventsourcingdb.springboot;

import io.thenativeweb.eventsourcingdb.testcontainers.Container;
import java.net.URI;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource;

// Takes the connection details from a test container annotated with
// @ServiceConnection. Spring Boot loads this factory from spring.factories.
// Applications only have the test container and the Testcontainers support of
// Spring Boot in their tests: without the latter, Spring Boot skips the
// factory, as it can not load it, and without the former, the factory skips
// itself, as it requires the class of the test container.
final class EventSourcingDbContainerConnectionDetailsFactory
extends ContainerConnectionDetailsFactory<Container, EventSourcingDbConnectionDetails> {
// The class is named as a string, since referencing it would fail exactly
// when it is missing.
EventSourcingDbContainerConnectionDetailsFactory() {
super(ANY_CONNECTION_NAME, "io.thenativeweb.eventsourcingdb.testcontainers.Container");
}

@Override
protected EventSourcingDbConnectionDetails getContainerConnectionDetails(
ContainerConnectionSource<Container> source) {
return new ContainerEventSourcingDbConnectionDetails(source);
}

private static final class ContainerEventSourcingDbConnectionDetails extends ContainerConnectionDetails<Container>
implements EventSourcingDbConnectionDetails {
private ContainerEventSourcingDbConnectionDetails(ContainerConnectionSource<Container> source) {
super(source);
}

@Override
public URI getBaseUrl() {
return getContainer().getBaseUrl();
}

@Override
public String getApiToken() {
return getContainer().getApiToken();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.thenativeweb.eventsourcingdb.springboot;

import java.net.URI;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* The properties of the client, under the prefix {@code eventsourcingdb}.
*
* @param baseUrl the URL of the EventSourcingDB instance, e.g. http://localhost:3000
* @param apiToken the API token to authenticate with
*/
@ConfigurationProperties("eventsourcingdb")
public record EventSourcingDbProperties(
@Nullable URI baseUrl, @Nullable String apiToken) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.thenativeweb.eventsourcingdb.springboot;

import java.net.URI;

// Takes the connection details from the eventsourcingdb.* properties. They are
// checked only when they are used, since with other connection details, e.g.
// from @ServiceConnection, the properties do not need to be set.
final class PropertiesEventSourcingDbConnectionDetails implements EventSourcingDbConnectionDetails {
private final EventSourcingDbProperties properties;

PropertiesEventSourcingDbConnectionDetails(EventSourcingDbProperties properties) {
this.properties = properties;
}

@Override
public URI getBaseUrl() {
var baseUrl = properties.baseUrl();
if (baseUrl == null) {
throw new IllegalStateException("eventsourcingdb.base-url must be set");
}

return baseUrl;
}

@Override
public String getApiToken() {
var apiToken = properties.apiToken();
if (apiToken == null || apiToken.isBlank()) {
throw new IllegalStateException("eventsourcingdb.api-token must be set");
}

return apiToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Integrates the client into Spring Boot: the starter creates a client from the {@code eventsourcingdb.*} properties,
* and supports {@code @ServiceConnection} for the test container.
*/
@NullMarked
package io.thenativeweb.eventsourcingdb.springboot;

import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
io.thenativeweb.eventsourcingdb.springboot.EventSourcingDbContainerConnectionDetailsFactory
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
io.thenativeweb.eventsourcingdb.springboot.EventSourcingDbAutoConfiguration
Loading
Loading