Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// 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.Options
{
public partial class DataAnnotationValidateOptions<TOptions> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions>
{
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
Expand Up @@ -7,6 +7,10 @@
<Compile Include="Microsoft.Extensions.Options.DataAnnotations.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == '$(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" />
Expand Down
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.

#if NET11_0_OR_GREATER
Comment thread
ViveliDuCh marked this conversation as resolved.

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>
/// Async validation implementation for <see cref="DataAnnotationValidateOptions{TOptions}"/>.
/// </summary>
public partial class DataAnnotationValidateOptions<TOptions>
{
/// <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>
/// <remarks>
/// The <paramref name="cancellationToken"/> is propagated from
/// <c>Host.StartAsync(CancellationToken)</c>. By default, no startup timeout
/// is applied. Configure <c>HostOptions.StartupTimeout</c> or pass
/// a <see cref="CancellationToken"/> with a timeout to <c>Host.StartAsync</c>
/// to bound I/O-bound async validators:
/// <code>
/// builder.Services.Configure&lt;HostOptions&gt;(opts =&gt;
/// opts.StartupTimeout = TimeSpan.FromSeconds(30));
/// </code>
/// </remarks>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode",
Justification = "Suppressing the warnings on this method since the constructor of the type is annotated as RequiresUnreferencedCode.")]
Comment thread
eiriktsarpalis marked this conversation as resolved.
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);
Comment thread
ViveliDuCh marked this conversation as resolved.

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);
}

/// <remarks>
/// Async counterpart of <see cref="TryValidateOptions"/>. Uses a tuple return
/// <c>(bool success, List&lt;string&gt;? errors)</c> instead of <c>ref</c> parameters
/// because <c>async</c> methods cannot have <c>ref</c>/<c>out</c> parameters.
///
/// <c>visited</c> does not need to be returned — it is created <em>before</em> each
/// recursive call, so the callee always receives a non-null, shared instance.
/// <c>errors</c> must be returned because it may be lazily created
/// (<c>errors ??= new List&lt;string&gt;()</c>) inside the callee.
/// </remarks>
[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(
Comment thread
ViveliDuCh marked this conversation as resolved.
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)
Comment thread
ViveliDuCh marked this conversation as resolved.
{
errors ??= new List<string>();

foreach (ValidationResult result in results)
{
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);

if (value is null)
{
continue;
}

if (propertyInfo.GetCustomAttribute<ValidateObjectMembersAttribute>() is not null)
{
visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
visited.Add(options);

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);

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);
}
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ namespace Microsoft.Extensions.Options
/// Implementation of <see cref="IValidateOptions{TOptions}"/> that uses DataAnnotation's <see cref="Validator"/> for validation.
/// </summary>
/// <typeparam name="TOptions">The instance being validated.</typeparam>
public class DataAnnotationValidateOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>
: IValidateOptions<TOptions> where TOptions : class
#if NET11_0_OR_GREATER
public partial class DataAnnotationValidateOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>
: IValidateOptions<TOptions>, IAsyncValidateOptions<TOptions>
where TOptions : class
#else
public partial class DataAnnotationValidateOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>
: IValidateOptions<TOptions>
where TOptions : class
#endif
{
/// <summary>
/// Initializes a new instance of <see cref="DataAnnotationValidateOptions{TOptions}"/> .
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,32 @@ namespace Microsoft.Extensions.DependencyInjection
public static class OptionsBuilderDataAnnotationsExtensions
{
/// <summary>
/// Register this options instance for validation of its DataAnnotations.
/// Registers this options instance for validation of its DataAnnotations.
/// </summary>
/// <remarks>
/// Synchronous validation runs when the options instance is created or accessed. When targeting .NET 11 or later,
/// asynchronous validation (including <c>AsyncValidationAttribute</c>-derived attributes)
/// runs once at startup, and only when <c>ValidateOnStart()</c> is also called.
/// If <c>ValidateOnStart()</c> is not called, attributes deriving from
/// <c>AsyncValidationAttribute</c> are never evaluated asynchronously: runtime options access triggers only
/// synchronous validation, which invokes the attribute's synchronous fallback instead.
/// When using <c>AsyncValidationAttribute</c>-derived attributes, ensure the synchronous
/// <c>IsValid</c> fallback does not throw: synchronous validation still runs on every
/// options access, so a throwing fallback surfaces as an exception on each access (for example
/// when resolving <c>IOptions{TOptions}.Value</c>), even if startup validation succeeded.
/// </remarks>
Comment thread
ViveliDuCh marked this conversation as resolved.
/// <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>
[RequiresUnreferencedCode("Uses DataAnnotationValidateOptions 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> ValidateDataAnnotations<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
{
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(new DataAnnotationValidateOptions<TOptions>(optionsBuilder.Name));
var instance = new DataAnnotationValidateOptions<TOptions>(optionsBuilder.Name);
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(instance);
#if NET11_0_OR_GREATER
optionsBuilder.Services.AddSingleton<IAsyncValidateOptions<TOptions>>(instance);
#endif
return optionsBuilder;
}
}
Expand Down
Loading
Loading