Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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()`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IServiceCollection>(services);

// DI injected services
services.AddTransient<IServicesContext, ServicesContext>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/// <summary>
Expand Down Expand Up @@ -78,6 +79,10 @@ public static IEnumerable<IValidator> GetValidators(

Type validatorType = typeof(IValidator<>).MakeGenericType(modelType);

// Track seen validators to deduplicate between keyed and non-keyed
var seen = new HashSet<IValidator>(ReferenceEqualityComparer.Instance);

// 1. Non-keyed validators (existing behavior)
var validators = serviceProvider
.GetServices(validatorType)
.OfType<IValidator>();
Expand All @@ -86,6 +91,7 @@ public static IEnumerable<IValidator> GetValidators(
{
if (validatorFilter is null || validatorFilter.Matches(new ValidatorContext(typeContext, validator)))
{
seen.Add(validator);
yield return validator;

if (options.ValidatorSearch.IsOneValidatorForType)
Expand All @@ -95,6 +101,23 @@ public static IEnumerable<IValidator> 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;
Expand All @@ -113,5 +136,31 @@ public static IEnumerable<IValidator> GetValidators(
}
}
}

/// <summary>
/// Resolves keyed IValidator services by scanning IServiceCollection descriptors.
/// </summary>
internal static IEnumerable<IValidator> GetKeyedValidators(
this IServiceProvider serviceProvider,
Type modelType)
{
Type validatorType = typeof(IValidator<>).MakeGenericType(modelType);
var serviceCollection = serviceProvider.GetService<IServiceCollection>();

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;
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IServiceCollection>(services);

// Adds IFluentValidationRuleProvider
services.TryAddSingleton<IFluentValidationRuleProvider<OpenApiSchema>, DefaultFluentValidationRuleProvider>();

Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Tests for keyed service support (Issue #165).
/// </summary>
public class KeyedServicesTests : UnitTestBase
{
public class KeyedModel
{
public string? Name { get; set; }

public int Age { get; set; }
}

public class KeyedModelValidator : AbstractValidator<KeyedModel>
{
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<IValidator<KeyedModel>, KeyedModelValidator>("myKey");

var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<IValidatorRegistry>();

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<IValidator<KeyedModel>, KeyedModelValidator>("myKey");

var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<IValidatorRegistry>();

registry.GetValidator(typeof(KeyedModel)).Should().NotBeNull();
}

[Fact]
public void NonKeyed_Validators_Still_Work()
{
var services = new ServiceCollection();
services.AddFluentValidationRulesToSwagger();
services.AddScoped<IValidator<KeyedModel>, KeyedModelValidator>();

var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<IValidatorRegistry>();

registry.GetValidators(typeof(KeyedModel)).Should().ContainSingle();
}

[Fact]
public void Mixed_Keyed_And_NonKeyed_No_Duplicates()
{
var services = new ServiceCollection();
services.AddFluentValidationRulesToSwagger();
services.AddScoped<IValidator<KeyedModel>, KeyedModelValidator>();
services.AddKeyedScoped<IValidator<KeyedModel>, KeyedModelValidator>("myKey");

var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var registry = scope.ServiceProvider.GetRequiredService<IValidatorRegistry>();

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();
}
}
}
2 changes: 1 addition & 1 deletion version.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<VersionPrefix>7.0.4</VersionPrefix>
<VersionSuffix>beta.1</VersionSuffix>
<VersionSuffix>beta.2</VersionSuffix>
</PropertyGroup>
</Project>
Loading