diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/ref/Microsoft.Extensions.DependencyInjection.Abstractions.cs b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/ref/Microsoft.Extensions.DependencyInjection.Abstractions.cs index ed9f0323d20685..4dd60c72841f36 100644 --- a/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/ref/Microsoft.Extensions.DependencyInjection.Abstractions.cs +++ b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/ref/Microsoft.Extensions.DependencyInjection.Abstractions.cs @@ -40,6 +40,10 @@ public FromKeyedServicesAttribute(object? key) { } public partial interface IServiceCollection : System.Collections.Generic.ICollection, System.Collections.Generic.IEnumerable, System.Collections.Generic.IList, System.Collections.IEnumerable { } + public partial interface IServiceCollectionValidator + { + Microsoft.Extensions.DependencyInjection.ValidationResult Validate(System.Collections.Generic.IReadOnlyList services); + } public partial interface IServiceProviderFactory where TContainerBuilder : notnull { TContainerBuilder CreateBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollection services); @@ -143,6 +147,12 @@ public static partial class ServiceCollectionServiceExtensions public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTransient(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) where TService : class where TImplementation : class, TService { throw null; } public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddTransient(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Func implementationFactory) where TService : class where TImplementation : class, TService { throw null; } } + public static partial class ServiceCollectionValidationExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidator<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TValidator>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) where TValidator : class, Microsoft.Extensions.DependencyInjection.IServiceCollectionValidator { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidator(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, Microsoft.Extensions.DependencyInjection.IServiceCollectionValidator validator) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidator(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Func, Microsoft.Extensions.DependencyInjection.ValidationResult> validator) { throw null; } + } public partial class ServiceDescriptor { public ServiceDescriptor(System.Type serviceType, System.Func factory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime) { } @@ -243,6 +253,18 @@ public static partial class ServiceProviderServiceExtensions public static System.Collections.Generic.IEnumerable GetServices(this System.IServiceProvider provider) { throw null; } public static T? GetService(this System.IServiceProvider provider) { throw null; } } + public readonly partial struct ValidationResult + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public ValidationResult(System.Collections.Generic.IReadOnlyList errors) { } + public static Microsoft.Extensions.DependencyInjection.ValidationResult Success { get { throw null; } } + public System.Collections.Generic.IReadOnlyList Errors { get { throw null; } } + public bool IsSuccess { get { throw null; } } + public static Microsoft.Extensions.DependencyInjection.ValidationResult Fail(string error) { throw null; } + public static Microsoft.Extensions.DependencyInjection.ValidationResult Fail(System.Collections.Generic.IReadOnlyList errors) { throw null; } + public static Microsoft.Extensions.DependencyInjection.ValidationResult operator +(Microsoft.Extensions.DependencyInjection.ValidationResult left, Microsoft.Extensions.DependencyInjection.ValidationResult right) { throw null; } + } } namespace Microsoft.Extensions.DependencyInjection.Extensions { diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/Extensions/ServiceCollectionValidationExtensions.cs b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/Extensions/ServiceCollectionValidationExtensions.cs new file mode 100644 index 00000000000000..7f636aeec5651f --- /dev/null +++ b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/Extensions/ServiceCollectionValidationExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for registering instances with an . + /// + public static class ServiceCollectionValidationExtensions + { + /// + /// Registers as an singleton. + /// + /// The validator type. It will be instantiated by the DI container, allowing constructor injection. + /// The to add the validator to. + /// The to allow chaining. + public static IServiceCollection AddValidator<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TValidator>(this IServiceCollection services) + where TValidator : class, IServiceCollectionValidator + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + return services; + } + + /// + /// Registers the given instance as an singleton. + /// + /// The to add the validator to. + /// The validator instance to register. + /// The to allow chaining. + public static IServiceCollection AddValidator(this IServiceCollection services, IServiceCollectionValidator validator) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(validator); + + services.AddSingleton(validator); + return services; + } + + /// + /// Registers a delegate as an singleton. + /// + /// The to add the validator to. + /// + /// A delegate that performs the validation. The first parameter is the built , + /// which can be used to resolve services; the second parameter is a read-only view of the registered + /// instances. + /// + /// The to allow chaining. + public static IServiceCollection AddValidator( + this IServiceCollection services, + Func, ValidationResult> validator) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(validator); + + services.AddSingleton( + sp => new DelegateValidator(sp, validator)); + return services; + } + + private sealed class DelegateValidator : IServiceCollectionValidator + { + private readonly IServiceProvider _serviceProvider; + private readonly Func, ValidationResult> _validator; + + public DelegateValidator( + IServiceProvider serviceProvider, + Func, ValidationResult> validator) + { + _serviceProvider = serviceProvider; + _validator = validator; + } + + public ValidationResult Validate(IReadOnlyList services) + => _validator(_serviceProvider, services); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/IServiceCollectionValidator.cs b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/IServiceCollectionValidator.cs new file mode 100644 index 00000000000000..91ab16b0324236 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/IServiceCollectionValidator.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Defines a mechanism to validate service registrations when building a service provider. + /// + /// + /// Implementations are registered in the via + /// or one of its overloads, + /// and are resolved and invoked automatically just before the built is returned to the caller. + /// Because validators are resolved from the container, constructor injection of any registered service is fully supported. + /// + public interface IServiceCollectionValidator + { + /// + /// Validates the service registrations described by . + /// + /// A read-only view of the service descriptors registered in the container. + /// + /// A that is when validation passed, + /// or contains one or more error messages when validation failed. + /// + ValidationResult Validate(IReadOnlyList services); + } +} diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/ValidationResult.cs b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/ValidationResult.cs new file mode 100644 index 00000000000000..baa18f9cd75c9c --- /dev/null +++ b/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/ValidationResult.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Represents the result of an validation. + /// + public readonly struct ValidationResult + { + private static readonly string[] s_empty = Array.Empty(); + + private readonly string[]? _errors; + + /// + /// Initializes a new instance of the struct with the specified errors. + /// + /// The list of validation errors. + public ValidationResult(IReadOnlyList errors) + { + ArgumentNullException.ThrowIfNull(errors); + + if (errors.Count == 0) + { + _errors = null; + } + else + { + _errors = new string[errors.Count]; + for (int i = 0; i < errors.Count; i++) + { + _errors[i] = errors[i]; + } + } + } + + private ValidationResult(string[] errors) + { + _errors = errors.Length == 0 ? null : errors; + } + + /// + /// Gets a that represents a successful validation. + /// + public static ValidationResult Success { get; } = new ValidationResult(s_empty); + + /// + /// Gets the list of validation errors. + /// + /// + /// A read-only list of error messages, or an empty list when validation succeeded. + /// + public IReadOnlyList Errors => _errors ?? s_empty; + + /// + /// Gets a value that indicates whether the validation succeeded. + /// + /// + /// if validation succeeded and there are no errors; otherwise, . + /// + public bool IsSuccess => _errors is null; + + /// + /// Creates a that represents a failure with the specified error message. + /// + /// The validation error message. + /// A with the specified error. + public static ValidationResult Fail(string error) + { + ArgumentNullException.ThrowIfNull(error); + return new ValidationResult(new[] { error }); + } + + /// + /// Creates a that represents a failure with the specified error messages. + /// + /// The list of validation error messages. + /// A with the specified errors. + public static ValidationResult Fail(IReadOnlyList errors) + { + ArgumentNullException.ThrowIfNull(errors); + if (errors.Count == 0) + { + return Success; + } + + var copy = new string[errors.Count]; + for (int i = 0; i < errors.Count; i++) + { + copy[i] = errors[i]; + } + + return new ValidationResult(copy); + } + + /// + /// Combines two instances by aggregating their errors. + /// + /// The first . + /// The second . + /// + /// A new whose contains the errors from both operands. + /// + /// + /// Each application of this operator allocates a new array to hold the combined errors. + /// When aggregating many values, prefer collecting all errors + /// manually and constructing a single via + /// to avoid repeated allocations. + /// + public static ValidationResult operator +(ValidationResult left, ValidationResult right) + { + if (left.IsSuccess) + { + return right; + } + + if (right.IsSuccess) + { + return left; + } + + var errors = new string[left._errors!.Length + right._errors!.Length]; + Array.Copy(left._errors, errors, left._errors.Length); + Array.Copy(right._errors, 0, errors, left._errors.Length, right._errors.Length); + return new ValidationResult(errors); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.DependencyInjection/src/Resources/Strings.resx index eca615b1666455..21b5bc34c98eee 100644 --- a/src/libraries/Microsoft.Extensions.DependencyInjection/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.DependencyInjection/src/Resources/Strings.resx @@ -198,4 +198,8 @@ No keyed service for type '{0}' using key type '{1}' has been registered. + + Some service registrations failed validation: +{0} + \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceCollectionContainerBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceCollectionContainerBuilderExtensions.cs index 8a7227dde1eeb7..dcac8fbfd960ba 100644 --- a/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceCollectionContainerBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceCollectionContainerBuilderExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; namespace Microsoft.Extensions.DependencyInjection { @@ -26,7 +28,7 @@ public static ServiceProvider BuildServiceProvider(this IServiceCollection servi /// /// The containing service descriptors. /// - /// true to perform check verifying that scoped services never gets resolved from root provider; otherwise false. + /// to perform check verifying that scoped services never gets resolved from root provider; otherwise . /// /// The . public static ServiceProvider BuildServiceProvider(this IServiceCollection services, bool validateScopes) @@ -43,12 +45,80 @@ public static ServiceProvider BuildServiceProvider(this IServiceCollection servi /// Configures various service provider behaviors. /// /// The . + /// + /// One or more registered instances reported validation errors. + /// public static ServiceProvider BuildServiceProvider(this IServiceCollection services, ServiceProviderOptions options) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(options); - return new ServiceProvider(services, options); + var provider = new ServiceProvider(services, options); + + RunValidators(provider, services); + + return provider; + } + + private static void RunValidators(ServiceProvider provider, IServiceCollection services) + { + // Fast path: avoid resolution overhead and EventSource noise when no validators are registered. + bool hasValidators = false; + foreach (ServiceDescriptor descriptor in services) + { + if (descriptor.ServiceType == typeof(IServiceCollectionValidator)) + { + hasValidators = true; + break; + } + } + + if (!hasValidators) + { + return; + } + + List? validators = null; + foreach (IServiceCollectionValidator validator in provider.GetServices()) + { + validators ??= new List(); + validators.Add(validator); + } + + if (validators is null) + { + return; + } + + IReadOnlyList descriptors = services is IReadOnlyList readOnly + ? readOnly + : new ReadOnlyCollection((IList)services); + + List? errors = null; + try + { + foreach (IServiceCollectionValidator validator in validators) + { + ValidationResult result = validator.Validate(descriptors); + if (!result.IsSuccess) + { + errors ??= new List(); + errors.AddRange(result.Errors); + } + } + } + catch + { + provider.Dispose(); + throw; + } + + if (errors is not null) + { + provider.Dispose(); + throw new InvalidOperationException( + SR.Format(SR.ValidatorsFailedWithErrors, string.Join(Environment.NewLine, errors))); + } } } } diff --git a/src/libraries/Microsoft.Extensions.DependencyInjection/tests/DI.Tests/ServiceProviderValidationTests.cs b/src/libraries/Microsoft.Extensions.DependencyInjection/tests/DI.Tests/ServiceProviderValidationTests.cs index aa372ce748cd56..bb64f474333be4 100644 --- a/src/libraries/Microsoft.Extensions.DependencyInjection/tests/DI.Tests/ServiceProviderValidationTests.cs +++ b/src/libraries/Microsoft.Extensions.DependencyInjection/tests/DI.Tests/ServiceProviderValidationTests.cs @@ -638,4 +638,201 @@ public KeyedScopedCollisionOtherSingleton([FromKeyedServices("Key")] IKeyedScope } } } + + public class ServiceCollectionValidatorTests + { + [Fact] + public void BuildServiceProvider_WithNoValidators_Succeeds() + { + var services = new ServiceCollection(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider); + provider.Dispose(); + } + + [Fact] + public void BuildServiceProvider_WithValidatorReturningSuccess_Succeeds() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddValidator(new AlwaysSuccessValidator()); + + var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider); + provider.Dispose(); + } + + [Fact] + public void BuildServiceProvider_WithValidatorReturningError_Throws() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddValidator(new AlwaysFailValidator("test error")); + + var ex = Assert.Throws(() => services.BuildServiceProvider()); + + Assert.Contains("test error", ex.Message); + } + + [Fact] + public void BuildServiceProvider_AggregatesErrorsFromMultipleValidators() + { + var services = new ServiceCollection(); + services.AddValidator(new AlwaysFailValidator("error1")); + services.AddValidator(new AlwaysFailValidator("error2")); + + var ex = Assert.Throws(() => services.BuildServiceProvider()); + + Assert.Contains("error1", ex.Message); + Assert.Contains("error2", ex.Message); + } + + [Fact] + public void BuildServiceProvider_ValidatorCanInjectServicesFromContainer() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddValidator(); + + var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider); + provider.Dispose(); + } + + [Fact] + public void BuildServiceProvider_DelegateValidator_Succeeds_WhenDelegateReturnsSuccess() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddValidator((sp, descriptors) => ValidationResult.Success); + + var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider); + provider.Dispose(); + } + + [Fact] + public void BuildServiceProvider_DelegateValidator_ReceivesServiceProvider() + { + IServiceProvider? capturedProvider = null; + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddValidator((sp, descriptors) => + { + capturedProvider = sp; + return ValidationResult.Success; + }); + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(capturedProvider); + } + + [Fact] + public void BuildServiceProvider_DelegateValidator_ReceivesDescriptors() + { + IReadOnlyList? capturedDescriptors = null; + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddValidator((sp, descriptors) => + { + capturedDescriptors = descriptors; + return ValidationResult.Success; + }); + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(capturedDescriptors); + Assert.True(capturedDescriptors!.Count > 0); + } + + [Fact] + public void BuildServiceProvider_DelegateValidator_Throws_WhenDelegateReturnsFail() + { + var services = new ServiceCollection(); + services.AddValidator((sp, descriptors) => ValidationResult.Fail("delegate error")); + + var ex = Assert.Throws(() => services.BuildServiceProvider()); + + Assert.Contains("delegate error", ex.Message); + } + + [Fact] + public void AddValidator_GenericOverload_RegistersValidator() + { + var services = new ServiceCollection(); + services.AddValidator(); + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider); + } + + [Fact] + public void BuildServiceProvider_ValidatorCanReturnMultipleErrors() + { + var services = new ServiceCollection(); + services.AddValidator(new AlwaysFailValidator("err1", "err2", "err3")); + + var ex = Assert.Throws(() => services.BuildServiceProvider()); + + Assert.Contains("err1", ex.Message); + Assert.Contains("err2", ex.Message); + Assert.Contains("err3", ex.Message); + } + + private interface IFoo { } + private class FooImpl : IFoo { } + + private class AlwaysSuccessValidator : IServiceCollectionValidator + { + public ValidationResult Validate(IReadOnlyList services) + => ValidationResult.Success; + } + + private class AlwaysFailValidator : IServiceCollectionValidator + { + private readonly string[] _errors; + + public AlwaysFailValidator(params string[] errors) + { + _errors = errors; + } + + public ValidationResult Validate(IReadOnlyList services) + => ValidationResult.Fail((IReadOnlyList)_errors); + } + + private class InjectingValidator : IServiceCollectionValidator + { + private readonly IFoo _foo; + + public InjectingValidator(IFoo foo) + { + _foo = foo; + } + + public ValidationResult Validate(IReadOnlyList services) + { + if (_foo is not null) + { + return ValidationResult.Success; + } + + return ValidationResult.Fail("IFoo was not injected"); + } + } + + private class TrackingValidator : IServiceCollectionValidator + { + public ValidationResult Validate(IReadOnlyList services) + => ValidationResult.Success; + } + } }