diff --git a/docs/api/connection_string_provider.md b/docs/api/connection_string_provider.md new file mode 100644 index 000000000..fbb30b6d3 --- /dev/null +++ b/docs/api/connection_string_provider.md @@ -0,0 +1,82 @@ +# Connection String Provider + +The Connection String Provider API provides a standardized way to access and manage connection information for Testcontainers (modules). It allows developers to customize module-provided connection strings or add their own, and to access module-specific connection strings or endpoints (e.g., database connection strings, HTTP API base addresses) in a uniform way. + +!!!note + + Testcontainers modules do not yet implement this feature. Developers can use the provider to define and manage their own connection strings or endpoints. Providers will be integrated by modules in future releases. + +## Example + +Register a custom connection string provider via the container builder: + +```csharp +IContainer container = new ContainerBuilder() + .WithConnectionStringProvider(new MyProvider1()) + .Build(); + +// Implicit host connection string (default) +var hostConnectionStringImplicit = container.GetConnectionString(); + +// Explicit host connection string +var hostConnectionStringExplicit = container.GetConnectionString(ConnectionMode.Host); + +// Container-to-container connection string +var containerConnectionString = container.GetConnectionString(ConnectionMode.Container); +``` + +## Implementing a custom provider + +To create a custom provider, implement the generic interface: `IConnectionStringProvider`. The `Configure(TContainer, TConfiguration)` method is invoked after the container has successfully started, ensuring that all runtime-assigned values are available. + +=== "Generic builder" + ```csharp + public sealed class MyProvider1 : IConnectionStringProvider + { + public void Configure(IContainer container, IContainerConfiguration configuration) + { + // Initialize provider with container information. + } + + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + // This method returns a default connection string. The connection mode argument + // lets you choose between a host connection or a container-to-container connection. + return "..."; + } + + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + // This method returns a connection string for the given name. Useful for modules + // with multiple endpoints (e.g., Azurite blob, queue, or table). + return "..."; + } + } + ``` + +=== "Module builder" + ```csharp + public sealed class MyProvider2 : IConnectionStringProvider + { + private string _host; + + private ushort _port; + + public void Configure(PostgreSqlContainer container, PostgreSqlConfiguration configuration) + { + // Initialize provider with container information. + _host = container.Hostname; + _port = container.GetMappedPublicPort(PostgreSqlBuilder.PostgreSqlPort); + } + + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + return $"Host={_host};Port={_port};..."; + } + + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + return $"Host={_host};Port={_port};...;SSL Mode=Require"; + } + } + ``` diff --git a/mkdocs.yml b/mkdocs.yml index c7f450268..a1c61bd90 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - api/resource_reaper.md - api/resource_reuse.md - api/wait_strategies.md + - api/connection_string_provider.md - api/best_practices.md - dind/index.md - full_framework/index.md diff --git a/src/Testcontainers.Couchbase/CouchbaseBuilder.cs b/src/Testcontainers.Couchbase/CouchbaseBuilder.cs index 710f14647..6fe2f0123 100644 --- a/src/Testcontainers.Couchbase/CouchbaseBuilder.cs +++ b/src/Testcontainers.Couchbase/CouchbaseBuilder.cs @@ -179,7 +179,7 @@ private CouchbaseBuilder WithBucket(params CouchbaseBucket[] bucket) /// /// Configures the Couchbase node. /// - /// The container. + /// The Couchbase container. /// Cancellation token. private async Task ConfigureCouchbaseAsync(IContainer container, CancellationToken ct = default) { diff --git a/src/Testcontainers/Builders/ContainerBuilder`3.cs b/src/Testcontainers/Builders/ContainerBuilder`3.cs index ef04b7881..b1c48147a 100644 --- a/src/Testcontainers/Builders/ContainerBuilder`3.cs +++ b/src/Testcontainers/Builders/ContainerBuilder`3.cs @@ -396,6 +396,12 @@ public TBuilderEntity WithStartupCallback(Func startupCallback((TContainerEntity)container, (TConfigurationEntity)configuration, ct))); } + /// + public TBuilderEntity WithConnectionStringProvider(IConnectionStringProvider connectionStringProvider) + { + return Clone(new ContainerConfiguration(connectionStringProvider: new ConnectionStringProvider(connectionStringProvider))); + } + /// protected override TBuilderEntity Init() { diff --git a/src/Testcontainers/Builders/IContainerBuilder`2.cs b/src/Testcontainers/Builders/IContainerBuilder`2.cs index 573a77ad8..71b3c9c5b 100644 --- a/src/Testcontainers/Builders/IContainerBuilder`2.cs +++ b/src/Testcontainers/Builders/IContainerBuilder`2.cs @@ -21,6 +21,8 @@ namespace DotNet.Testcontainers.Builders /// The configuration entity. [PublicAPI] public interface IContainerBuilder : IAbstractBuilder + where TContainerEntity : IContainer + where TConfigurationEntity : IContainerConfiguration { /// /// Accepts the license agreement. @@ -500,5 +502,13 @@ public interface IContainerBuilderA configured instance of . [PublicAPI] TBuilderEntity WithStartupCallback(Func startupCallback); + + /// + /// Sets the connection string provider. + /// + /// The connection string provider. + /// A configured instance of . + [PublicAPI] + TBuilderEntity WithConnectionStringProvider(IConnectionStringProvider connectionStringProvider); } } diff --git a/src/Testcontainers/Configurations/Containers/ConnectionMode.cs b/src/Testcontainers/Configurations/Containers/ConnectionMode.cs new file mode 100644 index 000000000..a6ec0a2fd --- /dev/null +++ b/src/Testcontainers/Configurations/Containers/ConnectionMode.cs @@ -0,0 +1,21 @@ +namespace DotNet.Testcontainers.Configurations +{ + using JetBrains.Annotations; + + /// + /// Represents the connection mode. + /// + [PublicAPI] + public enum ConnectionMode + { + /// + /// The connection string for the container. + /// + Container, + + /// + /// The connection string for the host. + /// + Host, + } +} diff --git a/src/Testcontainers/Configurations/Containers/ConnectionStringProvider.cs b/src/Testcontainers/Configurations/Containers/ConnectionStringProvider.cs new file mode 100644 index 000000000..319d84c19 --- /dev/null +++ b/src/Testcontainers/Configurations/Containers/ConnectionStringProvider.cs @@ -0,0 +1,39 @@ +namespace DotNet.Testcontainers.Configurations +{ + using DotNet.Testcontainers.Containers; + + /// + internal sealed class ConnectionStringProvider : IConnectionStringProvider + where TContainerEntity : IContainer + where TConfigurationEntity : IContainerConfiguration + { + private readonly IConnectionStringProvider _connectionStringProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The connection string provider. + public ConnectionStringProvider(IConnectionStringProvider connectionStringProvider) + { + _connectionStringProvider = connectionStringProvider; + } + + /// + public void Configure(IContainer container, IContainerConfiguration configuration) + { + _connectionStringProvider.Configure((TContainerEntity)container, (TConfigurationEntity)configuration); + } + + /// + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + return _connectionStringProvider.GetConnectionString(connectionMode); + } + + /// + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + return _connectionStringProvider.GetConnectionString(name, connectionMode); + } + } +} diff --git a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs index 9d3a5847a..8d29993f4 100644 --- a/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/ContainerConfiguration.cs @@ -62,6 +62,7 @@ public ContainerConfiguration( IOutputConsumer outputConsumer = null, IEnumerable waitStrategies = null, Func startupCallback = null, + IConnectionStringProvider connectionStringProvider = null, bool? autoRemove = null, bool? privileged = null) { @@ -87,6 +88,7 @@ public ContainerConfiguration( OutputConsumer = outputConsumer; WaitStrategies = waitStrategies; StartupCallback = startupCallback; + ConnectionStringProvider = connectionStringProvider; } /// @@ -135,6 +137,7 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig OutputConsumer = BuildConfiguration.Combine(oldValue.OutputConsumer, newValue.OutputConsumer); WaitStrategies = BuildConfiguration.Combine>(oldValue.WaitStrategies, newValue.WaitStrategies); StartupCallback = BuildConfiguration.Combine(oldValue.StartupCallback, newValue.StartupCallback); + ConnectionStringProvider = BuildConfiguration.Combine(oldValue.ConnectionStringProvider, newValue.ConnectionStringProvider); AutoRemove = (oldValue.AutoRemove.HasValue && oldValue.AutoRemove.Value) || (newValue.AutoRemove.HasValue && newValue.AutoRemove.Value); Privileged = (oldValue.Privileged.HasValue && oldValue.Privileged.Value) || (newValue.Privileged.HasValue && newValue.Privileged.Value); } @@ -217,5 +220,9 @@ public ContainerConfiguration(IContainerConfiguration oldValue, IContainerConfig /// [JsonIgnore] public Func StartupCallback { get; } + + /// + [JsonIgnore] + public IConnectionStringProvider ConnectionStringProvider { get; } } } diff --git a/src/Testcontainers/Configurations/Containers/IConnectionStringProvider.cs b/src/Testcontainers/Configurations/Containers/IConnectionStringProvider.cs new file mode 100644 index 000000000..6068ec929 --- /dev/null +++ b/src/Testcontainers/Configurations/Containers/IConnectionStringProvider.cs @@ -0,0 +1,31 @@ +namespace DotNet.Testcontainers.Configurations +{ + using DotNet.Testcontainers.Containers; + using JetBrains.Annotations; + + /// + /// A connection string provider. + /// + [PublicAPI] + public interface IConnectionStringProvider + { + /// + /// Gets the connection string. + /// + /// The connection mode. + /// The connection string. + /// Thrown when the connection string provider is not configured. + [NotNull] + string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host); + + /// + /// Gets the connection string. + /// + /// The connection string name. + /// The connection mode. + /// The connection string. + /// Thrown when the connection string provider is not configured. + [NotNull] + string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host); + } +} diff --git a/src/Testcontainers/Configurations/Containers/IConnectionStringProvider`2.cs b/src/Testcontainers/Configurations/Containers/IConnectionStringProvider`2.cs new file mode 100644 index 000000000..bb3312458 --- /dev/null +++ b/src/Testcontainers/Configurations/Containers/IConnectionStringProvider`2.cs @@ -0,0 +1,19 @@ +namespace DotNet.Testcontainers.Configurations +{ + using DotNet.Testcontainers.Containers; + using JetBrains.Annotations; + + /// + [PublicAPI] + public interface IConnectionStringProvider : IConnectionStringProvider + where TContainerEntity : IContainer + where TConfigurationEntity : IContainerConfiguration + { + /// + /// Configures the connection string provider. + /// + /// The container instance. + /// The container configuration. + void Configure(TContainerEntity container, TConfigurationEntity configuration); + } +} diff --git a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs index f67353256..e4369fe07 100644 --- a/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs +++ b/src/Testcontainers/Configurations/Containers/IContainerConfiguration.cs @@ -125,5 +125,10 @@ public interface IContainerConfiguration : IResourceConfiguration Func StartupCallback { get; } + + /// + /// Gets the connection string provider. + /// + IConnectionStringProvider ConnectionStringProvider { get; } } } diff --git a/src/Testcontainers/Configurations/WaitStrategies/IWaitUntil.cs b/src/Testcontainers/Configurations/WaitStrategies/IWaitUntil.cs index e7d4a033c..c536347f0 100644 --- a/src/Testcontainers/Configurations/WaitStrategies/IWaitUntil.cs +++ b/src/Testcontainers/Configurations/WaitStrategies/IWaitUntil.cs @@ -13,7 +13,7 @@ public interface IWaitUntil /// /// Evaluates the condition asynchronously against the specified container. /// - /// The container instance to check readiness against. + /// The container to check the condition for. /// A task that returns true when the condition is satisfied; otherwise, false. Task UntilAsync(IContainer container); } diff --git a/src/Testcontainers/Configurations/WaitStrategies/IWaitWhile.cs b/src/Testcontainers/Configurations/WaitStrategies/IWaitWhile.cs index 7af0480fb..2ce072c7a 100644 --- a/src/Testcontainers/Configurations/WaitStrategies/IWaitWhile.cs +++ b/src/Testcontainers/Configurations/WaitStrategies/IWaitWhile.cs @@ -13,7 +13,7 @@ public interface IWaitWhile /// /// Evaluates the condition asynchronously against the specified container. /// - /// The container instance to check readiness against. + /// The container to check the condition for. /// A task that returns true while the condition holds; otherwise, false. Task WhileAsync(IContainer container); } diff --git a/src/Testcontainers/Containers/ConnectionStringProviderNotConfiguredException.cs b/src/Testcontainers/Containers/ConnectionStringProviderNotConfiguredException.cs new file mode 100644 index 000000000..54f895810 --- /dev/null +++ b/src/Testcontainers/Containers/ConnectionStringProviderNotConfiguredException.cs @@ -0,0 +1,20 @@ +namespace DotNet.Testcontainers.Containers +{ + using System; + using JetBrains.Annotations; + + /// + /// Represents an exception that is thrown when the connection string provider is not configured. + /// + [PublicAPI] + public sealed class ConnectionStringProviderNotConfiguredException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public ConnectionStringProviderNotConfiguredException() + : base("No connection string provider is configured for this container.") + { + } + } +} diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index 1370f7f5b..510e66a5e 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -29,6 +29,8 @@ public class DockerContainer : Resource, IContainer private ContainerInspectResponse _container = new ContainerInspectResponse(); + private IConnectionStringProvider _connectionStringProvider; + /// /// Initializes a new instance of the class. /// @@ -416,6 +418,28 @@ public Task ExecAsync(IList command, CancellationToken ct = return _client.ExecAsync(Id, command, ct); } + /// + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + if (_connectionStringProvider == null) + { + throw new ConnectionStringProviderNotConfiguredException(); + } + + return _connectionStringProvider.GetConnectionString(connectionMode); + } + + /// + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + if (_connectionStringProvider == null) + { + throw new ConnectionStringProviderNotConfiguredException(); + } + + return _connectionStringProvider.GetConnectionString(name, connectionMode); + } + /// protected override async ValueTask DisposeAsyncCore() { @@ -560,6 +584,13 @@ await _configuration.StartupCallback(this, _configuration, ct) Logger.CompleteReadinessCheck(_container.ID); StartedTime = DateTime.TryParse(_container.State!.StartedAt, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var startedTime) ? startedTime : DateTime.UtcNow; + + if (_configuration.ConnectionStringProvider != null) + { + _connectionStringProvider = _configuration.ConnectionStringProvider; + _connectionStringProvider.Configure(this, _configuration); + } + Started?.Invoke(this, EventArgs.Empty); } diff --git a/src/Testcontainers/Containers/IContainer.cs b/src/Testcontainers/Containers/IContainer.cs index 92aaa4d46..04eb6a5a7 100644 --- a/src/Testcontainers/Containers/IContainer.cs +++ b/src/Testcontainers/Containers/IContainer.cs @@ -14,7 +14,7 @@ namespace DotNet.Testcontainers.Containers /// A container instance. /// [PublicAPI] - public interface IContainer : IAsyncDisposable + public interface IContainer : IConnectionStringProvider, IAsyncDisposable { /// /// Subscribes to the creating event. diff --git a/tests/Testcontainers.Platform.Linux.Tests/ConnectionStringProviderTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ConnectionStringProviderTest.cs new file mode 100644 index 000000000..c863e8387 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ConnectionStringProviderTest.cs @@ -0,0 +1,91 @@ +namespace Testcontainers.Tests; + +public static class ConnectionStringProviderTests +{ + private const string ExpectedConnectionString = "connection string"; + + public sealed class Configured : IAsyncLifetime + { + private readonly ConnectionStringProvider _connectionStringProvider = new ConnectionStringProvider(); + + private readonly IContainer _container; + + public Configured() + { + _container = new ContainerBuilder() + .WithImage(CommonImages.Alpine) + .WithCommand(CommonCommands.SleepInfinity) + .WithConnectionStringProvider(_connectionStringProvider) + .Build(); + } + + public async ValueTask InitializeAsync() + { + await _container.StartAsync() + .ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync() + .ConfigureAwait(false); + } + + [Fact] + public void GetConnectionStringReturnsExpectedValue() + { + Assert.True(_connectionStringProvider.IsConfigured, "Configure should have been called during container startup."); + Assert.Equal(ExpectedConnectionString, _container.GetConnectionString()); + Assert.Equal(ExpectedConnectionString, _container.GetConnectionString("name")); + } + } + + public sealed class NotConfigured : IAsyncLifetime + { + private readonly IContainer _container = new ContainerBuilder() + .WithImage(CommonImages.Alpine) + .WithCommand(CommonCommands.SleepInfinity) + .Build(); + + public async ValueTask InitializeAsync() + { + await _container.StartAsync() + .ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync() + .ConfigureAwait(false); + } + + [Fact] + public void GetConnectionStringThrowsException() + { + Assert.Throws(() => _container.GetConnectionString()); + Assert.Throws(() => _container.GetConnectionString("name")); + } + } + + private sealed class ConnectionStringProvider : IConnectionStringProvider + { + public bool IsConfigured { get; private set; } + + public void Configure(IContainer container, IContainerConfiguration configuration) + { + Assert.NotNull(container); + Assert.NotNull(configuration); + IsConfigured = true; + } + + public string GetConnectionString(ConnectionMode connectionMode = ConnectionMode.Host) + { + return ExpectedConnectionString; + } + + public string GetConnectionString(string name, ConnectionMode connectionMode = ConnectionMode.Host) + { + return ExpectedConnectionString; + } + } +} \ No newline at end of file