8.9 KiB
Shared library: ZB.MOM.WW.Configuration
The public surface that extracts the startup configuration-validation plumbing the three
projects share. Realizes ../spec/SPEC.md. BUILT @ 0.1.0 — the
implementation lives at ../../../ZB.MOM.WW.Configuration/
(.NET 10; single package; 27 tests; dotnet pack → 1 nupkg @ 0.1.0). Not yet adopted by the
three apps — adoption is the follow-on tracked in ../GAPS.md.
This doc is the contract; the source is authoritative. Signatures below match the built source
(src/ZB.MOM.WW.Configuration/*.cs) verified at 0.1.0.
Package (.NET 10)
ZB.MOM.WW.Configuration # OptionsValidatorBase, ValidationBuilder, AddValidatedOptions, ConfigPreflight
A single package, one DLL. Minimal dependency closure — only Microsoft.Extensions.*
abstractions, no third-party packages and no ASP.NET Core framework reference:
| Dependency | Why |
|---|---|
Microsoft.Extensions.Options |
IValidateOptions<T>, ValidateOptionsResult, OptionsBuilder<T> |
Microsoft.Extensions.Options.ConfigurationExtensions |
.Bind(IConfigurationSection) on the options builder |
Microsoft.Extensions.Configuration.Abstractions |
IConfiguration / GetSection for AddValidatedOptions + ConfigPreflight |
Microsoft.Extensions.DependencyInjection.Abstractions |
IServiceCollection, AddOptions, AddSingleton |
Library, not a service — linked into each app at build time; all validation runs in-process at startup. Published to the Gitea NuGet feed; SemVer.
OptionsValidatorBase<TOptions>
namespace ZB.MOM.WW.Configuration;
/// Base class for IValidateOptions<TOptions> that removes the failure-accumulation plumbing.
/// Override Validate(builder, options); the base aggregates ALL failures and returns
/// ValidateOptionsResult.Success only when none were recorded.
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
// Guards null, runs the override against a fresh ValidationBuilder, and returns
// Success when builder.IsValid else Fail(builder.Failures).
public ValidateOptionsResult Validate(string? name, TOptions options);
// Record failures for `options` on `builder`. Never return early — record everything.
protected abstract void Validate(ValidationBuilder builder, TOptions options);
}
The override is the only thing a consumer writes. Accumulation, the Success/Fail decision,
and null-guarding are owned by the base.
ValidationBuilder
namespace ZB.MOM.WW.Configuration;
/// Accumulates validation failures for a bound options object. Passed into the Validate override;
/// each primitive checks a value and appends a "<field> <reason>" message on failure.
public sealed class ValidationBuilder
{
public IReadOnlyList<string> Failures { get; } // accumulated messages (empty when valid)
public bool IsValid { get; } // true when no failures recorded
// Escape hatches (custom + cross-field rules):
public ValidationBuilder RequireThat(bool ok, string message); // records message when !ok
public ValidationBuilder Add(string message); // unconditional failure
// Rule primitives (each delegates wording to internal Checks):
public ValidationBuilder Required(string? value, string field);
public ValidationBuilder Port(int value, string field);
public ValidationBuilder HostPort(string? value, string field);
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field);
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field);
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field);
}
All methods are chainable (return this). OneOf treats a null value as a failure — call
Required first if you want a "required" message instead of a "must be one of" message.
ServiceCollectionExtensions.AddValidatedOptions
namespace ZB.MOM.WW.Configuration;
public static class ServiceCollectionExtensions
{
/// Binds TOptions to the section at sectionPath, registers TValidator as its
/// IValidateOptions<TOptions> (singleton), and enables ValidateOnStart so a bad
/// configuration fails fast at host start. Returns the OptionsBuilder for chaining.
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
this IServiceCollection services, IConfiguration configuration, string sectionPath)
where TOptions : class
where TValidator : class, IValidateOptions<TOptions>;
}
Guards null services/configuration and whitespace sectionPath. The validator is registered
as a singleton (it backs the singleton options factory) — it must be singleton-safe (no
scoped dependencies). Bad sections surface as OptionsValidationException at host start.
ConfigPreflight
namespace ZB.MOM.WW.Configuration;
/// Fluent aggregator for validating raw IConfiguration BEFORE the host/DI container exists
/// (pre-Akka startup). Collects all failures and surfaces them together via ThrowIfInvalid.
public sealed class ConfigPreflight
{
public static ConfigPreflight For(IConfiguration configuration); // start a preflight
public IReadOnlyList<string> Failures { get; } // accumulated (empty when valid)
public bool IsValid { get; }
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason); // "<key> <reason>" on fail
public ConfigPreflight RequireValue(string key); // non-empty value at key
public ConfigPreflight RequirePort(string key); // integer TCP port 1-65535 at key
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block); // role-conditional rules
/// Throws InvalidOperationException listing all failures when invalid; otherwise returns.
/// Message envelope (byte-compatible with ScadaBridge StartupValidator):
/// "Configuration validation failed:\n - <field> <reason>\n - <field> <reason>"
public void ThrowIfInvalid();
}
Require/RequireValue/RequirePort guard a whitespace key. ThrowIfInvalid() is the only
surfacing path — call it last. The message envelope is pinned to match ScadaBridge's
StartupValidator (see ../current-state/scadabridge/CURRENT-STATE.md
and SPEC §4); the swap is behaviour-preserving.
Internal Checks seam
namespace ZB.MOM.WW.Configuration;
// internal — shared by ValidationBuilder (bound options) and ConfigPreflight (raw config).
// Each method returns null when valid, else a "<field> <reason>" message. Centralizing the
// wording keeps a given rule identical across both front-ends.
internal static class Checks
{
internal static string? Required(string? value, string field);
internal static string? Port(int value, string field);
internal static string? PortValue(string? raw, string field); // parse + range, for raw-config callers
internal static string? HostPort(string? value, string field); // non-bracketed host:port; rejects [::1]:port
internal static string? PositiveTimeSpan(TimeSpan value, string field);
internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field);
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field);
}
Checks is the single source of failure wording. ValidationBuilder.Port uses Checks.Port
(typed int); ConfigPreflight.RequirePort uses Checks.PortValue (raw string → parse → range),
so a port failure reads the same whether it came from a bound options object or a raw config key.
Not public — consumers get the wording through the primitives, not the seam.
Consumer matrix
| Consumer | Package | What it adopts | Weight |
|---|---|---|---|
| ScadaBridge | ZB.MOM.WW.Configuration |
Four *OptionsValidator → OptionsValidatorBase; four module AddXxx → AddValidatedOptions; StartupValidator → ConfigPreflight (byte-compatible). |
Heaviest — the most validators + the preflight. |
| MxGateway | ZB.MOM.WW.Configuration |
GatewayOptionsValidator → OptionsValidatorBase (drop the List<string> + AddIfBlank/AddIfNotPositive/AddIfInvalidPath helpers); AddGatewayConfiguration → AddValidatedOptions. |
Medium — one large validator. |
| OtOpcUa | ZB.MOM.WW.Configuration |
Optional — add OptionsValidatorBase subclasses + AddValidatedOptions for Ldap / OpcUa sections (currently unvalidated). DraftValidator/DraftSnapshot stay per-project (out of scope). |
Lightest — no validators today. |
All three consume the same single package; none needs ASP.NET Core. The net48 x86 mxaccessgw
worker does no IConfiguration validation and is excluded.
See ../GAPS.md for the adoption order and effort/risk.