diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index c31dd05e048..ea0e4a5e5ca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -43,6 +43,7 @@ body: - MongoDB - MSSQLServer - MySQL + - NATS - Neo4j - NGINX - OceanBase diff --git a/.github/ISSUE_TEMPLATE/enhancement.yaml b/.github/ISSUE_TEMPLATE/enhancement.yaml index 9b9a06ecf6a..d552d8a5df2 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yaml +++ b/.github/ISSUE_TEMPLATE/enhancement.yaml @@ -43,6 +43,7 @@ body: - MongoDB - MSSQLServer - MySQL + - NATS - Neo4j - NGINX - OceanBase diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml index b655b4ac505..ad0774659c2 100644 --- a/.github/ISSUE_TEMPLATE/feature.yaml +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -43,6 +43,7 @@ body: - MongoDB - MSSQLServer - MySQL + - NATS - Neo4j - NGINX - OceanBase diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e1b5000846..9056cfbbe6a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -229,6 +229,11 @@ updates: schedule: interval: "monthly" open-pull-requests-limit: 10 + - package-ecosystem: "gradle" + directory: "/modules/nats" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 - package-ecosystem: "gradle" directory: "/modules/neo4j" schedule: diff --git a/.github/labeler.yml b/.github/labeler.yml index f4649bd7f99..a8c66ab76ef 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -143,6 +143,10 @@ - changed-files: - any-glob-to-any-file: - modules/mysql/**/* +"modules/nats": + - changed-files: + - any-glob-to-any-file: + - modules/nats/**/* "modules/neo4j": - changed-files: - any-glob-to-any-file: diff --git a/docs/modules/nats.md b/docs/modules/nats.md new file mode 100644 index 00000000000..4f4f5c829a4 --- /dev/null +++ b/docs/modules/nats.md @@ -0,0 +1,74 @@ +# NATS Module + +!!! note + This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. + +[NATS](https://nats.io) is a simple, secure and high performance open source messaging system for cloud native applications, IoT messaging, and microservices architectures. + +## Example + +Create a `NatsContainer` to use it in your tests: + + +[Creating a NatsContainer](../../modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java) inside_block:shouldStartNatsContainer + + +Now your tests can connect to NATS using the connection URL: + + +[Connecting to NATS](../../modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java) inside_block:shouldPublishAndSubscribeMessages + + +## Options + +### Using JetStream + +JetStream is NATS' built-in distributed persistence system. You can enable it easily: + + +[Enabling JetStream](../../modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java) inside_block:shouldSupportJetStream + + +### Using Authentication + +You can configure NATS to require username and password authentication: + + +[Using Authentication](../../modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java) inside_block:shouldSupportAuthentication + + +### Enabling Debug/Trace Logging + +For debugging purposes, you can enable verbose logging: + +```java +NatsContainer nats = new NatsContainer(DockerImageName.parse("nats:2.10")) + .withDebug() // Enable debug logging + .withProtocolTracing(); // Enable protocol tracing +``` + +## Accessing Monitoring + +NATS provides an HTTP monitoring endpoint that you can access: + +```java +String httpMonitoringUrl = natsContainer.getHttpMonitoringUrl(); +``` + +## Adding this module to your project dependencies + +Add the following dependency to your `pom.xml`/`build.gradle` file: + +=== "Gradle" + ```groovy + testImplementation "org.testcontainers:testcontainers-nats:{{latest_version}}" + ``` +=== "Maven" + ```xml + + org.testcontainers + testcontainers-nats + {{latest_version}} + test + + ``` \ No newline at end of file diff --git a/modules/nats/build.gradle b/modules/nats/build.gradle new file mode 100644 index 00000000000..5c4d40daab9 --- /dev/null +++ b/modules/nats/build.gradle @@ -0,0 +1,7 @@ +description = "Testcontainers :: NATS" + +dependencies { + api project(':testcontainers') + + testImplementation 'io.nats:jnats:2.24.0' +} diff --git a/modules/nats/src/main/java/org/testcontainers/nats/NatsContainer.java b/modules/nats/src/main/java/org/testcontainers/nats/NatsContainer.java new file mode 100644 index 00000000000..d95076f5842 --- /dev/null +++ b/modules/nats/src/main/java/org/testcontainers/nats/NatsContainer.java @@ -0,0 +1,149 @@ +package org.testcontainers.nats; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for NATS. + *

