From 22142882b535a78686ee32fde3acde8790a8868b Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 28 May 2026 20:12:00 +0200 Subject: [PATCH 1/2] 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 2/2] 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);