Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add NATS module #1003

Merged
14 changes: 14 additions & 0 deletions Testcontainers.sln
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Nats", "src\Testcontainers.Nats\Testcontainers.Nats.csproj", "{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Nats.Tests", "tests\Testcontainers.Nats.Tests\Testcontainers.Nats.Tests.csproj", "{87A3F137-6DC3-4CE5-91E6-01797D076086}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -394,6 +398,14 @@ Global
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU
{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Release|Any CPU.Build.0 = Release|Any CPU
{87A3F137-6DC3-4CE5-91E6-01797D076086}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{87A3F137-6DC3-4CE5-91E6-01797D076086}.Debug|Any CPU.Build.0 = Debug|Any CPU
{87A3F137-6DC3-4CE5-91E6-01797D076086}.Release|Any CPU.ActiveCfg = Release|Any CPU
{87A3F137-6DC3-4CE5-91E6-01797D076086}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3F2E254F-C203-43FD-A078-DC3E2CBC0F9F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
Expand Down Expand Up @@ -458,5 +470,7 @@ Global
{1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
{BF37BEA1-0816-4326-B1E0-E82290F8FCE0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
{87A3F137-6DC3-4CE5-91E6-01797D076086} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions src/Testcontainers.Nats/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
115 changes: 115 additions & 0 deletions src/Testcontainers.Nats/NatsBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using DotNet.Testcontainers;

namespace Testcontainers.Nats;

/// <inheritdoc cref="ContainerBuilder{TBuilderEntity, TContainerEntity, TConfigurationEntity}" />
[PublicAPI]
public sealed class NatsBuilder : ContainerBuilder<NatsBuilder, NatsContainer, NatsConfiguration>
{
public const string NatsImage = "nats:2.9";

public const ushort ClientPort = 4222;
public const ushort RoutingPort = 6222;
public const ushort MonitoringPort = 8222;

/// <summary>
/// Initializes a new instance of the <see cref="NatsBuilder" /> class.
/// </summary>
public NatsBuilder()
: this(new NatsConfiguration())
{
DockerResourceConfiguration = Init().DockerResourceConfiguration;
}

/// <summary>
/// Initializes a new instance of the <see cref="NatsBuilder" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
private NatsBuilder(NatsConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
DockerResourceConfiguration = resourceConfiguration;
}

/// <inheritdoc />
protected override NatsConfiguration DockerResourceConfiguration { get; }

/// <summary>
/// Sets the Nats Server password.
/// </summary>
/// <param name="password">The Nats Server password.</param>
/// <returns>A configured instance of <see cref="NatsBuilder" />.</returns>
public NatsBuilder WithPassword(string password)
{
return Merge(DockerResourceConfiguration, new NatsConfiguration(password: password))
.WithCommand("--pass", password);
}

/// <summary>
/// Sets the Nats Server username.
/// </summary>
/// <param name="username">The Nats Server username.</param>
/// <returns>A configured instance of <see cref="NatsBuilder" />.</returns>
public NatsBuilder WithUsername(string username)
{
return Merge(DockerResourceConfiguration, new NatsConfiguration(username: username))
.WithCommand("--user", username);
}

/// <inheritdoc />
public override NatsContainer Build()
{
Validate();
return new NatsContainer(DockerResourceConfiguration, TestcontainersSettings.Logger);
}


/// <inheritdoc />
protected override void Validate()
{
base.Validate();

if (DockerResourceConfiguration.Password != null || DockerResourceConfiguration.Username != null)
{
_ = Guard.Argument(DockerResourceConfiguration.Username, nameof(DockerResourceConfiguration.Username))
.NotNull();

_ = Guard.Argument(DockerResourceConfiguration.Password, nameof(DockerResourceConfiguration.Password))
.NotNull();
}
HofmeisterAn marked this conversation as resolved.
Show resolved Hide resolved
}

/// <inheritdoc />
protected override NatsBuilder Init()
{
return base.Init()
.WithImage(NatsImage)
.WithPortBinding(ClientPort, true)
.WithPortBinding(MonitoringPort, true)
.WithPortBinding(RoutingPort, true)
.WithCommand("--http_port", MonitoringPort.ToString()) // Enable monitoring endpoint.
.WithCommand("--jetstream") // Enable JetStream functionality.
.WithCommand("--debug") // Enable both debug
.WithCommand("--trace") // Enable protocol trace messages
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilMessageIsLogged("Listening for client connections on 0.0.0.0:4222"));
}

/// <inheritdoc />
protected override NatsBuilder Clone(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
{
return Merge(DockerResourceConfiguration, new NatsConfiguration(resourceConfiguration));
}

/// <inheritdoc />
protected override NatsBuilder Clone(IContainerConfiguration resourceConfiguration)
{
return Merge(DockerResourceConfiguration, new NatsConfiguration(resourceConfiguration));
}

/// <inheritdoc />
protected override NatsBuilder Merge(NatsConfiguration oldValue, NatsConfiguration newValue)
{
return new NatsBuilder(new NatsConfiguration(oldValue, newValue));
}
}
72 changes: 72 additions & 0 deletions src/Testcontainers.Nats/NatsConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
namespace Testcontainers.Nats;

/// <inheritdoc cref="ContainerConfiguration" />
[PublicAPI]
public sealed class NatsConfiguration : ContainerConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="NatsConfiguration" /> class.
/// </summary>
/// <param name="username">The nats server user name.</param>
/// <param name="password">The nats server password.</param>
public NatsConfiguration(
string username = null,
string password = null)
{
Username = username;
Password = password;
}

/// <summary>
/// Initializes a new instance of the <see cref="NatsConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public NatsConfiguration(IResourceConfiguration<CreateContainerParameters> resourceConfiguration)
: base(resourceConfiguration)
{
// Passes the configuration upwards to the base implementations to create an updated immutable copy.
}

/// <summary>
/// Initializes a new instance of the <see cref="NatsConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public NatsConfiguration(IContainerConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
// Passes the configuration upwards to the base implementations to create an updated immutable copy.
}

/// <summary>
/// Initializes a new instance of the <see cref="NatsConfiguration" /> class.
/// </summary>
/// <param name="resourceConfiguration">The Docker resource configuration.</param>
public NatsConfiguration(NatsConfiguration resourceConfiguration)
: this(new NatsConfiguration(), resourceConfiguration)
{
// Passes the configuration upwards to the base implementations to create an updated immutable copy.
}

/// <summary>
/// Initializes a new instance of the <see cref="NatsConfiguration" /> class.
/// </summary>
/// <param name="oldValue">The old Docker resource configuration.</param>
/// <param name="newValue">The new Docker resource configuration.</param>
public NatsConfiguration(NatsConfiguration oldValue, NatsConfiguration newValue)
: base(oldValue, newValue)
{
// // Create an updated immutable copy of the module configuration.
Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username);
Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password);
}

/// <summary>
/// The nats server user name.
/// </summary>
public string Username { get; }

/// <summary>
/// The nats server password.
/// </summary>
public string Password { get; }
}
49 changes: 49 additions & 0 deletions src/Testcontainers.Nats/NatsContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Testcontainers.Nats;

