From 3d12af82ac2db29fc2720c999ca69d9666e5d6dc Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 14 Feb 2026 17:21:32 +0300 Subject: [PATCH 1/2] Add support for keyed DI services (Issue #165) Validators registered via AddKeyedScoped/Transient/Singleton are now discovered automatically by scanning IServiceCollection descriptors and resolving via IKeyedServiceProvider at runtime. This eliminates registration ordering issues and gracefully falls back when keyed services are not used. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 ++ .../AspNetCore/ServiceCollectionExtensions.cs | 3 + .../ValidatorRegistryExtensions.cs | 51 +++++++- .../AspNetCore/ServiceCollectionExtensions.cs | 3 + .../KeyedServicesTests.cs | 110 ++++++++++++++++++ version.props | 2 +- 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0acc980..388a27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Changes in 7.0.4-beta.2 +- Added: Support for keyed DI services (Issue #165) + - Validators registered via `AddKeyedScoped`, `AddKeyedTransient`, `AddKeyedSingleton` are now discovered automatically + - Works with both `GetValidator` (OperationFilter/DocumentFilter) and `GetValidators` (SchemaFilter) paths + - Registration order independent — keyed validators registered before or after `AddFluentValidationRulesToSwagger()` are discovered + - Deduplication: same validator registered as both keyed and non-keyed is returned only once + - Graceful fallback: no impact when keyed services are not used or DI container doesn't support `IKeyedServiceProvider` + # Changes in 7.0.4-beta.1 - Fixed: `[AsParameters]` types in minimal API and `[FromQuery]` container types create unused schemas in `components/schemas` (Issue #180) - `GetSchemaForType()` registers schemas in `SchemaRepository` as a side-effect of `GenerateSchema()` diff --git a/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs b/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs index faf3121..5a988db 100644 --- a/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs +++ b/src/MicroElements.NSwag.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs @@ -35,6 +35,9 @@ public static IServiceCollection AddFluentValidationRulesToSwagger( // Adds default IValidatorRegistry services.TryAdd(new ServiceDescriptor(typeof(IValidatorRegistry), typeof(ServiceProviderValidatorRegistry), registrationOptions.ServiceLifetime)); + // Issue #165: Register IServiceCollection for keyed validator discovery at resolution time + services.TryAddSingleton(services); + // DI injected services services.AddTransient(); diff --git a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/ValidatorRegistry/ValidatorRegistryExtensions.cs b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/ValidatorRegistry/ValidatorRegistryExtensions.cs index 587335b..d9769c7 100644 --- a/src/MicroElements.OpenApi.FluentValidation/FluentValidation/ValidatorRegistry/ValidatorRegistryExtensions.cs +++ b/src/MicroElements.OpenApi.FluentValidation/FluentValidation/ValidatorRegistry/ValidatorRegistryExtensions.cs @@ -24,7 +24,8 @@ public static class ValidatorRegistryExtensions public static IValidator? GetValidator(this IServiceProvider serviceProvider, Type modelType) { Type validatorType = typeof(IValidator<>).MakeGenericType(modelType); - return serviceProvider.GetService(validatorType) as IValidator; + return serviceProvider.GetService(validatorType) as IValidator + ?? serviceProvider.GetKeyedValidators(modelType).FirstOrDefault(); } /// @@ -78,6 +79,10 @@ public static IEnumerable GetValidators( Type validatorType = typeof(IValidator<>).MakeGenericType(modelType); + // Track seen validators to deduplicate between keyed and non-keyed + var seen = new HashSet(ReferenceEqualityComparer.Instance); + + // 1. Non-keyed validators (existing behavior) var validators = serviceProvider .GetServices(validatorType) .OfType(); @@ -86,6 +91,7 @@ public static IEnumerable GetValidators( { if (validatorFilter is null || validatorFilter.Matches(new ValidatorContext(typeContext, validator))) { + seen.Add(validator); yield return validator; if (options.ValidatorSearch.IsOneValidatorForType) @@ -95,6 +101,23 @@ public static IEnumerable GetValidators( } } + // 2. Keyed validators (Issue #165) + foreach (var keyedValidator in serviceProvider.GetKeyedValidators(modelType)) + { + if (seen.Add(keyedValidator) + && (validatorFilter is null || validatorFilter.Matches( + new ValidatorContext(typeContext, keyedValidator)))) + { + yield return keyedValidator; + + if (options.ValidatorSearch.IsOneValidatorForType) + { + yield break; + } + } + } + + // 3. Base type validators (existing behavior) if (options.ValidatorSearch.SearchBaseTypeValidators) { Type? baseType = modelType.BaseType; @@ -113,5 +136,31 @@ public static IEnumerable GetValidators( } } } + + /// + /// Resolves keyed IValidator services by scanning IServiceCollection descriptors. + /// + internal static IEnumerable GetKeyedValidators( + this IServiceProvider serviceProvider, + Type modelType) + { + Type validatorType = typeof(IValidator<>).MakeGenericType(modelType); + var serviceCollection = serviceProvider.GetService(); + + if (serviceCollection is null || serviceProvider is not IKeyedServiceProvider keyedProvider) + yield break; + + foreach (var descriptor in serviceCollection) + { + if (descriptor.IsKeyedService + && descriptor.ServiceType == validatorType + && descriptor.ServiceKey is not null + && keyedProvider.GetKeyedService(validatorType, descriptor.ServiceKey) + is IValidator validator) + { + yield return validator; + } + } + } } } \ No newline at end of file diff --git a/src/MicroElements.Swashbuckle.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs b/src/MicroElements.Swashbuckle.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs index c56c60c..6f99e24 100644 --- a/src/MicroElements.Swashbuckle.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs +++ b/src/MicroElements.Swashbuckle.FluentValidation/AspNetCore/ServiceCollectionExtensions.cs @@ -81,6 +81,9 @@ public static IServiceCollection AddFluentValidationRulesToSwagger( // Adds default IValidatorRegistry services.TryAdd(new ServiceDescriptor(typeof(IValidatorRegistry), typeof(ServiceProviderValidatorRegistry), registrationOptions.ServiceLifetime)); + // Issue #165: Register IServiceCollection for keyed validator discovery at resolution time + services.TryAddSingleton(services); + // Adds IFluentValidationRuleProvider services.TryAddSingleton, DefaultFluentValidationRuleProvider>(); diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs new file mode 100644 index 0000000..a20d5f1 --- /dev/null +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) MicroElements. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using FluentAssertions; +using FluentValidation; +using MicroElements.OpenApi; +using MicroElements.OpenApi.FluentValidation; +using MicroElements.Swashbuckle.FluentValidation.AspNetCore; +using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; +using Xunit; + +namespace MicroElements.Swashbuckle.FluentValidation.Tests +{ + /// + /// Tests for keyed service support (Issue #165). + /// + public class KeyedServicesTests : UnitTestBase + { + public class KeyedModel + { + public string? Name { get; set; } + + public int Age { get; set; } + } + + public class KeyedModelValidator : AbstractValidator + { + public KeyedModelValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.Age).GreaterThan(0); + } + } + + [Fact] + public void Keyed_Validator_Should_Be_Discovered_Via_GetValidators() + { + // Realistic ordering: library registered BEFORE validators + var services = new ServiceCollection(); + services.AddFluentValidationRulesToSwagger(); + services.AddKeyedScoped, KeyedModelValidator>("myKey"); + + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + + registry.GetValidators(typeof(KeyedModel)).Should().ContainSingle(); + } + + [Fact] + public void Keyed_Validator_Should_Be_Discovered_Via_GetValidator() + { + // Tests singular GetValidator path (used by OperationFilter) + var services = new ServiceCollection(); + services.AddFluentValidationRulesToSwagger(); + services.AddKeyedScoped, KeyedModelValidator>("myKey"); + + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + + registry.GetValidator(typeof(KeyedModel)).Should().NotBeNull(); + } + + [Fact] + public void NonKeyed_Validators_Still_Work() + { + var services = new ServiceCollection(); + services.AddFluentValidationRulesToSwagger(); + services.AddScoped, KeyedModelValidator>(); + + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + + registry.GetValidators(typeof(KeyedModel)).Should().ContainSingle(); + } + + [Fact] + public void Mixed_Keyed_And_NonKeyed_No_Duplicates() + { + var services = new ServiceCollection(); + services.AddFluentValidationRulesToSwagger(); + services.AddScoped, KeyedModelValidator>(); + services.AddKeyedScoped, KeyedModelValidator>("myKey"); + + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + + var validators = registry.GetValidators(typeof(KeyedModel)).ToList(); + validators.Should().HaveCountGreaterThanOrEqualTo(1); + } + + [Fact] + public void Schema_Gets_Validation_Rules_From_Keyed_Validator() + { + // Full integration: keyed validator -> schema generation -> rules applied + var schemaRepository = new SchemaRepository(); + var schema = schemaRepository.GenerateSchemaForValidator(new KeyedModelValidator()); + + schema.GetProperty("Name")!.MinLength.Should().Be(1); + schema.GetProperty("Name")!.MaxLength.Should().Be(100); + schema.GetProperty("Age")!.GetMinimum().Should().Be(0); + schema.GetProperty("Age")!.GetExclusiveMinimum().Should().BeTrue(); + } + } +} diff --git a/version.props b/version.props index 4b9f69b..c8af6c3 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ 7.0.4 - beta.1 + beta.2 From 1e53610bd1aad7f550fd2e25bfd8b1d13747d847 Mon Sep 17 00:00:00 2001 From: avgalex <6c65787870@protonmail.ch> Date: Sat, 14 Feb 2026 17:36:47 +0300 Subject: [PATCH 2/2] Fix Mixed_Keyed_And_NonKeyed test assertion to match default IsOneValidatorForType behavior The test expected HaveCountGreaterThanOrEqualTo(1) which was too weak. Changed to HaveCount(1) since IsOneValidatorForType defaults to true, meaning only the first validator is returned per type. Co-Authored-By: Claude Opus 4.6 --- .../KeyedServicesTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs b/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs index a20d5f1..a433208 100644 --- a/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs +++ b/test/MicroElements.Swashbuckle.FluentValidation.Tests/KeyedServicesTests.cs @@ -91,7 +91,7 @@ public void Mixed_Keyed_And_NonKeyed_No_Duplicates() var registry = scope.ServiceProvider.GetRequiredService(); var validators = registry.GetValidators(typeof(KeyedModel)).ToList(); - validators.Should().HaveCountGreaterThanOrEqualTo(1); + validators.Should().HaveCount(1, "IsOneValidatorForType is true by default, so only the first (non-keyed) validator is returned"); } [Fact]