Files
scadaproj/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md
T

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 *OptionsValidatorOptionsValidatorBase; four module AddXxxAddValidatedOptions; StartupValidatorConfigPreflight (byte-compatible). Heaviest — the most validators + the preflight.
MxGateway ZB.MOM.WW.Configuration GatewayOptionsValidatorOptionsValidatorBase (drop the List<string> + AddIfBlank/AddIfNotPositive/AddIfInvalidPath helpers); AddGatewayConfigurationAddValidatedOptions. 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.