-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Add async startup validation for Microsoft.Extensions.Options #128788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // ------------------------------------------------------------------------------ | ||
| // Changes to this file must follow the https://aka.ms/api-review process. | ||
| // ------------------------------------------------------------------------------ | ||
|
|
||
| namespace Microsoft.Extensions.DependencyInjection | ||
| { | ||
| public static partial class OptionsBuilderDataAnnotationsExtensions | ||
| { | ||
| [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("Uses DataAnnotationValidateOptionsAsync which is unsafe given that the options type passed in when calling Validate cannot be statically analyzed so its members may be trimmed.")] | ||
| public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> ValidateDataAnnotationsAsync<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder<TOptions> optionsBuilder) where TOptions : class { throw null; } | ||
| } | ||
| } | ||
| namespace Microsoft.Extensions.Options | ||
| { | ||
| public partial class DataAnnotationValidateOptionsAsync<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class | ||
| { | ||
| [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute("The implementation of ValidateAsync method on this type will walk through all properties of the passed in options object, and its type cannot be statically analyzed so its members may be trimmed.")] | ||
| public DataAnnotationValidateOptionsAsync(string? name) { } | ||
| public string? Name { get { throw null; } } | ||
| public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,10 @@ | |
| <Compile Include="Microsoft.Extensions.Options.DataAnnotations.cs" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup Condition="'$(TargetFramework)' == '$(NetCoreAppCurrent)'"> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be I think we will always want this for net11+ builds, and don't want it to be affected by future bumps to NetCoreAppCurrent? |
||
| <Compile Include="Microsoft.Extensions.Options.DataAnnotations.Async.cs" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'"> | ||
| <Compile Include="$(CoreLibSharedDir)System\Diagnostics\CodeAnalysis\RequiresUnreferencedCodeAttribute.cs" /> | ||
| <Compile Include="$(CoreLibSharedDir)System\Diagnostics\CodeAnalysis\DynamicallyAccessedMemberTypes.cs" /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| // 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; | ||
| using System.Collections.Generic; | ||
| using System.ComponentModel.DataAnnotations; | ||
| using System.Diagnostics; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Reflection; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace Microsoft.Extensions.Options | ||
| { | ||
| /// <summary> | ||
| /// Implementation of <see cref="IAsyncValidateOptions{TOptions}"/> that uses DataAnnotation's <see cref="Validator"/> | ||
| /// for asynchronous validation. | ||
| /// </summary> | ||
| /// <typeparam name="TOptions">The instance being validated.</typeparam> | ||
| /// <remarks> | ||
| /// Async validators run only at startup when used with <c>ValidateOnStart</c>. | ||
| /// <see cref="IOptionsMonitor{TOptions}"/> reload validation uses only synchronous validators. | ||
| /// </remarks> | ||
| public class DataAnnotationValidateOptionsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions> | ||
| : IAsyncValidateOptions<TOptions> where TOptions : class | ||
| { | ||
| /// <summary> | ||
| /// Initializes a new instance of <see cref="DataAnnotationValidateOptionsAsync{TOptions}"/>. | ||
| /// </summary> | ||
| /// <param name="name">The name of the option.</param> | ||
| [RequiresUnreferencedCode("The implementation of ValidateAsync method on this type will walk through all properties of the passed in options object, and its type cannot be " + | ||
| "statically analyzed so its members may be trimmed.")] | ||
| public DataAnnotationValidateOptionsAsync(string? name) | ||
| { | ||
| Name = name; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the options name. | ||
| /// </summary> | ||
| public string? Name { get; } | ||
|
|
||
| /// <summary> | ||
| /// Asynchronously validates a specific named options instance (or all when <paramref name="name"/> is null). | ||
| /// </summary> | ||
| /// <param name="name">The name of the options instance being validated.</param> | ||
| /// <param name="options">The options instance.</param> | ||
| /// <param name="cancellationToken">The token to monitor for cancellation requests.</param> | ||
| /// <returns>The <see cref="ValidateOptionsResult"/> result.</returns> | ||
| [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", | ||
| Justification = "Suppressing the warnings on this method since the constructor of the type is annotated as RequiresUnreferencedCode.")] | ||
| public async Task<ValidateOptionsResult> ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) | ||
| { | ||
| // Null name is used to configure all named options. | ||
| if (Name is not null && Name != name) | ||
| { | ||
| // Ignored if not validating this instance. | ||
| return ValidateOptionsResult.Skip; | ||
| } | ||
|
|
||
| // Ensure options are provided to validate against | ||
| ArgumentNullException.ThrowIfNull(options); | ||
|
|
||
| var validationResults = new List<ValidationResult>(); | ||
| HashSet<object>? visited = null; | ||
| List<string>? errors = null; | ||
|
|
||
| (bool success, errors) = await TryValidateOptionsAsync(options, options.GetType().Name, validationResults, errors, visited, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| if (success) | ||
| { | ||
| return ValidateOptionsResult.Success; | ||
| } | ||
|
|
||
| Debug.Assert(errors is not null && errors.Count > 0); | ||
|
|
||
| return ValidateOptionsResult.Fail(errors); | ||
| } | ||
|
|
||
| [RequiresUnreferencedCode("This method on this type will walk through all properties of the passed in options object, and its type cannot be " + | ||
| "statically analyzed so its members may be trimmed.")] | ||
| private static async Task<(bool success, List<string>? errors)> TryValidateOptionsAsync( | ||
| object options, | ||
| string qualifiedName, | ||
| List<ValidationResult> results, | ||
| List<string>? errors, | ||
| HashSet<object>? visited, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| Debug.Assert(options is not null); | ||
|
|
||
| if (visited is not null && visited.Contains(options)) | ||
| { | ||
| return (true, errors); | ||
| } | ||
|
|
||
| results.Clear(); | ||
|
|
||
| bool res = await Validator.TryValidateObjectAsync(options, new ValidationContext(options), results, validateAllProperties: true, cancellationToken).ConfigureAwait(false); | ||
| if (!res) | ||
| { | ||
| errors ??= new List<string>(); | ||
|
|
||
| foreach (ValidationResult result in results!) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the null suppression here needed? |
||
| { | ||
| errors.Add($"DataAnnotation validation failed for '{qualifiedName}' members: '{string.Join(",", result.MemberNames)}' with the error: '{result.ErrorMessage}'."); | ||
| } | ||
| } | ||
|
|
||
| foreach (PropertyInfo propertyInfo in options.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) | ||
| { | ||
| // Indexers are properties which take parameters. Ignore them. | ||
| if (propertyInfo.GetMethod is null || propertyInfo.GetMethod.GetParameters().Length > 0) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| object? value = propertyInfo!.GetValue(options); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here for the null suppression. |
||
|
|
||
| if (value is null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (propertyInfo.GetCustomAttribute<ValidateObjectMembersAttribute>() is not null) | ||
| { | ||
| visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance); | ||
| visited.Add(options); | ||
|
|
||
| results ??= new List<ValidationResult>(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like results is never null here so this line should be redundant? |
||
| bool innerRes; | ||
| (innerRes, errors) = await TryValidateOptionsAsync(value, $"{qualifiedName}.{propertyInfo.Name}", results, errors, visited, cancellationToken).ConfigureAwait(false); | ||
| res = innerRes && res; | ||
| } | ||
| else if (value is IEnumerable enumerable && | ||
| propertyInfo.GetCustomAttribute<ValidateEnumeratedItemsAttribute>() is not null) | ||
| { | ||
| visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance); | ||
| visited.Add(options); | ||
| results ??= new List<ValidationResult>(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here. |
||
|
|
||
| int index = 0; | ||
| foreach (object item in enumerable) | ||
| { | ||
| bool innerRes; | ||
| (innerRes, errors) = await TryValidateOptionsAsync(item, $"{qualifiedName}.{propertyInfo.Name}[{index++}]", results, errors, visited, cancellationToken).ConfigureAwait(false); | ||
| res = innerRes && res; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return (res, errors); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,11 @@ | |
| <Compile Include="$(CoreLibSharedDir)System\Runtime\CompilerServices\CompilerLoweringPreserveAttribute.cs" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'"> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment here about the condition. |
||
| <Compile Remove="DataAnnotationValidateOptionsAsync.cs" /> | ||
| <Compile Remove="OptionsBuilderDataAnnotationsExtensions.Async.cs" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'"> | ||
| <ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.DependencyInjection.Abstractions\src\Microsoft.Extensions.DependencyInjection.Abstractions.csproj" /> | ||
| <ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Options\src\Microsoft.Extensions.Options.csproj" /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.Diagnostics.CodeAnalysis; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| namespace Microsoft.Extensions.DependencyInjection | ||
| { | ||
| public static partial class OptionsBuilderDataAnnotationsExtensions | ||
| { | ||
| /// <summary> | ||
| /// Register this options instance for asynchronous validation of its DataAnnotations. | ||
| /// </summary> | ||
| /// <typeparam name="TOptions">The options type to be configured.</typeparam> | ||
| /// <param name="optionsBuilder">The options builder to add the services to.</param> | ||
| /// <returns>The <see cref="OptionsBuilder{TOptions}"/> so that additional calls can be chained.</returns> | ||
| /// <remarks> | ||
| /// Async validators run only at startup when used with <c>ValidateOnStart</c>. | ||
| /// <see cref="IOptionsMonitor{TOptions}"/> reload validation uses only synchronous validators. | ||
| /// </remarks> | ||
| [RequiresUnreferencedCode("Uses DataAnnotationValidateOptionsAsync which is unsafe given that the options type passed in when calling Validate cannot be statically analyzed so its" + | ||
| " members may be trimmed.")] | ||
| public static OptionsBuilder<TOptions> ValidateDataAnnotationsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class | ||
| { | ||
| optionsBuilder.Services.AddSingleton<IAsyncValidateOptions<TOptions>>(new DataAnnotationValidateOptionsAsync<TOptions>(optionsBuilder.Name)); | ||
| return optionsBuilder; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In case of cancellation, do we want to just throw or do we still want that to go through logging?