+ * Supported image: {@code nats} + *

+ * Exposed ports: + *

+ */ +public class NatsContainer extends GenericContainer { + + /** + * Default port for NATS client connections. + */ + public static final int DEFAULT_NATS_CLIENT_PORT = 4222; + + /** + * Default port for NATS cluster/route connections. + */ + public static final int DEFAULT_NATS_ROUTING_PORT = 6222; + + /** + * Default port for NATS HTTP monitoring. + */ + public static final int DEFAULT_NATS_HTTP_MONITORING_PORT = 8222; + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("nats"); + + /** + * Creates a NATS container using a specific docker image name. + * + * @param dockerImageName The docker image to use. + */ + public NatsContainer(String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + /** + * Creates a NATS container using a specific docker image. + * + * @param dockerImageName The docker image to use. + */ + public NatsContainer(DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts( + DEFAULT_NATS_CLIENT_PORT, + DEFAULT_NATS_ROUTING_PORT, + DEFAULT_NATS_HTTP_MONITORING_PORT); + waitingFor(Wait.forLogMessage(".*Server is ready.*", 1)); + } + + /** + * Gets the port for client connections. + * + * @return The mapped port for client connections. + */ + public Integer getClientPort() { + return getMappedPort(DEFAULT_NATS_CLIENT_PORT); + } + + /** + * Gets the port for cluster/route connections. + * + * @return The mapped port for routing connections. + */ + public Integer getRoutingPort() { + return getMappedPort(DEFAULT_NATS_ROUTING_PORT); + } + + /** + * Gets the port for HTTP monitoring. + * + * @return The mapped port for HTTP monitoring. + */ + public Integer getHttpMonitoringPort() { + return getMappedPort(DEFAULT_NATS_HTTP_MONITORING_PORT); + } + + /** + * Gets the NATS connection URL. + * + * @return NATS URL for client connections in the format nats://host:port + */ + public String getConnectionUrl() { + return String.format("nats://%s:%d", getHost(), getClientPort()); + } + + /** + * Gets the NATS monitoring endpoint URL. + * + * @return HTTP URL for monitoring endpoint in the format http://host:port + */ + public String getHttpMonitoringUrl() { + return String.format("http://%s:%d", getHost(), getHttpMonitoringPort()); + } + + /** + * Enables JetStream for the NATS server. + * + * @return This container instance + */ + public NatsContainer withJetStream() { + withCommand("--jetstream"); + return this; + } + + /** + * Configures authentication with username and password. + * + * @param username The username for authentication + * @param password The password for authentication + * @return This container instance + */ + public NatsContainer withAuth(String username, String password) { + withCommand("--user", username, "--pass", password); + return this; + } + + /** + * Enables debug logging for the NATS server. + * + * @return This container instance + */ + public NatsContainer withDebug() { + withCommand("-D"); + return this; + } + + /** + * Enables protocol tracing for the NATS server. + * + * @return This container instance + */ + public NatsContainer withProtocolTracing() { + withCommand("-V"); + return this; + } +} diff --git a/modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java b/modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java new file mode 100644 index 00000000000..2dad3c0a5a8 --- /dev/null +++ b/modules/nats/src/test/java/org/testcontainers/nats/NatsContainerTest.java @@ -0,0 +1,158 @@ +package org.testcontainers.nats; + +import io.nats.client.Connection; +import io.nats.client.JetStream; +import io.nats.client.JetStreamManagement; +import io.nats.client.Message; +import io.nats.client.Nats; +import io.nats.client.Options; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import org.junit.jupiter.api.Test; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +class NatsContainerTest { + + private static final DockerImageName NATS_IMAGE = DockerImageName.parse("nats:2.12.1"); + + @Test + void shouldStartNATSContainer() { + try (NatsContainer natsContainer = new NatsContainer(NATS_IMAGE)) { + natsContainer.start(); + + assertThat(natsContainer.getClientPort()).isNotNull(); + assertThat(natsContainer.getRoutingPort()).isNotNull(); + assertThat(natsContainer.getHttpMonitoringPort()).isNotNull(); + + assertThat(natsContainer.getConnectionUrl()) + .isEqualTo(String.format("nats://%s:%d", natsContainer.getHost(), natsContainer.getClientPort())); + + assertThat(natsContainer.getHttpMonitoringUrl()) + .isEqualTo( + String.format("http://%s:%d", natsContainer.getHost(), natsContainer.getHttpMonitoringPort()) + ); + } + } + + @Test + void shouldPublishAndSubscribeMessages() throws IOException, InterruptedException, TimeoutException { + try (NatsContainer natsContainer = new NatsContainer(NATS_IMAGE)) { + natsContainer.start(); + + String subject = "test-subject"; + String message = "Hello NATS!"; + + Options options = new Options.Builder().server(natsContainer.getConnectionUrl()).build(); + + try (Connection nc = Nats.connect(options)) { + // Subscribe + io.nats.client.Subscription subscription = nc.subscribe(subject); + nc.flush(Duration.ofSeconds(1)); + + // Publish + nc.publish(subject, message.getBytes(StandardCharsets.UTF_8)); + nc.flush(Duration.ofSeconds(1)); + + // Receive + Message msg = subscription.nextMessage(Duration.ofSeconds(5)); + assertThat(msg).isNotNull(); + assertThat(new String(msg.getData(), StandardCharsets.UTF_8)).isEqualTo(message); + } + } + } + + @Test + void shouldSupportJetStream() throws Exception { + try (NatsContainer natsContainer = new NatsContainer(NATS_IMAGE).withJetStream()) { + natsContainer.start(); + + Options options = new Options.Builder().server(natsContainer.getConnectionUrl()).build(); + + try (Connection nc = Nats.connect(options)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Create a stream + StreamConfiguration streamConfig = StreamConfiguration + .builder() + .name("test-stream") + .subjects("test.>") + .storageType(StorageType.Memory) + .build(); + + jsm.addStream(streamConfig); + + // Get JetStream context + JetStream js = nc.jetStream(); + + // Publish a message + String subject = "test.foo"; + String message = "JetStream test message"; + js.publish(subject, message.getBytes(StandardCharsets.UTF_8)); + + // Subscribe and receive + io.nats.client.JetStreamSubscription subscription = js.subscribe(subject); + Message msg = subscription.nextMessage(Duration.ofSeconds(5)); + + assertThat(msg).isNotNull(); + assertThat(new String(msg.getData(), StandardCharsets.UTF_8)).isEqualTo(message); + + msg.ack(); + } + } + } + + @Test + void shouldSupportAuthentication() throws IOException, InterruptedException, TimeoutException { + String username = "testuser"; + String password = "testpassword"; + + try (NatsContainer natsContainer = new NatsContainer(NATS_IMAGE).withAuth(username, password)) { + natsContainer.start(); + + Options options = new Options.Builder() + .server(natsContainer.getConnectionUrl()) + .userInfo(username, password) + .build(); + + try (Connection nc = Nats.connect(options)) { + assertThat(nc.getStatus()).isEqualTo(Connection.Status.CONNECTED); + + // Test basic pub/sub + String subject = "auth-test"; + String message = "Authenticated message"; + + io.nats.client.Subscription subscription = nc.subscribe(subject); + nc.flush(Duration.ofSeconds(1)); + + nc.publish(subject, message.getBytes(StandardCharsets.UTF_8)); + nc.flush(Duration.ofSeconds(1)); + + Message msg = subscription.nextMessage(Duration.ofSeconds(5)); + assertThat(msg).isNotNull(); + assertThat(new String(msg.getData(), StandardCharsets.UTF_8)).isEqualTo(message); + } + } + } + + @Test + void shouldExposeCorrectPorts() { + try (NatsContainer natsContainer = new NatsContainer(NATS_IMAGE)) { + natsContainer.start(); + + assertThat(natsContainer.getExposedPorts()).contains(4222, 6222, 8222); + assertThat(natsContainer.getLivenessCheckPortNumbers()) + .containsExactlyInAnyOrder( + natsContainer.getMappedPort(4222), + natsContainer.getMappedPort(6222), + natsContainer.getMappedPort(8222) + ); + } + } +}