diff --git a/src/Vault/Configuration/VaultConfigurationExtensions.cs b/src/Vault/Configuration/VaultConfigurationExtensions.cs
index a7c3070..ff70c86 100644
--- a/src/Vault/Configuration/VaultConfigurationExtensions.cs
+++ b/src/Vault/Configuration/VaultConfigurationExtensions.cs
@@ -11,16 +11,19 @@ namespace Vault.Configuration;
public static class VaultConfigurationExtensions
{
///
- /// Adds HashiCorp Vault as a configuration source.
- /// Note: AddVault() must be called BEFORE this method to register VaultService.
+ /// Adds HashiCorp Vault as a configuration source with an existing VaultService.
+ /// Secrets are loaded immediately during Build() so they are available for
+ /// subsequent configuration such as Entity Framework connection strings.
///
/// The configuration builder.
/// The Vault environment to load (e.g., DEV, PROD).
+ /// VaultService instance to use.
/// Optional action to configure the source.
/// The configuration builder for chaining.
public static IConfigurationBuilder AddVaultConfiguration(
this IConfigurationBuilder builder,
string environment,
+ IVaultService vaultService,
Action? configureSource = null)
{
if (builder == null)
@@ -35,29 +38,39 @@ public static IConfigurationBuilder AddVaultConfiguration(
nameof(environment));
}
+ if (vaultService == null)
+ {
+ throw new ArgumentNullException(nameof(vaultService));
+ }
+
var source = new VaultConfigurationSource
{
- Environment = environment
+ Environment = environment,
+ VaultService = vaultService
};
configureSource?.Invoke(source);
+ // The source will create the provider and load secrets immediately in Build()
return builder.Add(source);
}
///
- /// Adds HashiCorp Vault as a configuration source with an existing VaultService.
- /// Useful for tests or when VaultService is created manually.
+ /// Adds HashiCorp Vault as a configuration source with an existing VaultService and logger.
+ /// Secrets are loaded immediately during Build() so they are available for
+ /// subsequent configuration such as Entity Framework connection strings.
///
/// The configuration builder.
/// The Vault environment to load (e.g., DEV, PROD).
/// VaultService instance to use.
+ /// Optional logger for the configuration provider.
/// Optional action to configure the source.
/// The configuration builder for chaining.
public static IConfigurationBuilder AddVaultConfiguration(
this IConfigurationBuilder builder,
string environment,
IVaultService vaultService,
+ ILogger? logger,
Action? configureSource = null)
{
if (builder == null)
@@ -79,79 +92,14 @@ public static IConfigurationBuilder AddVaultConfiguration(
var source = new VaultConfigurationSource
{
- Environment = environment
+ Environment = environment,
+ VaultService = vaultService,
+ Logger = logger
};
configureSource?.Invoke(source);
- var provider = new VaultConfigurationProvider(source, vaultService);
- builder.Add(new VaultConfigurationSourceWrapper(source, provider));
-
- return builder;
- }
-
- ///
- /// Inject VaultService into all existing VaultConfigurationProvider instances.
- /// To be called after IConfiguration is built to initialize the providers.
- ///
- /// The built configuration.
- /// The service provider containing VaultService.
- public static void InitializeVaultProviders(
- this IConfiguration configuration,
- IServiceProvider serviceProvider)
- {
- if (configuration == null)
- {
- throw new ArgumentNullException(nameof(configuration));
- }
-
- if (serviceProvider == null)
- {
- throw new ArgumentNullException(nameof(serviceProvider));
- }
-
- if (configuration is not IConfigurationRoot configurationRoot)
- {
- return;
- }
-
- var vaultService = serviceProvider.GetService();
- if (vaultService == null)
- {
- return;
- }
-
- var logger = serviceProvider.GetService>();
-
- foreach (var provider in configurationRoot.Providers)
- {
- if (provider is VaultConfigurationProvider vaultProvider)
- {
- vaultProvider.SetVaultService(vaultService, logger);
- vaultProvider.Load();
- }
- }
- }
-
- ///
- /// Wrapper to allow manual provider injection.
- ///
- private class VaultConfigurationSourceWrapper : IConfigurationSource
- {
- private readonly VaultConfigurationSource _source;
- private readonly VaultConfigurationProvider _provider;
-
- public VaultConfigurationSourceWrapper(
- VaultConfigurationSource source,
- VaultConfigurationProvider provider)
- {
- _source = source;
- _provider = provider;
- }
-
- public IConfigurationProvider Build(IConfigurationBuilder builder)
- {
- return _provider;
- }
+ // The source will create the provider and load secrets immediately in Build()
+ return builder.Add(source);
}
}
diff --git a/src/Vault/Configuration/VaultConfigurationProvider.cs b/src/Vault/Configuration/VaultConfigurationProvider.cs
index 2391171..405bfc4 100644
--- a/src/Vault/Configuration/VaultConfigurationProvider.cs
+++ b/src/Vault/Configuration/VaultConfigurationProvider.cs
@@ -13,15 +13,17 @@ public class VaultConfigurationProvider
: ConfigurationProvider, IDisposable
{
private readonly VaultConfigurationSource _source;
- private readonly IVaultService? _vaultService;
+ private readonly IVaultService _vaultService;
private readonly ILogger? _logger;
private Timer? _reloadTimer;
private bool _disposed;
///
/// Initializes a new instance of the class.
- /// Main constructor used when VaultService is already available.
///
+ /// The configuration source.
+ /// The Vault service instance.
+ /// Optional logger for the provider.
public VaultConfigurationProvider(
VaultConfigurationSource source,
IVaultService vaultService,
@@ -38,22 +40,6 @@ public VaultConfigurationProvider(
}
}
- ///
- /// Initializes a new instance of the class.
- /// Constructor for compatibility with IConfigurationSource.Build
- /// VaultService will be injected later via SetVaultService.
- ///
- internal VaultConfigurationProvider(VaultConfigurationSource source)
- {
- _source = source ?? throw new ArgumentNullException(nameof(source));
-
- if (string.IsNullOrWhiteSpace(_source.Environment))
- {
- throw new InvalidOperationException(
- "Vault environment must be specified (e.g., DEV, PROD)");
- }
- }
-
///
/// Load secrets from Vault.
///
@@ -79,28 +65,6 @@ public void Dispose()
GC.SuppressFinalize(this);
}
- ///
- /// Inject VaultService after creation (used by extension method).
- ///
- internal void SetVaultService(IVaultService vaultService, ILogger? logger = null)
- {
- if (_vaultService != null)
- {
- throw new InvalidOperationException("VaultService already set");
- }
-
- // Use reflection to assign the readonly field
- var field = typeof(VaultConfigurationProvider).GetField(
- "_vaultService",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
- field?.SetValue(this, vaultService);
-
- var loggerField = typeof(VaultConfigurationProvider).GetField(
- "_logger",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
- loggerField?.SetValue(this, logger);
- }
-
///
/// Convert an object value to string.
///
@@ -120,35 +84,60 @@ internal void SetVaultService(IVaultService vaultService, ILogger
- /// Check if a value is JSON.
+ /// Check if a value is JSON (object or array).
+ /// Handles both string values and JsonElement values.
///
- private static bool IsJsonValue(object? value)
+ private static bool IsJsonValue(object? value, out string? jsonString)
{
- if (value is not string str)
+ jsonString = null;
+
+ if (value == null)
{
return false;
}
- str = str.Trim();
- return (str.StartsWith("{") && str.EndsWith("}")) || (str.StartsWith("[") && str.EndsWith("]"));
- }
-
- private async Task LoadAsync()
- {
- if (_vaultService == null)
+ // Handle JsonElement from VaultSharp
+ if (value is JsonElement jsonElement)
{
- var errorMessage = "VaultService is not initialized. " +
- "Make sure to call AddVault() before AddVaultConfiguration()";
+ if (jsonElement.ValueKind == JsonValueKind.Object ||
+ jsonElement.ValueKind == JsonValueKind.Array)
+ {
+ jsonString = jsonElement.GetRawText();
+ return true;
+ }
- if (_source.Optional)
+ if (jsonElement.ValueKind == JsonValueKind.String)
{
- _logger?.LogWarning(errorMessage);
- return;
+ var str = jsonElement.GetString()?.Trim();
+ if (!string.IsNullOrEmpty(str) &&
+ ((str.StartsWith("{") && str.EndsWith("}")) ||
+ (str.StartsWith("[") && str.EndsWith("]"))))
+ {
+ jsonString = str;
+ return true;
+ }
}
- throw new InvalidOperationException(errorMessage);
+ return false;
+ }
+
+ // Handle string values
+ if (value is string strValue)
+ {
+ var trimmed = strValue.Trim();
+ if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) ||
+ (trimmed.StartsWith("[") && trimmed.EndsWith("]")))
+ {
+ jsonString = trimmed;
+ return true;
+ }
}
+ return false;
+ }
+
+ private async Task LoadAsync()
+ {
try
{
_logger?.LogInformation(
@@ -162,9 +151,9 @@ private async Task LoadAsync()
foreach (var kvp in secrets)
{
// If the value is JSON, flatten it
- if (IsJsonValue(kvp.Value))
+ if (IsJsonValue(kvp.Value, out var jsonString))
{
- FlattenJsonValue(kvp.Key, kvp.Value, data);
+ FlattenJsonValue(kvp.Key, jsonString, data);
}
else
{
@@ -229,11 +218,11 @@ private void LoadAndNotifyChange()
}
///
- /// Flatten a JSON value into dotted keys with dot notation.
+ /// Flatten a JSON value into configuration keys.
///
- private void FlattenJsonValue(string parentKey, object? value, Dictionary data)
+ private void FlattenJsonValue(string parentKey, string? jsonString, Dictionary data)
{
- if (value is not string jsonString)
+ if (string.IsNullOrEmpty(jsonString))
{
return;
}
diff --git a/src/Vault/Configuration/VaultConfigurationSource.cs b/src/Vault/Configuration/VaultConfigurationSource.cs
index a2a85fd..56bc898 100644
--- a/src/Vault/Configuration/VaultConfigurationSource.cs
+++ b/src/Vault/Configuration/VaultConfigurationSource.cs
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Vault.Abstractions;
namespace Vault.Configuration;
@@ -29,11 +31,34 @@ public class VaultConfigurationSource
///
public int ReloadIntervalSeconds { get; set; } = 300; // 5 minutes by default
+ ///
+ /// Gets or sets the Vault service instance to use for loading secrets.
+ ///
+ internal IVaultService? VaultService { get; set; }
+
+ ///
+ /// Gets or sets the logger for the configuration provider.
+ ///
+ internal ILogger? Logger { get; set; }
+
///
/// Build the configuration provider.
+ /// Secrets are loaded immediately to make them available for subsequent
+ /// configuration (e.g., connection strings for Entity Framework).
///
+ /// Thrown when VaultService is not set.
public IConfigurationProvider Build(IConfigurationBuilder builder)
{
- return new VaultConfigurationProvider(this);
+ if (VaultService == null)
+ {
+ throw new InvalidOperationException(
+ "VaultService must be set. Use AddVault() or AddVaultConfiguration() with a VaultService instance.");
+ }
+
+ // Create provider with VaultService and load immediately
+ var provider = new VaultConfigurationProvider(this, VaultService, Logger);
+ provider.Load();
+
+ return provider;
}
}
diff --git a/src/Vault/Extensions/VaultExtension.cs b/src/Vault/Extensions/VaultExtension.cs
index a8a1fe2..2c7dbff 100644
--- a/src/Vault/Extensions/VaultExtension.cs
+++ b/src/Vault/Extensions/VaultExtension.cs
@@ -1,5 +1,4 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Vault.Abstractions;
using Vault.Configuration;
@@ -16,6 +15,8 @@ public static class VaultExtension
{
///
/// Fully configures Vault: adds configuration source and registers VaultService.
+ /// Secrets are loaded immediately during configuration build, making them available
+ /// for subsequent configuration such as Entity Framework connection strings.
///
/// The service collection.
/// The application configuration.
@@ -50,34 +51,6 @@ public static IServiceCollection AddVault(
throw new ArgumentException("Environment cannot be empty", nameof(environment));
}
- configuration.AddVaultConfiguration(environment, configureSource);
- services.AddVaultService(vaultOptions);
-
- return services;
- }
-
- ///
- /// Initialize Vault providers after the application is built.
- ///
- /// The web application.
- /// The web application for chaining.
- public static WebApplication UseVault(this WebApplication app)
- {
- if (app == null)
- {
- throw new ArgumentNullException(nameof(app));
- }
-
- app.Configuration.InitializeVaultProviders(app.Services);
-
- return app;
- }
-
- ///
- /// Register VaultService in the DI container with specified options.
- ///
- private static IServiceCollection AddVaultService(this IServiceCollection services, VaultOptions vaultOptions)
- {
// Register VaultOptions singleton
services.AddSingleton(vaultOptions);
@@ -90,8 +63,14 @@ private static IServiceCollection AddVaultService(this IServiceCollection servic
// Validate VaultOptions configuration
VaultOptionsValidator.Validate(vaultOptions);
- // Register VaultService as singleton
- services.AddSingleton();
+ // Create VaultService immediately so secrets can be loaded during configuration build
+ var vaultService = new VaultService(vaultOptions);
+
+ // Add Vault configuration with immediate loading
+ configuration.AddVaultConfiguration(environment, vaultService, configureSource);
+
+ // Register the same VaultService instance in DI for later use
+ services.AddSingleton(vaultService);
return services;
}
diff --git a/src/Vault/Services/VaultService.cs b/src/Vault/Services/VaultService.cs
index aeee6a6..e78be3b 100644
--- a/src/Vault/Services/VaultService.cs
+++ b/src/Vault/Services/VaultService.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using Vault.Abstractions;
using Vault.Helpers;
using Vault.Options;
@@ -22,13 +23,13 @@ public class VaultService
/// Initializes a new instance of the class.
///
/// The Vault configuration options.
- /// The logger instance.
+ /// The logger instance. If null, a NullLogger is used.
public VaultService(
VaultOptions options,
- ILogger logger)
+ ILogger? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _logger = logger ?? NullLogger.Instance;
if (!options.IsActivated)
{
diff --git a/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs b/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs
index 942bd41..55b3d6c 100644
--- a/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs
+++ b/test/Vault.Tests/Configuration/VaultConfigurationSourceTests.cs
@@ -2,6 +2,8 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using Vault.Abstractions;
using Vault.Configuration;
using Xunit;
@@ -78,71 +80,101 @@ public void VaultConfigurationSource_CanSetReloadIntervalSeconds()
}
[Fact]
- public void Build_WithEmptyEnvironment_ThrowsInvalidOperationException()
+ public void Build_WithoutVaultService_ThrowsInvalidOperationException()
{
// Arrange
var source = new VaultConfigurationSource
{
- Environment = string.Empty,
+ Environment = "dev",
};
var builder = new ConfigurationBuilder();
// Act & Assert
InvalidOperationException exception = Assert.Throws(() => source.Build(builder));
- Assert.Contains("environment must be specified", exception.Message);
+ Assert.Contains("VaultService must be set", exception.Message);
}
[Fact]
- public void Build_WithValidEnvironment_ReturnsProvider()
+ public void AddVaultConfiguration_WithEmptyEnvironment_ThrowsArgumentException()
{
// Arrange
- var source = new VaultConfigurationSource
- {
- Environment = "dev",
- };
+ var vaultService = Substitute.For();
+ var builder = new ConfigurationBuilder();
+
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ builder.AddVaultConfiguration(string.Empty, vaultService));
+ Assert.Equal("environment", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddVaultConfiguration_WithValidEnvironmentAndVaultService_ReturnsBuilder()
+ {
+ // Arrange
+ var vaultService = Substitute.For();
+ vaultService.GetSecretsAsync("dev").Returns(new Dictionary());
var builder = new ConfigurationBuilder();
// Act
- IConfigurationProvider provider = source.Build(builder);
+ IConfigurationBuilder result = builder.AddVaultConfiguration("dev", vaultService);
// Assert
- Assert.NotNull(provider);
- Assert.IsType(provider);
+ Assert.Same(builder, result);
}
[Fact]
- public void Build_WithWhitespaceEnvironment_ThrowsInvalidOperationException()
+ public void AddVaultConfiguration_WithWhitespaceEnvironment_ThrowsArgumentException()
{
// Arrange
- var source = new VaultConfigurationSource
- {
- Environment = " ",
- };
+ var vaultService = Substitute.For();
var builder = new ConfigurationBuilder();
// Act & Assert
- InvalidOperationException exception = Assert.Throws(() => source.Build(builder));
- Assert.Contains("environment must be specified", exception.Message);
+ ArgumentException exception = Assert.Throws(() =>
+ builder.AddVaultConfiguration(" ", vaultService));
+ Assert.Equal("environment", exception.ParamName);
}
[Fact]
- public void Build_WithCustomReloadInterval_PreservesConfiguration()
+ public void AddVaultConfiguration_LoadsSecretsImmediately()
{
// Arrange
- var source = new VaultConfigurationSource
+ var vaultService = Substitute.For();
+ var secrets = new Dictionary
{
- Environment = "dev",
- Optional = true,
- ReloadOnChange = true,
- ReloadIntervalSeconds = 600,
+ ["ConnectionStrings:Default"] = "Server=localhost;Database=Test",
+ ["AppSettings:ApiKey"] = "secret-key",
};
+ vaultService.GetSecretsAsync("dev").Returns(secrets);
var builder = new ConfigurationBuilder();
// Act
- IConfigurationProvider provider = source.Build(builder);
+ builder.AddVaultConfiguration("dev", vaultService);
+ IConfigurationRoot config = builder.Build();
// Assert
- Assert.NotNull(provider);
- Assert.IsType(provider);
+ Assert.Equal("Server=localhost;Database=Test", config["ConnectionStrings:Default"]);
+ Assert.Equal("secret-key", config["AppSettings:ApiKey"]);
+ }
+
+ [Fact]
+ public void AddVaultConfiguration_WithConfigureSource_AppliesConfiguration()
+ {
+ // Arrange
+ var vaultService = Substitute.For();
+ vaultService.GetSecretsAsync("prod").Returns(new Dictionary());
+ var builder = new ConfigurationBuilder();
+
+ // Act
+ builder.AddVaultConfiguration("prod", vaultService, source =>
+ {
+ source.Optional = true;
+ source.ReloadOnChange = true;
+ source.ReloadIntervalSeconds = 600;
+ });
+ IConfigurationRoot config = builder.Build();
+
+ // Assert - configuration was built successfully
+ Assert.NotNull(config);
}
}
diff --git a/test/Vault.Tests/Extensions/VaultExtensionTests.cs b/test/Vault.Tests/Extensions/VaultExtensionTests.cs
index fc9c36b..16e9897 100644
--- a/test/Vault.Tests/Extensions/VaultExtensionTests.cs
+++ b/test/Vault.Tests/Extensions/VaultExtensionTests.cs
@@ -117,41 +117,11 @@ public void AddVault_WithInactivatedVault_RegistersOptionsOnly()
Assert.NotNull(registeredOptions);
Assert.Same(vaultOptions, registeredOptions);
- // VaultService should not be registered
+ // VaultService should not be registered when Vault is not activated
IVaultService? vaultService = serviceProvider.GetService();
Assert.Null(vaultService);
}
- [Fact]
- public void AddVault_WithValidConfiguration_RegistersVaultOptions()
- {
- // Arrange
- var services = new ServiceCollection();
- var configuration = new ConfigurationBuilder();
- IAuthMethodInfo mockAuthMethod = Substitute.For();
- var vaultOptions = new VaultOptions
- {
- IsActivated = true,
- AuthenticationType = VaultAuthenticationType.Custom,
- Configuration = new VaultCustomConfiguration
- {
- VaultUrl = "https://vault.example.com",
- MountPoint = "secret",
- AuthMethodFactory = () => mockAuthMethod,
- },
- };
- var environment = "dev";
-
- // Act
- services.AddVault(configuration, vaultOptions, environment);
- ServiceProvider serviceProvider = services.BuildServiceProvider();
-
- // Assert
- VaultOptions? registeredOptions = serviceProvider.GetService();
- Assert.NotNull(registeredOptions);
- Assert.Same(vaultOptions, registeredOptions);
- }
-
[Fact]
public void AddVault_WithInvalidConfiguration_ThrowsInvalidOperationException()
{
diff --git a/test/Vault.Tests/Services/VaultServiceTests.cs b/test/Vault.Tests/Services/VaultServiceTests.cs
index a5af31a..bc4405b 100644
--- a/test/Vault.Tests/Services/VaultServiceTests.cs
+++ b/test/Vault.Tests/Services/VaultServiceTests.cs
@@ -37,10 +37,9 @@ public void Constructor_WithNullOptions_ThrowsArgumentNullException()
}
[Fact]
- public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+ public void Constructor_WithNullLogger_UsesNullLogger()
{
// Arrange
- IAuthMethodInfo mockAuthMethod = Substitute.For();
var options = new VaultOptions
{
IsActivated = true,
@@ -49,15 +48,15 @@ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
{
VaultUrl = "https://vault.example.com",
MountPoint = "secret",
- AuthMethodFactory = () => mockAuthMethod,
+ AuthMethodFactory = () => new VaultSharp.V1.AuthMethods.Token.TokenAuthMethodInfo("test-token"),
},
};
- ILogger? logger = null;
- // Act & Assert
- ArgumentNullException exception = Assert.Throws(() =>
- new VaultService(options, logger!));
- Assert.Equal(nameof(logger), exception.ParamName);
+ // Act - should not throw, logger is optional
+ var service = new VaultService(options, null);
+
+ // Assert
+ Assert.NotNull(service);
}
[Fact]