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

184 lines
8.9 KiB
Markdown

# 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<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>`
```csharp
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`
```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 "<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`
```csharp
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`
```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<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`](../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 "<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`](../GAPS.md) for the adoption order and effort/risk.