80e4d59209
The sibling libs (Auth/Theme/Health/Telemetry) are tracked as regular files in the outer scadaproj repo, not separate git repos. Remove the git-init/nested-repo instructions; all commits target the outer repo on feat/zb-mom-ww-configuration.
915 lines
37 KiB
Markdown
915 lines
37 KiB
Markdown
# ZB.MOM.WW.Configuration Shared Library Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Author the `components/configuration/` normalization docs and build the
|
||
`ZB.MOM.WW.Configuration` shared library (1 NuGet package) that gives the fleet one
|
||
startup-options-validation toolkit — a failure-accumulating `IValidateOptions<T>` base, reusable
|
||
rule primitives, a bind+validate+`ValidateOnStart` DI helper, and a pre-host `ConfigPreflight`
|
||
aggregator — removing the duplicated validation plumbing the three sister apps each hand-roll.
|
||
|
||
**Architecture:** A new self-contained solution directory committed into the outer `scadaproj`
|
||
repo at `~/Desktop/scadaproj/ZB.MOM.WW.Configuration` (same layout as the sibling
|
||
`ZB.MOM.WW.Telemetry`/`Health`/`Auth`/`Theme` — regular tracked files, **not** a submodule and
|
||
**not** a separate `.git`; build output `bin/`/`obj/`/`artifacts/` is gitignored). .NET 10, one
|
||
library project `ZB.MOM.WW.Configuration` with four public types
|
||
(`OptionsValidatorBase<TOptions>`, `ValidationBuilder`, `ServiceCollectionExtensions`,
|
||
`ConfigPreflight`) over one internal `Checks` helper that keeps rule wording identical across the
|
||
two front-ends (options-object validation vs raw-`IConfiguration` preflight). Scope is **startup
|
||
options validation only** — OtOpcUa's runtime draft/snapshot validation stays per-project. **No
|
||
app is modified in this pass**; adoption is a tracked `GAPS.md` follow-on, matching Auth/Health/Theme.
|
||
|
||
**Tech Stack:** .NET 10, C#; xUnit + coverlet; central package management; `.slnx`; `Version`
|
||
0.1.0. Library deps are minimal and framework-aligned:
|
||
`Microsoft.Extensions.Options` (carries `IValidateOptions`, `OptionsBuilder`, `AddOptions`,
|
||
**`ValidateOnStart`** — verified in net10's `Microsoft.Extensions.Options` assembly),
|
||
`Microsoft.Extensions.Options.ConfigurationExtensions` (`Bind`),
|
||
`Microsoft.Extensions.Configuration.Abstractions`,
|
||
`Microsoft.Extensions.DependencyInjection.Abstractions` — all already referenced by all three
|
||
apps, which is why this is a **single package** (no Akka/EF/Serilog-style heavy dep to isolate).
|
||
|
||
**Version note:** Pin the `Microsoft.Extensions.*` packages at `10.0.0` (the floor that matches
|
||
the installed net10 shared framework); the executor may bump to the latest restorable `10.0.x`
|
||
patch. Consumers' own central package management governs the final version at adoption.
|
||
|
||
---
|
||
|
||
## Design summary (approved via brainstorming)
|
||
|
||
| Decision | Choice | Rationale |
|
||
|---|---|---|
|
||
| Deliverable | **Full pass** (normalization docs + built library) | Match Auth/Theme/Health/Telemetry |
|
||
| Scope | **Startup options validation only** | Genuinely common across all 3; YAGNI |
|
||
| Package name | **`ZB.MOM.WW.Configuration`** | Full-word style; aligns with `Microsoft.Extensions.Configuration`/`Options` |
|
||
| API style | **Approach A** — lightweight base + rule primitives + DI/startup helpers | Mirrors what all 3 already do; no new fleet-wide dependency; single package |
|
||
|
||
**Out of scope (YAGNI):** runtime draft/snapshot validation (stays in OtOpcUa's `DraftValidator`);
|
||
any FluentValidation/DataAnnotations dependency; editing the three apps' code.
|
||
|
||
**Public API (namespace `ZB.MOM.WW.Configuration`; everything else internal):**
|
||
- `OptionsValidatorBase<TOptions>` — abstract `IValidateOptions<TOptions>`; override one
|
||
`Validate(ValidationBuilder, TOptions)`; base aggregates ALL failures, `Success` only when clean.
|
||
- `ValidationBuilder` — failure accumulator with primitives (`Required`, `Port`, `HostPort`,
|
||
`PositiveTimeSpan`, `OneOf`, `MinCount`) + `RequireThat`/`Add` escape hatch for cross-field rules.
|
||
- `ServiceCollectionExtensions.AddValidatedOptions<TOptions,TValidator>(config, sectionPath)` —
|
||
bind + register validator + `ValidateOnStart()`; returns `OptionsBuilder<TOptions>`.
|
||
- `ConfigPreflight` — fluent aggregator over raw `IConfiguration` for pre-host checks
|
||
(`For`/`Require`/`RequireValue`/`RequirePort`/`When`/`ThrowIfInvalid`).
|
||
|
||
**Error-handling contract:** accumulate (never fail-fast-on-first); options validators surface via
|
||
`ValidateOnStart()` → host throws `OptionsValidationException`; `ConfigPreflight.ThrowIfInvalid()`
|
||
throws one `InvalidOperationException` with a `"Configuration validation failed:\n - …"` body —
|
||
**byte-compatible with ScadaBridge's current `StartupValidator`** so its swap is a drop-in. Message
|
||
format is `"<field> <reason>"`.
|
||
|
||
**Conventions for every task:** TDD — failing test first, minimal impl, green, commit. File-scoped
|
||
namespaces, `sealed` by default, XML doc comments on public members (match the sibling libs).
|
||
**All work — library and docs — is committed to the outer `scadaproj` repo on branch
|
||
`feat/zb-mom-ww-configuration`** (the library is tracked files inside it, like the sibling libs;
|
||
there is no separate nested `.git`). `dotnet build`/`test` may `cd` into the library dir, but every
|
||
`git commit` targets the outer repo. The `Files:` block IS the `files_to_edit` contract.
|
||
|
||
**Source references (read-only, to verify current-state against — do NOT modify):**
|
||
- OtOpcUa: `~/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/{DraftValidator,DraftSnapshot}.cs` (draft validation stays per-project)
|
||
- MxAccessGateway: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Configuration/{GatewayOptionsValidator,GatewayConfigurationServiceCollectionExtensions}.cs`
|
||
- ScadaBridge: per-module `*OptionsValidator.cs` (`ClusterInfrastructure`, `Security`, `HealthMonitoring`, `AuditLog`) + `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs` + each module's `ServiceCollectionExtensions.cs`
|
||
- Design + conventions to mirror: `~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/` (props, `.slnx`, csproj, code/test style)
|
||
|
||
---
|
||
|
||
## Phase 0 — Normalization docs (spec drives the API)
|
||
|
||
### Task 1: components/configuration spec + shared-contract
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2
|
||
|
||
**Files:**
|
||
- Create: `components/configuration/spec/SPEC.md`
|
||
- Create: `components/configuration/shared-contract/ZB.MOM.WW.Configuration.md`
|
||
|
||
**Steps:**
|
||
1. `spec/SPEC.md` — **Scope** section: normalized = the `IValidateOptions<T>` failure-accumulation
|
||
convention, the reusable rule primitives, the `AddValidatedOptions` bind+validate+`ValidateOnStart`
|
||
wiring, and the pre-host `ConfigPreflight` aggregator (generalizes ScadaBridge's `StartupValidator`).
|
||
NOT normalized = each app's options classes + their domain rules (cluster/security/gateway), and
|
||
OtOpcUa's runtime draft/snapshot validation. Document the error-handling contract (accumulate;
|
||
two surfacing paths; `"<field> <reason>"` message format).
|
||
2. `shared-contract/ZB.MOM.WW.Configuration.md` — paper API of the single package: the four public
|
||
types with signatures (copy from the Design summary above), the internal `Checks` seam, and the
|
||
consumer matrix (all three apps; ScadaBridge heaviest — `StartupValidator` → `ConfigPreflight`).
|
||
|
||
**Acceptance:** both files exist; SPEC explicitly lists normalized vs per-project; shared-contract
|
||
signatures match the code built in Phase 1.
|
||
|
||
**Commit** (outer repo): `git -C ~/Desktop/scadaproj add components/configuration && git -C ~/Desktop/scadaproj commit -m "docs(config): normalization spec + shared-contract"`
|
||
|
||
---
|
||
|
||
### Task 2: components/configuration current-state ×3 + GAPS + README
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 1
|
||
|
||
**Files:**
|
||
- Create: `components/configuration/current-state/otopcua/CURRENT-STATE.md`
|
||
- Create: `components/configuration/current-state/mxaccessgw/CURRENT-STATE.md`
|
||
- Create: `components/configuration/current-state/scadabridge/CURRENT-STATE.md`
|
||
- Create: `components/configuration/GAPS.md`
|
||
- Create: `components/configuration/README.md`
|
||
|
||
**Steps:**
|
||
1. For each app, read the validators under the Source references and write `CURRENT-STATE.md` with
|
||
`file:line` refs describing how it validates config today + an **Adoption plan** (what it
|
||
deletes/replaces to reach the spec; what stays bespoke). Emphasize ScadaBridge's
|
||
`StartupValidator` (raw-config, pre-Akka) → `ConfigPreflight`, and that OtOpcUa's `DraftValidator`
|
||
stays put.
|
||
2. `GAPS.md` — per-project divergence vs SPEC + a prioritized extraction/adoption backlog (each app
|
||
adopts the base + `AddValidatedOptions`; ScadaBridge additionally swaps `StartupValidator`).
|
||
3. `README.md` — overview + per-project status table linking the docs above (mirror
|
||
`components/observability/README.md`).
|
||
|
||
**Acceptance:** all five files exist; each current-state has real `file:line` refs and an Adoption
|
||
plan; README status table links resolve.
|
||
|
||
**Commit** (outer repo): `git -C ~/Desktop/scadaproj add components/configuration && git -C ~/Desktop/scadaproj commit -m "docs(config): current-state x3 + GAPS + README"`
|
||
|
||
---
|
||
|
||
## Phase 1 — Build the library (TDD)
|
||
|
||
### Task 3: Scaffold nested repo, solution, library + test projects
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Create: `ZB.MOM.WW.Configuration/.gitignore` (copy from `~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/.gitignore`)
|
||
- Create: `ZB.MOM.WW.Configuration/Directory.Build.props`
|
||
- Create: `ZB.MOM.WW.Configuration/Directory.Packages.props`
|
||
- Create: `ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.slnx`
|
||
- Create: `ZB.MOM.WW.Configuration/src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj`
|
||
- Create: `ZB.MOM.WW.Configuration/tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj`
|
||
|
||
**Step 1: `Directory.Build.props`** (identical to Telemetry's)
|
||
```xml
|
||
<Project>
|
||
<PropertyGroup>
|
||
<TargetFramework>net10.0</TargetFramework>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<LangVersion>latest</LangVersion>
|
||
<Version>0.1.0</Version>
|
||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||
</PropertyGroup>
|
||
</Project>
|
||
```
|
||
|
||
**Step 2: `Directory.Packages.props`**
|
||
```xml
|
||
<Project>
|
||
<PropertyGroup>
|
||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<!-- Library -->
|
||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||
<!-- Test only -->
|
||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
**Step 3: `src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj`**
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<IsPackable>true</IsPackable>
|
||
<PackageId>ZB.MOM.WW.Configuration</PackageId>
|
||
<Authors>ZB.MOM.WW</Authors>
|
||
<Description>Startup configuration-validation toolkit for the ZB.MOM.WW SCADA family: a failure-accumulating IValidateOptions base, reusable rule primitives (port, host:port, required, positive-duration, one-of, min-count), a bind+validate+ValidateOnStart DI helper, and a pre-host ConfigPreflight aggregator for raw IConfiguration. Extracts the validation plumbing the apps share; domain rules stay per-project.</Description>
|
||
<PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
|
||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
|
||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
**Step 4: `tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj`**
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<IsPackable>false</IsPackable>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<PackageReference Include="coverlet.collector" />
|
||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||
<PackageReference Include="xunit" />
|
||
<PackageReference Include="xunit.runner.visualstudio" />
|
||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<Using Include="Xunit" />
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Configuration\ZB.MOM.WW.Configuration.csproj" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
**Step 5: `ZB.MOM.WW.Configuration.slnx`**
|
||
```xml
|
||
<Solution>
|
||
<Folder Name="/src/">
|
||
<Project Path="src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj" />
|
||
</Folder>
|
||
<Folder Name="/tests/">
|
||
<Project Path="tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj" />
|
||
</Folder>
|
||
</Solution>
|
||
```
|
||
|
||
**Step 6: verify restore/build** (NO `git init` — the library is tracked inside the outer repo)
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration
|
||
dotnet build ZB.MOM.WW.Configuration.slnx
|
||
```
|
||
Expected: build succeeds (0 source files yet → empty assembly is fine).
|
||
|
||
**Step 7: Commit** (outer repo, branch `feat/zb-mom-ww-configuration`)
|
||
```bash
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||
git -C ~/Desktop/scadaproj commit -m "chore: scaffold ZB.MOM.WW.Configuration solution"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `Checks` (internal) + `ValidationBuilder`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (Tasks 5–7 depend on it)
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.Configuration/Checks.cs`
|
||
- Create: `src/ZB.MOM.WW.Configuration/ValidationBuilder.cs`
|
||
- Test: `tests/ZB.MOM.WW.Configuration.Tests/ValidationBuilderTests.cs`
|
||
|
||
**Step 1: Write the failing tests** (`ValidationBuilderTests.cs`)
|
||
```csharp
|
||
using ZB.MOM.WW.Configuration;
|
||
|
||
namespace ZB.MOM.WW.Configuration.Tests;
|
||
|
||
public sealed class ValidationBuilderTests
|
||
{
|
||
[Theory]
|
||
[InlineData(0, false)]
|
||
[InlineData(1, true)]
|
||
[InlineData(65535, true)]
|
||
[InlineData(65536, false)]
|
||
public void Port_validates_range(int port, bool valid)
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.Port(port, "X:Port");
|
||
Assert.Equal(valid, b.IsValid);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(null, false)]
|
||
[InlineData("", false)]
|
||
[InlineData(" ", false)]
|
||
[InlineData("ok", true)]
|
||
public void Required_rejects_null_empty_whitespace(string? value, bool valid)
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.Required(value, "X:Name");
|
||
Assert.Equal(valid, b.IsValid);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("host:5000", true)]
|
||
[InlineData("host", false)]
|
||
[InlineData("host:0", false)]
|
||
[InlineData("host:notaport", false)]
|
||
public void HostPort_validates_endpoint(string value, bool valid)
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.HostPort(value, "X:Endpoint");
|
||
Assert.Equal(valid, b.IsValid);
|
||
}
|
||
|
||
[Fact]
|
||
public void PositiveTimeSpan_rejects_zero_and_negative()
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.PositiveTimeSpan(TimeSpan.Zero, "X:T1").PositiveTimeSpan(TimeSpan.FromSeconds(-1), "X:T2");
|
||
Assert.Equal(2, b.Failures.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void OneOf_is_case_insensitive()
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.OneOf("CENTRAL", new[] { "Central", "Site" }, "X:Role");
|
||
Assert.True(b.IsValid);
|
||
}
|
||
|
||
[Fact]
|
||
public void MinCount_requires_minimum()
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.MinCount(new[] { "a" }, 2, "X:Seeds");
|
||
Assert.False(b.IsValid);
|
||
}
|
||
|
||
[Fact]
|
||
public void Accumulates_all_failures_and_RequireThat_Add_work()
|
||
{
|
||
var b = new ValidationBuilder();
|
||
b.Required(null, "A").RequireThat(false, "B failed").Add("C failed");
|
||
Assert.Equal(3, b.Failures.Count);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run — expect FAIL** (`Checks`/`ValidationBuilder` not defined)
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||
```
|
||
|
||
**Step 3: Implement `Checks.cs`**
|
||
```csharp
|
||
namespace ZB.MOM.WW.Configuration;
|
||
|
||
/// <summary>
|
||
/// Internal rule primitives shared by <see cref="ValidationBuilder"/> (validates a bound options
|
||
/// object) and <see cref="ConfigPreflight"/> (validates raw <c>IConfiguration</c>). Each method
|
||
/// returns <c>null</c> when valid, or a formatted <c>"<field> <reason>"</c> message
|
||
/// otherwise. Centralizing them keeps wording identical across both front-ends.
|
||
/// </summary>
|
||
internal static class Checks
|
||
{
|
||
internal static string? Required(string? value, string field) =>
|
||
string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null;
|
||
|
||
internal static string? Port(int value, string field) =>
|
||
value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null;
|
||
|
||
internal static string? HostPort(string? value, string field)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value)) return $"{field} is required";
|
||
var idx = value.LastIndexOf(':');
|
||
if (idx <= 0 || idx == value.Length - 1
|
||
|| !int.TryParse(value[(idx + 1)..], out var port)
|
||
|| port is < 1 or > 65535)
|
||
return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
|
||
return null;
|
||
}
|
||
|
||
internal static string? PositiveTimeSpan(TimeSpan value, string field) =>
|
||
value <= TimeSpan.Zero ? $"{field} must be a positive duration (was {value})" : null;
|
||
|
||
internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field) =>
|
||
value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase)
|
||
? null
|
||
: $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')";
|
||
|
||
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) =>
|
||
value is null || value.Count < min
|
||
? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})"
|
||
: null;
|
||
}
|
||
```
|
||
|
||
**Step 4: Implement `ValidationBuilder.cs`**
|
||
```csharp
|
||
namespace ZB.MOM.WW.Configuration;
|
||
|
||
/// <summary>
|
||
/// Accumulates validation failures for an options object. Passed by
|
||
/// <see cref="OptionsValidatorBase{TOptions}"/> into your <c>Validate</c> override; each primitive
|
||
/// both checks a value and appends a consistently-formatted message on failure. Use
|
||
/// <see cref="RequireThat"/>/<see cref="Add"/> for custom or cross-field rules.
|
||
/// </summary>
|
||
public sealed class ValidationBuilder
|
||
{
|
||
private readonly List<string> _failures = [];
|
||
|
||
/// <summary>The accumulated failure messages (empty when validation passed).</summary>
|
||
public IReadOnlyList<string> Failures => _failures;
|
||
|
||
/// <summary>True when no failures have been accumulated.</summary>
|
||
public bool IsValid => _failures.Count == 0;
|
||
|
||
/// <summary>Records <paramref name="message"/> as a failure when <paramref name="ok"/> is false.</summary>
|
||
public ValidationBuilder RequireThat(bool ok, string message)
|
||
{
|
||
if (!ok) _failures.Add(message);
|
||
return this;
|
||
}
|
||
|
||
/// <summary>Unconditionally records <paramref name="message"/> as a failure.</summary>
|
||
public ValidationBuilder Add(string message)
|
||
{
|
||
_failures.Add(message);
|
||
return this;
|
||
}
|
||
|
||
/// <summary>Requires a non-null, non-whitespace string.</summary>
|
||
public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field));
|
||
|
||
/// <summary>Requires a TCP port in 1-65535.</summary>
|
||
public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field));
|
||
|
||
/// <summary>Requires a 'host:port' endpoint with a valid port.</summary>
|
||
public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field));
|
||
|
||
/// <summary>Requires a strictly positive duration.</summary>
|
||
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field));
|
||
|
||
/// <summary>Requires the value to be one of <paramref name="allowed"/> (case-insensitive).</summary>
|
||
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field) => AddIf(Checks.OneOf(value, allowed, field));
|
||
|
||
/// <summary>Requires a collection with at least <paramref name="min"/> items.</summary>
|
||
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) => AddIf(Checks.MinCount(value, min, field));
|
||
|
||
private ValidationBuilder AddIf(string? message)
|
||
{
|
||
if (message is not null) _failures.Add(message);
|
||
return this;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 5: Run — expect PASS**
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||
```
|
||
|
||
**Step 6: Commit** (nested repo)
|
||
```bash
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||
git -C ~/Desktop/scadaproj commit -m "feat: Checks primitives + ValidationBuilder"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `OptionsValidatorBase<TOptions>`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 6, Task 7
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.Configuration/OptionsValidatorBase.cs`
|
||
- Test: `tests/ZB.MOM.WW.Configuration.Tests/OptionsValidatorBaseTests.cs`
|
||
|
||
**Step 1: Write the failing tests**
|
||
```csharp
|
||
using Microsoft.Extensions.Options;
|
||
using ZB.MOM.WW.Configuration;
|
||
|
||
namespace ZB.MOM.WW.Configuration.Tests;
|
||
|
||
public sealed class OptionsValidatorBaseTests
|
||
{
|
||
private sealed class SampleOptions
|
||
{
|
||
public int Port { get; set; }
|
||
public string? Name { get; set; }
|
||
}
|
||
|
||
private sealed class SampleValidator : OptionsValidatorBase<SampleOptions>
|
||
{
|
||
protected override void Validate(ValidationBuilder v, SampleOptions o)
|
||
{
|
||
v.Port(o.Port, "Sample:Port");
|
||
v.Required(o.Name, "Sample:Name");
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Success_when_clean()
|
||
{
|
||
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 8080, Name = "ok" });
|
||
Assert.True(r.Succeeded);
|
||
}
|
||
|
||
[Fact]
|
||
public void Fails_and_reports_all_failures()
|
||
{
|
||
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 0, Name = "" });
|
||
Assert.True(r.Failed);
|
||
Assert.Equal(2, r.Failures!.Count());
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run — expect FAIL**
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter OptionsValidatorBaseTests
|
||
```
|
||
|
||
**Step 3: Implement `OptionsValidatorBase.cs`**
|
||
```csharp
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace ZB.MOM.WW.Configuration;
|
||
|
||
/// <summary>
|
||
/// Base class for <see cref="IValidateOptions{TOptions}"/> implementations that removes the
|
||
/// failure-accumulation plumbing. Override <see cref="Validate(ValidationBuilder, TOptions)"/> and
|
||
/// use the supplied <see cref="ValidationBuilder"/>; the base aggregates ALL failures and returns
|
||
/// <see cref="ValidateOptionsResult.Success"/> only when none were recorded.
|
||
/// </summary>
|
||
/// <typeparam name="TOptions">The options type being validated.</typeparam>
|
||
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
|
||
where TOptions : class
|
||
{
|
||
/// <inheritdoc />
|
||
public ValidateOptionsResult Validate(string? name, TOptions options)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(options);
|
||
var builder = new ValidationBuilder();
|
||
Validate(builder, options);
|
||
return builder.IsValid
|
||
? ValidateOptionsResult.Success
|
||
: ValidateOptionsResult.Fail(builder.Failures);
|
||
}
|
||
|
||
/// <summary>Records validation failures for <paramref name="options"/> on <paramref name="builder"/>.</summary>
|
||
/// <param name="builder">The accumulator to record failures on.</param>
|
||
/// <param name="options">The options instance to validate.</param>
|
||
protected abstract void Validate(ValidationBuilder builder, TOptions options);
|
||
}
|
||
```
|
||
|
||
**Step 4: Run — expect PASS**, then **Commit** (outer repo)
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||
git -C ~/Desktop/scadaproj commit -m "feat: OptionsValidatorBase<TOptions>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `ServiceCollectionExtensions.AddValidatedOptions`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 5, Task 7
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs`
|
||
- Test: `tests/ZB.MOM.WW.Configuration.Tests/AddValidatedOptionsTests.cs`
|
||
|
||
**Step 1: Write the failing tests** (drives `ValidateOnStart` via a real host start)
|
||
```csharp
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Options;
|
||
using ZB.MOM.WW.Configuration;
|
||
|
||
namespace ZB.MOM.WW.Configuration.Tests;
|
||
|
||
public sealed class AddValidatedOptionsTests
|
||
{
|
||
private sealed class NodeOptions { public int Port { get; set; } public string? Name { get; set; } }
|
||
|
||
private sealed class NodeValidator : OptionsValidatorBase<NodeOptions>
|
||
{
|
||
protected override void Validate(ValidationBuilder v, NodeOptions o)
|
||
{
|
||
v.Port(o.Port, "Node:Port");
|
||
v.Required(o.Name, "Node:Name");
|
||
}
|
||
}
|
||
|
||
private static IHost BuildHost(Dictionary<string, string?> config)
|
||
{
|
||
var builder = Host.CreateApplicationBuilder();
|
||
builder.Configuration.AddInMemoryCollection(config);
|
||
builder.Services.AddValidatedOptions<NodeOptions, NodeValidator>(builder.Configuration, "Node");
|
||
return builder.Build();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Bad_config_throws_at_startup()
|
||
{
|
||
using var host = BuildHost(new() { ["Node:Port"] = "0", ["Node:Name"] = "" });
|
||
await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Good_config_starts_and_binds()
|
||
{
|
||
using var host = BuildHost(new() { ["Node:Port"] = "8080", ["Node:Name"] = "central" });
|
||
await host.StartAsync();
|
||
var opts = host.Services.GetRequiredService<IOptions<NodeOptions>>().Value;
|
||
Assert.Equal(8080, opts.Port);
|
||
Assert.Equal("central", opts.Name);
|
||
await host.StopAsync();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run — expect FAIL**
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter AddValidatedOptionsTests
|
||
```
|
||
|
||
**Step 3: Implement `ServiceCollectionExtensions.cs`**
|
||
```csharp
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace ZB.MOM.WW.Configuration;
|
||
|
||
/// <summary>DI extensions for binding-and-validating an options section in one call.</summary>
|
||
public static class ServiceCollectionExtensions
|
||
{
|
||
/// <summary>
|
||
/// Binds <typeparamref name="TOptions"/> to the configuration section at
|
||
/// <paramref name="sectionPath"/>, registers <typeparamref name="TValidator"/> as its
|
||
/// <see cref="IValidateOptions{TOptions}"/>, and enables <c>ValidateOnStart</c> so a bad
|
||
/// configuration fails fast at host startup rather than on first use.
|
||
/// </summary>
|
||
/// <returns>The <see cref="OptionsBuilder{TOptions}"/> for further chaining.</returns>
|
||
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
|
||
this IServiceCollection services, IConfiguration configuration, string sectionPath)
|
||
where TOptions : class
|
||
where TValidator : class, IValidateOptions<TOptions>
|
||
{
|
||
ArgumentNullException.ThrowIfNull(services);
|
||
ArgumentNullException.ThrowIfNull(configuration);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||
|
||
services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
|
||
return services.AddOptions<TOptions>()
|
||
.Bind(configuration.GetSection(sectionPath))
|
||
.ValidateOnStart();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: Run — expect PASS**, then **Commit** (outer repo)
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||
git -C ~/Desktop/scadaproj commit -m "feat: AddValidatedOptions bind+validate+ValidateOnStart"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `ConfigPreflight`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 5, Task 6
|
||
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.Configuration/ConfigPreflight.cs`
|
||
- Test: `tests/ZB.MOM.WW.Configuration.Tests/ConfigPreflightTests.cs`
|
||
|
||
**Step 1: Write the failing tests**
|
||
```csharp
|
||
using Microsoft.Extensions.Configuration;
|
||
using ZB.MOM.WW.Configuration;
|
||
|
||
namespace ZB.MOM.WW.Configuration.Tests;
|
||
|
||
public sealed class ConfigPreflightTests
|
||
{
|
||
private static IConfiguration Config(Dictionary<string, string?> 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<InvalidOperationException>(() =>
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Run — expect FAIL**
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test --filter ConfigPreflightTests
|
||
```
|
||
|
||
**Step 3: Implement `ConfigPreflight.cs`**
|
||
```csharp
|
||
using Microsoft.Extensions.Configuration;
|
||
|
||
namespace ZB.MOM.WW.Configuration;
|
||
|
||
/// <summary>
|
||
/// Fluent aggregator for validating raw <see cref="IConfiguration"/> BEFORE the host/DI container
|
||
/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via
|
||
/// <see cref="ThrowIfInvalid"/>. For options that flow through DI, prefer
|
||
/// <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions, TValidator}"/>.
|
||
/// </summary>
|
||
public sealed class ConfigPreflight
|
||
{
|
||
private readonly IConfiguration _configuration;
|
||
private readonly List<string> _failures = [];
|
||
|
||
private ConfigPreflight(IConfiguration configuration) => _configuration = configuration;
|
||
|
||
/// <summary>Starts a preflight over <paramref name="configuration"/>.</summary>
|
||
public static ConfigPreflight For(IConfiguration configuration)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(configuration);
|
||
return new ConfigPreflight(configuration);
|
||
}
|
||
|
||
/// <summary>The accumulated failure messages (empty when valid).</summary>
|
||
public IReadOnlyList<string> Failures => _failures;
|
||
|
||
/// <summary>True when no failures have been accumulated.</summary>
|
||
public bool IsValid => _failures.Count == 0;
|
||
|
||
/// <summary>Requires the value at <paramref name="key"/> to satisfy <paramref name="predicate"/>.</summary>
|
||
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(predicate);
|
||
if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}");
|
||
return this;
|
||
}
|
||
|
||
/// <summary>Requires a non-empty value at <paramref name="key"/>.</summary>
|
||
public ConfigPreflight RequireValue(string key) => AddIf(Checks.Required(_configuration[key], key));
|
||
|
||
/// <summary>Requires a valid integer TCP port (1-65535) at <paramref name="key"/>.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>Runs <paramref name="block"/> only when <paramref name="condition"/> holds (role-conditional rules).</summary>
|
||
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(block);
|
||
if (condition) block(this);
|
||
return this;
|
||
}
|
||
|
||
/// <summary>Throws <see cref="InvalidOperationException"/> listing all failures when invalid; otherwise returns.</summary>
|
||
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;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: Run — expect PASS**, then **Commit** (outer repo)
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration && dotnet test
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||
git -C ~/Desktop/scadaproj commit -m "feat: ConfigPreflight raw-config aggregator"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — Package + register
|
||
|
||
### Task 8: README + `dotnet pack` → verify single nupkg
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Create: `ZB.MOM.WW.Configuration/README.md`
|
||
- Create: `ZB.MOM.WW.Configuration/CLAUDE.md` (short — mirror Telemetry's: purpose, build/test, layout)
|
||
|
||
**Steps:**
|
||
1. `README.md` — what the package is, the four types with a 6-line usage snippet each
|
||
(`OptionsValidatorBase` subclass, `AddValidatedOptions` call, `ConfigPreflight` chain), build/test
|
||
command (`dotnet test`), `Version` 0.1.0.
|
||
2. `CLAUDE.md` — one-screen orientation mirroring `~/Desktop/scadaproj/ZB.MOM.WW.Telemetry/CLAUDE.md`.
|
||
3. Run full test + pack and verify exactly one nupkg:
|
||
```bash
|
||
cd ~/Desktop/scadaproj/ZB.MOM.WW.Configuration
|
||
dotnet test
|
||
dotnet pack -c Release -o artifacts
|
||
ls artifacts/*.nupkg # expect: ZB.MOM.WW.Configuration.0.1.0.nupkg (exactly one)
|
||
```
|
||
Expected: all tests green; exactly one `.nupkg` at `0.1.0`.
|
||
|
||
**Step 4: Commit** (nested repo)
|
||
```bash
|
||
git -C ~/Desktop/scadaproj add ZB.MOM.WW.Configuration
|
||
git -C ~/Desktop/scadaproj commit -m "docs: README + CLAUDE.md; verify 0.1.0 pack"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Register in indexes (components/README, root CLAUDE.md, upcoming.md)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none
|
||
**Depends on:** Tasks 1, 2, 8
|
||
|
||
**Files:**
|
||
- Modify: `components/README.md` (add registry row for Configuration)
|
||
- Modify: `CLAUDE.md` (component-normalization table row + a normalization paragraph mirroring the others)
|
||
- Modify: `upcoming.md` (tick the "Config + validation" Tier-2 item; add a "Suggested order" Done entry)
|
||
|
||
**Steps:**
|
||
1. `components/README.md` — add a Configuration row to the component registry table (Status `Draft`,
|
||
Applies to all three, Goal `Shared ZB.MOM.WW.Configuration lib (1 package)`, Folder
|
||
`configuration/`).
|
||
2. `CLAUDE.md` — add a row to the Component-normalization table (Config | Built (lib `0.1.0`) | …)
|
||
and a short normalization paragraph like the Health/Telemetry ones (packages, tests count,
|
||
`dotnet pack` → 1 nupkg @ 0.1.0, consumer matrix, "not yet adopted → GAPS").
|
||
3. `upcoming.md` — strike/annotate the Tier-2 "Config validation conventions" line as **Done** with
|
||
links to `components/configuration/` and `../ZB.MOM.WW.Configuration/`.
|
||
|
||
**Acceptance:** all three indexes reference the new component/library; links resolve.
|
||
|
||
**Step 4: Commit** (outer repo)
|
||
```bash
|
||
git -C ~/Desktop/scadaproj add components/README.md CLAUDE.md upcoming.md
|
||
git -C ~/Desktop/scadaproj commit -m "docs: register ZB.MOM.WW.Configuration in indexes"
|
||
```
|
||
|
||
---
|
||
|
||
## Done criteria
|
||
|
||
- `dotnet test` green in `ZB.MOM.WW.Configuration/`; `dotnet pack` → exactly one nupkg `ZB.MOM.WW.Configuration.0.1.0.nupkg`.
|
||
- Four public types implemented with the error-handling contract above; `ConfigPreflight` message
|
||
is byte-compatible with ScadaBridge's `StartupValidator`.
|
||
- `components/configuration/` fully populated (spec, shared-contract, 3× current-state, GAPS, README).
|
||
- Indexes updated (`components/README.md`, root `CLAUDE.md`, `upcoming.md`).
|
||
- **No sister-app code modified** — adoption deferred to `components/configuration/GAPS.md`.
|