In the previous article, I introduced FerretDB, an OSS database compatible with MongoDB.
However, DocumentDB itself, which is used as the backend for Documentdb, now supports the MongoDB API, making FerretDB unnecessary.

In this article, I'll memo how to access DocumentDB using Spring Boot + Spring Data MongoDB.
From an application perspective, it's the same as MongoDB - only the Testcontainers configuration changes.

The following is basically a rework of the previous article.

Creating Project Template

Use Spring Initializr to create a Spring Boot project template.

curl -s https://start.spring.io/starter.tgz \
       -d artifactId=demo-documentdb \
       -d name=demo-documentdb \
       -d baseDir=demo-documentdb  \
       -d packageName=com.example \
       -d dependencies=web,data-mongodb,actuator,configuration-processor,prometheus,native,testcontainers \
       -d type=maven-project \
       -d applicationName=DemoDocumentDbApplication | tar -xzvf -
cd demo-documentdb

Creating Sample Application

Create a very simple application that saves and retrieves messages.

cat <<EOF > src/main/java/com/example/Message.java
package com.example;

public record Message(String id, String text) {
}
EOF
cat <<EOF > src/main/java/com/example/HelloController.java
package com.example;

import java.util.List;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    private final MongoTemplate mongoTemplate;

    public HelloController(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    @PostMapping(path = "/messages")
    public Message postMessage(@RequestBody String text) {
        return mongoTemplate.save(new Message(null, text));
    }

    @GetMapping(path = "/messages")
    public List<Message> getMessages() {
        return mongoTemplate.findAll(Message.class);
    }

}
EOF

Since we added Testcontainers with Spring Initializr, the following file is included in the project.

package com.example;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

    @Bean
    @ServiceConnection
    MongoDBContainer mongoDbContainer() {
        return new MongoDBContainer(DockerImageName.parse("mongo:latest"));
    }

}

Let's start the app using Testcontainers. Run src/test/java/com/example/TestDemoDocumentDbApplication.java or execute the following command:

./mvnw spring-boot:test-run

Once the application and MongoDB are started, try POSTing & GETting messages as follows:

$ curl http://localhost:8080/messages -H content-type:text/plain -d "Hello MongoDB\!"
{"id":"6879a6f3ba99e4ec5c9419fd","text":"Hello MongoDB!"}

$ curl http://localhost:8080/messages -H content-type:text/plain -d "Hello DocumentDB\!"
{"id":"6879a6f8ba99e4ec5c9419fe","text":"Hello DocumentDB!"}

$ curl -s http://localhost:8080/messages | jq .
[
  {
    "id": "6879a6f3ba99e4ec5c9419fd",
    "text": "Hello MongoDB!"
  },
  {
    "id": "6879a6f8ba99e4ec5c9419fe",
    "text": "Hello DocumentDB!"
  }
]

The test code equivalent to this operation verification would be as follows:

cat <<EOF > src/test/java/com/example/DemoDocumentDbApplicationTests.java
package com.example;

import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;

import static org.assertj.core.api.Assertions.assertThat;

@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DemoDocumentDbApplicationTests {

    RestClient restClient;

    @BeforeEach
    void setUp(@LocalServerPort int port, @Autowired RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.defaultStatusHandler(statusCode -> true, (req, res) -> {
            /* NO-OP */}).baseUrl("http://localhost:" + port).build();
    }

    @Test
    void contextLoads() {
        {
            ResponseEntity<Message> res = this.restClient.post()
                .uri("/messages")
                .contentType(MediaType.TEXT_PLAIN)
                .body("Hello MongoDB!")
                .retrieve()
                .toEntity(Message.class);
            assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);
            Message message = res.getBody();
            assertThat(message).isNotNull();
            assertThat(message.text()).isEqualTo("Hello MongoDB!");
            assertThat(message.id()).isNotNull();
        }
        {
            ResponseEntity<Message> res = this.restClient.post()
                .uri("/messages")
                .contentType(MediaType.TEXT_PLAIN)
                .body("Hello DocumentDB!")
                .retrieve()
                .toEntity(Message.class);
            assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);
            Message message = res.getBody();
            assertThat(message).isNotNull();
            assertThat(message.text()).isEqualTo("Hello DocumentDB!");
            assertThat(message.id()).isNotNull();
        }
        {
            ResponseEntity<List<Message>> res = this.restClient.get()
                .uri("/messages")
                .retrieve()
                .toEntity(new ParameterizedTypeReference<>() {
                });
            assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);
            List<Message> messages = res.getBody();
            assertThat(messages).isNotNull();
            assertThat(messages).hasSize(2);
            assertThat(messages).map(Message::id).allSatisfy(id -> assertThat(id).isNotNull());
            assertThat(messages).map(Message::text).containsExactly("Hello MongoDB!", "Hello DocumentDB!");
        }
    }

}
EOF

