From 2bfbbefacde2afb7b1d4441959b6a05dab60db89 Mon Sep 17 00:00:00 2001 From: George Date: Sat, 30 May 2026 18:08:19 +0200 Subject: [PATCH 1/2] Harden bootstrap and validation error contract --- .../Abstractions/ValidationError.cs | 12 ++ .../Abstractions/ValidationResult.cs | 8 +- ...nyValidationServiceCollectionExtensions.cs | 6 + .../Execution/ValidationErrorCollection.cs | 6 + .../Generation/TinyValidationBootstrap.cs | 55 +++++++- tests/TinyValidations.Tests/RuntimeTests.cs | 120 +++++++++++++++++- .../ValidationBehaviorTests.cs | 35 +++++ 7 files changed, 239 insertions(+), 3 deletions(-) diff --git a/src/TinyValidations/Abstractions/ValidationError.cs b/src/TinyValidations/Abstractions/ValidationError.cs index 6411d65..c860b10 100644 --- a/src/TinyValidations/Abstractions/ValidationError.cs +++ b/src/TinyValidations/Abstractions/ValidationError.cs @@ -1,9 +1,21 @@ +using System; + namespace TinyValidations; public sealed class ValidationError { public ValidationError(string member, string message) { + if (string.IsNullOrWhiteSpace(member)) + { + throw new ArgumentException("Validation error member is required.", nameof(member)); + } + + if (string.IsNullOrWhiteSpace(message)) + { + throw new ArgumentException("Validation error message is required.", nameof(message)); + } + Member = member; Message = message; } diff --git a/src/TinyValidations/Abstractions/ValidationResult.cs b/src/TinyValidations/Abstractions/ValidationResult.cs index 9a6d807..5290190 100644 --- a/src/TinyValidations/Abstractions/ValidationResult.cs +++ b/src/TinyValidations/Abstractions/ValidationResult.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -9,7 +10,12 @@ public sealed class ValidationResult public ValidationResult(IReadOnlyCollection errors) { - Errors = errors; + if (errors is null) + { + throw new ArgumentNullException(nameof(errors)); + } + + Errors = errors.ToArray(); } public IReadOnlyCollection Errors { get; } diff --git a/src/TinyValidations/DependencyInjection/TinyValidationServiceCollectionExtensions.cs b/src/TinyValidations/DependencyInjection/TinyValidationServiceCollectionExtensions.cs index cc6fde5..53fc9b1 100644 --- a/src/TinyValidations/DependencyInjection/TinyValidationServiceCollectionExtensions.cs +++ b/src/TinyValidations/DependencyInjection/TinyValidationServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -7,6 +8,11 @@ public static class TinyValidationServiceCollectionExtensions { public static IServiceCollection UseTinyValidations(this IServiceCollection services) { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + services.TryAddScoped(); TinyValidationBootstrap.Apply(services); return services; diff --git a/src/TinyValidations/Execution/ValidationErrorCollection.cs b/src/TinyValidations/Execution/ValidationErrorCollection.cs index 77dece8..764ae79 100644 --- a/src/TinyValidations/Execution/ValidationErrorCollection.cs +++ b/src/TinyValidations/Execution/ValidationErrorCollection.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace TinyValidations; @@ -15,6 +16,11 @@ public void Add(string member, string message) public void AddRange(IEnumerable errors) { + if (errors is null) + { + throw new ArgumentNullException(nameof(errors)); + } + _errors.AddRange(errors); } diff --git a/src/TinyValidations/Generation/TinyValidationBootstrap.cs b/src/TinyValidations/Generation/TinyValidationBootstrap.cs index fad39a7..6cecf2b 100644 --- a/src/TinyValidations/Generation/TinyValidationBootstrap.cs +++ b/src/TinyValidations/Generation/TinyValidationBootstrap.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; @@ -12,17 +13,34 @@ public static void AddContribution(ITinyValidationContribution contribution) { if (contribution is null) { - throw new System.ArgumentNullException(nameof(contribution)); + throw new ArgumentNullException(nameof(contribution)); } lock (SyncRoot) { + if (HasContribution(contribution)) + { + return; + } + Contributions.Add(contribution); } } public static void Apply(IServiceCollection services) { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (HasAppliedMarker(services)) + { + return; + } + + services.AddSingleton(); + ITinyValidationContribution[] snapshot; lock (SyncRoot) @@ -35,4 +53,39 @@ public static void Apply(IServiceCollection services) contribution.Register(services); } } + + private static bool HasContribution(ITinyValidationContribution contribution) + { + foreach (var registered in Contributions) + { + if (ReferenceEquals(registered, contribution)) + { + return true; + } + + if (registered.GetType() == contribution.GetType()) + { + return true; + } + } + + return false; + } + + private static bool HasAppliedMarker(IServiceCollection services) + { + foreach (var descriptor in services) + { + if (descriptor.ServiceType == typeof(TinyValidationBootstrapAppliedMarker)) + { + return true; + } + } + + return false; + } + + private sealed class TinyValidationBootstrapAppliedMarker + { + } } diff --git a/tests/TinyValidations.Tests/RuntimeTests.cs b/tests/TinyValidations.Tests/RuntimeTests.cs index baa7f7d..82077a1 100644 --- a/tests/TinyValidations.Tests/RuntimeTests.cs +++ b/tests/TinyValidations.Tests/RuntimeTests.cs @@ -14,6 +14,18 @@ public void Validation_error_stores_member_and_message() Assert.Equal("Email is required.", error.Message); } + [Theory] + [InlineData(null, "Email is required.")] + [InlineData("", "Email is required.")] + [InlineData(" ", "Email is required.")] + [InlineData("Email", null)] + [InlineData("Email", "")] + [InlineData("Email", " ")] + public void Validation_error_rejects_missing_member_or_message(string? member, string? message) + { + Assert.Throws(() => new ValidationError(member!, message!)); + } + [Fact] public void Empty_error_collection_returns_valid_result() { @@ -38,6 +50,35 @@ public void Error_collection_returns_invalid_result_when_errors_exist() Assert.Single(result.Errors); } + [Fact] + public void Error_collection_rejects_null_ranges() + { + var errors = new ValidationErrorCollection(); + + Assert.Throws(() => errors.AddRange(null!)); + } + + [Fact] + public void Invalid_result_uses_error_snapshot() + { + var errors = new List + { + new ValidationError("Email", "Email is required.") + }; + + var result = new ValidationResult(errors); + errors.Add(new ValidationError("Name", "Name is required.")); + + Assert.False(result.IsValid); + Assert.Single(result.Errors); + } + + [Fact] + public void Validation_result_rejects_null_error_collection() + { + Assert.Throws(() => new ValidationResult(null!)); + } + [Fact] public async Task Validator_returns_valid_result_when_no_runner_is_registered() { @@ -71,6 +112,61 @@ public void Use_tiny_validations_registers_validator_once() Assert.Equal(1, count); } + [Fact] + public void Use_tiny_validations_rejects_null_services() + { + Assert.Throws(() => TinyValidationServiceCollectionExtensions.UseTinyValidations(null!)); + } + + [Fact] + public void Bootstrap_apply_rejects_null_services() + { + Assert.Throws(() => TinyValidationBootstrap.Apply(null!)); + } + + [Fact] + public void Use_tiny_validations_can_be_called_twice_without_duplicate_runner_registrations() + { + var services = new ServiceCollection(); + + services.UseTinyValidations(); + services.UseTinyValidations(); + + var count = CountRegistrations>(services); + + Assert.Equal(1, count); + } + + [Fact] + public void Bootstrap_can_apply_contributions_to_multiple_service_collections() + { + var first = new ServiceCollection(); + var second = new ServiceCollection(); + + first.UseTinyValidations(); + second.UseTinyValidations(); + + var firstCount = CountRegistrations>(first); + var secondCount = CountRegistrations>(second); + + Assert.Equal(1, firstCount); + Assert.Equal(1, secondCount); + } + + [Fact] + public void Duplicate_contribution_does_not_register_duplicate_services() + { + var services = new ServiceCollection(); + + TinyValidationBootstrap.AddContribution(new DuplicateTestContribution()); + TinyValidationBootstrap.AddContribution(new DuplicateTestContribution()); + services.UseTinyValidations(); + + var count = CountRegistrations(services); + + Assert.Equal(1, count); + } + private static ITinyValidator BuildValidator() { var services = new ServiceCollection(); @@ -82,12 +178,22 @@ private static ITinyValidator BuildValidator() } private static int CountValidatorRegistrations(IServiceCollection services) + { + return CountRegistrations(services); + } + + private static int CountRegistrations(IServiceCollection services) + { + return CountRegistrations(services, typeof(TService)); + } + + private static int CountRegistrations(IServiceCollection services, Type serviceType) { var count = 0; foreach (var service in services) { - if (service.ServiceType != typeof(ITinyValidator)) + if (service.ServiceType != serviceType) { continue; } @@ -100,3 +206,15 @@ private static int CountValidatorRegistrations(IServiceCollection services) } public sealed record CommandWithoutValidation; + +public sealed class DuplicateContributionMarker +{ +} + +public sealed class DuplicateTestContribution : ITinyValidationContribution +{ + public void Register(IServiceCollection services) + { + services.AddSingleton(); + } +} diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index d039d1e..2f998f9 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -229,6 +229,17 @@ public async Task Custom_rules_use_scoped_dependencies() Assert.NotEqual(firstMessage, secondMessage); } + [Fact] + public async Task Custom_rules_receive_cancellation_token() + { + var validator = BuildValidator(); + var source = new CancellationTokenSource(); + + await validator.ValidateAsync(new CancellationAwareCommand(), source.Token); + + Assert.Equal(source.Token, CancellationAwareRule.LastToken); + } + private static ITinyValidator BuildValidator(Action? configureServices = null) { var services = new ServiceCollection(); @@ -492,3 +503,27 @@ public sealed class ScopedRuleState { public Guid Id { get; } = Guid.NewGuid(); } + +public sealed record CancellationAwareCommand; + +public sealed class CancellationAwareCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + } +} + +public sealed class CancellationAwareRule : IAsyncValidationRule +{ + public static CancellationToken LastToken { get; private set; } + + public ValueTask ValidateAsync( + CancellationAwareCommand instance, + ValidationErrorCollection errors, + CancellationToken cancellationToken) + { + LastToken = cancellationToken; + return ValueTask.CompletedTask; + } +} From 4d51f991f0a519835f01dfa9a0f6a49c2c27cd3e Mon Sep 17 00:00:00 2001 From: George Date: Sat, 30 May 2026 18:12:16 +0200 Subject: [PATCH 2/2] Document design principles and hardening behavior --- README.md | 21 +++++++++++++++++---- docs/architecture.md | 16 ++++++++++++++++ docs/custom-rules.md | 4 ++++ docs/getting-started.md | 6 ++++-- docs/roadmap.md | 16 +++++++--------- 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 85a4b2f..a77ac2c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ TinyValidations is a small compile-time validation library for application-layer It was built for TinyDispatcher-style applications first: define a command, define its validation, register services, and let generated code do the boring work before the handler runs. Native ASP.NET integration is planned, but the core package is intentionally host-agnostic. -> Status: beta. The public shape is small and usable, but host integrations and some advanced syntax support are still evolving. +> Status: preparing for 1.0. The core package is intentionally small and host-agnostic. Native host integrations may ship separately. ## Contents @@ -13,6 +13,7 @@ It was built for TinyDispatcher-style applications first: define a command, defi - [Validation declarations](#validation-declarations) - [Custom rules](#custom-rules) - [TinyDispatcher](#tinydispatcher) +- [Design principles](#design-principles) - [Documentation](#documentation) - [Current limitations](#current-limitations) @@ -33,7 +34,7 @@ The goal is not to be a huge validation framework. The goal is calm command vali ## Quick start -Install the beta package: +Install the current package: ```bash dotnet add package TinyValidations --version 0.1.0-beta.1 @@ -164,6 +165,18 @@ See [`samples/TinyDispatcherAspNetCore`](samples/TinyDispatcherAspNetCore) for a See [`samples/MediatRAspNetCore`](samples/MediatRAspNetCore) for the same application shape using a MediatR pipeline behavior. +## Design principles + +TinyValidations is intentionally boring in the places that matter. + +- Keep the public API small before adding convenience. +- Prefer source-generated C# over runtime reflection or expression interpretation. +- Keep the core package host-agnostic. +- Treat custom rules as the extension point for business behavior. +- Keep built-in rule declaration shapes narrow and explicit. +- Protect behavior with tests instead of testing private implementation details. +- Make dependency injection registration predictable and idempotent. + ## Documentation - [Getting started](docs/getting-started.md) @@ -177,11 +190,11 @@ See [`samples/MediatRAspNetCore`](samples/MediatRAspNetCore) for the same applic ## Current limitations -TinyValidations is intentionally small and currently early. +TinyValidations is intentionally small. - Source generator diagnostics are intentionally focused. - Supported rule declaration shapes are intentionally narrow. -- Generated registration uses the current bootstrap mechanism and may change. +- Generated registration uses a bootstrap contribution mechanism that is idempotent per service collection. - Native ASP.NET integration is planned but not implemented yet. - Tests cover runtime behavior, generated behavior, diagnostics, and multi-assembly contributions. diff --git a/docs/architecture.md b/docs/architecture.md index 087030a..90b71f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,18 @@ The runtime library contains the public API, execution types, generated-code han The source generator reads validation declarations and emits validation runners. +## Design Principles + +TinyValidations is designed to stay small enough to understand. + +- The runtime package is host-agnostic. +- Validation declarations are compile-time input, not runtime rule objects. +- Generated runners use direct C# checks for built-in rules. +- Custom async rules are the extension point for business logic and scoped services. +- Dependency injection registration must be idempotent and predictable. +- Public contracts should stay small before 1.0. +- Tests should protect user-visible behavior, not private helper trivia. + ## Runtime Project ```text @@ -25,6 +37,8 @@ src/TinyValidations ```text src/TinyValidations.SourceGen Analysis + Declarations + RuleInvocations Model Planning Emission @@ -66,6 +80,8 @@ Generated runners implement `ITinyValidationRunner`. Generated contributions register runners and custom rules with Microsoft dependency injection. +Generated contributions are added to the runtime bootstrap through module initializers. The bootstrap keeps a process-wide contribution list, but applies contributions only once per `IServiceCollection`. + ## Runtime Execution `ITinyValidator` resolves all generated runners for the command type and asks each runner to validate the command. diff --git a/docs/custom-rules.md b/docs/custom-rules.md index 59f15e9..8a3f33e 100644 --- a/docs/custom-rules.md +++ b/docs/custom-rules.md @@ -70,4 +70,8 @@ errors.Add(nameof(CreateUser.Email), "Email is already registered."); The first argument is the member name. The second argument is the user-facing message. +Both values are required. TinyValidations rejects null, empty, or whitespace-only members and messages so custom-rule failures stay explicit. + +`ValidationErrorCollection` is intentionally part of the public custom-rule contract for 1.0. It is small: custom rules add errors, and TinyValidations turns the collection into an immutable `ValidationResult` snapshot after validation. + For generator-native static checks, see [Extending TinyValidations](extending.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index b10ca0e..17f2863 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,13 +4,13 @@ This guide shows the basic TinyValidations flow. ## Install -TinyValidations is currently published as a beta package: +Install TinyValidations: ```bash dotnet add package TinyValidations --version 0.1.0-beta.1 ``` -For local development, reference the project directly. +The package is preparing for 1.0. For local development, reference the project directly. ## Register Services @@ -25,6 +25,8 @@ var services = new ServiceCollection(); services.UseTinyValidations(); ``` +Calling `UseTinyValidations` more than once on the same service collection is safe. + Register any services required by custom rules before building the provider: ```csharp diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ddde7b..b5aaf23 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,17 +1,15 @@ # Roadmap -TinyValidations is beta software. +TinyValidations is preparing for 1.0. This roadmap describes the expected direction, not a compatibility promise. ## Near Term -- Add a `.gitignore`. -- Improve source generator diagnostics. -- Add compile-and-execute generator tests. -- Expand sample projects. -- Document all supported syntax shapes. -- Add NuGet package metadata. +- Final public API review. +- Final documentation review. +- Release package metadata review. +- Keep adding behavioral tests only where they protect user-visible contracts. ## TinyDispatcher @@ -34,6 +32,6 @@ This roadmap describes the expected direction, not a compatibility promise. ## Runtime -- Revisit `ValidationErrorCollection` as a public custom-rule surface. -- Decide whether bootstrap registration remains the long-term mechanism. +- Keep `ValidationErrorCollection` as the small public custom-rule error contract for 1.0. +- Keep bootstrap registration idempotent per service collection. - Add more built-in helper rules only when they stay small and boring.