feat: Checks primitives + ValidationBuilder
This commit is contained in:
@@ -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>"<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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user