The test also starts MongoDB with Testcontainers. You can run the test with the following command:

./mvnw clean test

Switching to DocumentDB

Switch from MongoDB to DocumentDB. The application code remains the same, only the Testcontainers configuration needs to be changed.
Since the Documentdb container image was not compatible with MongoDBContainer, we use GenericContainer.
Therefore, instead of using ServiceConnection, we use DynamicPropertyRegistrar to dynamically register MongoDB connection information. Since Documentdb has authentication enabled by default, we set spring.data.mongodb.uri including username and password.

Change TestcontainersConfiguration as follows:

cat <<EOF > src/test/java/com/example/TestcontainersConfiguration.java
package com.example;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistrar;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

    @Bean
    GenericContainer<?> documentdbContainer() {
        return new GenericContainer<>(DockerImageName.parse("ghcr.io/microsoft/documentdb/documentdb-local:latest"))
            .withExposedPorts(10260 /* MongoDB Port */, 9712 /* PostgreSQL port */)
            .withEnv("USERNAME", "user")
            .withEnv("PASSWORD", "password")
            .withEnv("ENFORCE_SSL", "false")
            .waitingFor(new HostPortWaitStrategy().forPorts(10260, 9712));
    }

    @Bean
    DynamicPropertyRegistrar dynamicPropertyRegistrar(GenericContainer<?> documentdbContainer) {
        return registry -> registry.add("spring.data.mongodb.uri", () -> "mongodb://user:password@%s:%d/test"
            .formatted(documentdbContainer.getHost(), documentdbContainer.getMappedPort(10260)));
    }

}
EOF

The same test should pass after changing TestcontainersConfiguration:

./mvnw clean test

Re-run src/test/java/com/example/TestDemoDocumentDbApplication.java or re-execute the following command:

./mvnw spring-boot:test-run

Try POSTing & GETting messages as before:

$ curl http://localhost:8080/messages -H content-type:text/plain -d "Hello MongoDB\!"
{"id":"6879af654c503243968ecba0","text":"Hello MongoDB!"}

$ curl http://localhost:8080/messages -H content-type:text/plain -d "Hello DocumentDB\!"
{"id":"6879af6a4c503243968ecba1","text":"Hello DocumentDB!"}

$ curl -s http://localhost:8080/messages | jq .
[
  {
    "id": "6879af654c503243968ecba0",
    "text": "Hello MongoDB!"
  },
  {
    "id": "6879af6a4c503243968ecba1",
    "text": "Hello DocumentDB!"
  }
]

We confirmed that switching from MongoDB to Documentdb works without any issues.

Running Standalone

Let's try running the application standalone without using Testcontainers.

Start Documentdb with the following docker run command:

docker run --rm --name documentdb -p 10260:10260 -e USERNAME=user -e PASSWORD=password -e ENFORCE_SSL=false ghcr.io/microsoft/documentdb/documentdb-local:latest

Create an executable jar file with the following command:

./mvnw clean package

Specify spring.data.mongodb.uri at runtime to connect to Documentdb:

java -jar target/demo-documentdb-0.0.1-SNAPSHOT.jar --spring.data.mongodb.uri=mongodb://user:password@localhost:10260/test

You should be able to POST & GET messages as before.

Enabling TLS Connection to DocumentDB

The Docker image ghcr.io/microsoft/documentdb/documentdb-local has TLS enabled by default.
In the previous example, we disabled TLS with the environment variable ENFORCE_SSL=false, but removing this setting requires TLS connection.

To enable TLS connection to MongoDB during testing, you need to register the CA certificate of the TLS certificate configured in DocumentDB to the TrustStore.
This time, we'll use netty-pkitesting to create a self-signed certificate for testing and dynamically configure to use this certificate.

Add the following dependency to pom.xml:

    <dependency>
      <groupId>io.netty</groupId>
      <artifactId>netty-pkitesting</artifactId>
      <version>4.2.4.Final</version>
      <scope>test</scope>
    </dependency>

Change TestcontainersConfiguration as follows:

cat <<EOF > src/test/java/com/example/TestcontainersConfiguration.java
package com.example;