/// <inheritdoc cref="DockerContainer" />
[PublicAPI]
public sealed class NatsContainer : DockerContainer
{
private readonly NatsConfiguration _natsConfig;

/// <summary>
/// Initializes a new instance of the <see cref="NatsContainer" /> class.
/// </summary>
/// <param name="configuration">The container configuration.</param>
/// <param name="logger">The logger.</param>
public NatsContainer(NatsConfiguration configuration, ILogger logger)
: base(configuration, logger)
{
_natsConfig = configuration;
}

/// <summary>
/// Gets the nats connection string
/// </summary>
/// <returns>A nats connection string in the form: nats://hostname:mappedPort/>.</returns>
/// <remarks>
/// If either username or password is set, the connection string will contain the credentials.
/// </remarks>
public string GetConnectionString()
{
var endpoint = new UriBuilder("nats", Hostname, GetMappedPublicPort(NatsBuilder.ClientPort));

// Both should be set, or neither, this is validated in the builder.
if (_natsConfig.Password != null && _natsConfig.Username != null)
{
endpoint.UserName = Uri.EscapeDataString(_natsConfig.Username);
endpoint.Password = Uri.EscapeDataString(_natsConfig.Password);
}

return endpoint.ToString();
}

/// <summary>
/// Gets the nats monitor url
/// </summary>
/// <returns>A url in the form: http://hostname:mappedPort/>.</returns>
public string GetMonitorUrl()
{
return new UriBuilder("http", Hostname, GetMappedPublicPort(NatsBuilder.MonitoringPort)).ToString();
HofmeisterAn marked this conversation as resolved.
Show resolved Hide resolved
}
}
13 changes: 13 additions & 0 deletions src/Testcontainers.Nats/Testcontainers.Nats.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All"/>
<PackageReference Include="JetBrains.Annotations" Version="2022.3.1" PrivateAssets="All"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SolutionDir)src/Testcontainers/Testcontainers.csproj"/>
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions src/Testcontainers.Nats/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
global using System;
global using Docker.DotNet.Models;
global using DotNet.Testcontainers.Builders;
global using DotNet.Testcontainers.Configurations;
global using DotNet.Testcontainers.Containers;
global using JetBrains.Annotations;
global using Microsoft.Extensions.Logging;
1 change: 1 addition & 0 deletions tests/Testcontainers.Nats.Tests/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
87 changes: 87 additions & 0 deletions tests/Testcontainers.Nats.Tests/NatsContainerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Testcontainers.Nats;

