# Shared library: `ZB.MOM.WW.Configuration` The public surface that extracts the startup configuration-validation plumbing the three projects share. Realizes [`../spec/SPEC.md`](../spec/SPEC.md). **BUILT @ `0.1.0`** — the implementation lives at [`../../../ZB.MOM.WW.Configuration/`](../../../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`](../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`, `ValidateOptionsResult`, `OptionsBuilder` | | `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` ```csharp namespace ZB.MOM.WW.Configuration; /// Base class for IValidateOptions 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 : IValidateOptions 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` ```csharp 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 " " message on failure. public sealed class ValidationBuilder { public IReadOnlyList 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 allowed, string field); public ValidationBuilder MinCount(IReadOnlyCollection? 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` ```csharp namespace ZB.MOM.WW.Configuration; public static class ServiceCollectionExtensions { /// Binds TOptions to the section at sectionPath, registers TValidator as its /// IValidateOptions (singleton), and enables ValidateOnStart so a bad /// configuration fails fast at host start. Returns the OptionsBuilder for chaining. public static OptionsBuilder AddValidatedOptions( this IServiceCollection services, IConfiguration configuration, string sectionPath) where TOptions : class where TValidator : class, IValidateOptions; } ``` 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` ```csharp 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 Failures { get; } // accumulated (empty when valid) public bool IsValid { get; } public ConfigPreflight Require(string key, Func predicate, string 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 block); // role-conditional rules /// Throws InvalidOperationException listing all failures when invalid; otherwise returns. /// Message envelope (byte-compatible with ScadaBridge StartupValidator): /// "Configuration validation failed:\n - \n - " 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`](../current-state/scadabridge/CURRENT-STATE.md) and SPEC §4); the swap is behaviour-preserving. --- ## Internal `Checks` seam ```csharp namespace ZB.MOM.WW.Configuration; // internal — shared by ValidationBuilder (bound options) and ConfigPreflight (raw config). // Each method returns null when valid, else a " " 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 allowed, string field); internal static string? MinCount(IReadOnlyCollection? 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` + `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`](../GAPS.md) for the adoption order and effort/risk.