feat: ConfigPreflight raw-config aggregator
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent aggregator for validating raw <see cref="IConfiguration"/> BEFORE the host/DI container
|
||||
/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via
|
||||
/// <see cref="ThrowIfInvalid"/>. For options that flow through DI, prefer
|
||||
/// <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions, TValidator}"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigPreflight
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly List<string> _failures = [];
|
||||
|
||||
private ConfigPreflight(IConfiguration configuration) => _configuration = configuration;
|
||||
|
||||
/// <summary>Starts a preflight over <paramref name="configuration"/>.</summary>
|
||||
public static ConfigPreflight For(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return new ConfigPreflight(configuration);
|
||||
}
|
||||
|
||||
/// <summary>The accumulated failure messages (empty when valid).</summary>
|
||||
public IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
/// <summary>True when no failures have been accumulated.</summary>
|
||||
public bool IsValid => _failures.Count == 0;
|
||||
|
||||
/// <summary>Requires the value at <paramref name="key"/> to satisfy <paramref name="predicate"/>.</summary>
|
||||
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Requires a non-empty value at <paramref name="key"/>.</summary>
|
||||
public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key));
|
||||
|
||||
/// <summary>Requires a valid integer TCP port (1-65535) at <paramref name="key"/>.</summary>
|
||||
public ConfigPreflight RequirePort(string key)
|
||||
{
|
||||
var raw = _configuration[key];
|
||||
if (!int.TryParse(raw, out var port))
|
||||
{
|
||||
_failures.Add($"{key} must be an integer port 1-65535 (was '{raw ?? "null"}')");
|
||||
return this;
|
||||
}
|
||||
return AddIf(Checks.Port(port, key));
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="block"/> only when <paramref name="condition"/> holds (role-conditional rules).</summary>
|
||||
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(block);
|
||||
if (condition) block(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Throws <see cref="InvalidOperationException"/> listing all failures when invalid; otherwise returns.</summary>
|
||||
public void ThrowIfInvalid()
|
||||
{
|
||||
if (_failures.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Configuration validation failed:\n{string.Join("\n", _failures.Select(e => $" - {e}"))}");
|
||||
}
|
||||
|
||||
private ConfigPreflight AddIf(string? message)
|
||||
{
|
||||
if (message is not null) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class ConfigPreflightTests
|
||||
{
|
||||
private static IConfiguration Config(Dictionary<string, string?> values) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_all_failures()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Bogus", ["Node:RemotingPort"] = "0" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
|
||||
.RequirePort("Node:RemotingPort");
|
||||
Assert.False(pf.IsValid);
|
||||
Assert.Equal(2, pf.Failures.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_runs_block_only_if_condition_true()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Site" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.When(cfg["Node:Role"] == "Site",
|
||||
p => p.RequireValue("Node:SiteId"));
|
||||
Assert.False(pf.IsValid); // SiteId missing
|
||||
Assert.Contains(pf.Failures, f => f.Contains("Node:SiteId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfInvalid_throws_aggregated_message()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Name"] = "" });
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid());
|
||||
Assert.StartsWith("Configuration validation failed:", ex.Message);
|
||||
Assert.Contains(" - Node:Name", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfInvalid_is_noop_when_valid()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Name"] = "ok" });
|
||||
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid(); // does not throw
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user