From 21ca8a37577a3e2a51fd58e3bd5aa446de477eb1 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Tue, 26 May 2026 22:26:46 +0200 Subject: [PATCH 01/11] chore: post release version changes --- Directory.Build.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0a0e05e..097d542 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - 1.0.0 + 1.1.0 + preview From 22142882b535a78686ee32fde3acde8790a8868b Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 28 May 2026 20:12:00 +0200 Subject: [PATCH 02/11] feat: add support for validator resolution from dependency injection --- CHANGELOG.md | 5 + .../DependencyInjectionTests.cs | 177 ++++++++++++++++++ .../_fixtures/AnotherPersonValidator.cs | 12 ++ .../_fixtures/ConstrainedValidator.cs | 12 ++ .../_fixtures/ConstrainedWrapperValidator.cs | 12 ++ .../_fixtures/GenericValidator.cs | 12 ++ .../_fixtures/ICustomConstraint.cs | 6 + .../_fixtures/MultiInterfaceValidator.cs | 32 ++++ .../_fixtures/PersonValidator.cs | 12 ++ .../_fixtures/PolymorphicTypes.cs | 44 +++++ .../_fixtures/Team.cs | 2 +- .../_fixtures/Wrapper.cs | 6 + .../DispatchingValidator.cs | 69 +++++++ .../Geekeey.Request.Validation.csproj | 5 + src/request.validation/IValidatorBuilder.cs | 17 ++ .../ServiceCollectionExtensions.cs | 49 +++++ src/request.validation/ValidationOptions.cs | 145 ++++++++++++++ .../ValidatorBuilderExtensions.cs | 167 +++++++++++++++++ src/request.validation/ValidatorExtensions.cs | 23 +++ 19 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 src/request.validation.tests/DependencyInjectionTests.cs create mode 100644 src/request.validation.tests/_fixtures/AnotherPersonValidator.cs create mode 100644 src/request.validation.tests/_fixtures/ConstrainedValidator.cs create mode 100644 src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs create mode 100644 src/request.validation.tests/_fixtures/GenericValidator.cs create mode 100644 src/request.validation.tests/_fixtures/ICustomConstraint.cs create mode 100644 src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs create mode 100644 src/request.validation.tests/_fixtures/PersonValidator.cs create mode 100644 src/request.validation.tests/_fixtures/PolymorphicTypes.cs create mode 100644 src/request.validation.tests/_fixtures/Wrapper.cs create mode 100644 src/request.validation/DispatchingValidator.cs create mode 100644 src/request.validation/IValidatorBuilder.cs create mode 100644 src/request.validation/ServiceCollectionExtensions.cs create mode 100644 src/request.validation/ValidationOptions.cs create mode 100644 src/request.validation/ValidatorBuilderExtensions.cs create mode 100644 src/request.validation/ValidatorExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c26b92..6cd6591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- **request.validation:** Dependency injection support for validators via `IServiceCollection.AddValidation()` +- **request.validation:** `IValidatorBuilder` for fluent validator registration and assembly scanning +- **request.validation:** Support for open generic validators and automatic closing during resolution +- **request.validation:** `Validate` extension method for simplified validator invocation + ### Changed ### Removed diff --git a/src/request.validation.tests/DependencyInjectionTests.cs b/src/request.validation.tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..d3cf625 --- /dev/null +++ b/src/request.validation.tests/DependencyInjectionTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class DependencyInjectionTests +{ + [Test] + public async Task I_can_resolve_single_validator() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + var result = validator.Validate(new StringValueModel()); + + await Assert.That(result.Problems).Count().IsEqualTo(3); + } + + [Test] + public async Task I_can_resolve_multiple_validators() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + var result = validator.Validate(new Person()); + + await Assert.That(result.Problems).Count().IsEqualTo(2); + await Assert.That(result.Problems.Any(p => p.Message == "PersonValidator failure.")).IsTrue(); + await Assert.That(result.Problems.Any(p => p.Message == "AnotherPersonValidator failure.")).IsTrue(); + } + + [Test] + public async Task I_can_resolve_open_generic_validator() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(typeof(GenericValidator<>), ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + var result = validator.Validate(new Person()); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems[0].Message).IsEqualTo("GenericValidator failure."); + } + + [Test] + public async Task I_can_resolve_multi_interface_validator() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + + var personResult = validator.Validate(new Person()); + await Assert.That(personResult.Problems).Count().IsEqualTo(1); + await Assert.That(personResult.Problems[0].Message).IsEqualTo("MultiInterfaceValidator failure."); + + var addressResult = validator.Validate(new Address()); + await Assert.That(addressResult.Problems).Count().IsEqualTo(1); + await Assert.That(addressResult.Problems[0].Message).IsEqualTo("MultiInterfaceValidator failure."); + } + + [Test] + public async Task I_can_resolve_generic_validator_with_constraints() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(typeof(ConstrainedValidator<>), ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + + var teamResult = validator.Validate(new Team()); + await Assert.That(teamResult.Problems).Count().IsEqualTo(1); + await Assert.That(teamResult.Problems[0].Message).IsEqualTo("ConstrainedValidator failure."); + + var personResult = validator.Validate(new Person()); + await Assert.That(personResult.Problems).Count().IsEqualTo(0); + } + + [Test] + public async Task I_can_resolve_generic_wrapper_validator_with_constraints() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(typeof(ConstrainedWrapperValidator<>), ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + + var teamWrapperResult = validator.Validate(new Wrapper()); + await Assert.That(teamWrapperResult.Problems).Count().IsEqualTo(1); + await Assert.That(teamWrapperResult.Problems[0].Message).IsEqualTo("ConstrainedWrapperValidator failure."); + + var personWrapperResult = validator.Validate(new Wrapper()); + await Assert.That(personWrapperResult.Problems).Count().IsEqualTo(0); + } + + [Test] + public async Task I_can_resolve_aggregate_validator_directly() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + + var person = new Person { Name = "John" }; + var result = validator.Validate(person); + await Assert.That(result.Problems).Count().IsEqualTo(2); + } + + [Test] + public async Task I_can_select_validators_from_base_classes_and_interfaces() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + var dog = new Dog { Id = 0, Name = "", Breed = "" }; + var result = validator.Validate(dog); + + await Assert.That(result.Problems).Count().IsEqualTo(3); + } + + [Test] + public async Task I_can_select_validators_polymorphically_based_on_the_instance_type() + { + var services = new ServiceCollection(); + services.AddValidation(builder => + { + builder.Add(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + var dog = new Dog { Name = "", Breed = "" }; + var result = validator.Validate(new ValidationContext(dog)); + + await Assert.That(result.Problems).Count().IsEqualTo(2); + } +} diff --git a/src/request.validation.tests/_fixtures/AnotherPersonValidator.cs b/src/request.validation.tests/_fixtures/AnotherPersonValidator.cs new file mode 100644 index 0000000..be49f70 --- /dev/null +++ b/src/request.validation.tests/_fixtures/AnotherPersonValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class AnotherPersonValidator : Validator +{ + public AnotherPersonValidator() + { + RuleFor(p => p.Name).Must(_ => false, "AnotherPersonValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/ConstrainedValidator.cs b/src/request.validation.tests/_fixtures/ConstrainedValidator.cs new file mode 100644 index 0000000..11e9833 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ConstrainedValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ConstrainedValidator : Validator where T : ICustomConstraint +{ + public ConstrainedValidator() + { + RuleFor(x => x).Must(_ => false, "ConstrainedValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs b/src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs new file mode 100644 index 0000000..4dd88e2 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ConstrainedWrapperValidator : Validator> where T : ICustomConstraint +{ + public ConstrainedWrapperValidator() + { + RuleFor(x => x).Must(_ => false, "ConstrainedWrapperValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/GenericValidator.cs b/src/request.validation.tests/_fixtures/GenericValidator.cs new file mode 100644 index 0000000..6c9254e --- /dev/null +++ b/src/request.validation.tests/_fixtures/GenericValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class GenericValidator : Validator +{ + public GenericValidator() + { + RuleFor(x => x).Must(_ => false, "GenericValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/ICustomConstraint.cs b/src/request.validation.tests/_fixtures/ICustomConstraint.cs new file mode 100644 index 0000000..4b135a6 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ICustomConstraint.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal interface ICustomConstraint { } diff --git a/src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs b/src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs new file mode 100644 index 0000000..5a1f732 --- /dev/null +++ b/src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs @@ -0,0 +1,32 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class MultiInterfaceValidator : IValidator, IValidator
+{ + public Validation Validate(Person instance) + { + return Validate(new ValidationContext(instance)); + } + + public Validation Validate(Address instance) + { + return Validate(new ValidationContext
(instance)); + } + + public Validation Validate(ValidationContext context) + { + if (context.Instance is Person) + { + return new Validation([new Problem { PropertyPath = new PropertyPath("Person"), Message = "MultiInterfaceValidator failure." }]); + } + + if (context.Instance is Address) + { + return new Validation([new Problem { PropertyPath = new PropertyPath("Address"), Message = "MultiInterfaceValidator failure." }]); + } + + return new Validation([]); + } +} diff --git a/src/request.validation.tests/_fixtures/PersonValidator.cs b/src/request.validation.tests/_fixtures/PersonValidator.cs new file mode 100644 index 0000000..788fdea --- /dev/null +++ b/src/request.validation.tests/_fixtures/PersonValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class PersonValidator : Validator +{ + public PersonValidator() + { + RuleFor(p => p.Name).Must(_ => false, "PersonValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/PolymorphicTypes.cs b/src/request.validation.tests/_fixtures/PolymorphicTypes.cs new file mode 100644 index 0000000..fe060fa --- /dev/null +++ b/src/request.validation.tests/_fixtures/PolymorphicTypes.cs @@ -0,0 +1,44 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +public interface IEntity +{ + int Id { get; set; } +} + +public abstract class Animal : IEntity +{ + public int Id { get; set; } + public string? Name { get; set; } +} + +public class Dog : Animal +{ + public string? Breed { get; set; } +} + +internal sealed class EntityValidator : Validator +{ + public EntityValidator() + { + RuleFor(x => x.Id).GreaterThan(0); + } +} + +internal sealed class AnimalValidator : Validator +{ + public AnimalValidator() + { + RuleFor(x => x.Name).NotEmpty(); + } +} + +internal sealed class DogValidator : Validator +{ + public DogValidator() + { + RuleFor(x => x.Breed).NotEmpty(); + } +} diff --git a/src/request.validation.tests/_fixtures/Team.cs b/src/request.validation.tests/_fixtures/Team.cs index 6b17c01..ce4e7b6 100644 --- a/src/request.validation.tests/_fixtures/Team.cs +++ b/src/request.validation.tests/_fixtures/Team.cs @@ -3,7 +3,7 @@ namespace Geekeey.Request.Validation.Tests; -internal sealed class Team +internal sealed class Team : ICustomConstraint { public IEnumerable Members { get; init; } = []; } diff --git a/src/request.validation.tests/_fixtures/Wrapper.cs b/src/request.validation.tests/_fixtures/Wrapper.cs new file mode 100644 index 0000000..ecb0feb --- /dev/null +++ b/src/request.validation.tests/_fixtures/Wrapper.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class Wrapper { } diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs new file mode 100644 index 0000000..fe43b6b --- /dev/null +++ b/src/request.validation/DispatchingValidator.cs @@ -0,0 +1,69 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections.Concurrent; +using System.Reflection.Metadata; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +[assembly: MetadataUpdateHandler(typeof(Geekeey.Request.Validation.DispatchingValidator))] + +namespace Geekeey.Request.Validation; + +internal sealed class DispatchingValidator : IValidator +{ + private static readonly ConcurrentDictionary ValidatorsHandlers = new(); + + private readonly IServiceProvider _serviceProvider; + + public DispatchingValidator(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public Validation Validate(ValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Instance is null) + { + return new Validation([]); + } + + var handler = ValidatorsHandlers.GetOrAdd(context.Instance.GetType(), static key => + { + var type = typeof(ValidatorInvoker<>).MakeGenericType(key); + return (ValidatorInvoker)Activator.CreateInstance(type)!; + }); + + return handler.Validate(context, _serviceProvider); + } + + private abstract class ValidatorInvoker + { + public abstract Validation Validate(ValidationContext context, IServiceProvider serviceProvider); + } + + private sealed class ValidatorInvoker : ValidatorInvoker + { + public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>().Value; + + var validators = options.GetValidators(serviceProvider); + + var problems = new List(); + + foreach (var validator in validators) + { + if (validator.Validate(context) is { IsValid: false, Problems: { } result }) + { + problems.AddRange(result); + } + } + + return new Validation(problems); + } + } +} diff --git a/src/request.validation/Geekeey.Request.Validation.csproj b/src/request.validation/Geekeey.Request.Validation.csproj index 098287f..33bea6b 100644 --- a/src/request.validation/Geekeey.Request.Validation.csproj +++ b/src/request.validation/Geekeey.Request.Validation.csproj @@ -28,4 +28,9 @@ + + + + + diff --git a/src/request.validation/IValidatorBuilder.cs b/src/request.validation/IValidatorBuilder.cs new file mode 100644 index 0000000..17d01ca --- /dev/null +++ b/src/request.validation/IValidatorBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation; + +/// +/// Defines a builder for configuring validator registrations. +/// +public interface IValidatorBuilder +{ + /// + /// Gets the service collection where the validators are registered. + /// + IServiceCollection Services { get; } +} diff --git a/src/request.validation/ServiceCollectionExtensions.cs b/src/request.validation/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8574f4f --- /dev/null +++ b/src/request.validation/ServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation; + +/// +/// Provides extension methods for configuring and registering validator services in the . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds validator services to the specified . + /// + /// The service collection to which the validator services will be added. + /// An instance of to configure the validator registrations. + public static IValidatorBuilder AddValidation(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + services.AddTransient(); + + return new ValidatorBuilder(services); + } + + /// + /// Adds validator services to the specified + /// and configures them using the provided . + /// + /// The service collection to which the validator services will be added. + /// A delegate to configure the validator builder. + /// The service collection with the validator services added. + public static IServiceCollection AddValidation(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + configure(services.AddValidation()); + + return services; + } + + private sealed class ValidatorBuilder(IServiceCollection services) : IValidatorBuilder + { + public IServiceCollection Services { get; } = services; + } +} diff --git a/src/request.validation/ValidationOptions.cs b/src/request.validation/ValidationOptions.cs new file mode 100644 index 0000000..b541e86 --- /dev/null +++ b/src/request.validation/ValidationOptions.cs @@ -0,0 +1,145 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections; +using System.Collections.Concurrent; + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation; + +internal sealed class ValidationOptions +{ + private readonly List _search = []; + private readonly Lazy _validatorsTypeIndex; + + public ValidationOptions() + { + _validatorsTypeIndex = new Lazy(() => new ValidatorTypeIndex(_search.Distinct())); + } + + public void Inspect(IEnumerable assembly) + { + if (_validatorsTypeIndex.IsValueCreated) + { + throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); + } + + _search.AddRange(assembly); + } + + public IEnumerable> GetValidators(IServiceProvider services) + { + return _validatorsTypeIndex.Value.Resolve>(services); + } + + private abstract class TypeIndex + { + private readonly ConcurrentDictionary> _cache = new(); + + protected readonly Dictionary> _closedTypeInfo = []; + protected readonly List _openTypeInfo = []; + + protected TypeIndex(IEnumerable collection, Func predicate) + { + foreach (var type in collection) + { + if (type.IsGenericTypeDefinition) + { + if (type.GetInterfaces().Any(predicate)) + { + _openTypeInfo.Add(type); + } + } + else + { + foreach (var @interface in type.GetInterfaces().Where(predicate)) + { + (_closedTypeInfo.TryGetValue(@interface, out var list) ? list : _closedTypeInfo[@interface] = []).Add(type); + } + } + } + } + + public IEnumerable Resolve(IServiceProvider services) + { + return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(services); + } + + protected abstract IReadOnlyList IsAssignableTo(Type type); + + private Func> CreateResolverFactory(Type @interface) + { + var list = IsAssignableTo(@interface); + return ResolverFactory; + + IEnumerable ResolverFactory(IServiceProvider services) + { + foreach (var type in list) + { + yield return (T)ActivatorUtilities.GetServiceOrCreateInstance(services, type); + } + } + } + } + + internal static bool IsValidatorType(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IValidator<>); + } + + private sealed class ValidatorTypeIndex(IEnumerable collection) + : TypeIndex(collection, IsValidatorType) + { + protected override IReadOnlyList IsAssignableTo(Type @interface) + { + var result = new List(); + + foreach (var kvp in _closedTypeInfo) + { + if (@interface.IsAssignableFrom(kvp.Key)) + { + result.AddRange(kvp.Value); + } + } + + var validatedType = @interface.GetGenericArguments()[0]; + + foreach (var type in _openTypeInfo) + { + try + { + // open type case one: Validator : IValidator + // We try to close it with the validated type. + var impl = type.MakeGenericType(validatedType); + if (impl.IsAssignableTo(@interface)) + { + result.Add(impl); + } + } + catch (ArgumentException) + { + } + + try + { + // open type case two: Validator : IValidator> + // If the validated type is generic, we try to close the validator with the validated type's generic arguments. + if (validatedType.IsGenericType) + { + var impl = type.MakeGenericType(validatedType.GetGenericArguments()); + if (impl.IsAssignableTo(@interface)) + { + result.Add(impl); + } + } + } + catch (ArgumentException) + { + } + } + + return result; + } + } +} diff --git a/src/request.validation/ValidatorBuilderExtensions.cs b/src/request.validation/ValidatorBuilderExtensions.cs new file mode 100644 index 0000000..b71c728 --- /dev/null +++ b/src/request.validation/ValidatorBuilderExtensions.cs @@ -0,0 +1,167 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Reflection; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using static Geekeey.Request.Validation.ValidationOptions; + +namespace Geekeey.Request.Validation; + +/// +/// Provides extension methods for configuring +/// with additional capabilities such as searching and registering validators in assemblies or adding types directly. +/// +public static class ValidatorBuilderExtensions +{ + /// + /// Searches for validator types within the specified assembly and adds them to the validator + /// configuration. + /// + /// The to configure. + /// The assembly to search for validator types. + /// The instance for further configuration. + public static IValidatorBuilder SearchInAssembly(this IValidatorBuilder builder, Assembly assembly) + { + ArgumentNullException.ThrowIfNull(builder); + + var exports = assembly.GetTypes() + .Where(type => type is { IsClass: true, IsAbstract: false }) + .Where(IsValidatorImplementationType); + + builder.Add(exports); + + return builder; + } + + /// + /// Searches for validator types within the specified assembly and adds them to the validator + /// configuration with the given service lifetime. + /// + /// The to configure. + /// The assembly to search for validator types. + /// The lifetime with which the validators are registered in the dependency injection container. + /// The instance for further configuration. + public static IValidatorBuilder SearchInAssembly(this IValidatorBuilder builder, Assembly assembly, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + var exports = assembly.GetTypes() + .Where(type => type is { IsClass: true, IsAbstract: false }) + .Where(IsValidatorImplementationType); + + builder.Add(exports, lifetime); + + return builder; + } + + /// + /// Adds the specified type to the validator configuration for inspection. + /// + /// The to configure. + /// The type to be added to the validator configuration. + /// The instance for further configuration. + public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([type])); + + return builder; + } + + /// + /// Adds the specified type to the validator configuration. + /// This also adds the type to the service collection with the specified lifetime, + /// allowing it to be resolved as a dependency. + /// + /// The used to configure the validators. + /// The type to be added to the validator configuration. + /// The lifetime scope of the type in the service container. + /// The instance for further configuration. + public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([type])); + + builder.Services.Add(new ServiceDescriptor(type, type, lifetime)); + + return builder; + } + + /// + /// Adds the specified collection of types to the validator configuration for inspection. + /// + /// The to configure. + /// The collection of types to be added to the validator configuration. + /// The instance for further configuration. + public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable types) + { + ArgumentNullException.ThrowIfNull(builder); + + var typeList = types.ToList(); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(typeList)); + + return builder; + } + + /// + /// Adds the specified collection of types to the validator configuration for inspection. + /// This also adds the specified collection of types to the service collection with the specified lifetime, + /// allowing it to be resolved as a dependency. + /// + /// The to configure. + /// The collection of types to be added to the validator configuration. + /// The lifetime scope of the types in the service container. + /// The instance for further configuration. + public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable types, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + var typeList = types.ToList(); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(typeList)); + + builder.Services.Add(typeList.Select(export => new ServiceDescriptor(export, export, lifetime))); + + return builder; + } + + /// + /// Adds the specified validator type to the validator configuration. + /// + /// The type of the validator to add. + /// The to configure. + /// The instance for further configuration. + public static IValidatorBuilder Add(this IValidatorBuilder builder) + where TValidator : class, IValidator + { + return builder.Add(typeof(TValidator)); + } + + /// + /// Adds the specified validator type to the validator configuration with the specified lifetime. + /// + /// The type of the validator to add. + /// The to configure. + /// The lifetime scope of the validator in the service container. + /// The instance for further configuration. + public static IValidatorBuilder Add(this IValidatorBuilder builder, ServiceLifetime lifetime) + where TValidator : class, IValidator + { + return builder.Add(typeof(TValidator), lifetime); + } + + private static bool IsValidatorImplementationType(Type type) + { + return type.GetInterfaces().Any(IsValidatorType); + } +} diff --git a/src/request.validation/ValidatorExtensions.cs b/src/request.validation/ValidatorExtensions.cs new file mode 100644 index 0000000..7f0c69c --- /dev/null +++ b/src/request.validation/ValidatorExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Extension methods for . +/// +public static class ValidatorExtensions +{ + /// + /// Executes the validation logic for a specified instance of type and returns the validation result. + /// + /// The instance used to perform the validation. + /// The instance of type to validate. + /// The service provider available for nested validator resolution. + /// Per-call state shared across nested validation operations. + /// A object containing the results of the validation, including any problems encountered. + public static Validation Validate(this IValidator validator, T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary? items = null) + { + return validator.Validate(new ValidationContext(instance, serviceProvider, items)); + } +} From 21b6f04f8c6bbebfae4205221c15646afefc0565 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 28 May 2026 20:39:51 +0200 Subject: [PATCH 03/11] fix: type cache reset when metadata updates occure --- CHANGELOG.md | 3 +++ src/request.dispatcher/RequestDispatcher.cs | 6 ++++++ src/request.validation/DispatchingValidator.cs | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd6591..9f581d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- **request.dispatcher:** Reset type caches when reloading assemblies +- **request.validation:** Reset type caches when reloading assemblies + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 diff --git a/src/request.dispatcher/RequestDispatcher.cs b/src/request.dispatcher/RequestDispatcher.cs index c4083bd..2acb426 100644 --- a/src/request.dispatcher/RequestDispatcher.cs +++ b/src/request.dispatcher/RequestDispatcher.cs @@ -20,6 +20,12 @@ internal sealed class RequestDispatcher : IRequestDispatcher _serviceProvider = serviceProvider; } + public static void ClearCache(Type[]? _) + { + ScalarRequestHandlers.Clear(); + StreamRequestHandlers.Clear(); + } + public Task DispatchAsync(IScalarRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs index fe43b6b..4fe7bff 100644 --- a/src/request.validation/DispatchingValidator.cs +++ b/src/request.validation/DispatchingValidator.cs @@ -22,6 +22,11 @@ internal sealed class DispatchingValidator : IValidator _serviceProvider = serviceProvider; } + public static void ClearCache(Type[]? _) + { + ValidatorsHandlers.Clear(); + } + public Validation Validate(ValidationContext context) { ArgumentNullException.ThrowIfNull(context); From cc2a643f63a03e7bcdd28ee6bc1bc2d298bd6d34 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 28 May 2026 22:05:56 +0200 Subject: [PATCH 04/11] feat: release 1.1.0 --- CHANGELOG.md | 11 +++++++++-- Directory.Build.props | 1 - 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f581d1..6da7e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **request.result:** Optional result wrapper types (`Result`) for structured responses - **request.validation:** Automatic validation pipeline behavior with FluentValidation integration -## [Unreleased] +## [1.1.0] - 2026-05-28 ### Added @@ -26,7 +26,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **request.dispatcher:** Reset type caches when reloading assemblies - **request.validation:** Reset type caches when reloading assemblies +## [Unreleased] + +### Added + +### Changed + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 -[Unreleased]: https://code.geekeey.de/geekeey/request/compare/1.0.0...HEAD +[1.1.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.1.0 +[Unreleased]: https://code.geekeey.de/geekeey/request/compare/1.1.0...HEAD diff --git a/Directory.Build.props b/Directory.Build.props index 097d542..63c71c4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,6 @@ 1.1.0 - preview From bae9b2a5f1d8bc8cd3ec622898c86c36d8512db7 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 28 May 2026 22:06:18 +0200 Subject: [PATCH 05/11] chore: post release version changes --- Directory.Build.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 63c71c4..408380c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - 1.1.0 + 1.2.0 + preview From 4d33d5ab4c9447d1818252fcdea511674c53cef9 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Fri, 29 May 2026 22:53:58 +0200 Subject: [PATCH 06/11] feat: hide pipelines internals from stack trace --- CHANGELOG.md | 2 + .../StackTraceTests.cs | 73 +++++++++++++++++++ .../_fixtures/StreamOpenBehavior.cs | 9 ++- .../ScalarRequestInvoker.cs | 4 +- .../StreamRequestInvoker.cs | 3 +- 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/request.dispatcher.tests/StackTraceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da7e17..47e4306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- **request.dispatcher:** Hide pipeline internals in stack frames + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 diff --git a/src/request.dispatcher.tests/StackTraceTests.cs b/src/request.dispatcher.tests/StackTraceTests.cs new file mode 100644 index 0000000..636dfb6 --- /dev/null +++ b/src/request.dispatcher.tests/StackTraceTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class StackTraceTests +{ + [Test] + public async Task I_can_not_see_pipeline_internals_are_hidden_from_stack_trace_scalar() + { + // Arrange + var sc = new ServiceCollection(); + sc.AddSingleton(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(FailingScalarHandler)) + .Add(typeof(ScalarOpenBehavior<,>))); + + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var request = new FailingScalarRequest(); + + // Act + var exception = await Assert.ThrowsAsync(() => + dispatcher.DispatchAsync(request)); + + // Assert + await Assert.That(exception).IsNotNull(); + + var stackTrace = await Assert.That(exception.StackTrace).IsNotNull(); + + await Assert.That(stackTrace).Contains(nameof(FailingScalarHandler)); + await Assert.That(stackTrace).Contains(nameof(ScalarOpenBehavior<,>)); + + // 3. Verify that the internal lambda from ScalarRequestInvoker.Chain is HIDDEN. + // In C#, these lambdas usually appear as "ScalarRequestInvoker`2.<>c__DisplayClass..." or similar. + // Since we added [StackTraceHidden], this frame should be omitted. + await Assert.That(stackTrace).DoesNotContain("ScalarRequestInvoker+<>"); + } + + [Test] + public async Task I_can_not_see_pipeline_internals_are_hidden_from_stack_trace_stream() + { + // Arrange + var sc = new ServiceCollection(); + sc.AddSingleton(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(FailingStreamHandler)) + .Add(typeof(StreamOpenBehavior<,>))); + + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var request = new FailingStreamRequest(); + + // Act + var exception = await Assert.ThrowsAsync(() => + dispatcher.DispatchAsync(request).ToListAsync().AsTask()); + + // Assert + await Assert.That(exception).IsNotNull(); + + var stackTrace = await Assert.That(exception.StackTrace).IsNotNull(); + + await Assert.That(stackTrace).Contains(nameof(FailingStreamHandler)); + await Assert.That(stackTrace).Contains(nameof(StreamOpenBehavior<,>)); + + // 3. Verify that the internal lambda from ScalarRequestInvoker.Chain is HIDDEN. + // In C#, these lambdas usually appear as "ScalarRequestInvoker`2.<>c__DisplayClass..." or similar. + // Since we added [StackTraceHidden], this frame should be omitted. + await Assert.That(stackTrace).DoesNotContain("StreamRequestInvoker+<>"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs index 3d90be3..d5ded39 100644 --- a/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs +++ b/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs @@ -1,14 +1,19 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Runtime.CompilerServices; + namespace Geekeey.Request.Dispatcher.Tests; public class StreamOpenBehavior(StreamTestTracker tracker) : IStreamRequestBehavior where TRequest : IStreamRequest { - public IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + public async IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, [EnumeratorCancellation] CancellationToken cancellationToken) { tracker.Executed = true; - return next(request, cancellationToken); + await foreach (var response in next(request, cancellationToken)) + { + yield return response; + } } } diff --git a/src/request.dispatcher/ScalarRequestInvoker.cs b/src/request.dispatcher/ScalarRequestInvoker.cs index feb02bf..63de03e 100644 --- a/src/request.dispatcher/ScalarRequestInvoker.cs +++ b/src/request.dispatcher/ScalarRequestInvoker.cs @@ -1,6 +1,8 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Diagnostics; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -36,7 +38,7 @@ internal sealed class ScalarRequestInvoker : ScalarRequestI static ScalarHandlerDelegate Chain(ScalarHandlerDelegate next, IScalarRequestBehavior filter) { - return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); + return [StackTraceHidden] (req, ct) => filter.HandleAsync((TRequest)req, next, ct); } Task Head(IScalarRequest r, CancellationToken ct) diff --git a/src/request.dispatcher/StreamRequestInvoker.cs b/src/request.dispatcher/StreamRequestInvoker.cs index 6557146..882d538 100644 --- a/src/request.dispatcher/StreamRequestInvoker.cs +++ b/src/request.dispatcher/StreamRequestInvoker.cs @@ -1,6 +1,7 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Diagnostics; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; @@ -41,7 +42,7 @@ internal sealed class StreamRequestInvoker : StreamRequestI static StreamHandlerDelegate Chain(StreamHandlerDelegate next, IStreamRequestBehavior filter) { - return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); + return [StackTraceHidden] (req, ct) => filter.HandleAsync((TRequest)req, next, ct); } IAsyncEnumerable Head(IStreamRequest r, CancellationToken ct) From 36f1a9eb1ba5c774526f9e0d1c581e19fae1ac6c Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Fri, 29 May 2026 23:05:24 +0200 Subject: [PATCH 07/11] feat: rename validation builder for parity Rename extensions functions and `IValidatorBuilder` for parity with dispatcher options and builder. --- CHANGELOG.md | 1 + .../DependencyInjectionTests.cs | 18 +++--- .../DispatchingValidator.cs | 2 +- ...Builder.cs => IRequestValidatorBuilder.cs} | 2 +- ...s => RequestValidatorBuilderExtensions.cs} | 62 +++++++++---------- ...nOptions.cs => RequestValidatorOptions.cs} | 4 +- .../ServiceCollectionExtensions.cs | 14 ++--- 7 files changed, 52 insertions(+), 51 deletions(-) rename src/request.validation/{IValidatorBuilder.cs => IRequestValidatorBuilder.cs} (90%) rename src/request.validation/{ValidatorBuilderExtensions.cs => RequestValidatorBuilderExtensions.cs} (59%) rename src/request.validation/{ValidationOptions.cs => RequestValidatorOptions.cs} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e4306..640e8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed - **request.dispatcher:** Hide pipeline internals in stack frames +- **request.validation:** Rename `IValidatorBuilder` to `IRequestValidatorBuilder` incl. extensions methods ### Removed diff --git a/src/request.validation.tests/DependencyInjectionTests.cs b/src/request.validation.tests/DependencyInjectionTests.cs index d3cf625..ae98a7a 100644 --- a/src/request.validation.tests/DependencyInjectionTests.cs +++ b/src/request.validation.tests/DependencyInjectionTests.cs @@ -11,7 +11,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_single_validator() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); }); @@ -27,7 +27,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_multiple_validators() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); @@ -46,7 +46,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_open_generic_validator() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(typeof(GenericValidator<>), ServiceLifetime.Transient); }); @@ -63,7 +63,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_multi_interface_validator() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); }); @@ -84,7 +84,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_generic_validator_with_constraints() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(typeof(ConstrainedValidator<>), ServiceLifetime.Transient); }); @@ -104,7 +104,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_generic_wrapper_validator_with_constraints() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(typeof(ConstrainedWrapperValidator<>), ServiceLifetime.Transient); }); @@ -124,7 +124,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_aggregate_validator_directly() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); @@ -142,7 +142,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_select_validators_from_base_classes_and_interfaces() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); @@ -161,7 +161,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_select_validators_polymorphically_based_on_the_instance_type() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs index 4fe7bff..2bd17e6 100644 --- a/src/request.validation/DispatchingValidator.cs +++ b/src/request.validation/DispatchingValidator.cs @@ -54,7 +54,7 @@ internal sealed class DispatchingValidator : IValidator { public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider) { - var options = serviceProvider.GetRequiredService>().Value; + var options = serviceProvider.GetRequiredService>().Value; var validators = options.GetValidators(serviceProvider); diff --git a/src/request.validation/IValidatorBuilder.cs b/src/request.validation/IRequestValidatorBuilder.cs similarity index 90% rename from src/request.validation/IValidatorBuilder.cs rename to src/request.validation/IRequestValidatorBuilder.cs index 17d01ca..f699fbc 100644 --- a/src/request.validation/IValidatorBuilder.cs +++ b/src/request.validation/IRequestValidatorBuilder.cs @@ -8,7 +8,7 @@ namespace Geekeey.Request.Validation; /// /// Defines a builder for configuring validator registrations. /// -public interface IValidatorBuilder +public interface IRequestValidatorBuilder { /// /// Gets the service collection where the validators are registered. diff --git a/src/request.validation/ValidatorBuilderExtensions.cs b/src/request.validation/RequestValidatorBuilderExtensions.cs similarity index 59% rename from src/request.validation/ValidatorBuilderExtensions.cs rename to src/request.validation/RequestValidatorBuilderExtensions.cs index b71c728..054da7c 100644 --- a/src/request.validation/ValidatorBuilderExtensions.cs +++ b/src/request.validation/RequestValidatorBuilderExtensions.cs @@ -6,24 +6,24 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using static Geekeey.Request.Validation.ValidationOptions; +using static Geekeey.Request.Validation.RequestValidatorOptions; namespace Geekeey.Request.Validation; /// -/// Provides extension methods for configuring +/// Provides extension methods for configuring /// with additional capabilities such as searching and registering validators in assemblies or adding types directly. /// -public static class ValidatorBuilderExtensions +public static class RequestValidatorBuilderExtensions { /// /// Searches for validator types within the specified assembly and adds them to the validator /// configuration. /// - /// The to configure. + /// The to configure. /// The assembly to search for validator types. - /// The instance for further configuration. - public static IValidatorBuilder SearchInAssembly(this IValidatorBuilder builder, Assembly assembly) + /// The instance for further configuration. + public static IRequestValidatorBuilder SearchInAssembly(this IRequestValidatorBuilder builder, Assembly assembly) { ArgumentNullException.ThrowIfNull(builder); @@ -40,11 +40,11 @@ public static class ValidatorBuilderExtensions /// Searches for validator types within the specified assembly and adds them to the validator /// configuration with the given service lifetime. /// - /// The to configure. + /// The to configure. /// The assembly to search for validator types. /// The lifetime with which the validators are registered in the dependency injection container. - /// The instance for further configuration. - public static IValidatorBuilder SearchInAssembly(this IValidatorBuilder builder, Assembly assembly, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder SearchInAssembly(this IRequestValidatorBuilder builder, Assembly assembly, ServiceLifetime lifetime) { ArgumentNullException.ThrowIfNull(builder); @@ -60,14 +60,14 @@ public static class ValidatorBuilderExtensions /// /// Adds the specified type to the validator configuration for inspection. /// - /// The to configure. + /// The to configure. /// The type to be added to the validator configuration. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type) { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect([type])); return builder; @@ -78,15 +78,15 @@ public static class ValidatorBuilderExtensions /// This also adds the type to the service collection with the specified lifetime, /// allowing it to be resolved as a dependency. /// - /// The used to configure the validators. + /// The used to configure the validators. /// The type to be added to the validator configuration. /// The lifetime scope of the type in the service container. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type, ServiceLifetime lifetime) { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect([type])); builder.Services.Add(new ServiceDescriptor(type, type, lifetime)); @@ -97,16 +97,16 @@ public static class ValidatorBuilderExtensions /// /// Adds the specified collection of types to the validator configuration for inspection. /// - /// The to configure. + /// The to configure. /// The collection of types to be added to the validator configuration. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable types) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable types) { ArgumentNullException.ThrowIfNull(builder); var typeList = types.ToList(); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect(typeList)); return builder; @@ -117,17 +117,17 @@ public static class ValidatorBuilderExtensions /// This also adds the specified collection of types to the service collection with the specified lifetime, /// allowing it to be resolved as a dependency. /// - /// The to configure. + /// The to configure. /// The collection of types to be added to the validator configuration. /// The lifetime scope of the types in the service container. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable types, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable types, ServiceLifetime lifetime) { ArgumentNullException.ThrowIfNull(builder); var typeList = types.ToList(); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect(typeList)); builder.Services.Add(typeList.Select(export => new ServiceDescriptor(export, export, lifetime))); @@ -139,9 +139,9 @@ public static class ValidatorBuilderExtensions /// Adds the specified validator type to the validator configuration. /// /// The type of the validator to add. - /// The to configure. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder) + /// The to configure. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder) where TValidator : class, IValidator { return builder.Add(typeof(TValidator)); @@ -151,10 +151,10 @@ public static class ValidatorBuilderExtensions /// Adds the specified validator type to the validator configuration with the specified lifetime. /// /// The type of the validator to add. - /// The to configure. + /// The to configure. /// The lifetime scope of the validator in the service container. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, ServiceLifetime lifetime) where TValidator : class, IValidator { return builder.Add(typeof(TValidator), lifetime); diff --git a/src/request.validation/ValidationOptions.cs b/src/request.validation/RequestValidatorOptions.cs similarity index 97% rename from src/request.validation/ValidationOptions.cs rename to src/request.validation/RequestValidatorOptions.cs index b541e86..d7c5d75 100644 --- a/src/request.validation/ValidationOptions.cs +++ b/src/request.validation/RequestValidatorOptions.cs @@ -8,12 +8,12 @@ using Microsoft.Extensions.DependencyInjection; namespace Geekeey.Request.Validation; -internal sealed class ValidationOptions +internal sealed class RequestValidatorOptions { private readonly List _search = []; private readonly Lazy _validatorsTypeIndex; - public ValidationOptions() + public RequestValidatorOptions() { _validatorsTypeIndex = new Lazy(() => new ValidatorTypeIndex(_search.Distinct())); } diff --git a/src/request.validation/ServiceCollectionExtensions.cs b/src/request.validation/ServiceCollectionExtensions.cs index 8574f4f..52b47bf 100644 --- a/src/request.validation/ServiceCollectionExtensions.cs +++ b/src/request.validation/ServiceCollectionExtensions.cs @@ -14,15 +14,15 @@ public static class ServiceCollectionExtensions /// Adds validator services to the specified . /// /// The service collection to which the validator services will be added. - /// An instance of to configure the validator registrations. - public static IValidatorBuilder AddValidation(this IServiceCollection services) + /// An instance of to configure the validator registrations. + public static IRequestValidatorBuilder AddRequestValidation(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.AddOptions(); + services.AddOptions(); services.AddTransient(); - return new ValidatorBuilder(services); + return new RequestValidatorBuilder(services); } /// @@ -32,17 +32,17 @@ public static class ServiceCollectionExtensions /// The service collection to which the validator services will be added. /// A delegate to configure the validator builder. /// The service collection with the validator services added. - public static IServiceCollection AddValidation(this IServiceCollection services, Action configure) + public static IServiceCollection AddRequestValidation(this IServiceCollection services, Action configure) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); - configure(services.AddValidation()); + configure(services.AddRequestValidation()); return services; } - private sealed class ValidatorBuilder(IServiceCollection services) : IValidatorBuilder + private sealed class RequestValidatorBuilder(IServiceCollection services) : IRequestValidatorBuilder { public IServiceCollection Services { get; } = services; } From c674de31f708ea10d17a2c2a6d40626a7cf203a7 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 19:36:28 +0200 Subject: [PATCH 08/11] chore: rename internal types Rename internal types in the validation to be more consistent with other projects --- .../DispatchingValidator.cs | 32 +---------------- src/request.validation/ValidatorInvoker.cs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 src/request.validation/ValidatorInvoker.cs diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs index 2bd17e6..3c3db7f 100644 --- a/src/request.validation/DispatchingValidator.cs +++ b/src/request.validation/DispatchingValidator.cs @@ -4,14 +4,11 @@ using System.Collections.Concurrent; using System.Reflection.Metadata; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - [assembly: MetadataUpdateHandler(typeof(Geekeey.Request.Validation.DispatchingValidator))] namespace Geekeey.Request.Validation; -internal sealed class DispatchingValidator : IValidator +internal sealed partial class DispatchingValidator : IValidator { private static readonly ConcurrentDictionary ValidatorsHandlers = new(); @@ -44,31 +41,4 @@ internal sealed class DispatchingValidator : IValidator return handler.Validate(context, _serviceProvider); } - - private abstract class ValidatorInvoker - { - public abstract Validation Validate(ValidationContext context, IServiceProvider serviceProvider); - } - - private sealed class ValidatorInvoker : ValidatorInvoker - { - public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider) - { - var options = serviceProvider.GetRequiredService>().Value; - - var validators = options.GetValidators(serviceProvider); - - var problems = new List(); - - foreach (var validator in validators) - { - if (validator.Validate(context) is { IsValid: false, Problems: { } result }) - { - problems.AddRange(result); - } - } - - return new Validation(problems); - } - } } diff --git a/src/request.validation/ValidatorInvoker.cs b/src/request.validation/ValidatorInvoker.cs new file mode 100644 index 0000000..ad41030 --- /dev/null +++ b/src/request.validation/ValidatorInvoker.cs @@ -0,0 +1,34 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Geekeey.Request.Validation; + +internal abstract class ValidatorInvoker +{ + public abstract Validation Validate(ValidationContext context, IServiceProvider serviceProvider); +} + +internal sealed class ValidatorInvoker : ValidatorInvoker +{ + public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>().Value; + + var validators = options.GetValidators(serviceProvider); + + var problems = new List(); + + foreach (var validator in validators) + { + if (validator.Validate(context) is { IsValid: false, Problems: { } result }) + { + problems.AddRange(result); + } + } + + return new Validation(problems); + } +} From a0d69fde41b09b21c30ea0b946f755bfbccd24de Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 19:53:00 +0200 Subject: [PATCH 09/11] feat: release 2.0.0 --- CHANGELOG.md | 15 ++++++++++++--- Directory.Build.props | 3 +-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 640e8a5..9f1288b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,17 +26,26 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **request.dispatcher:** Reset type caches when reloading assemblies - **request.validation:** Reset type caches when reloading assemblies -## [Unreleased] +## [2.0.0] - 2026-05-30 -### Added +## Breaking changes + +To have a consistent experience across all packages, some public interfaces have been renamed. ### Changed - **request.dispatcher:** Hide pipeline internals in stack frames - **request.validation:** Rename `IValidatorBuilder` to `IRequestValidatorBuilder` incl. extensions methods +## [Unreleased] + +### Added + +### Changed + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 [1.1.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.1.0 -[Unreleased]: https://code.geekeey.de/geekeey/request/compare/1.1.0...HEAD +[2.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/2.0.0 +[Unreleased]: https://code.geekeey.de/geekeey/request/compare/2.0.0...HEAD diff --git a/Directory.Build.props b/Directory.Build.props index 408380c..aec0af3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,8 +9,7 @@ - 1.2.0 - preview + 2.0.0 From 29be774abc6c4d95e3d3ac857f9f855eb49af145 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 19:56:57 +0200 Subject: [PATCH 10/11] chore: post release version changes --- Directory.Build.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index aec0af3..86d2794 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - 2.0.0 + 2.1.0 + preview From eff887b49f4c9458fa4926e97e16cdee46ae3c71 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 21:17:14 +0200 Subject: [PATCH 11/11] feat: add json property path supports for object keys --- CHANGELOG.md | 2 + .../PropertyPathTests.cs | 39 +++++++++++++++++++ src/request.validation/PropertyPath.cs | 17 ++++++++ 3 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1288b..240c251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ To have a consistent experience across all packages, some public interfaces have ### Added +- **request.validation:** Support `PropertyPath` JSON converter for string values and dictionary property names + ### Changed ### Removed diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs index bce4614..2551450 100644 --- a/src/request.validation.tests/PropertyPathTests.cs +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -12,6 +12,19 @@ internal sealed class PropertyPathTests PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private static readonly JsonSerializerOptions CamelCaseDictionaryKeyJsonOptions = new() + { + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task I_can_serialize_property_paths_as_json_strings_using_the_json_naming_policy() + { + var json = JsonSerializer.Serialize((PropertyPath)"Address.Street", CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ "\"address.street\""); + } + [Test] public async Task I_can_serialize_property_paths_using_the_json_naming_policy() { @@ -54,6 +67,32 @@ internal sealed class PropertyPathTests await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"matrix[1][2].value","severity":0,"message":"Value is required.","code":null,"attemptedValue":null}"""); } + [Test] + public async Task I_can_serialize_property_paths_as_dictionary_keys_using_the_dictionary_key_policy() + { + Dictionary errors = new() + { + ["Address.Street"] = "Street is required.", + }; + + var json = JsonSerializer.Serialize(errors, CamelCaseDictionaryKeyJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"address.street":"Street is required."}"""); + } + + [Test] + public async Task I_can_deserialize_property_paths_from_dictionary_keys() + { + var json = /*lang=json,strict*/ """{"Address.Street":"Street is required."}"""; + + var errors = JsonSerializer.Deserialize>(json); + + await Assert.That(errors).IsNotNull(); + await Assert.That(errors!.Count).IsEqualTo(1); + await Assert.That(errors.ContainsKey("Address.Street")).IsTrue(); + await Assert.That(errors["Address.Street"]).IsEqualTo("Street is required."); + } + [Test] public async Task I_can_iterate_and_index_segments() { diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs index 4bf0e62..4b9bd50 100644 --- a/src/request.validation/PropertyPath.cs +++ b/src/request.validation/PropertyPath.cs @@ -399,9 +399,26 @@ internal sealed class PropertyPathJsonConverter : JsonConverter throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); } + /// + public override PropertyPath ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.PropertyName) + { + return new PropertyPath(reader.GetString()!); + } + + throw new JsonException($"Expected {nameof(JsonTokenType.PropertyName)} but got {reader.TokenType}."); + } + /// public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); } + + /// + public override void WriteAsPropertyName(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) + { + writer.WritePropertyName(value.ToJsonName(options.DictionaryKeyPolicy)); + } }