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..a433208 --- /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().HaveCount(1, "IsOneValidatorForType is true by default, so only the first (non-keyed) validator is returned"); + } + + [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