feat: add support for validator resolution from dependency injection

This commit is contained in:
Louis Seubert 2026-05-28 20:12:00 +02:00
commit 22142882b5
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
19 changed files with 806 additions and 1 deletions

View file

@ -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<Type, ValidatorInvoker> 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<T> : ValidatorInvoker
{
public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<IOptions<ValidationOptions>>().Value;
var validators = options.GetValidators<T>(serviceProvider);
var problems = new List<Problem>();
foreach (var validator in validators)
{
if (validator.Validate(context) is { IsValid: false, Problems: { } result })
{
problems.AddRange(result);
}
}
return new Validation(problems);
}
}
}

View file

@ -28,4 +28,9 @@
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,17 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation;
/// <summary>
/// Defines a builder for configuring validator registrations.
/// </summary>
public interface IValidatorBuilder
{
/// <summary>
/// Gets the service collection where the validators are registered.
/// </summary>
IServiceCollection Services { get; }
}

View file

@ -0,0 +1,49 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation;
/// <summary>
/// Provides extension methods for configuring and registering validator services in the <see cref="IServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds validator services to the specified <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The service collection to which the validator services will be added.</param>
/// <returns>An instance of <see cref="IValidatorBuilder"/> to configure the validator registrations.</returns>
public static IValidatorBuilder AddValidation(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<ValidationOptions>();
services.AddTransient<IValidator, DispatchingValidator>();
return new ValidatorBuilder(services);
}
/// <summary>
/// Adds validator services to the specified <see cref="IServiceCollection"/>
/// and configures them using the provided <see cref="Action{T}"/>.
/// </summary>
/// <param name="services">The service collection to which the validator services will be added.</param>
/// <param name="configure">A delegate to configure the validator builder.</param>
/// <returns>The service collection with the validator services added.</returns>
public static IServiceCollection AddValidation(this IServiceCollection services, Action<IValidatorBuilder> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
configure(services.AddValidation());
return services;
}
private sealed class ValidatorBuilder(IServiceCollection services) : IValidatorBuilder
{
public IServiceCollection Services { get; } = services;
}
}

View file

@ -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<Type> _search = [];
private readonly Lazy<TypeIndex> _validatorsTypeIndex;
public ValidationOptions()
{
_validatorsTypeIndex = new Lazy<TypeIndex>(() => new ValidatorTypeIndex(_search.Distinct()));
}
public void Inspect(IEnumerable<Type> assembly)
{
if (_validatorsTypeIndex.IsValueCreated)
{
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
}
_search.AddRange(assembly);
}
public IEnumerable<IValidator<T>> GetValidators<T>(IServiceProvider services)
{
return _validatorsTypeIndex.Value.Resolve<IValidator<T>>(services);
}
private abstract class TypeIndex
{
private readonly ConcurrentDictionary<Type, Func<IServiceProvider, IEnumerable>> _cache = new();
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
protected readonly List<Type> _openTypeInfo = [];
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> 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<T> Resolve<T>(IServiceProvider services)
{
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
}
protected abstract IReadOnlyList<Type> IsAssignableTo(Type type);
private Func<IServiceProvider, IEnumerable<T>> CreateResolverFactory<T>(Type @interface)
{
var list = IsAssignableTo(@interface);
return ResolverFactory;
IEnumerable<T> 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<Type> collection)
: TypeIndex(collection, IsValidatorType)
{
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{
var result = new List<Type>();
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<T> : IValidator<T>
// 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<T> : IValidator<Wrapper<T>>
// 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;
}
}
}

View file

@ -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;
/// <summary>
/// Provides extension methods for configuring <see cref="IValidatorBuilder"/>
/// with additional capabilities such as searching and registering validators in assemblies or adding types directly.
/// </summary>
public static class ValidatorBuilderExtensions
{
/// <summary>
/// Searches for validator types within the specified assembly and adds them to the validator
/// configuration.
/// </summary>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <param name="assembly">The assembly to search for validator types.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
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;
}
/// <summary>
/// Searches for validator types within the specified assembly and adds them to the validator
/// configuration with the given service lifetime.
/// </summary>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <param name="assembly">The assembly to search for validator types.</param>
/// <param name="lifetime">The lifetime with which the validators are registered in the dependency injection container.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
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;
}
/// <summary>
/// Adds the specified type to the validator configuration for inspection.
/// </summary>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <param name="type">The type to be added to the validator configuration.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<ValidationOptions>()
.Configure(options => options.Inspect([type]));
return builder;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="builder">The <see cref="IValidatorBuilder"/> used to configure the validators.</param>
/// <param name="type">The type to be added to the validator configuration.</param>
/// <param name="lifetime">The lifetime scope of the type in the service container.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<ValidationOptions>()
.Configure(options => options.Inspect([type]));
builder.Services.Add(new ServiceDescriptor(type, type, lifetime));
return builder;
}
/// <summary>
/// Adds the specified collection of types to the validator configuration for inspection.
/// </summary>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <param name="types">The collection of types to be added to the validator configuration.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable<Type> types)
{
ArgumentNullException.ThrowIfNull(builder);
var typeList = types.ToList();
builder.Services.AddOptions<ValidationOptions>()
.Configure(options => options.Inspect(typeList));
return builder;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <param name="types">The collection of types to be added to the validator configuration.</param>
/// <param name="lifetime">The lifetime scope of the types in the service container.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable<Type> types, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
var typeList = types.ToList();
builder.Services.AddOptions<ValidationOptions>()
.Configure(options => options.Inspect(typeList));
builder.Services.Add(typeList.Select(export => new ServiceDescriptor(export, export, lifetime)));
return builder;
}
/// <summary>
/// Adds the specified validator type to the validator configuration.
/// </summary>
/// <typeparam name="TValidator">The type of the validator to add.</typeparam>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
public static IValidatorBuilder Add<TValidator>(this IValidatorBuilder builder)
where TValidator : class, IValidator
{
return builder.Add(typeof(TValidator));
}
/// <summary>
/// Adds the specified validator type to the validator configuration with the specified lifetime.
/// </summary>
/// <typeparam name="TValidator">The type of the validator to add.</typeparam>
/// <param name="builder">The <see cref="IValidatorBuilder"/> to configure.</param>
/// <param name="lifetime">The lifetime scope of the validator in the service container.</param>
/// <returns>The <see cref="IValidatorBuilder"/> instance for further configuration.</returns>
public static IValidatorBuilder Add<TValidator>(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);
}
}

View file

@ -0,0 +1,23 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Extension methods for <see cref="IValidator"/>.
/// </summary>
public static class ValidatorExtensions
{
/// <summary>
/// Executes the validation logic for a specified instance of type <typeparamref name="T"/> and returns the validation result.
/// </summary>
/// <param name="validator">The <see cref="IValidator"/> instance used to perform the validation.</param>
/// <param name="instance">The instance of type <typeparamref name="T"/> to validate.</param>
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
/// <param name="items">Per-call state shared across nested validation operations.</param>
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
public static Validation Validate<T>(this IValidator validator, T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
{
return validator.Validate(new ValidationContext<T>(instance, serviceProvider, items));
}
}