feat: Checks primitives + ValidationBuilder

This commit is contained in:
Joseph Doherty
2026-06-01 09:28:19 -04:00
parent a104372eac
commit d18c121033
3 changed files with 170 additions and 0 deletions
@@ -0,0 +1,40 @@
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>"&lt;field&gt; &lt;reason&gt;"</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;
}
@@ -0,0 +1,56 @@
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;
}
}