public sealed class NatsContainerTest : IAsyncLifetime
{
private readonly NatsContainer _natsContainer = new NatsBuilder().Build();

public Task InitializeAsync()
{
return _natsContainer.StartAsync();
}

public Task DisposeAsync()
{
return _natsContainer.DisposeAsync().AsTask();
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task ContainerIsStartedWithCorrectParameters()
{
using var client = new ConnectionFactory()
.CreateConnection(_natsContainer.GetConnectionString());

Assert.Equal(ConnState.CONNECTED, client.State);
Assert.True(client.ServerInfo.JetStreamAvailable);

using var monitorClient = new HttpClient();
monitorClient.BaseAddress = new Uri(_natsContainer.GetMonitorUrl());

using var response = await monitorClient.GetAsync("/healthz");
var s = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode);
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task PubSubSendsAndReturnsMessages()
{
using var client = new ConnectionFactory()
.CreateConnection(_natsContainer.GetConnectionString());

using ISyncSubscription subSync = client.SubscribeSync("greet.pam");
client.Publish("greet.pam", Encoding.UTF8.GetBytes("hello pam 1"));

var msg = subSync.NextMessage(1000);
var text = Encoding.UTF8.GetString(msg.Data);


Assert.Equal("hello pam 1", text);
await client.DrainAsync();
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task BuilderShouldBuildWithUserNameAndPassword()
{
var encodedPassword = Uri.EscapeDataString("??&&testpass");
var encodedUsername = Uri.EscapeDataString("??&&test");

var builder = new NatsBuilder()
.WithUsername("??&&test")
.WithPassword("??&&testpass");
await using var container = builder.Build();

await container.StartAsync();

var uri = new Uri(container.GetConnectionString());

Assert.Equal($"{encodedUsername}:{encodedPassword}", uri.UserInfo);

using var client = new ConnectionFactory()
.CreateConnection(_natsContainer.GetConnectionString());

Assert.Equal(ConnState.CONNECTED, client.State);
}

[Fact]
[Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public void BuilderShouldFailWithOnlyUserNameOrPassword()
{
var builder = new NatsBuilder().WithUsername("??&&test");
Assert.Throws<ArgumentException>(() => builder.Build());

builder = new NatsBuilder().WithPassword("??&&test");
Assert.Throws<ArgumentException>(() => builder.Build());
}
}
Loading