From 8145d79dc6aeec462a90de50a4e901944bff1c96 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 09:32:44 -0400 Subject: [PATCH] feat: ConfigPreflight raw-config aggregator --- .../ConfigPreflight.cs | 75 +++++++++++++++++++ .../ConfigPreflightTests.cs | 49 ++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs create mode 100644 ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs diff --git a/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs new file mode 100644 index 0000000..0ebfff6 --- /dev/null +++ b/ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ConfigPreflight.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.Configuration; + +/// +/// Fluent aggregator for validating raw BEFORE the host/DI container +/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via +/// . For options that flow through DI, prefer +/// . +/// +public sealed class ConfigPreflight +{ + private readonly IConfiguration _configuration; + private readonly List _failures = []; + + private ConfigPreflight(IConfiguration configuration) => _configuration = configuration; + + /// Starts a preflight over . + public static ConfigPreflight For(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new ConfigPreflight(configuration); + } + + /// The accumulated failure messages (empty when valid). + public IReadOnlyList Failures => _failures; + + /// True when no failures have been accumulated. + public bool IsValid => _failures.Count == 0; + + /// Requires the value at to satisfy . + public ConfigPreflight Require(string key, Func predicate, string reason) + { + ArgumentNullException.ThrowIfNull(predicate); + if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}"); + return this; + } + + /// Requires a non-empty value at . + public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key)); + + /// Requires a valid integer TCP port (1-65535) at . + 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)); + } + + /// Runs only when holds (role-conditional rules). + public ConfigPreflight When(bool condition, Action block) + { + ArgumentNullException.ThrowIfNull(block); + if (condition) block(this); + return this; + } + + /// Throws listing all failures when invalid; otherwise returns. + 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; + } +} diff --git a/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs new file mode 100644 index 0000000..8b8428d --- /dev/null +++ b/ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs @@ -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 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(() => + 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 + } +}