diff --git a/README.md b/README.md index a49df6e..0639f04 100644 --- a/README.md +++ b/README.md @@ -700,7 +700,9 @@ public sealed class PurchaseOrderValidator : Validator PurchaseOrderDto dto ) { - context.Check(dto.OrderId).IsNotEmpty(); + // The client mints the order ID, so require a UUIDv7 — its leading timestamp keeps + // client-generated keys roughly sortable and index-friendly. Guid.Empty and v4 GUIDs fail. + context.Check(dto.OrderId).IsUuidV7(); dto.CustomerEmail = context.Check(dto.CustomerEmail).IsEmail(); // If dto.ShippingAddress is null the child validator emits a null error automatically. @@ -755,6 +757,8 @@ public sealed class OrderItemValidator : Validator } ``` +`IsUuidV7` fails with the `UuidV7` error code unless the GUID's RFC 9562 version field is `7` **and** its variant bits are the RFC variant. The same invariant is available standalone as `guid.IsUuidV7()` (`GuidExtensions`) when a repository or message handler needs to guard it outside a check chain. + > **What is `ValidatedValue`?** > > `ValidatedValue` is the handshake type between a validator and its callers within a single validation pipeline run. Rather than surfacing errors immediately as `Result`, it carries the signal back: either a successfully validated value via `ValidatedValue.Success(value)`, or `ValidatedValue.NoValue` when errors were added. `checkpoint.ToValidatedValue(dto)` chooses the right outcome based on whether any errors were added since the checkpoint was created. You never need to construct `ValidatedValue` directly unless you are writing a transforming validator — see [Mapping to Domain Objects](#mapping-to-domain-objects). diff --git a/ai-plans/0072-is-uuid-v7.md b/ai-plans/0072-is-uuid-v7.md new file mode 100644 index 0000000..377d7b9 --- /dev/null +++ b/ai-plans/0072-is-uuid-v7.md @@ -0,0 +1,108 @@ +# Add an `IsUuidV7` Assertion + +## Rationale + +UUIDv7 (RFC 9562 §5.7) leads with a 48-bit Unix millisecond timestamp, which keeps client-generated keys roughly sortable and index-friendly — the reason distributed systems let clients mint their own identifiers. A server accepting one cannot currently state that requirement: `Check` offers only `IsEmpty`/`IsNotEmpty`, so a v4 GUID from a misconfigured client is accepted and surfaces later as index fragmentation. + +`Checks.IsUuidV7` closes the gap as the first Guid-shaped format assertion, structured like the string format assertions (`IsEmail`, `ContainsOnlyDigits`): a dedicated error code, a customizable message template, and a metadata-free OpenAPI contract. More assertions are planned for the library; this plan covers only this one. + +## Acceptance Criteria + +- [x] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring `shortCircuitOnError` and the already-short-circuited state, matching every other built-in assertion. +- [x] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one name, with the assertion delegating to it so the bit manipulation exists in a single place. +- [x] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values with a non-RFC variant all fail. +- [x] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. +- [x] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a validator using it documents the `UuidV7` code without an explicit hint and without an unknown-code diagnostic. +- [x] An exhaustive version-nibble × variant-nibble matrix checks the predicate against an oracle built from **both** `Guid.Version` and `Guid.Variant`, so the BCL guards the whole layout assumption rather than half of it. +- [x] Automated tests additionally cover both overloads, short-circuit propagation, template customization, and the generated OpenAPI metadata. Solution test coverage stays above 95%. +- [x] One predicate implementation serves both target frameworks, without conditional compilation, and the passing path allocates nothing on either. +- [x] The README shows the assertion where client-generated identifiers are validated, and the 0.7.0 `` of **both** affected packages record their own part of the change. + +## Technical Details + +### Predicate + +RFC 9562 puts the version in the high nibble of octet 6 and the variant in the two most significant bits of octet 8, and both must be checked. `Guid.Version` reports the version regardless of the variant, so `017f22e2-79b0-7cc3-08c4-dc0c0c07398f` — an NCS-reserved variant that `Guid.CreateVersion7` cannot produce — would pass a version-only test. The variant's remaining two bits are free, so nibbles `8`–`b` are all acceptable. + +Read both fields by reinterpreting the `Guid` over a struct mirroring its layout, which is allocation-free on both targets and needs no conditional compilation: + +```csharp +#pragma warning disable CS0649 // Fields are populated by reinterpreting Guid storage via Unsafe.As. +private struct GuidFields +{ + public int A; + public short B; + public short C; // high nibble of the high byte carries the version + public byte D; // top two bits carry the variant +} +#pragma warning restore CS0649 + +ref var fields = ref Unsafe.As(ref value); +// version: (fields.C >> 12) & 0x0F variant: (fields.D & 0xC0) == 0x80 +``` + +`CS0649` fires because nothing assigns these fields in source, and `TreatWarningsAsErrors` turns it into a Release build failure, so the suppression is mandatory rather than cosmetic. Keep it narrow, around the type only. + +Neither obvious alternative works. `value.Version == 7 && (value.Variant & 0xC) == 0x8` needs two properties that `netstandard2.0` lacks (`Version` arrived in .NET 9; `Polyfill` supplies `CreateVersion7` there without either), so it would reintroduce the per-target split this section exists to avoid. They are the right *oracle*, not the right implementation, and the `net10.0`-only test project may use them freely. The byte-based route is worse: `TryWriteBytes(…, bigEndian: true, …)` is .NET 8+ and `netstandard2.0` has neither it nor the `netstandard2.1` overload, leaving `ToByteArray()`, which allocates 16 bytes per call *and* splits the targets apart — `Guid`'s default order is mixed-endian, putting the version nibble at index 7 there against index 6 in a big-endian span. A `net10.0`-only suite cannot catch that off-by-one. A `short` read carries no such dependency. + +The technique is already established here, which is the main argument for it. `CanonicalTextFormatter` declares an identical overlay — same shape, same suppression (`src/Light.PortableResults/Text/CanonicalTextFormatter.cs:1085`) — and reinterprets through it at line 785 to format GUIDs canonically, so the layout assumption is load-bearing in shipping core code rather than new to this change. Mirror that declaration, pragma and comment included. The duplication is deliberate: the existing type is private to another assembly, and promoting it would widen this change across package boundaries for no benefit. Two supporting facts already hold inside the Validation assembly: `TrimStringNormalizer` uses `Unsafe.As` with no TFM guard, so `System.Runtime.CompilerServices.Unsafe` resolves there — reaching `netstandard2.0` consumers through `Light.PortableResults` → `System.Text.Json` → `System.Memory` — and the technique needs no `AllowUnsafeBlocks`, which this project does not set even though the core project does. + +The assumption itself is fixed by `Guid`'s `(int, short, short, byte…)` constructor, by `ToByteArray()`, and by its role as the Win32 `GUID` interop mapping; the two expressions above are what `Guid.Version` and `Guid.Variant` compute from `_c` and `_d`. It spans both fields, so the oracle must too — checking only the version would leave the `D` offset and the variant mask unguarded. + +Prototyped against .NET 10: across all 256 matrix cases the predicate agrees with the oracle and each field agrees with its property, one million calls allocate zero bytes, and the same source compiles clean for `netstandard2.0` under `TreatWarningsAsErrors` with `Unsafe` resolved only transitively. The per-field agreement is evidence for this plan, not a shipped test. + +### Public surface + +```csharp +namespace Light.PortableResults.Validation; + +public static class GuidExtensions +{ + public static bool IsUuidV7(this Guid value); +} + +public static partial class Checks +{ + public static Check IsUuidV7(this Check check, bool shortCircuitOnError = false); + public static Check IsUuidV7(this Check check, ErrorOverrides overrides, bool shortCircuitOnError = false); +} +``` + +The predicate is public rather than a private helper like `LooksLikeEmail`: it is properly encapsulated, useful outside a check chain — a repository or message handler guarding the same invariant — and the root `AGENTS.md` prefers public APIs. It gets its own class so that `Checks` remains exclusively the home of `Check` extensions; `CheckExtensions` is the precedent for a top-level `*Extensions` class in this namespace, and no `GuidExtensions` exists in the solution today. Both members share the one name because they state the same thing and their receivers differ, so overload resolution never has to choose even though a single `using` imports both. The assertion is then the usual short-circuit test plus a call to the predicate. Importing the namespace does surface `IsUuidV7` on every `Guid` in scope: intended, and why the name is specific enough to carry its meaning at a bare `Guid` call site. + +### Registration points + +Six places change beyond the assertion. Two fail silently when missed: + +- `ValidationErrorTemplates`' **copy constructor** must copy the new property. It is what `with` expressions run, so omitting it silently resets a caller's customized template to the default. A test must customize through `ValidationErrorTemplates.Default with { … }` and assert the resulting message. +- `BuiltInValidationErrorContracts.Contracts` must register `[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata`, and `BuiltInValidationErrorContractsTests`' expected no-metadata list must grow with it, since that test asserts the registry's full key set. Without the entry the code cannot be documented from the built-in registry. + +The other four follow their `Email` counterparts: the `ValidationErrorCodes.UuidV7` constant; a `UuidV7ValidationErrorDefinition` modeled on `EmailValidationErrorDefinition`, including `TryGetStableMessageProvider` because the message has no per-error parameters and is cacheable; the shared `BuiltInValidationErrorDefinitions.UuidV7` static property returning that instance, which is what the assertion passes to `AddBuiltInError`; and the `ValidationErrorTemplates.UuidV7` property defaulting to `new DisplayName(" must be a version 7 UUID")` — a separate edit from the copy-constructor line above. + +Place the assertion and definition in `Checks.Guids.cs` and `BuiltInValidationErrorDefinitions.Guids.cs`, matching the partial-class-per-family layout, and the predicate in `GuidExtensions.cs` beside `CheckExtensions.cs`. Source generation needs no generator change: it discovers the rule through `[ValidationRule(ValidationErrorCodes.UuidV7)]` on the built-in-message overload, with `[ValidationRuleMessage("{displayName} must be a version 7 UUID")]` as the compile-time example message. The default `ValidationRuleMetadataShape.Registered` is correct — the rule carries no metadata of its own. + +### Test data and cases + +One exhaustive matrix replaces hand-picked boundaries. From RFC 9562's own example `017f22e2-79b0-7cc3-98c4-dc0c0c07398f`, substitute the version nibble at string index 14 and the variant nibble at index 19 across all 16 × 16 combinations, asserting for each of the 256 GUIDs that + +```csharp +guid.IsUuidV7() == (guid.Version == 7 && (guid.Variant & 0xC) == 0x8) +``` + +`Guid.Variant` returns octet 8's full high nibble rather than the two variant bits alone, hence masking with `0xC` rather than comparing against `0x2`. + +That single boolean pins both offsets and both masks without reading the fields individually — which it could not do anyway, since only the `bool` is public, and inventing accessors purely to observe internals would be the wrong trade. Because the matrix varies both nibbles independently over their full ranges, any shifted offset or altered mask changes which of the 256 inputs are accepted, and the oracle disagrees. Assert the accepted count as a blunter statement of the same property: exactly 4 of 256 pass, version `7` crossed with variant nibbles `8`–`b`. Any drift moves that number, which makes it a useful mutation-testing target. + +This subsumes every boundary a hand-written list would enumerate (versions `6` and `8`; NCS, Microsoft, and reserved variants) and, being deterministic, also replaces a random sample. Keep `Guid.Empty`, `Guid.NewGuid()`, and `Guid.CreateVersion7()` as named cases: they state things about the platform's own values that the matrix cannot. Assertion-level tests then stay small — one accepted and one rejected value through `Check`, both overloads, short-circuit propagation, and template customization. + +The suite runs on `net10.0` only, so `netstandard2.0` is compile-verified rather than executed. That argues for the shared implementation rather than for testing around it: with no per-target branch, what these tests pin is what both target assets ship, and the layout holds identically on .NET Framework, where `Guid` is the same interop-mapped type. + +### Release notes + +Both packages' 0.7.0 notes move. **Validation** gains the assertion, the `UuidV7` code, the customizable template, and the new public `GuidExtensions` type; **Validation.OpenApi** gains the built-in metadata contract registered for that code. The second is easy to skip because that package's own source barely changes, but its registry's key set is public behavior — a consumer reading the built-in contracts, or narrowing error schemas against them, sees a new entry. Those notes are capability-level rather than a per-release changelog, so it is one short line there. + +### Deliberately out of scope + +- **Timestamp plausibility.** A v7 identifier from a client with a wrong clock is structurally valid but useless for ordering. Checking the leading timestamp against a permitted skew window needs a clock abstraction, a configurable tolerance, and metadata carrying the bounds: a separate rule with its own error code, not a parameter on this one. +- **`Check` overloads.** No built-in assertion offers nullable value-type overloads today; `IsNotNull` covers the null case generically. Adding them for one assertion would be inconsistent. +- **Other UUID versions.** A generalized `IsUuidVersion(int)` would need version metadata on the error and a matching OpenAPI contract. Revisit only if a second version is actually requested. diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs index fd80e14..81c8e1b 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -126,7 +126,8 @@ private static FrozenDictionary CreateContracts() [ValidationErrorCodes.NotNullOrWhiteSpace] = ErrorMetadataContract.NoMetadata, [ValidationErrorCodes.Email] = ErrorMetadataContract.NoMetadata, [ValidationErrorCodes.DigitsOnly] = ErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata + [ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata }.ToFrozenDictionary(StringComparer.Ordinal); } diff --git a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj index 6fbbe30..7f39cd6 100644 --- a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj +++ b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj @@ -12,6 +12,7 @@ - Typed comparison and range helper metadata contracts for endpoint-specific narrowing. - Opt-in source generator for deriving Minimal API and MVC validation OpenAPI metadata from synchronous validators. - Native AOT compatible. + - Registers a built-in metadata-free contract for the new UuidV7 validation error code. diff --git a/src/Light.PortableResults.Validation/Checks.Guids.cs b/src/Light.PortableResults.Validation/Checks.Guids.cs new file mode 100644 index 0000000..27513e2 --- /dev/null +++ b/src/Light.PortableResults.Validation/Checks.Guids.cs @@ -0,0 +1,72 @@ +using System; +using Light.PortableResults.Validation.Definitions; + +namespace Light.PortableResults.Validation; + +/// +/// Provides assertions for instances. +/// +public static partial class Checks +{ + /// + /// Adds a validation error when the checked GUID is not a version 7 UUID as defined by RFC 9562 (section 5.7). + /// + /// The check carrying the value and validation context. + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// The value passes only when its version field is 7 and its two most significant variant bits are + /// 10. and version 4 GUIDs created by therefore + /// fail. Use to check the same invariant outside a check chain. + /// + [ValidationRule(ValidationErrorCodes.UuidV7)] + [ValidationRuleMessage("{displayName} must be a version 7 UUID")] + public static Check IsUuidV7(this Check check, bool shortCircuitOnError = false) => + check.IsShortCircuited || check.Value.IsUuidV7() ? + check : + AddBuiltInError(check, BuiltInValidationErrorDefinitions.UuidV7, shortCircuitOnError); + + /// + /// Adds a validation error when the checked GUID is not a version 7 UUID as defined by RFC 9562 + /// (section 5.7), applying the specified inline error overrides. + /// + /// The check carrying the value and validation context. + /// + /// Inline overrides for the built-in error details. Pass a plain to replace only + /// the message, or supply a full to also override the code, category, or + /// metadata. At least one field must be set. + /// + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// The value passes only when its version field is 7 and its two most significant variant bits are + /// 10. and version 4 GUIDs created by therefore + /// fail. Use to check the same invariant outside a check chain. + /// + /// + /// Thrown when has no field set, or when + /// is non- but empty or whitespace. + /// + public static Check IsUuidV7( + this Check check, + ErrorOverrides overrides, + bool shortCircuitOnError = false + ) + { + EnsureErrorOverrides(overrides); + return check.IsShortCircuited || check.Value.IsUuidV7() ? + check : + AddBuiltInErrorWithOverrides( + check, + BuiltInValidationErrorDefinitions.UuidV7, + overrides, + shortCircuitOnError + ); + } +} diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs new file mode 100644 index 0000000..93e94a0 --- /dev/null +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs @@ -0,0 +1,32 @@ +using Light.PortableResults.Validation.Messaging; + +namespace Light.PortableResults.Validation.Definitions; + +public static partial class BuiltInValidationErrorDefinitions +{ + /// + /// Gets the shared definition for version 7 UUID validation failures. + /// + public static ValidationErrorDefinition UuidV7 { get; } = new UuidV7ValidationErrorDefinition(); + + /// + /// Reusable built-in validation error definition for version 7 UUID validation failures. + /// + public sealed class UuidV7ValidationErrorDefinition : ValidationErrorDefinition + { + /// + /// Initializes a new instance of . + /// + public UuidV7ValidationErrorDefinition() : base(code: ValidationErrorCodes.UuidV7) { } + + /// + public override bool TryGetStableMessageProvider( + ReadOnlyValidationContext context, + out object provider + ) => TryGetStableProvider(context.ErrorTemplates.UuidV7, out provider); + + /// + public override ValidationErrorMessage ProvideMessage(in ValidationErrorMessageContext context) => + context.ValidationContext.ErrorTemplates.UuidV7.ProvideMessage(in context); + } +} diff --git a/src/Light.PortableResults.Validation/GuidExtensions.cs b/src/Light.PortableResults.Validation/GuidExtensions.cs new file mode 100644 index 0000000..8a9350d --- /dev/null +++ b/src/Light.PortableResults.Validation/GuidExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Light.PortableResults.Validation; + +/// +/// Provides extension methods for . +/// +public static class GuidExtensions +{ + /// + /// Checks whether the specified GUID is a version 7 UUID as defined by RFC 9562 (section 5.7). + /// + /// The GUID to inspect. + /// + /// when the version field is 7 and the two most significant variant bits + /// are 10 (the RFC 9562 variant); otherwise . + /// + /// + /// Both fields are checked: a value whose version nibble is 7 but that carries a non-RFC variant + /// (for example the NCS-reserved variant) is not a UUIDv7 and cannot be produced by + /// Guid.CreateVersion7. This method reads the GUID's storage directly and allocates nothing. + /// + public static bool IsUuidV7(this Guid value) + { + ref var fields = ref Unsafe.As(ref value); + return ((fields.C >> 12) & 0x0F) == 7 && (fields.D & 0xC0) == 0x80; + } + +#pragma warning disable CS0649 // Fields are populated by reinterpreting Guid storage via Unsafe.As. + private struct GuidFields + { + public int A; + public short B; + public short C; // the high nibble of the high byte carries the version + public byte D; // the two most significant bits carry the variant + } +#pragma warning restore CS0649 +} diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index 13b83cc..d8cb236 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -15,6 +15,7 @@ - Built-in comparison and range metadata now preserves the typed scalar vocabulary and its canonical validation-message encodings. - Adds a net10.0 asset so DateOnly and TimeOnly validation boundaries retain their dedicated metadata kinds. + - Adds the IsUuidV7 assertion for Check<Guid>, with the UuidV7 error code, a customizable message template, and the standalone GuidExtensions.IsUuidV7 predicate. diff --git a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs index c6e4446..cf5c3f1 100644 --- a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs +++ b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs @@ -81,6 +81,9 @@ public sealed partial record ValidationErrorTemplates private static readonly IValidationErrorMessageTemplate DefaultLettersAndDigitsOnlyTemplate = new DisplayName(" must contain only letters and digits"); + private static readonly IValidationErrorMessageTemplate DefaultUuidV7Template = + new DisplayName(" must be a version 7 UUID"); + private static readonly IValidationErrorMessageTemplate DefaultCountTemplate = new DisplayNameWithParameter(" must contain exactly ", " item(s)"); @@ -136,6 +139,7 @@ private ValidationErrorTemplates(ValidationErrorTemplates original) Email = original.Email; DigitsOnly = original.DigitsOnly; LettersAndDigitsOnly = original.LettersAndDigitsOnly; + UuidV7 = original.UuidV7; Count = original.Count; MinCount = original.MinCount; MaxCount = original.MaxCount; @@ -368,6 +372,15 @@ public IValidationErrorMessageTemplate LettersAndDigitsOnly init => field = value ?? throw new ArgumentNullException(nameof(value)); } = DefaultLettersAndDigitsOnlyTemplate; + /// + /// Gets the template for version 7 UUID validation failures. + /// + public IValidationErrorMessageTemplate UuidV7 + { + get; + init => field = value ?? throw new ArgumentNullException(nameof(value)); + } = DefaultUuidV7Template; + /// /// Gets the template for exact-count validation failures. /// diff --git a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs index bf32445..cdf6773 100644 --- a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs +++ b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs @@ -59,6 +59,8 @@ public static class ValidationErrorCodes public const string DigitsOnly = "DigitsOnly"; /// Validation error code for letters-and-digits-only failures. public const string LettersAndDigitsOnly = "LettersAndDigitsOnly"; + /// Validation error code for version 7 UUID failures. + public const string UuidV7 = "UuidV7"; /// Validation error code for predicate-based failures. public const string Predicate = "Predicate"; } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index 77631ae..35228ff 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -108,7 +108,8 @@ public void Contracts_ShouldContainExpectedBuiltInCodes() ValidationErrorCodes.NotNullOrWhiteSpace, ValidationErrorCodes.Email, ValidationErrorCodes.DigitsOnly, - ValidationErrorCodes.LettersAndDigitsOnly + ValidationErrorCodes.LettersAndDigitsOnly, + ValidationErrorCodes.UuidV7 ]; BuiltInValidationErrorContracts.Contracts.Keys.Should() diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs index 46a1914..caa7755 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs @@ -75,6 +75,47 @@ public async Task ProducesPortableValidationProblemFor_ShouldApplyGeneratedSchem genericProblemResponse.Content!["application/problem+json"].Examples.Should().BeNullOrEmpty(); } + [Fact] + public async Task ProducesPortableValidationProblemFor_ShouldDocumentUuidV7FromTheBuiltInContracts() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapPost("/generated-validation/uuid-v7", static () => Results.BadRequest()) + .WithName("GeneratedUuidV7Validation") + .ProducesPortableValidationProblemFor( + configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich) + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var operation = document.Paths["/generated-validation/uuid-v7"].Operations![HttpMethod.Post]; + var response = (OpenApiResponse) operation.Responses![StatusCodes.Status400BadRequest.ToString()]; + var mediaType = response.Content!["application/problem+json"]; + var schemaReference = (OpenApiSchemaReference) mediaType.Schema!; + var envelope = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(schemaReference) + ); + var errors = (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errors"]).Items!; + + errors.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo("PortableError__UuidV7"); + + var example = (OpenApiExample) mediaType.Examples!["ValidationProblem"]; + var body = example.Value.Should().BeOfType().Subject; + var exampleErrors = body["errors"].Should().BeOfType().Subject; + exampleErrors.ToJsonString().Should().Contain("\"code\":\"UuidV7\""); + exampleErrors.ToJsonString().Should().Contain("\"message\":\"id must be a version 7 UUID\""); + } + [Fact] public async Task MvcAttribute_ShouldApplyGeneratedSchemasExamplesAndOverrides() { @@ -294,6 +335,28 @@ GeneratedRatingDto dto } } +public sealed class GeneratedClientIdentifierDto +{ + public Guid Id { get; init; } +} + +[GeneratePortableValidationOpenApi] +public sealed partial class GeneratedClientIdentifierValidator : Validator +{ + public GeneratedClientIdentifierValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + GeneratedClientIdentifierDto dto + ) + { + context.Check(dto.Id).IsUuidV7(); + return checkpoint.ToValidatedValue(dto); + } +} + public sealed class GeneratedValidationMvcMetadata { public string TraceId { get; init; } = string.Empty; diff --git a/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs b/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs new file mode 100644 index 0000000..ff7a49d --- /dev/null +++ b/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Light.PortableResults.Validation.Messaging; +using Xunit; + +namespace Light.PortableResults.Validation.Tests; + +public sealed class UuidV7ValidationTests +{ + // The example UUIDv7 from RFC 9562. The version nibble sits at index 14 and the variant nibble at index 19. + private const string RfcExampleUuid = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f"; + private const int VersionNibbleIndex = 14; + private const int VariantNibbleIndex = 19; + private const string HexDigits = "0123456789abcdef"; + + private static readonly Guid AcceptedUuidV7 = Guid.Parse(RfcExampleUuid); + private static readonly Guid RejectedUuid = Guid.Parse("017f22e2-79b0-4cc3-98c4-dc0c0c07398f"); + + // The BCL computes Version as (_c >> 12) & 0xF and Variant as _d >> 4, which are the same two expressions + // the predicate evaluates over its own field overlay. This oracle therefore pins the overlay's field + // offsets — the load-bearing assumption — but cannot pin the masks, because a wrong mask would have to be + // wrong in the BCL too. The masks are pinned by the RFC-derived accepted set in the test below. + [Fact] + public void IsUuidV7_ShouldAgreeWithVersionAndVariantOracle_AcrossTheFullNibbleMatrix() + { + for (var versionNibble = 0; versionNibble < 16; versionNibble++) + { + for (var variantNibble = 0; variantNibble < 16; variantNibble++) + { + var guid = CreateGuidWithNibbles(versionNibble, variantNibble); + + guid.IsUuidV7() + .Should() + .Be( + guid.Version == 7 && (guid.Variant & 0xC) == 0x8, + "the version nibble is {0:x} and the variant nibble is {1:x}", + versionNibble, + variantNibble + ); + } + } + } + + // Not redundant with the matrix above: the expected set is read off RFC 9562 rather than derived from + // Guid.Version and Guid.Variant, so this is the only test that constrains the version and variant masks + // independently of the BCL. Keep it even though both tests walk the same 256 inputs. + [Fact] + public void IsUuidV7_ShouldAcceptOnlyVersion7CrossedWithTheRfcVariantNibbles() + { + var acceptedNibbles = new List<(int VersionNibble, int VariantNibble)>(); + + for (var versionNibble = 0; versionNibble < 16; versionNibble++) + { + for (var variantNibble = 0; variantNibble < 16; variantNibble++) + { + if (CreateGuidWithNibbles(versionNibble, variantNibble).IsUuidV7()) + { + acceptedNibbles.Add((versionNibble, variantNibble)); + } + } + } + + acceptedNibbles.Should().BeEquivalentTo( + [(7, 0x8), (7, 0x9), (7, 0xA), (7, 0xB)] + ); + } + + [Fact] + public void IsUuidV7_ShouldRejectEmptyGuid() => Guid.Empty.IsUuidV7().Should().BeFalse(); + + [Fact] + public void IsUuidV7_ShouldRejectNewGuid() => Guid.NewGuid().IsUuidV7().Should().BeFalse(); + + [Fact] + public void IsUuidV7_ShouldAcceptCreateVersion7() => Guid.CreateVersion7().IsUuidV7().Should().BeTrue(); + + [Fact] + public void IsUuidV7_ShouldAddError_WhenGuidIsNotAVersion7Uuid() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(RejectedUuid, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().ContainSingle( + error => + error.Target == "orderId" && + error.Code == "UuidV7" && + error.Message == "Order ID must be a version 7 UUID" + ); + } + + [Fact] + public void IsUuidV7_ShouldNotAddError_WhenGuidIsAVersion7Uuid() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(AcceptedUuidV7, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldAddError_WhenOverridesAreUsed() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context + .Check(RejectedUuid, target: "orderId", displayName: "Order ID") + .IsUuidV7(new ErrorOverrides { Code = "OrderIdNotSortable" }); + + context.Errors.Should().ContainSingle( + error => error.Target == "orderId" && error.Code == "OrderIdNotSortable" + ); + } + + [Fact] + public void IsUuidV7_ShouldNotAddError_WhenOverridesAreUsedAndGuidIsAVersion7Uuid() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context + .Check(AcceptedUuidV7, target: "orderId", displayName: "Order ID") + .IsUuidV7(new ErrorOverrides { Code = "Unused" }); + + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldThrow_WhenOverridesAreEmpty() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(AcceptedUuidV7, target: "orderId"); + + var act = () => check.IsUuidV7(new ErrorOverrides()); + + act.Should().Throw(); + } + + [Fact] + public void IsUuidV7_ShouldRespectShortCircuit() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId").ShortCircuit(); + + check.IsUuidV7().IsShortCircuited.Should().BeTrue(); + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldRespectShortCircuit_WhenOverridesAreUsed() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId").ShortCircuit(); + + check.IsUuidV7(new ErrorOverrides { Code = "Unused" }).IsShortCircuited.Should().BeTrue(); + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldShortCircuit_WhenRequested() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId", displayName: "Order ID"); + + check.IsUuidV7(shortCircuitOnError: true).IsShortCircuited.Should().BeTrue(); + context.Errors.Should().ContainSingle(error => error.Target == "orderId" && error.Code == "UuidV7"); + } + + [Fact] + public void IsUuidV7_ShouldShortCircuit_WhenOverridesAreUsedAndRequested() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId", displayName: "Order ID"); + + check + .IsUuidV7(new ErrorOverrides { Message = "Order ID must be sortable" }, shortCircuitOnError: true) + .IsShortCircuited.Should() + .BeTrue(); + context.Errors.Should().ContainSingle( + error => error.Target == "orderId" && error.Message == "Order ID must be sortable" + ); + } + + [Fact] + public void IsUuidV7_ShouldNotShortCircuit_WhenNotRequested() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId", displayName: "Order ID"); + + check.IsUuidV7().IsShortCircuited.Should().BeFalse(); + } + + [Fact] + public void IsUuidV7_ShouldUseTheCustomizedTemplate() + { + var context = CreateContextWithTemplates( + ValidationErrorTemplates.Default with + { + UuidV7 = new ValidationErrorTemplates.Constant("Only time-ordered identifiers are accepted") + } + ); + + context.Check(RejectedUuid, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().ContainSingle( + error => + error.Code == "UuidV7" && + error.Message == "Only time-ordered identifiers are accepted" + ); + } + + [Fact] + public void IsUuidV7_ShouldKeepTheCustomizedTemplate_WhenTemplatesAreCopiedAgain() + { + var customizedTemplates = ValidationErrorTemplates.Default with + { + UuidV7 = new ValidationErrorTemplates.Constant("Only time-ordered identifiers are accepted") + }; + var context = CreateContextWithTemplates( + customizedTemplates with { NotNull = new ValidationErrorTemplates.Constant("Value is required") } + ); + + context.Check(RejectedUuid, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().ContainSingle( + error => + error.Code == "UuidV7" && + error.Message == "Only time-ordered identifiers are accepted" + ); + } + + private static ValidationContext CreateContextWithTemplates(ValidationErrorTemplates templates) + { + var options = new ValidationContextOptions() with { ErrorTemplates = templates }; + return new DefaultValidationContextFactory(options).CreateValidationContext(); + } + + private static Guid CreateGuidWithNibbles(int versionNibble, int variantNibble) + { + var characters = RfcExampleUuid.ToCharArray(); + characters[VersionNibbleIndex] = HexDigits[versionNibble]; + characters[VariantNibbleIndex] = HexDigits[variantNibble]; + return Guid.Parse(new string(characters)); + } +} diff --git a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs index 1769468..df6aefe 100644 --- a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs @@ -519,7 +519,7 @@ public void ParameterizedTemplateDefinitions_ShouldReportStableProviders() } [Fact] - public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProviders() + public void CountEqualityStringEnumDecimalAndGuidDefinitions_ShouldExposeStableProviders() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); var readOnlyContext = context.AsReadOnly(); @@ -538,6 +538,7 @@ public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProvi var lettersAndDigitsOnly = BuiltInValidationErrorDefinitions.LettersAndDigitsOnly; var enumValue = BuiltInValidationErrorDefinitions.IsInEnum(); var precisionScale = BuiltInValidationErrorDefinitions.PrecisionScale(4, 2, ignoreTrailingZeros: true); + var uuidV7 = BuiltInValidationErrorDefinitions.UuidV7; count.TryGetStableMessageProvider(readOnlyContext, out var countProvider).Should().BeTrue(); minCount.TryGetStableMessageProvider(readOnlyContext, out var minCountProvider).Should().BeTrue(); @@ -555,6 +556,7 @@ public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProvi .BeTrue(); enumValue.TryGetStableMessageProvider(readOnlyContext, out var enumProvider).Should().BeTrue(); precisionScale.TryGetStableMessageProvider(readOnlyContext, out var precisionScaleProvider).Should().BeTrue(); + uuidV7.TryGetStableMessageProvider(readOnlyContext, out var uuidV7Provider).Should().BeTrue(); countProvider.Should().BeSameAs(context.ErrorTemplates.Count); minCountProvider.Should().BeSameAs(context.ErrorTemplates.MinCount); @@ -571,6 +573,7 @@ public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProvi lettersDigitsProvider.Should().BeSameAs(context.ErrorTemplates.LettersAndDigitsOnly); enumProvider.Should().BeSameAs(context.ErrorTemplates.Enum); precisionScaleProvider.Should().BeSameAs(context.ErrorTemplates.PrecisionScale); + uuidV7Provider.Should().BeSameAs(context.ErrorTemplates.UuidV7); } [Fact] @@ -708,6 +711,16 @@ public void PrecisionScale_ShouldProvideExpectedMessage() precisionScale.ProvideMessage(messageContext).Text.Should().ContainAll("4", "2"); } + [Fact] + public void UuidV7_ShouldProvideExpectedMessage() + { + var context = DefaultValidationContextFactory.Create().CreateValidationContext(); + var messageContext = context.Check(Guid.Empty, target: "orderId", displayName: "Order ID") + .CreateMessageContext(); + var uuidV7 = BuiltInValidationErrorDefinitions.UuidV7; + uuidV7.ProvideMessage(messageContext).Text.Should().ContainAll("Order ID", "version 7 UUID"); + } + [Fact] public void Count_ShouldThrow_WhenCacheIsNull() {