import io.netty.pkitesting.CertificateBuilder;
import io.netty.pkitesting.X509Bundle;
import java.io.File;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ssl.DefaultSslBundleRegistry;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.pem.PemSslStoreBundle;
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistrar;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

    @Bean
    X509Bundle selfSignedCertificate() throws Exception {
        return new CertificateBuilder().subject("CN=localhost").setIsCertificateAuthority(true).buildSelfSigned();
    }

    @Bean
    GenericContainer<?> documentdbContainer(X509Bundle selfSignedCertificate) throws Exception {
        File tempCertChainPem = selfSignedCertificate.toTempCertChainPem();
        File tempPrivateKeyPem = selfSignedCertificate.toTempPrivateKeyPem();
        return new GenericContainer<>(DockerImageName.parse("ghcr.io/microsoft/documentdb/documentdb-local:latest"))
            .withExposedPorts(10260, 9712)
            .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("documentdb")))
            .waitingFor(new HostPortWaitStrategy().forPorts(10260, 9712))
            .withFileSystemBind(tempCertChainPem.getAbsolutePath(), "/tmp/cert.pem", BindMode.READ_ONLY)
            .withFileSystemBind(tempPrivateKeyPem.getAbsolutePath(), "/tmp/private.key", BindMode.READ_ONLY)
            .withEnv("CERT_PATH", "/tmp/cert.pem")
            .withEnv("KEY_FILE", "/tmp/private.key")
            .withEnv("USERNAME", "user")
            .withEnv("PASSWORD", "password");
    }

    @Bean
    DefaultSslBundleRegistry sslBundles(X509Bundle selfSignedCertificate) {
        DefaultSslBundleRegistry bundles = new DefaultSslBundleRegistry();
        bundles.registerBundle("self-signed", SslBundle.of(new PemSslStoreBundle(null,
                PemSslStoreDetails.forCertificate(selfSignedCertificate.getRootCertificatePEM()))));
        return bundles;
    }

    @Bean
    DynamicPropertyRegistrar dynamicPropertyRegistrar(GenericContainer<?> documentdbContainer) {
        return registry -> {
            registry.add("spring.data.mongodb.uri", () -> "mongodb://user:password@%s:%d/test"
                .formatted(documentdbContainer.getHost(), documentdbContainer.getMappedPort(10260)));
            registry.add("spring.data.mongodb.ssl.enabled", () -> "true");
            registry.add("spring.data.mongodb.ssl.bundle", () -> "self-signed");
        };
    }

}
EOF

Confirm that the test succeeds even with this configuration change:

./mvnw clean test

As before, let's try running the application standalone without using Testcontainers.

Create a self-signed certificate with the following command:

DIR=/tmp/self-signed
mkdir -p ${DIR}

# Create CA certificate
openssl req -new -nodes -out ${DIR}/ca.csr -keyout ${DIR}/ca.key -subj "/CN=@making/O=LOL.MAKI/C=JP"
chmod og-rwx ${DIR}/ca.key

cat <<EOF > ${DIR}/ext_ca.txt
basicConstraints=CA:TRUE
keyUsage=digitalSignature,keyCertSign
EOF

openssl x509 -req -in ${DIR}/ca.csr -days 3650 -signkey ${DIR}/ca.key -out ${DIR}/ca.crt -extfile ${DIR}/ext_ca.txt

cat <<EOF > ${DIR}/ext.txt
basicConstraints=CA:FALSE
keyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement
extendedKeyUsage=serverAuth,clientAuth
EOF

# Create Server certificate signed by CA
openssl req -new -nodes -out ${DIR}/server.csr -keyout ${DIR}/server.key -subj "/CN=localhost"
chmod og-rwx ${DIR}/server.key
openssl x509 -req -in ${DIR}/server.csr -days 3650 -CA ${DIR}/ca.crt -CAkey ${DIR}/ca.key -CAcreateserial -out ${DIR}/server.crt -extfile ${DIR}/ext.txt

Start Documentdb with the following docker run command:

docker run --rm --name documentdb -p 10260:10260 -v /tmp/self-signed:/tmp/self-signed -e USERNAME=user -e PASSWORD=password -e CERT_PATH=/tmp/self-signed/server.crt -e KEY_FILE=/tmp/self-signed/server.key ghcr.io/microsoft/documentdb/documentdb-local:latest

Create an executable jar file with the following command:

./mvnw clean package

Specify spring.data.mongodb.uri at runtime to connect to Documentdb:

java -jar target/demo-documentdb-0.0.1-SNAPSHOT.jar --spring.data.mongodb.uri=mongodb://user:password@localhost:10260/test --spring.ssl.bundle.pem.self-signed.truststore.certificate=file:/tmp/self-signed/ca.crt --spring.data.mongodb.ssl.enabled=true --spring.data.mongodb.ssl.bundle=self-signed

You should be able to POST & GET messages as before.

The verified source code is available here.


I introduced how to use Documentdb with Spring Boot + Spring Data MongoDB + Testcontainers.
By using DocumentDB as an alternative to SSPL MongoDB, it seems possible to avoid license issues while utilizing MongoDB-compatible features.

For other information, please refer to the documentation.

Found a mistake? Update the entry.
Share this article: