diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs index 4625a1e57685d2..c8a817e3b76cbb 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs @@ -65,8 +65,7 @@ public Host(IServiceProvider services, /// /// Order: /// IHostLifetime.WaitForStartAsync - /// Services.GetService{IStartupValidator}().Validate() - /// Services.GetService{IAsyncStartupValidator}().ValidateAsync() + /// Startup validation: a custom sync IStartupValidator (if any) via Validate(), otherwise every IAsyncStartupValidator via ValidateAsync() /// IHostedLifecycleService.StartingAsync /// IHostedService.Start /// IHostedLifecycleService.StartedAsync @@ -94,26 +93,26 @@ public async Task StartAsync(CancellationToken cancellationToken = default) try { - _hostedServices ??= Services.GetRequiredService>(); - _hostedLifecycleServices = GetHostLifecycles(_hostedServices); - - // Two-stage startup validation: - // Stage 1 (sync): Run IStartupValidator.Validate() — iterates _validators dictionary - // (or user's custom implementation if registered). - // If sync validation fails, skip async to avoid expensive I/O on invalid config. - // Stage 2 (async): Run IAsyncStartupValidator.ValidateAsync() — iterates _asyncValidators - // dictionary (or user's custom implementation if registered). - // - // Each interface is resolved independently via DI. TryAddTransient semantics ensure - // user-registered implementations replace the built-in for each interface separately. - IStartupValidator? validator = Services.GetService(); - validator?.Validate(); - - IAsyncStartupValidator? asyncValidator = Services.GetService(); - if (asyncValidator is not null) + // Run startup validation before resolving hosted services so a hosted service that + // reads validated options in its constructor observes the validated instance. + IStartupValidator? startupValidator = Services.GetService(); + if (startupValidator is not null && startupValidator is not IAsyncStartupValidator) { - await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false); + // A custom IStartupValidator takes precedence for back-compatibility and fully controls + // startup validation, overriding any registered IAsyncStartupValidator instances + // (including the one registered by ValidateOnStart). + startupValidator.Validate(); } + else + { + foreach (IAsyncStartupValidator asyncValidator in Services.GetServices()) + { + await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false); + } + } + + _hostedServices ??= Services.GetRequiredService>(); + _hostedLifecycleServices = GetHostLifecycles(_hostedServices); } catch (Exception ex) { diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs index 6a67cbcaa9508e..9348c20cc6f6bd 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -404,6 +405,125 @@ public async Task ValidateOnStart_MultipleErrorsInOneValidationCallUsingCustomEr } } + [Fact] + public async Task ValidateOnStart_CustomSyncStartupValidator_OverridesAsyncValidationOnStart() + { + var custom = new TrackingStartupValidator(); + var hostBuilder = CreateHostBuilder(services => + { + services.AddSingleton(custom); + services.AddOptions() + .Configure(o => o.Boolean = false) + .Validate(o => o.Boolean, "should not run") + .ValidateOnStart(); + }); + + using (var host = hostBuilder.Build()) + { + // The custom synchronous validator takes precedence and fully controls startup validation, + // so the failing ValidateOnStart (async) validation never runs and the host starts. + await host.StartAsync(); + } + + Assert.True(custom.Validated); + } + + [Fact] + public async Task ValidateOnStart_CustomSyncStartupValidatorThatFails_ThrowsOnStart() + { + var hostBuilder = CreateHostBuilder(services => + services.AddSingleton(new ThrowingStartupValidator())); + + using (var host = hostBuilder.Build()) + { + await Assert.ThrowsAsync(async () => await host.StartAsync()); + } + } + + [Fact] + public async Task ValidateOnStart_MultipleAsyncStartupValidators_AllRunOnStart() + { + var custom = new TrackingAsyncStartupValidator(); + bool validateOnStartRan = false; + var hostBuilder = CreateHostBuilder(services => + { + services.AddSingleton(custom); + services.AddOptions() + .Configure(o => o.Boolean = true) + .Validate(o => + { + validateOnStartRan = true; + return o.Boolean; + }) + .ValidateOnStart(); + }); + + using (var host = hostBuilder.Build()) + { + await host.StartAsync(); + } + + // Both the custom async validator and the built-in ValidateOnStart validator participate. + Assert.True(custom.Validated); + Assert.True(validateOnStartRan); + } + + [Fact] + public async Task ValidateOnStart_StandaloneAsyncStartupValidator_RunsOnStart() + { + var custom = new TrackingAsyncStartupValidator(); + var hostBuilder = CreateHostBuilder(services => services.AddSingleton(custom)); + + using (var host = hostBuilder.Build()) + { + await host.StartAsync(); + } + + Assert.True(custom.Validated); + } + + [Fact] + public async Task ValidateOnStart_AsyncStartupValidatorThatFails_ThrowsOnStart() + { + var hostBuilder = CreateHostBuilder(services => + services.AddSingleton(new ThrowingAsyncStartupValidator())); + + using (var host = hostBuilder.Build()) + { + await Assert.ThrowsAsync(async () => await host.StartAsync()); + } + } + + private sealed class TrackingStartupValidator : IStartupValidator + { + public bool Validated { get; private set; } + + public void Validate() => Validated = true; + } + + private sealed class TrackingAsyncStartupValidator : IAsyncStartupValidator + { + public bool Validated { get; private set; } + + public Task ValidateAsync(CancellationToken cancellationToken = default) + { + Validated = true; + return Task.CompletedTask; + } + } + + private sealed class ThrowingStartupValidator : IStartupValidator + { + public void Validate() => + throw new OptionsValidationException("name", typeof(object), new[] { "sync startup validation failed" }); + } + + private sealed class ThrowingAsyncStartupValidator : IAsyncStartupValidator + { + public Task ValidateAsync(CancellationToken cancellationToken = default) => + throw new OptionsValidationException("name", typeof(object), new[] { "async startup validation failed" }); + } + private static void ValidateFailure(Type type, OptionsValidationException e, int count = 1, params string[] errorsToMatch) { Assert.Equal(type, e.OptionsType); diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs index 748af2943b1f80..7dce4253c406ce 100644 --- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs @@ -35,9 +35,6 @@ public static class OptionsBuilderDataAnnotationsExtensions { var instance = new DataAnnotationValidateOptions(optionsBuilder.Name); optionsBuilder.Services.AddSingleton>(instance); -#if NET11_0_OR_GREATER - optionsBuilder.Services.AddSingleton>(instance); -#endif return optionsBuilder; } } diff --git a/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs b/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs index 49562a0a128c2b..eea1d749072df5 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs @@ -119,5 +119,17 @@ internal sealed class DiagDescriptors : DiagDescriptorsBase messageFormat: SR.TypeCannotBeUsedWithTheValidationAttributeMessage, category: Category, defaultSeverity: DiagnosticSeverity.Warning); + + public static DiagnosticDescriptor AsyncValidationRequiresNet11 { get; } = Make( + id: "SYSLIB1218", + title: SR.AsyncValidationRequiresNet11Title, + messageFormat: SR.AsyncValidationRequiresNet11Message, + category: Category); + + public static DiagnosticDescriptor AlreadyImplementsValidateAsyncMethod { get; } = Make( + id: "SYSLIB1219", + title: SR.AlreadyImplementsValidateAsyncMethodTitle, + messageFormat: SR.AlreadyImplementsValidateAsyncMethodMessage, + category: Category); } } diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs b/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs index bc020a61aa70ce..d7c1a003b41124 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs @@ -130,6 +130,14 @@ private void GenValidatorType(ValidatorType vt, ref Dictionary for this model and the required async validation symbols are available (.NET 11+). + if (modelToValidate.GenerateAsyncValidateMethod && _symbolHolder.AsyncValidateOptionsSymbol is not null && _symbolHolder.IAsyncValidatableObjectSymbol is not null) + { + OutLn(); + GenAsyncModelValidationMethod(modelToValidate, vt.IsSynthetic, ref staticValidationAttributesDict, ref staticValidatorsDict); + } } OutCloseBrace(); @@ -734,6 +742,251 @@ private void GenModelValidationMethod( OutCloseBrace(); } + private void GenAsyncModelValidationMethod( + ValidatedModel modelToValidate, + bool makeStatic, + ref Dictionary staticValidationAttributesDict, + ref Dictionary staticValidatorsDict) + { + // Determine whether the generated method body will contain any await. When it won't (for example the model + // only self-validates synchronously via IValidatableObject, or every nested validator resolves to a + // synchronous one), we emit a non-async method that returns a completed Task to avoid a CS1998 + // "async method lacks await" warning. Only nested validators that actually emit ValidateAsync are awaited. + bool willAwait = modelToValidate.SelfValidatesAsync; + if (!willAwait) + { + foreach (var vm in modelToValidate.MembersToValidate) + { + if (vm.ValidationAttributes.Count > 0 || + vm.TransValidatorEmitsAsync || + vm.EnumerationValidatorEmitsAsync) + { + willAwait = true; + break; + } + } + } + + OutLn($"/// "); + OutLn($"/// Validates a specific named options instance asynchronously (or all when is )."); + OutLn($"/// "); + OutLn($"/// The name of the options instance being validated."); + OutLn($"/// The options instance."); + OutLn($"/// The to monitor for cancellation requests."); + OutLn($"/// A task representing the asynchronous validation operation, containing the validation result."); + OutGeneratedCodeAttribute(); + + if (_symbolHolder.UnconditionalSuppressMessageAttributeSymbol is not null) + { + // We disable the warning on `new ValidationContext(object)` usage as we use it in a safe way that not require executing the reflection code. + // This is done by initializing the DisplayName in the context which is the part trigger reflection if it is not initialized. For + // projects targeting .NET 10 and above, we can avoid the suppression since we use the new trim-safe constructor. + OutLn("#if !NET"); + OutLn($"[global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"Trimming\", \"IL2026:RequiresUnreferencedCode\","); + OutLn($" Justification = \"The created ValidationContext object is used in a way that never call reflection\")]"); + OutLn("#endif"); + } + + OutLn($"public {(makeStatic ? "static " : string.Empty)}{(willAwait ? "async " : string.Empty)}global::System.Threading.Tasks.Task ValidateAsync(string? name, {modelToValidate.Name} options, global::System.Threading.CancellationToken cancellationToken = default)"); + OutOpenBrace(); + OutLn($"global::Microsoft.Extensions.Options.ValidateOptionsResultBuilder? builder = null;"); + OutLn("#if NET"); + OutLn($"var context = new {StaticValidationContextType}(options, \"{modelToValidate.SimpleName}\", null, null);"); + OutLn("#else"); + OutLn($"var context = new {StaticValidationContextType}(options);"); + OutLn("#endif"); + + int capacity = modelToValidate.MembersToValidate.Count == 0 ? 0 : modelToValidate.MembersToValidate.Max(static vm => vm.ValidationAttributes.Count); + if (capacity > 0) + { + OutLn($"var validationResults = new {StaticListType}<{StaticValidationResultType}>();"); + OutLn($"var validationAttributes = new {StaticListType}<{StaticValidationAttributeType}>({capacity});"); + } + OutLn(); + + bool cleanListsBeforeUse = false; + foreach (var vm in modelToValidate.MembersToValidate) + { + if (vm.ValidationAttributes.Count > 0) + { + GenAsyncMemberValidation(vm, ref staticValidationAttributesDict, cleanListsBeforeUse); + cleanListsBeforeUse = true; + OutLn(); + } + + if (vm.TransValidatorType is not null) + { + GenAsyncTransitiveValidation(vm, ref staticValidatorsDict); + OutLn(); + } + + if (vm.EnumerationValidatorType is not null) + { + GenAsyncEnumerationValidation(vm, ref staticValidatorsDict); + OutLn(); + } + } + + GenAsyncModelSelfValidationIfNecessary(modelToValidate); + + if (willAwait) + { + OutLn($"return builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build();"); + } + else + { + OutLn($"return global::System.Threading.Tasks.Task.FromResult(builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build());"); + } + + OutCloseBrace(); + } + + private void GenAsyncMemberValidation(ValidatedMember vm, ref Dictionary staticValidationAttributesDict, bool cleanListsBeforeUse) + { + OutLn($"context.MemberName = \"{vm.Name}\";"); + OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"{vm.Name}\" : $\"{{name}}.{vm.Name}\";"); + + if (cleanListsBeforeUse) + { + OutLn($"validationResults.Clear();"); + OutLn($"validationAttributes.Clear();"); + } + + foreach (var attr in vm.ValidationAttributes) + { + var staticValidationAttributeInstance = GetOrAddStaticValidationAttribute(ref staticValidationAttributesDict, attr); + OutLn($"validationAttributes.Add({_staticValidationAttributeHolderClassFQN}.{staticValidationAttributeInstance.FieldName});"); + } + + OutLn($"if (!await global::System.ComponentModel.DataAnnotations.Validator.TryValidateValueAsync(options.{vm.Name}{_TryGetValueNullableAnnotation}, context, validationResults, validationAttributes, cancellationToken).ConfigureAwait(false))"); + OutOpenBrace(); + OutLn($"(builder ??= new()).AddResults(validationResults);"); + OutCloseBrace(); + } + + private void GenAsyncTransitiveValidation(ValidatedMember vm, ref Dictionary staticValidatorsDict) + { + string callSequence; + if (vm.TransValidateTypeIsSynthetic) + { + callSequence = vm.TransValidatorType!; + } + else + { + var staticValidatorInstance = GetOrAddStaticValidator(ref staticValidatorsDict, vm.TransValidatorType!); + + callSequence = $"{_staticValidatorHolderClassFQN}.{staticValidatorInstance.FieldName}"; + } + + var valueAccess = (vm.IsNullable && vm.IsValueType) ? ".Value" : string.Empty; + + var baseName = $"string.IsNullOrEmpty(name) ? \"{vm.Name}\" : $\"{{name}}.{vm.Name}\""; + + // A nested validator emits an awaited ValidateAsync call only when it was synthesized for an async context or + // when it implements IAsyncValidateOptions for the member type. + // Otherwise we fall back to its synchronous Validate method to guarantee the generated code compiles. + string resultExpression = vm.TransValidatorEmitsAsync + ? $"await {callSequence}.ValidateAsync({baseName}, options.{vm.Name}{valueAccess}, cancellationToken).ConfigureAwait(false)" + : $"{callSequence}.Validate({baseName}, options.{vm.Name}{valueAccess})"; + + if (vm.IsNullable) + { + OutLn($"if (options.{vm.Name} is not null)"); + OutOpenBrace(); + OutLn($"(builder ??= new()).AddResult({resultExpression});"); + OutCloseBrace(); + } + else + { + OutLn($"(builder ??= new()).AddResult({resultExpression});"); + } + } + + private void GenAsyncEnumerationValidation(ValidatedMember vm, ref Dictionary staticValidatorsDict) + { + var valueAccess = (vm.IsValueType && vm.IsNullable) ? ".Value" : string.Empty; + var enumeratedValueAccess = (vm.EnumeratedIsNullable && vm.EnumeratedIsValueType) ? ".Value" : string.Empty; + string callSequence; + if (vm.EnumerationValidatorTypeIsSynthetic) + { + callSequence = vm.EnumerationValidatorType!; + } + else + { + var staticValidatorInstance = GetOrAddStaticValidator(ref staticValidatorsDict, vm.EnumerationValidatorType!); + + callSequence = $"{_staticValidatorHolderClassFQN}.{staticValidatorInstance.FieldName}"; + } + + bool awaitItem = vm.EnumerationValidatorEmitsAsync; + + if (vm.IsNullable) + { + OutLn($"if (options.{vm.Name} is not null)"); + } + + OutOpenBrace(); + + OutLn($"var count = 0;"); + OutLn($"foreach (var o in options.{vm.Name}{valueAccess})"); + OutOpenBrace(); + + if (vm.EnumeratedIsNullable) + { + OutLn($"if (o is not null)"); + OutOpenBrace(); + var propertyName = $"string.IsNullOrEmpty(name) ? $\"{vm.Name}[{{count}}]\" : $\"{{name}}.{vm.Name}[{{count}}]\""; + string itemResult = awaitItem + ? $"await {callSequence}.ValidateAsync({propertyName}, o{enumeratedValueAccess}, cancellationToken).ConfigureAwait(false)" + : $"{callSequence}.Validate({propertyName}, o{enumeratedValueAccess})"; + OutLn($"(builder ??= new()).AddResult({itemResult});"); + OutCloseBrace(); + + if (!vm.EnumeratedMayBeNull) + { + OutLn($"else"); + OutOpenBrace(); + var error = $"string.IsNullOrEmpty(name) ? $\"{vm.Name}[{{count}}] is null\" : $\"{{name}}.{vm.Name}[{{count}}] is null\""; + OutLn($"(builder ??= new()).AddError({error});"); + OutCloseBrace(); + } + + OutLn($"count++;"); + } + else + { + var propertyName = $"string.IsNullOrEmpty(name) ? $\"{vm.Name}[{{count++}}]\" : $\"{{name}}.{vm.Name}[{{count++}}]\""; + string itemResult = awaitItem + ? $"await {callSequence}.ValidateAsync({propertyName}, o{enumeratedValueAccess}, cancellationToken).ConfigureAwait(false)" + : $"{callSequence}.Validate({propertyName}, o{enumeratedValueAccess})"; + OutLn($"(builder ??= new()).AddResult({itemResult});"); + } + + OutCloseBrace(); + OutCloseBrace(); + } + + private void GenAsyncModelSelfValidationIfNecessary(ValidatedModel modelToValidate) + { + if (modelToValidate.SelfValidatesAsync) + { + OutLn($"context.MemberName = \"ValidateAsync\";"); + OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"ValidateAsync\" : $\"{{name}}.ValidateAsync\";"); + OutLn($"await foreach (var asyncValidationResult in global::System.Threading.Tasks.TaskAsyncEnumerableExtensions.ConfigureAwait(((global::System.ComponentModel.DataAnnotations.IAsyncValidatableObject)options).ValidateAsync(context, cancellationToken), false))"); + OutOpenBrace(); + OutLn($"(builder ??= new()).AddResult(asyncValidationResult);"); + OutCloseBrace(); + OutLn(); + } + else if (modelToValidate.SelfValidates) + { + OutLn($"context.MemberName = \"Validate\";"); + OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"Validate\" : $\"{{name}}.Validate\";"); + OutLn($"(builder ??= new()).AddResults(((global::System.ComponentModel.DataAnnotations.IValidatableObject)options).Validate(context));"); + OutLn(); + } + } + private void GenMemberValidation(ValidatedMember vm, ref Dictionary staticValidationAttributesDict, bool cleanListsBeforeUse) { OutLn($"context.MemberName = \"{vm.Name}\";"); diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs index cfed0135916740..3403567cbd775e 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs @@ -16,5 +16,7 @@ internal sealed record class ValidatedMember( bool IsValueType, bool EnumeratedIsNullable, bool EnumeratedIsValueType, - bool EnumeratedMayBeNull); + bool EnumeratedMayBeNull, + bool TransValidatorEmitsAsync = false, + bool EnumerationValidatorEmitsAsync = false); } diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs index 0d40d930051b1b..3fb44fc4c6f89e 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs @@ -9,5 +9,7 @@ internal sealed record class ValidatedModel( string Name, string SimpleName, bool SelfValidates, + bool SelfValidatesAsync, + bool GenerateAsyncValidateMethod, List MembersToValidate); } diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs b/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs index 647259a87aab06..b682466d6fe361 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs @@ -26,7 +26,12 @@ internal sealed class Parser private readonly Action _reportDiagnostic; private readonly SymbolHolder _symbolHolder; private readonly OptionsSourceGenContext _optionsSourceGenContext; - private readonly Dictionary _synthesizedValidators = new(SymbolEqualityComparer.Default); + // Synthesized validators are cached per model type. A model reached from a synchronous root and a model + // reached from an asynchronous root produce different generated types (the latter also emits ValidateAsync), + // so each model caches its synchronous and asynchronous validators separately. Keying by model type alone + // would let an asynchronous root reuse a synchronous-only validator (or vice versa) depending on discovery + // order, which would silently drop nested async validation. + private readonly Dictionary _synthesizedValidators = new(SymbolEqualityComparer.Default); private readonly HashSet _visitedModelTypes = new(SymbolEqualityComparer.Default); public Parser( @@ -97,14 +102,43 @@ public IReadOnlyList GetValidatorTypes(IEnumerable<(TypeDeclarati continue; } + // Decide, per model, whether we additionally emit a ValidateAsync method. We do so when the + // validator type explicitly implements IAsyncValidateOptions for this specific model. A + // multi-model validator therefore only gets async validation for the models it opted into. + // The async requirement is propagated to any synthesized child validators reached from this model. + bool wantsAsync = ValidatorImplementsAsyncInterfaceFor(validatorType, modelType); + + if (wantsAsync && AlreadyImplementsValidateAsyncMethod(validatorType, modelType)) + { + // The user hand-wrote a matching ValidateAsync on an [OptionsValidator] type. This is an + // error (symmetric with SYSLIB1205 for a hand-written Validate): a generated synchronous + // Validate combined with a hand-written ValidateAsync would validate differently on the + // sync vs async access paths, silently skipping the generated attribute validation on the + // async path. Report it and skip only the async generation - the synchronous Validate is + // still generated so the sole failure surfaced is this diagnostic, not a cascading CS0535. + Diag(DiagDescriptors.AlreadyImplementsValidateAsyncMethod, syntax.GetLocation(), validatorType.Name); + wantsAsync = false; + } + + // Asynchronous validation relies on the async DataAnnotations APIs (IAsyncValidatableObject / + // Validator.TryValidateValueAsync), which are only available when targeting .NET 11 or later. + // When they are unavailable, emit a diagnostic instead of generating code that wouldn't compile. + bool generateAsync = wantsAsync; + if (wantsAsync && _symbolHolder.IAsyncValidatableObjectSymbol is null) + { + Diag(DiagDescriptors.AsyncValidationRequiresNet11, syntax.GetLocation(), validatorType.Name); + generateAsync = false; + } + Location? modelTypeLocation = modelType.GetLocation(); Location lowerLocationInCompilation = modelTypeLocation is not null && modelTypeLocation.SourceTree is not null && _compilation.ContainsSyntaxTree(modelTypeLocation.SourceTree) ? modelTypeLocation : syntax.GetLocation(); - var membersToValidate = GetMembersToValidate(modelType, true, lowerLocationInCompilation, validatorType); + var membersToValidate = GetMembersToValidate(modelType, true, lowerLocationInCompilation, validatorType, generateAsync); bool selfValidate = ModelSelfValidates(modelType); - if (membersToValidate.Count == 0 && !selfValidate) + bool selfValidateAsync = generateAsync && ModelSelfValidatesAsync(modelType); + if (membersToValidate.Count == 0 && !selfValidate && !selfValidateAsync) { // this type lacks any eligible members Diag(DiagDescriptors.NoEligibleMembersFromValidator, syntax.GetLocation(), modelType.ToString(), validatorType.ToString()); @@ -115,6 +149,8 @@ public IReadOnlyList GetValidatorTypes(IEnumerable<(TypeDeclarati GetFQN(modelType), modelType.Name, selfValidate, + selfValidateAsync, + generateAsync, membersToValidate)); } @@ -145,7 +181,19 @@ public IReadOnlyList GetValidatorTypes(IEnumerable<(TypeDeclarati } } - results.AddRange(_synthesizedValidators.Values); + foreach (var entry in _synthesizedValidators.Values) + { + if (entry.Sync is not null) + { + results.Add(entry.Sync); + } + + if (entry.Async is not null) + { + results.Add(entry.Async); + } + } + _synthesizedValidators.Clear(); if (results.Count > 0 && _compilation is CSharpCompilation { LanguageVersion : LanguageVersion version and < LanguageVersion.CSharp8 }) @@ -193,16 +241,44 @@ private static string GetMinimalFQNWithoutGenerics(ISymbol type) => type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat.WithGenericsOptions(SymbolDisplayGenericsOptions.None)); /// - /// Checks whether the given validator already implement the IValidationOptions>T< interface. + /// Checks whether the given validator already implements the generated Validate method + /// (implicitly or as an explicit interface implementation). /// private static bool AlreadyImplementsValidateMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType) => validatorType - .GetMembers("Validate") - .Where(m => m.Kind == SymbolKind.Method) - .Select(m => (IMethodSymbol)m) - .Any(m => m.Parameters.Length == NumValidationMethodArgs - && m.Parameters[0].Type.SpecialType == SpecialType.System_String - && SymbolEqualityComparer.Default.Equals(m.Parameters[1].Type, modelType)); + .GetMembers() + .OfType() + .Any(m => MatchesGeneratedValidateSignature(m, "Validate", NumValidationMethodArgs, modelType, requireCancellationToken: false)); + + // Detects a user-supplied ValidateAsync(string, TModel, CancellationToken) overload so the generator doesn't emit + // a duplicate member for a validator that already provides its own asynchronous implementation. + private static bool AlreadyImplementsValidateAsyncMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType) + => validatorType + .GetMembers() + .OfType() + .Any(m => MatchesGeneratedValidateSignature(m, "ValidateAsync", NumValidationMethodArgs + 1, modelType, requireCancellationToken: true)); + + // Matches a method with the signature the generator would emit, whether declared implicitly or as an explicit + // interface implementation. The return type is intentionally ignored (C# does not overload on return type), and + // no display-string comparison is used. + private static bool MatchesGeneratedValidateSignature(IMethodSymbol method, string name, int parameterCount, ISymbol modelType, bool requireCancellationToken) + { + bool nameMatches = method.Name == name + || method.ExplicitInterfaceImplementations.Any(impl => impl.Name == name); + + if (!nameMatches + || method.Parameters.Length != parameterCount + || method.Parameters[0].Type.SpecialType != SpecialType.System_String + || !SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, modelType)) + { + return false; + } + + return !requireCancellationToken || IsCancellationToken(method.Parameters[parameterCount - 1].Type); + } + + private static bool IsCancellationToken(ITypeSymbol type) + => type is { Name: "CancellationToken", ContainingNamespace: { Name: "Threading", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } }; /// /// Checks whether the given type contain any unbound generic type arguments. @@ -263,7 +339,7 @@ private static bool HasOpenGenerics(ITypeSymbol type, out string genericType) return null; } - private List GetMembersToValidate(ITypeSymbol modelType, bool speculate, Location lowerLocationInCompilation, ITypeSymbol validatorType) + private List GetMembersToValidate(ITypeSymbol modelType, bool speculate, Location lowerLocationInCompilation, ITypeSymbol validatorType, bool isAsync) { // make a list of the most derived members in the model type @@ -297,7 +373,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s ? memberLocation : lowerLocationInCompilation; - var memberInfo = GetMemberInfo(member, speculate, location, modelType, validatorType); + var memberInfo = GetMemberInfo(member, speculate, location, modelType, validatorType, isAsync); if (memberInfo is not null) { if (member.DeclaredAccessibility != Accessibility.Public) @@ -313,7 +389,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s return membersToValidate; } - private ValidatedMember? GetMemberInfo(ISymbol member, bool speculate, Location location, ITypeSymbol modelType, ITypeSymbol validatorType) + private ValidatedMember? GetMemberInfo(ISymbol member, bool speculate, Location location, ITypeSymbol modelType, ITypeSymbol validatorType, bool isAsync) { ITypeSymbol memberType; switch (member) @@ -346,6 +422,8 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s var enumeratedMayBeNull = false; var transValidatorIsSynthetic = false; var enumerationValidatorIsSynthetic = false; + var transValidatorEmitsAsync = false; + var enumerationValidatorEmitsAsync = false; foreach (var attribute in member.GetAttributes().Where(a => a.AttributeClass is not null)) { @@ -373,6 +451,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s if (transValidatorType.Constructors.Where(c => !c.Parameters.Any()).Any()) { transValidatorTypeName = transValidatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + transValidatorEmitsAsync = isAsync && ValidatorImplementsAsyncInterfaceFor(transValidatorType, memberType); } else { @@ -399,7 +478,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s if (transValidatorTypeName == null) { transValidatorIsSynthetic = true; - transValidatorTypeName = AddSynthesizedValidator(memberType, member, location, validatorType); + transValidatorTypeName = AddSynthesizedValidator(memberType, member, location, validatorType, isAsync, out transValidatorEmitsAsync); } // pop the stack @@ -436,6 +515,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s if (enumerationValidatorType.Constructors.Where(c => c.Parameters.Length == 0).Any()) { enumerationValidatorTypeName = enumerationValidatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + enumerationValidatorEmitsAsync = isAsync && ValidatorImplementsAsyncInterfaceFor(enumerationValidatorType, enumeratedType); } else { @@ -462,7 +542,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s if (enumerationValidatorTypeName == null) { enumerationValidatorIsSynthetic = true; - enumerationValidatorTypeName = AddSynthesizedValidator(enumeratedType, member, location, validatorType); + enumerationValidatorTypeName = AddSynthesizedValidator(enumeratedType, member, location, validatorType, isAsync, out enumerationValidatorEmitsAsync); } // pop the stack @@ -551,7 +631,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s { if (!HasOpenGenerics(memberType, out var genericType)) { - var membersToValidate = GetMembersToValidate(memberType, false, location, validatorType); + var membersToValidate = GetMembersToValidate(memberType, false, location, validatorType, isAsync); if (membersToValidate.Count > 0) { Diag(DiagDescriptors.PotentiallyMissingTransitiveValidation, location, memberType.Name, member.Name); @@ -567,7 +647,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s { if (!HasOpenGenerics(enumeratedType, out var genericType)) { - var membersToValidate = GetMembersToValidate(enumeratedType, false, location, validatorType); + var membersToValidate = GetMembersToValidate(enumeratedType, false, location, validatorType, isAsync); if (membersToValidate.Count > 0) { Diag(DiagDescriptors.PotentiallyMissingEnumerableValidation, location, enumeratedType.Name, member.Name); @@ -589,7 +669,9 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s memberType.IsValueType, enumeratedIsNullable, enumeratedIsValueType, - enumeratedMayBeNull); + enumeratedMayBeNull, + transValidatorEmitsAsync, + enumerationValidatorEmitsAsync); } return null; @@ -681,8 +763,9 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy } } - private string? AddSynthesizedValidator(ITypeSymbol modelType, ISymbol member, Location location, ITypeSymbol validatorType) + private string? AddSynthesizedValidator(ITypeSymbol modelType, ISymbol member, Location location, ITypeSymbol validatorType, bool isAsync, out bool emitsAsync) { + emitsAsync = false; var mt = modelType.WithNullableAnnotation(NullableAnnotation.None); if (mt.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) { @@ -690,14 +773,19 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy mt = ((INamedTypeSymbol)mt).TypeArguments[0]; } - if (_synthesizedValidators.TryGetValue(mt, out var validator)) + _synthesizedValidators.TryGetValue(mt, out var cached); + ValidatorType? reusable = isAsync ? cached.Async : cached.Sync; + if (reusable is not null) { - return "global::" + validator.Namespace + "." + validator.Name; + // A validator matching the requested capability was already synthesized for this model, so reuse it. + emitsAsync = isAsync; + return "global::" + (reusable.Namespace.Length > 0 ? reusable.Namespace + "." + reusable.Name : reusable.Name); } bool selfValidate = ModelSelfValidates(mt); - var membersToValidate = GetMembersToValidate(mt, true, location, validatorType); - if (membersToValidate.Count == 0 && !selfValidate) + bool selfValidateAsync = isAsync && ModelSelfValidatesAsync(mt); + var membersToValidate = GetMembersToValidate(mt, true, location, validatorType, isAsync); + if (membersToValidate.Count == 0 && !selfValidate && !selfValidateAsync) { // this type lacks any eligible members Diag(DiagDescriptors.NoEligibleMember, location, mt.ToString(), member.ToString()); @@ -708,9 +796,13 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy GetFQN(mt), mt.Name, selfValidate, + selfValidateAsync, + isAsync, membersToValidate); - var validatorTypeName = "__" + mt.Name + "Validator__"; + // Asynchronous validators get a distinct name so a model reached from both a synchronous and an + // asynchronous root can emit two non-conflicting synthesized types. Synchronous naming is unchanged. + var validatorTypeName = "__" + mt.Name + (isAsync ? "AsyncValidator__" : "Validator__"); var result = new ValidatorType( mt.ContainingNamespace.IsGlobalNamespace ? string.Empty : mt.ContainingNamespace.ToString()!, @@ -721,7 +813,8 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy true, new[] { model }); - _synthesizedValidators[mt] = result; + _synthesizedValidators[mt] = isAsync ? (cached.Sync, result) : (result, cached.Async); + emitsAsync = isAsync; return "global::" + (result.Namespace.Length > 0 ? result.Namespace + "." + result.Name : result.Name); } @@ -744,6 +837,43 @@ private bool ModelSelfValidates(ITypeSymbol modelType) return false; } + private bool ModelSelfValidatesAsync(ITypeSymbol modelType) + { + if (_symbolHolder.IAsyncValidatableObjectSymbol is null) + { + return false; + } + + foreach (var implementingInterface in modelType.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(implementingInterface.OriginalDefinition, _symbolHolder.IAsyncValidatableObjectSymbol)) + { + return true; + } + } + + return false; + } + + private bool ValidatorImplementsAsyncInterfaceFor(ITypeSymbol validatorType, ITypeSymbol modelType) + { + if (_symbolHolder.AsyncValidateOptionsSymbol is null) + { + return false; + } + + foreach (var implementingInterface in validatorType.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(implementingInterface.OriginalDefinition, _symbolHolder.AsyncValidateOptionsSymbol) + && SymbolEqualityComparer.Default.Equals(implementingInterface.TypeArguments.First(), modelType)) + { + return true; + } + } + + return false; + } + private List GetModelTypes(ITypeSymbol validatorType) { var result = new List(); diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx index be9fe963b83724..6cd461ceaa762d 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx @@ -117,12 +117,24 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Type {0} already implements the ValidateAsync method. + + + A type already includes an implementation of the 'ValidateAsync' method. + Type {0} already implements the Validate method. A type already includes an implementation of the 'Validate' method. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + Asynchronous options validation requires targeting .NET 11 or later. + [OptionsValidator] cannot be applied to static class {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf index c8490d951761cb..f386aeab895490 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. Typ {0} již implementuje metodu Validate. @@ -12,6 +22,16 @@ Typ už obsahuje implementaci metody Validate. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] nelze použít pro statickou třídu {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf index eb7a7f423a3e70..d7377aa1716cd2 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. Der Typ "{0}" implementiert bereits die Validate-Methode. @@ -12,6 +22,16 @@ Ein Typ enthält bereits eine Implementierung der Validate-Methode. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] kann nicht auf die statische Klasse "{0}" angewendet werden. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf index 19c69bb8c864f9..bc6c48c40722ac 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. El tipo {0} ya implementa el método Validate. @@ -12,6 +22,16 @@ Un tipo ya incluye una implementación del método “Validate”. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. No se puede aplicar [OptionsValidator] a la clase estática {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf index 651ab8b303719a..84e88a00890804 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. Le type {0} implémente déjà la méthode Validate. @@ -12,6 +22,16 @@ Un type inclut déjà une implémentation de la méthode 'Validate'. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] ne peut pas être appliqué à la classe statique {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf index 575f60469e48dc..a015ecd7564421 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. Il tipo {0} implementa già il metodo Validate. @@ -12,6 +22,16 @@ Un tipo include già un'implementazione del metodo 'Validate'. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. impossibile applicare [OptionsValidator] alla classe statica {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf index 8251ac27c6da15..6c81ecb81c2354 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. 型 {0} に Validate メソッドが既に実装されています。 @@ -12,6 +22,16 @@ 型には 'Validate' メソッドの実装が既に含まれています。 + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. 静的クラス {0} には [OptionsValidator] を適用できません。 diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf index 4c8ce2865d1070..913271ec0b19c7 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. 형식 {0}은(는) Validate 메서드를 이미 구현하고 있습니다. @@ -12,6 +22,16 @@ 형식에 'Validate' 메서드 구현이 이미 포함되어 있습니다. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator]은(는) 정적 클래스 {0}에 적용할 수 없습니다. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf index e07e568d131874..6f984561f85200 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. Typ {0} już implementuje metodę Validate. @@ -12,6 +22,16 @@ Typ zawiera już implementację metody „Validate”. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. Nie można zastosować elementu [OptionsValidator] do klasy statycznej {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf index d422309a40995b..f396cd5b422fb8 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. O tipo {0} já implementa o método Validar. @@ -12,6 +22,16 @@ Um tipo já inclui uma implementação do método "Validar". + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] não pode ser aplicado à classe estática {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf index bd3a0be55eb270..6a3ffa0969d4ff 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. Тип {0} уже реализует метод Validate. @@ -12,6 +22,16 @@ Тип уже содержит реализацию метода Validate. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] невозможно применить к статическому классу {0}. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf index e478a77cd3c238..d7441e5d7440fd 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. {0} türü Doğrulama yöntemini zaten uyguluyor. @@ -12,6 +22,16 @@ Bir tür zaten 'Validate' yönteminin bir uygulamasını içeriyor. + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator], {0} statik sınıfına uygulanamıyor. diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf index 76a26db32a673c..8870d990790d07 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. 类型 {0} 已实现 Validate 方法。 @@ -12,6 +22,16 @@ 类型已包含“Validate”方法的实现。 + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] 无法应用于静态类 {0}。 diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf index 9997092e3be6f3..cfc64895d57d10 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf @@ -2,6 +2,16 @@ + + Type {0} already implements the ValidateAsync method. + Type {0} already implements the ValidateAsync method. + + + + A type already includes an implementation of the 'ValidateAsync' method. + A type already includes an implementation of the 'ValidateAsync' method. + + Type {0} already implements the Validate method. 類型 {0} 已實作 [驗證] 方法。 @@ -12,6 +22,16 @@ 類型已經包含 [驗證] 方法的實作。 + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + + + + Asynchronous options validation requires targeting .NET 11 or later. + Asynchronous options validation requires targeting .NET 11 or later. + + [OptionsValidator] cannot be applied to static class {0}. [OptionsValidator] 無法套用至靜態類別 {0}。 diff --git a/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs b/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs index 8e7073f8ec2182..57da98b3a56b2a 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs @@ -25,5 +25,7 @@ internal sealed record class SymbolHolder( INamedTypeSymbol TypeSymbol, INamedTypeSymbol TimeSpanSymbol, INamedTypeSymbol ValidateObjectMembersAttributeSymbol, - INamedTypeSymbol ValidateEnumeratedItemsAttributeSymbol); + INamedTypeSymbol ValidateEnumeratedItemsAttributeSymbol, + INamedTypeSymbol? IAsyncValidatableObjectSymbol = null, + INamedTypeSymbol? AsyncValidateOptionsSymbol = null); } diff --git a/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs b/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs index 5be4932d8d6c41..6e6f8e1c47cd9e 100644 --- a/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs +++ b/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs @@ -24,6 +24,8 @@ internal static class SymbolLoader internal const string ValidateEnumeratedItemsAttribute = "Microsoft.Extensions.Options.ValidateEnumeratedItemsAttribute"; internal const string GenericIEnumerableType = "System.Collections.Generic.IEnumerable`1"; internal const string UnconditionalSuppressMessageAttributeType = "System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute"; + internal const string IAsyncValidatableObjectType = "System.ComponentModel.DataAnnotations.IAsyncValidatableObject"; + internal const string IAsyncValidateOptionsType = "Microsoft.Extensions.Options.IAsyncValidateOptions`1"; public static bool TryLoad(Compilation compilation, out SymbolHolder? symbolHolder) { @@ -81,6 +83,11 @@ public static bool TryLoad(Compilation compilation, out SymbolHolder? symbolHold } #pragma warning restore S1067 // Expressions should not be too complex + // Optional async validation symbols (available in .NET 11+). + // When absent (e.g. downlevel targets), the generator degrades to emitting only the synchronous Validate method. + var iAsyncValidatableObjectSymbol = GetSymbol(IAsyncValidatableObjectType); + var asyncValidateOptionsSymbol = GetSymbol(IAsyncValidateOptionsType); + symbolHolder = new( optionsValidatorSymbol, validationAttributeSymbol, @@ -98,7 +105,9 @@ public static bool TryLoad(Compilation compilation, out SymbolHolder? symbolHold typeSymbol, timeSpanSymbol, validateObjectMembersAttribute, - validateEnumeratedItemsAttribute); + validateEnumeratedItemsAttribute, + iAsyncValidatableObjectSymbol, + asyncValidateOptionsSymbol); return true; } diff --git a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs index 1618851dc52682..8e7189b260581e 100644 --- a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs +++ b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs @@ -9,6 +9,7 @@ namespace Microsoft.Extensions.DependencyInjection public static partial class OptionsBuilderExtensions { public static Microsoft.Extensions.Options.OptionsBuilder ValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder optionsBuilder) where TOptions : class { throw null; } + public static Microsoft.Extensions.Options.OptionsBuilder ValidateOnChange(this Microsoft.Extensions.Options.OptionsBuilder optionsBuilder, Microsoft.Extensions.Options.OptionsReloadValidationBehavior behavior = Microsoft.Extensions.Options.OptionsReloadValidationBehavior.KeepLastGood, System.Action? onError = null) where TOptions : class { throw null; } } public static partial class OptionsServiceCollectionExtensions { @@ -153,7 +154,7 @@ public partial interface IValidateOptions where TOptions : class { Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options); } - public partial interface IAsyncValidateOptions where TOptions : class + public partial interface IAsyncValidateOptions : Microsoft.Extensions.Options.IValidateOptions where TOptions : class { System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default); } @@ -217,6 +218,7 @@ public partial class OptionsFactory<[System.Diagnostics.CodeAnalysis.Dynamically { public OptionsFactory(System.Collections.Generic.IEnumerable> setups, System.Collections.Generic.IEnumerable> postConfigures) { } public OptionsFactory(System.Collections.Generic.IEnumerable> setups, System.Collections.Generic.IEnumerable> postConfigures, System.Collections.Generic.IEnumerable> validations) { } + public OptionsFactory(System.Collections.Generic.IEnumerable> setups, System.Collections.Generic.IEnumerable> postConfigures, System.Collections.Generic.IEnumerable> validations, System.Collections.Generic.IEnumerable> asyncValidations) { } public TOptions Create(string name) { throw null; } protected virtual TOptions CreateInstance(string name) { throw null; } } @@ -233,11 +235,17 @@ public static partial class OptionsMonitorExtensions public partial class OptionsMonitor<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> : Microsoft.Extensions.Options.IOptionsMonitor, System.IDisposable where TOptions : class { public OptionsMonitor(Microsoft.Extensions.Options.IOptionsFactory factory, System.Collections.Generic.IEnumerable> sources, Microsoft.Extensions.Options.IOptionsMonitorCache cache) { } + public OptionsMonitor(Microsoft.Extensions.Options.IOptionsFactory factory, System.Collections.Generic.IEnumerable> sources, Microsoft.Extensions.Options.IOptionsMonitorCache cache, System.Collections.Generic.IEnumerable> reloadValidationConfigs) { } public TOptions CurrentValue { get { throw null; } } public void Dispose() { } public virtual TOptions Get(string? name) { throw null; } public System.IDisposable OnChange(System.Action listener) { throw null; } } + public enum OptionsReloadValidationBehavior + { + KeepLastGood = 0, + FailReads = 1, + } public partial class OptionsValidationException : System.Exception { public OptionsValidationException(string optionsName, System.Type optionsType, System.Collections.Generic.IEnumerable? failureMessages) { } @@ -317,6 +325,13 @@ public PostConfigureOptions(string? name, TDep1 dependency1, TDep2 dependency2, public virtual void PostConfigure(string? name, TOptions options) { } public void PostConfigure(TOptions options) { } } + public sealed partial class ReloadValidationConfiguration where TOptions : class + { + public ReloadValidationConfiguration(string name, Microsoft.Extensions.Options.OptionsReloadValidationBehavior behavior, System.Action? onError) { } + public Microsoft.Extensions.Options.OptionsReloadValidationBehavior Behavior { get { throw null; } } + public string Name { get { throw null; } } + public System.Action? OnError { get { throw null; } } + } [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field)] public sealed class ValidateEnumeratedItemsAttribute : System.Attribute { @@ -425,6 +440,7 @@ public AsyncValidateOptions(string? name, System.Func> Validation { get { throw null; } } + public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } } public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class @@ -434,6 +450,7 @@ public AsyncValidateOptions(string? name, TDep dependency, System.Func> Validation { get { throw null; } } + public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } } public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class @@ -444,6 +461,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, public string FailureMessage { get { throw null; } } public string? Name { get { throw null; } } public System.Func> Validation { get { throw null; } } + public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } } public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class @@ -455,6 +473,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, public string FailureMessage { get { throw null; } } public string? Name { get { throw null; } } public System.Func> Validation { get { throw null; } } + public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } } public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class @@ -467,6 +486,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, public string FailureMessage { get { throw null; } } public string? Name { get { throw null; } } public System.Func> Validation { get { throw null; } } + public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } } public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class @@ -480,6 +500,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, public string FailureMessage { get { throw null; } } public string? Name { get { throw null; } } public System.Func> Validation { get { throw null; } } + public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; } public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; } } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs index 8dbdd76605ce20..bbafa6cd991432 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs @@ -43,6 +43,24 @@ public AsyncValidateOptions(string? name, Func public string FailureMessage { get; } + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + public ValidateOptionsResult Validate(string? name, TOptions options) + { + // null name is used to configure all named options + if (Name is null || name == Name) + { + return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions))); + } + + // ignored if not validating this instance + return ValidateOptionsResult.Skip; + } + /// /// Asynchronously validates a specific named options instance (or all when is null). /// @@ -112,6 +130,22 @@ public AsyncValidateOptions(string? name, TDep dependency, Func public string FailureMessage { get; } + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + public ValidateOptionsResult Validate(string? name, TOptions options) + { + if (Name is null || name == Name) + { + return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions))); + } + + return ValidateOptionsResult.Skip; + } + /// public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) { @@ -181,6 +215,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, /// public string FailureMessage { get; } + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + public ValidateOptionsResult Validate(string? name, TOptions options) + { + if (Name is null || name == Name) + { + return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions))); + } + + return ValidateOptionsResult.Skip; + } + /// public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) { @@ -258,6 +308,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, /// public string FailureMessage { get; } + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + public ValidateOptionsResult Validate(string? name, TOptions options) + { + if (Name is null || name == Name) + { + return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions))); + } + + return ValidateOptionsResult.Skip; + } + /// public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) { @@ -343,6 +409,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, /// public string FailureMessage { get; } + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + public ValidateOptionsResult Validate(string? name, TOptions options) + { + if (Name is null || name == Name) + { + return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions))); + } + + return ValidateOptionsResult.Skip; + } + /// public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) { @@ -436,6 +518,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, /// public string FailureMessage { get; } + /// + /// Validates a specific named options instance (or all when is ). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + public ValidateOptionsResult Validate(string? name, TOptions options) + { + if (Name is null || name == Name) + { + return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions))); + } + + return ValidateOptionsResult.Skip; + } + /// public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default) { diff --git a/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs index 66caef599340b1..6556af634e74c1 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.Options /// Asynchronously validates options. /// /// The options type to validate. - public interface IAsyncValidateOptions where TOptions : class + public interface IAsyncValidateOptions : IValidateOptions where TOptions : class { /// /// Asynchronously validates a specified named options instance (or all if is ). diff --git a/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj b/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj index babbacd5fab98b..84f208addd8606 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj +++ b/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj @@ -40,7 +40,8 @@ - + + diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs index 3f7b914bf81ee9..809a787235b0ce 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs @@ -591,7 +591,7 @@ public virtual OptionsBuilder Validate(Func>(new AsyncValidateOptions(Name, validation, failureMessage)); + Services.AddSingleton>(new AsyncValidateOptions(Name, validation, failureMessage)); return this; } @@ -615,7 +615,7 @@ public virtual OptionsBuilder Validate(Func>(sp => + Services.AddTransient>(sp => new AsyncValidateOptions(Name, sp.GetRequiredService(), validation, failureMessage)); return this; } @@ -646,7 +646,7 @@ public virtual OptionsBuilder Validate(Func>(sp => + Services.AddTransient>(sp => new AsyncValidateOptions(Name, sp.GetRequiredService(), sp.GetRequiredService(), @@ -685,7 +685,7 @@ public virtual OptionsBuilder Validate(Func>(sp => + Services.AddTransient>(sp => new AsyncValidateOptions(Name, sp.GetRequiredService(), sp.GetRequiredService(), @@ -729,7 +729,7 @@ public virtual OptionsBuilder Validate(Fun { ArgumentNullException.ThrowIfNull(validation); - Services.AddTransient>(sp => + Services.AddTransient>(sp => new AsyncValidateOptions(Name, sp.GetRequiredService(), sp.GetRequiredService(), @@ -778,7 +778,7 @@ public virtual OptionsBuilder Validate>(sp => + Services.AddTransient>(sp => new AsyncValidateOptions(Name, sp.GetRequiredService(), sp.GetRequiredService(), diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs index 205adb41022949..e0a634ca70af7b 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -27,50 +26,77 @@ public static class OptionsBuilderExtensions { ArgumentNullException.ThrowIfNull(optionsBuilder); + string name = optionsBuilder.Name; + + // Register the built-in validator as a single IStartupValidator (for back-compatibility) + // and as an enumerable IAsyncStartupValidator so the host can run it alongside any custom async validators. optionsBuilder.Services.TryAddTransient(); - optionsBuilder.Services.TryAddTransient(); + optionsBuilder.Services.TryAddEnumerable(ServiceDescriptor.Transient()); optionsBuilder.Services.AddOptions() - .Configure>((vo, options) => + .Configure, IOptionsFactory, IOptionsMonitorCache>((vo, monitor, factory, cache) => { - // This adds an action that resolves the options value to force evaluation - // We don't care about the result as duplicates are not important - vo._validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name); - }); + // Sync path (custom sync-only IStartupValidator): force evaluation through the + // monitor, which runs every validator (including an async validator's fail-fast + // synchronous Validate) and populates the cache. + vo._validators[(typeof(TOptions), name)] = () => monitor.Get(name); - // Register async validator entries if any IAsyncValidateOptions are registered - optionsBuilder.Services.AddOptions() - .Configure, IEnumerable>>((vo, options, asyncValidators) => - { - // Materialize the validators into a list to check if any are registered - var validators = new List>(asyncValidators); - if (validators.Count > 0) + // Async path: run the complete validation (both sync and async validators) for + // this (type, name) and seed the monitor cache with the validated instance so the + // first Get after startup returns it instead of re-running Create. + vo._asyncValidators[(typeof(TOptions), name)] = async (CancellationToken ct) => { - vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) => + if (factory is OptionsFactory asyncFactory) { - // Retrieve the options value (already created by sync Validate() call) - TOptions optionsValue = options.Get(optionsBuilder.Name); - - // Run async validators - List? failures = null; - foreach (IAsyncValidateOptions validator in validators) + TOptions validated = await asyncFactory.CreateAsync(name, ct).ConfigureAwait(false); + if (cache is OptionsCache optionsCache) { - ValidateOptionsResult result = await validator.ValidateAsync(optionsBuilder.Name, optionsValue, ct).ConfigureAwait(false); - if (result is not null && result.Failed) - { - failures ??= new List(); - failures.AddRange(result.Failures); - } + optionsCache.AddOrReplace(name, validated); } - - if (failures is not null && failures.Count > 0) + else { - throw new OptionsValidationException(optionsBuilder.Name, typeof(TOptions), failures); + cache.TryRemove(name); + cache.TryAdd(name, validated); } - }; - } + } + else + { + // Custom IOptionsFactory: no async validation path is available, + // so fall back to synchronous evaluation (also populates the cache). + monitor.Get(name); + } + }; }); return optionsBuilder; } + + /// + /// Enables eager asynchronous revalidation of the options whenever their configuration reloads, instead of the + /// default lazy revalidation on next access. + /// + /// The type of options. + /// The to configure. + /// How reads are served when revalidation of a reloaded configuration fails. + /// An optional callback invoked with the options name and the exception when revalidation of a reloaded configuration fails. + /// The so that additional calls can be chained. + /// + /// On reload the options are re-created and validated in the background (running every registered validator, + /// awaiting asynchronous ones); the last successfully validated value keeps being served until the new value + /// validates, at which point it is swapped in atomically. This does not run startup validation; combine it with + /// to also validate the initial value. + /// + public static OptionsBuilder ValidateOnChange( + this OptionsBuilder optionsBuilder, + OptionsReloadValidationBehavior behavior = OptionsReloadValidationBehavior.KeepLastGood, + Action? onError = null) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(optionsBuilder); + + optionsBuilder.Services.AddOptions(); + optionsBuilder.Services.AddSingleton(new ReloadValidationConfiguration(optionsBuilder.Name, behavior, onError)); + + return optionsBuilder; + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs index ac6bab4c221656..83d112e2dd800c 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs @@ -104,6 +104,31 @@ public virtual bool TryAdd(string? name, TOptions options) options)); } + /// + /// Adds or replaces a named options instance atomically. + /// + /// The name of the options instance. + /// The options instance. + internal void AddOrReplace(string? name, TOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + name ??= Options.DefaultName; + + if (GetType() != typeof(OptionsCache)) + { + TryRemove(name); + TryAdd(name, options); + return; + } + + _cache[name] = new Lazy( +#if !(NET || NETSTANDARD2_1) + () => +#endif + options); + } + /// /// Tries to remove an options instance. /// diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsEventSource.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsEventSource.cs new file mode 100644 index 00000000000000..b30e985726b56f --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsEventSource.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; +using System.Diagnostics.Tracing; + +namespace Microsoft.Extensions.Options +{ + [EventSource(Name = "Microsoft-Extensions-Options")] + internal sealed partial class OptionsEventSource : EventSource + { + public static readonly OptionsEventSource Log = new OptionsEventSource(); + + [Event(1, Level = EventLevel.Error)] + public void ReloadValidationFailed(string optionsName, string optionsType, string exception) + { + WriteEvent(1, optionsName, optionsType, exception); + } + + [NonEvent] + public void ReloadValidationFailed(string? optionsName, Type optionsType, Exception exception) + { + if (IsEnabled(EventLevel.Error, EventKeywords.All)) + { + ReloadValidationFailed(optionsName ?? string.Empty, optionsType.ToString(), exception.ToString()); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs index c0da2adccbf887..c179c875b3a8a4 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Extensions.Options { @@ -18,6 +20,8 @@ public class OptionsFactory<[DynamicallyAccessedMembers(Options.DynamicallyAcces private readonly IConfigureOptions[] _setups; private readonly IPostConfigureOptions[] _postConfigures; private readonly IValidateOptions[] _validations; + private readonly bool _hasAsyncOnlyValidators; + private readonly bool _hasAsyncValidators; /// /// Initializes a new instance with the specified options configurations. @@ -43,8 +47,43 @@ public OptionsFactory(IEnumerable> setups, IEnumerab _setups = setups as IConfigureOptions[] ?? new List>(setups).ToArray(); _postConfigures = postConfigures as IPostConfigureOptions[] ?? new List>(postConfigures).ToArray(); _validations = validations as IValidateOptions[] ?? new List>(validations).ToArray(); + + foreach (IValidateOptions validation in _validations) + { + if (validation is IAsyncValidateOptions) + { + _hasAsyncValidators = true; + break; + } + } } + /// + /// Initializes a new instance with the specified options configurations. + /// + /// The configuration actions to run. + /// The initialization actions to run. + /// The validations to run. + /// Services registered as , used to detect an invalid registration. + /// A validator is registered only as and not as . + public OptionsFactory(IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> validations, IEnumerable> asyncValidations) + : this(setups, postConfigures, validations) + { + // Any service resolved here is registered only as the IAsyncValidateOptions interface, + // which is an invalid registration. Synchronous paths enumerate IValidateOptions and would never see it. + // The error is surfaced when the options are created via Create or CreateAsync. + foreach (IAsyncValidateOptions _ in asyncValidations) + { + _hasAsyncOnlyValidators = true; + break; + } + } + + // True when at least one validator registered as IValidateOptions also implements + // IAsyncValidateOptions, so a synchronous Create may fail for a genuinely-asynchronous validator. + // Used by the options managers to prefer a startup-validated value over re-running synchronous validation. + internal bool HasAsyncValidators => _hasAsyncValidators; + /// /// Returns a configured instance with the given . /// @@ -53,6 +92,53 @@ public OptionsFactory(IEnumerable> setups, IEnumerab /// One or more return failed when validating the instance created. /// The does not have a public parameterless constructor or is . public TOptions Create(string name) + { + ThrowIfAsyncOnlyValidatorRegistered(); + + TOptions options = CreateAndConfigure(name); + + if (_validations.Length > 0) + { + List? failures = null; + foreach (IValidateOptions validate in _validations) + { + CollectFailures(ref failures, validate.Validate(name, options)); + } + ThrowIfValidationFailed(name, failures); + } + + return options; + } + + /// + /// Creates, configures, and asynchronously validates a instance with the given . + /// + internal async Task CreateAsync(string name, CancellationToken cancellationToken) + { + ThrowIfAsyncOnlyValidatorRegistered(); + + TOptions options = CreateAndConfigure(name); + + if (_validations.Length > 0) + { + List? failures = null; + foreach (IValidateOptions validate in _validations) + { + // Dispatch in registration order depending on capability: + // async validators are awaited, all others run synchronously. + ValidateOptionsResult result = validate is IAsyncValidateOptions asyncValidate + ? await asyncValidate.ValidateAsync(name, options, cancellationToken).ConfigureAwait(false) + : validate.Validate(name, options); + + CollectFailures(ref failures, result); + } + ThrowIfValidationFailed(name, failures); + } + + return options; + } + + private TOptions CreateAndConfigure(string name) { TOptions options = CreateInstance(name); foreach (IConfigureOptions setup in _setups) @@ -70,25 +156,31 @@ public TOptions Create(string name) { post.PostConfigure(name, options); } + return options; + } - if (_validations.Length > 0) + private static void CollectFailures(ref List? failures, ValidateOptionsResult? result) + { + if (result is not null && result.Failed) { - var failures = new List(); - foreach (IValidateOptions validate in _validations) - { - ValidateOptionsResult result = validate.Validate(name, options); - if (result is not null && result.Failed) - { - failures.AddRange(result.Failures); - } - } - if (failures.Count > 0) - { - throw new OptionsValidationException(name, typeof(TOptions), failures); - } + (failures ??= new List()).AddRange(result.Failures); } + } - return options; + private static void ThrowIfValidationFailed(string name, List? failures) + { + if (failures is { Count: > 0 }) + { + throw new OptionsValidationException(name, typeof(TOptions), failures); + } + } + + private void ThrowIfAsyncOnlyValidatorRegistered() + { + if (_hasAsyncOnlyValidators) + { + throw new InvalidOperationException(SR.Format(SR.AsyncOnlyValidatorRegistration, typeof(TOptions))); + } } /// diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs index 2c8180d0205674..1e39ab66bb4240 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Primitives; namespace Microsoft.Extensions.Options @@ -20,6 +23,8 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces private readonly IOptionsMonitorCache _cache; private readonly IOptionsFactory _factory; private readonly List _registrations = new List(); + private readonly Dictionary>? _reloadConfigs; + private readonly ConcurrentDictionary? _eagerStates; internal event Action? _onChange; /// @@ -29,10 +34,33 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces /// The sources used to listen for changes to the options instance. /// The cache used to store options. public OptionsMonitor(IOptionsFactory factory, IEnumerable> sources, IOptionsMonitorCache cache) + : this(factory, sources, cache, reloadValidationConfigs: Array.Empty>()) + { + } + + /// + /// Initializes a new instance of with the specified factory, sources, cache, and reload-validation opt-ins. + /// + /// The factory to use to create options. + /// The sources used to listen for changes to the options instance. + /// The cache used to store options. + /// The reload-validation opt-ins registered through the ValidateOnChange options-builder extension. + public OptionsMonitor(IOptionsFactory factory, IEnumerable> sources, IOptionsMonitorCache cache, IEnumerable> reloadValidationConfigs) { _factory = factory; _cache = cache; + foreach (ReloadValidationConfiguration config in reloadValidationConfigs) + { + // The last opt-in for a given name wins, mirroring the "greediest configuration wins" behavior elsewhere. + (_reloadConfigs ??= new Dictionary>(StringComparer.Ordinal))[config.Name] = config; + } + + if (_reloadConfigs is not null) + { + _eagerStates = new ConcurrentDictionary(StringComparer.Ordinal); + } + void RegisterSource(IOptionsChangeTokenSource source) { IDisposable registration = ChangeToken.OnChange( @@ -64,11 +92,124 @@ void RegisterSource(IOptionsChangeTokenSource source) private void InvokeChanged(string? name) { name ??= Options.DefaultName; + + if (_reloadConfigs is not null && _reloadConfigs.TryGetValue(name, out ReloadValidationConfiguration? config)) + { + // Eager revalidation: keep serving the last validated value while the reloaded configuration is + // re-created and validated in the background, then swap it in only if validation succeeds. + InvokeEagerReload(name, config); + return; + } + _cache.TryRemove(name); TOptions options = Get(name); _onChange?.Invoke(options, name); } + private void InvokeEagerReload(string name, ReloadValidationConfiguration config) + { + EagerReloadState state = _eagerStates!.GetOrAdd(name, static _ => new EagerReloadState()); + + int generation; + CancellationToken cancellationToken; + lock (state) + { + // Latest-wins: supersede any in-flight refresh for this name so only the newest reload publishes. + state.Cancel(); + state.Cts = new CancellationTokenSource(); + cancellationToken = state.Cts.Token; + generation = ++state.Generation; + } + + _ = RefreshAsync(name, config, state, generation, cancellationToken); + } + + private async Task RefreshAsync(string name, ReloadValidationConfiguration config, EagerReloadState state, int generation, CancellationToken cancellationToken) + { + TOptions? published = null; + try + { + TOptions validated = _factory is OptionsFactory asyncFactory + ? await asyncFactory.CreateAsync(name, cancellationToken).ConfigureAwait(false) + : _factory.Create(name); + + lock (state) + { + // Re-check under the lock and publish atomically so a superseded reload can never overwrite a newer + // published value (the generation check and the cache write must not be interleaved with another reload). + if (state.Generation != generation) + { + return; + } + + if (_cache is OptionsCache optionsCache) + { + optionsCache.AddOrReplace(name, validated); + } + else + { + _cache.TryRemove(name); + _cache.TryAdd(name, validated); + } + + published = validated; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Our token was canceled: superseded by a newer reload (or disposal). + // A cancellation that does not originate from our token (e.g. a validator's own timeout) falls + // through to the general handler below and is treated as a genuine reload failure. + return; + } + catch (Exception ex) + { + lock (state) + { + if (state.Generation != generation) + { + return; + } + + if (config.Behavior == OptionsReloadValidationBehavior.FailReads) + { + // Drop the cached value so the next read re-creates and surfaces the failure. + _cache.TryRemove(name); + } + } + + // Always surface the failure through the event source so a reload failure is observable even when no + // error callback was supplied. This is independent of the user callback below. + OptionsEventSource.Log.ReloadValidationFailed(name, typeof(TOptions), ex); + + try + { + config.OnError?.Invoke(name, ex); + } + catch + { + // The error callback is user code. We catch here to prevent an unobserved task exception. + } + + return; + } + + if (published is not null) + { + try + { + _onChange?.Invoke(published, name); + } + catch + { + // Change listeners are user code. RefreshAsync runs as a fire-and-forget task, so a throwing + // listener must be swallowed here to avoid faulting that task (an unobserved task exception). + // A listener throwing is a listener bug and is not a reload failure, so it is not surfaced + // through the failure channels above. + } + } + } + /// /// Gets the present value of the options (equivalent to Get(Options.DefaultName)). /// @@ -125,6 +266,37 @@ public void Dispose() } _registrations.Clear(); + + if (_eagerStates is not null) + { + // Cancel any in-flight eager revalidation and bump the generation so a refresh that ignores the + // cancellation token still fails the generation check and does not publish after disposal. + foreach (EagerReloadState state in _eagerStates.Values) + { + lock (state) + { + state.Cancel(); + state.Generation++; + } + } + } + } + + private sealed class EagerReloadState + { + public int Generation; + public CancellationTokenSource? Cts; + + public void Cancel() + { + CancellationTokenSource? cts = Cts; + if (cts is not null) + { + cts.Cancel(); + cts.Dispose(); + Cts = null; + } + } } internal sealed class ChangeTrackerDisposable : IDisposable diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsReloadValidationBehavior.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsReloadValidationBehavior.cs new file mode 100644 index 00000000000000..256846985f7d59 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsReloadValidationBehavior.cs @@ -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. + +namespace Microsoft.Extensions.Options +{ + /// + /// Controls how an serves values when asynchronous revalidation of a + /// reloaded configuration fails, for options that opted in through the ValidateOnChange options-builder extension. + /// + public enum OptionsReloadValidationBehavior + { + /// + /// Keeps serving the last successfully validated value. + /// The failure is reported to the Microsoft-Extensions-Options event source + /// and, if one was supplied, to the error callback. + /// + KeepLastGood, + + /// + /// Clears the cached value so the next read re-creates and validates it, surfacing the failure to the reader. + /// + FailReads, + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/ReloadValidationConfiguration.cs b/src/libraries/Microsoft.Extensions.Options/src/ReloadValidationConfiguration.cs new file mode 100644 index 00000000000000..4eba2c14368a20 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Options/src/ReloadValidationConfiguration.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Options +{ + /// + /// Represents a single opt-in to eager asynchronous revalidation on configuration reload for a named + /// instance, as registered by the ValidateOnChange options-builder extension. + /// + /// The options type this registration applies to. + public sealed class ReloadValidationConfiguration where TOptions : class + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the options instance to revalidate on reload. + /// How reads are served when revalidation of a reloaded configuration fails. + /// An optional callback invoked with the options name and the exception when revalidation of a reloaded configuration fails. + public ReloadValidationConfiguration(string name, OptionsReloadValidationBehavior behavior, Action? onError) + { + ArgumentNullException.ThrowIfNull(name); + + Name = name; + Behavior = behavior; + OnError = onError; + } + + /// + /// Gets the name of the options instance to revalidate on reload. + /// + public string Name { get; } + + /// + /// Gets a value that indicates how reads are served when revalidation of a reloaded configuration fails. + /// + public OptionsReloadValidationBehavior Behavior { get; } + + /// + /// Gets the callback invoked with the options name and the exception when revalidation of a reloaded configuration fails. + /// + public Action? OnError { get; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx index fcb0d92415843d..33a60e867a656c 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx @@ -135,4 +135,10 @@ No IConfigureOptions<>, IPostConfigureOptions<>, or IValidateOptions<> implementations were found, did you mean to call Configure<> or PostConfigure<>? + + Options instance of type '{0}' is validated by an asynchronous validator that cannot run during synchronous validation. Validate these options asynchronously before accessing them synchronously. + + + A validator for options type '{0}' is registered as 'IAsyncValidateOptions<TOptions>'. Register it as 'IValidateOptions<TOptions>' instead. + \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs index 7dbb192de4858d..53ddd4d4d22af6 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs @@ -13,7 +13,8 @@ internal sealed class StartupValidatorOptions // Maps each pair of a) options type and b) options name to a method that forces its evaluation, e.g. IOptionsMonitor.Get(name) public Dictionary<(Type, string), Action> _validators { get; } = new Dictionary<(Type, string), Action>(); - // Maps each pair of a) options type and b) options name to an async method that forces evaluation and runs async validators + // Maps each pair of a) options type and b) options name to an async method that runs the complete + // validation (synchronous and asynchronous validators) and seeds the monitor cache with the result public Dictionary<(Type, string), Func> _asyncValidators { get; } = new Dictionary<(Type, string), Func>(); } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs b/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs index e6ce20b94d1871..fa844c6adcd0b4 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs @@ -11,11 +11,24 @@ internal sealed class UnnamedOptionsManager<[DynamicallyAccessedMembers(Options. where TOptions : class { private readonly IOptionsFactory _factory; + private readonly IOptionsMonitorCache? _validatedCache; private object? _syncObj; private volatile TOptions? _value; public UnnamedOptionsManager(IOptionsFactory factory) => _factory = factory; + public UnnamedOptionsManager(IOptionsFactory factory, IOptionsMonitorCache cache) + { + _factory = factory; + + // Only consult the shared validated cache for types that have an asynchronous validator; sync-only types + // keep the existing behavior of creating and validating synchronously. + if (factory is OptionsFactory optionsFactory && optionsFactory.HasAsyncValidators) + { + _validatedCache = cache; + } + } + public TOptions Value { get @@ -27,9 +40,23 @@ public TOptions Value lock (_syncObj ?? Interlocked.CompareExchange(ref _syncObj, new object(), null) ?? _syncObj) { - return _value ??= _factory.Create(Options.DefaultName); + return _value ??= CreateValue(); } } } + + private TOptions CreateValue() + { + // For an async-validated type, prefer the value validated during startup (seeded into the shared cache) so a + // synchronous access returns the last validated value instead of re-running the throwing synchronous Validate. + // If nothing has been validated yet (no host, pre-startup, or no ValidateOnStart), fall back to Create, which + // fails fast with an actionable message. + if (_validatedCache is OptionsCache optionsCache && optionsCache.TryGetValue(Options.DefaultName, out TOptions? validated)) + { + return validated; + } + + return _factory.Create(Options.DefaultName); + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs index 6a611700c090fa..e87c2831cc74f1 100644 --- a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs +++ b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs @@ -55,8 +55,6 @@ public void Validate() public async Task ValidateAsync(CancellationToken cancellationToken = default) { - // Async validators only — sync validation is handled separately by - // IStartupValidator.Validate() in Host.StartAsync() (two-stage orchestration). List? exceptions = null; foreach (Func asyncValidator in _validatorOptions._asyncValidators.Values) diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs index 8979090f01a3c5..81cb0488197fe6 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs @@ -2,16 +2,22 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; using Xunit; namespace Microsoft.Extensions.Options.Tests { public class AsyncOptionsValidationTests { + private static IAsyncStartupValidator GetAsyncStartupValidator(IServiceProvider sp) => + Assert.IsAssignableFrom(sp.GetRequiredService()); + [Fact] public async Task AsyncValidateOptions_SkipsWhenNameDoesNotMatch() { @@ -68,7 +74,7 @@ public async Task OptionsBuilder_AsyncValidate_RegistersAndExecutes() .ValidateOnStart(); ServiceProvider sp = services.BuildServiceProvider(); - var validator = sp.GetRequiredService(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); await validator.ValidateAsync(CancellationToken.None); @@ -76,7 +82,7 @@ public async Task OptionsBuilder_AsyncValidate_RegistersAndExecutes() } [Fact] - public async Task StartupValidator_TwoStage_RunsBothSyncAndAsyncValidators() + public async Task StartupValidator_SinglePath_RunsBothSyncAndAsyncValidators() { var services = new ServiceCollection(); bool syncRan = false; @@ -94,18 +100,17 @@ public async Task StartupValidator_TwoStage_RunsBothSyncAndAsyncValidators() ServiceProvider sp = services.BuildServiceProvider(); - // Two-stage orchestration: Host.cs calls Validate() then ValidateAsync() independently - var syncValidator = sp.GetRequiredService(); - syncValidator.Validate(); - Assert.True(syncRan); + // Single-path orchestration: one ValidateAsync runs every validator (sync and async) for the type, + // dispatching each by capability. + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); + await validator.ValidateAsync(CancellationToken.None); - var asyncValidator = sp.GetRequiredService(); - await asyncValidator.ValidateAsync(CancellationToken.None); + Assert.True(syncRan); Assert.True(asyncRan); } [Fact] - public async Task StartupValidator_TwoStage_SyncFailureSkipsAsyncValidators() + public async Task StartupValidator_SinglePath_AggregatesSyncAndAsyncFailures() { var services = new ServiceCollection(); bool asyncRan = false; @@ -116,19 +121,21 @@ public async Task StartupValidator_TwoStage_SyncFailureSkipsAsyncValidators() .Validate(async (FakeOptions o, CancellationToken ct) => { asyncRan = true; - return await Task.FromResult(true); - }, "async") + return await Task.FromResult(false); + }, "async validation failed") .ValidateOnStart(); ServiceProvider sp = services.BuildServiceProvider(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); - // Stage 1: Sync throws — in Host.cs, this prevents reaching Stage 2 - var syncValidator = sp.GetRequiredService(); - Assert.Throws(() => syncValidator.Validate()); + // The single path does not short-circuit on the first failure: every validator runs and + // all failures are aggregated into one OptionsValidationException. + OptionsValidationException ex = await Assert.ThrowsAsync( + () => validator.ValidateAsync(CancellationToken.None)); - // Stage 2 is never reached because the exception propagates. - // Verify async didn't run (simulating Host.cs short-circuit behavior). - Assert.False(asyncRan); + Assert.True(asyncRan); + Assert.Contains("sync validation failed", ex.Failures); + Assert.Contains("async validation failed", ex.Failures); } [Fact] @@ -147,7 +154,7 @@ public async Task StartupValidator_ValidateAsync_OnlyAsyncValidators() .ValidateOnStart(); ServiceProvider sp = services.BuildServiceProvider(); - var validator = sp.GetRequiredService(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); await validator.ValidateAsync(CancellationToken.None); @@ -169,7 +176,7 @@ public async Task StartupValidator_ValidateAsync_AsyncFailureThrowsOptionsValida .ValidateOnStart(); ServiceProvider sp = services.BuildServiceProvider(); - var validator = sp.GetRequiredService(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); OptionsValidationException ex = await Assert.ThrowsAsync( () => validator.ValidateAsync(CancellationToken.None)); @@ -177,11 +184,12 @@ public async Task StartupValidator_ValidateAsync_AsyncFailureThrowsOptionsValida } [Fact] - public async Task ValidateOnStart_CustomSyncOnlyValidator_DoesNotThrowInvalidCast() + public void ValidateOnStart_CustomSyncOnlyValidator_UsesSyncPath() { var services = new ServiceCollection(); - // Register a custom IStartupValidator that does NOT implement IAsyncStartupValidator + // A custom sync-only IStartupValidator registered before ValidateOnStart wins the + // TryAddTransient, so it is the resolved IStartupValidator. services.AddSingleton(new CustomSyncOnlyValidator()); services.AddOptions() @@ -191,9 +199,56 @@ public async Task ValidateOnStart_CustomSyncOnlyValidator_DoesNotThrowInvalidCas ServiceProvider sp = services.BuildServiceProvider(); - // Should NOT throw InvalidCastException — IAsyncStartupValidator gets its own StartupValidator instance - var asyncValidator = sp.GetRequiredService(); - await asyncValidator.ValidateAsync(CancellationToken.None); + // The custom validator is not async-capable, so the host falls back to the sync path (validator.Validate()) + // This means no InvalidCastException and no async validation. + IStartupValidator validator = sp.GetRequiredService(); + Assert.IsType(validator); + Assert.False(validator is IAsyncStartupValidator); + validator.Validate(); + } + + [Fact] + public void ValidateOnStart_RegistersBuiltInValidatorAsBothInterfaces() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => o.Message = "test") + .Validate(o => true) + .ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + + IStartupValidator sync = sp.GetRequiredService(); + Assert.IsType(sync, exactMatch: false); + Assert.Single(sp.GetServices()); + } + + [Fact] + public void ValidateOnStart_CalledMultipleTimes_RegistersSingleAsyncStartupValidator() + { + var services = new ServiceCollection(); + + services.AddOptions("a").Configure(o => o.Message = "a").Validate(o => true).ValidateOnStart(); + services.AddOptions("b").Configure(o => o.Message = "b").Validate(o => true).ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + + Assert.Single(sp.GetServices()); + } + + [Fact] + public void ValidateOnStart_CustomAsyncStartupValidator_CoexistsWithBuiltInInEnumerable() + { + var services = new ServiceCollection(); + + services.AddSingleton(new TrackingAsyncStartupValidator()); + services.AddOptions().Configure(o => o.Message = "test").Validate(o => true).ValidateOnStart(); + + ServiceProvider sp = services.BuildServiceProvider(); + + // A custom async startup validator (a different implementation type) coexists with the built-in one. + Assert.Equal(2, sp.GetServices().Count()); } [Fact] @@ -212,7 +267,7 @@ public async Task StartupValidator_ValidateAsync_CancellationTokenPropagated() .ValidateOnStart(); ServiceProvider sp = services.BuildServiceProvider(); - var validator = sp.GetRequiredService(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); cts.Cancel(); await Assert.ThrowsAsync(() => validator.ValidateAsync(cts.Token)); @@ -264,16 +319,648 @@ public async Task StartupValidator_ValidateAsync_MultipleFailures_ThrowsAggregat .ValidateOnStart(); using ServiceProvider sp = services.BuildServiceProvider(); - var validator = sp.GetRequiredService(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); AggregateException ex = await Assert.ThrowsAsync(() => validator.ValidateAsync()); Assert.Equal(2, ex.InnerExceptions.Count); Assert.All(ex.InnerExceptions, e => Assert.IsType(e)); } + [Fact] + public async Task StartupValidator_ValidatorImplementingBoth_DispatchesToAsync() + { + var spy = new CapabilitySpyValidator(); + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => o.Message = "test") + .ValidateOnStart(); + services.AddSingleton>(spy); + + ServiceProvider sp = services.BuildServiceProvider(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); + + await validator.ValidateAsync(CancellationToken.None); + + // A validator that implements both contracts is dispatched through ValidateAsync only. + Assert.True(spy.AsyncCalled); + Assert.False(spy.SyncCalled); + } + + [Fact] + public void MisregisteredAsyncOnlyValidator_ThrowsClearErrorOnSyncAccess() + { + var services = new ServiceCollection(); + services.AddOptions(); + // Misregistration: registered only as the IAsyncValidateOptions capability interface. + services.AddSingleton>( + new AsyncValidateOptions(null, (o, ct) => Task.FromResult(false), "async fail")); + using ServiceProvider sp = services.BuildServiceProvider(); + + InvalidOperationException ex = Assert.Throws( + () => sp.GetRequiredService>().Value); + + Assert.Contains(nameof(FakeOptions), ex.Message); + Assert.Contains(nameof(IValidateOptions), ex.Message); + } + + [Fact] + public async Task MisregisteredAsyncOnlyValidator_ThrowsClearErrorOnStartup() + { + var services = new ServiceCollection(); + services.AddOptions().ValidateOnStart(); + services.AddSingleton>( + new AsyncValidateOptions(null, (o, ct) => Task.FromResult(false), "async fail")); + using ServiceProvider sp = services.BuildServiceProvider(); + IAsyncStartupValidator validator = GetAsyncStartupValidator(sp); + + await Assert.ThrowsAsync(() => validator.ValidateAsync(CancellationToken.None)); + } + + [Fact] + public void CorrectlyRegisteredAsyncValidator_DoesNotThrowMisregistrationError() + { + var services = new ServiceCollection(); + services.AddOptions().Configure(o => o.Message = "ok"); + // Correct: an async-capable validator registered through IValidateOptions. + services.AddSingleton>(new CapabilitySpyValidator()); + using ServiceProvider sp = services.BuildServiceProvider(); + + FakeOptions value = sp.GetRequiredService>().Value; + + Assert.Equal("ok", value.Message); + } + + [Fact] + public void NoAsyncValidators_DoesNotThrowMisregistrationError() + { + var services = new ServiceCollection(); + services.AddOptions().Configure(o => o.Message = "ok"); + using ServiceProvider sp = services.BuildServiceProvider(); + + FakeOptions value = sp.GetRequiredService>().Value; + + Assert.Equal("ok", value.Message); + } + + [Fact] + public async Task AsyncValidatedOptions_SyncAccessorsReturnStartupValidatedValue() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = "validated") + .Validate(async (FakeOptions o, CancellationToken ct) => await Task.FromResult(true), "async fail") + .ValidateOnStart(); + using ServiceProvider sp = services.BuildServiceProvider(); + + // Before startup nothing has been validated, so synchronous access fails fast rather than silently skipping. + Assert.Throws(() => sp.GetRequiredService>().Value); + + await GetAsyncStartupValidator(sp).ValidateAsync(CancellationToken.None); + + // After startup seeds the shared cache, the singleton IOptions.Value returns the validated value. + Assert.Equal("validated", sp.GetRequiredService>().Value.Message); + + // IOptionsSnapshot is scoped and creates per scope; it does not consult the shared validated cache, so + // for an async-only validator (whose synchronous Validate cannot run) a scoped access fails fast. + using IServiceScope scope = sp.CreateScope(); + Assert.Throws( + () => scope.ServiceProvider.GetRequiredService>().Get(null)); + } + + [Fact] + public void AsyncValidatedOptions_SyncCapableValidator_SnapshotIsPerScope() + { + int configureCount = 0; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = $"v{Interlocked.Increment(ref configureCount)}") + .ValidateOnStart(); + services.AddSingleton>(new SyncCapableAsyncValidator()); + using ServiceProvider sp = services.BuildServiceProvider(); + + FakeOptions first, second; + using (IServiceScope scope = sp.CreateScope()) + { + first = scope.ServiceProvider.GetRequiredService>().Get(null); + } + using (IServiceScope scope = sp.CreateScope()) + { + second = scope.ServiceProvider.GetRequiredService>().Get(null); + } + + // A validator that is async-capable but whose synchronous Validate works keeps the per-scope IOptionsSnapshot + // semantics: each scope creates and validates a fresh instance rather than sharing the startup-validated one. + Assert.NotSame(first, second); + } + + [Fact] + public void SyncOnlyValidatedOptions_SyncAccessorsBehaviorUnchanged() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = "sync") + .Validate(o => o.Message == "sync", "sync fail"); + using ServiceProvider sp = services.BuildServiceProvider(); + + // A sync-only type is not async-capable, so the accessors create and validate synchronously as before. + Assert.Equal("sync", sp.GetRequiredService>().Value.Message); + using IServiceScope scope = sp.CreateScope(); + Assert.Equal("sync", scope.ServiceProvider.GetRequiredService>().Get(null).Message); + } + + [Fact] + public void ValidateOnChange_ValidReload_PublishesNewValidatedValueAndNotifies() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator(); + int configureCount = 0; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = $"v{Interlocked.Increment(ref configureCount)}") + .ValidateOnChange(); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "seed"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + Assert.Equal("seed", monitor.CurrentValue.Message); + + using var changed = new ManualResetEventSlim(); + string? notified = null; + using IDisposable _ = monitor.OnChange((o, n) => { notified = o.Message; changed.Set(); }); + + source.Trigger(); + + Assert.True(changed.Wait(TimeSpan.FromSeconds(30)), "The change notification was not raised."); + Assert.NotEqual("seed", monitor.CurrentValue.Message); + Assert.Equal(monitor.CurrentValue.Message, notified); + } + + [Fact] + public void ValidateOnChange_ThrowingChangeListener_DoesNotEvictPublishedValueOrReportError() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator(); + var services = new ServiceCollection(); + bool errorReported = false; + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(OptionsReloadValidationBehavior.FailReads, (name, ex) => errorReported = true); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "seed"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + using var listenerRan = new ManualResetEventSlim(); + using IDisposable _ = monitor.OnChange((o, n) => + { + listenerRan.Set(); + throw new InvalidOperationException("listener boom"); + }); + + source.Trigger(); + + Assert.True(listenerRan.Wait(TimeSpan.FromSeconds(30)), "The change listener did not run."); + Thread.Sleep(200); + + // A throwing listener runs after a successful reload; it must not be treated as a reload failure: the + // published value is served (not evicted by FailReads) and the error callback is not invoked. + Assert.Equal("reloaded", monitor.CurrentValue.Message); + Assert.False(errorReported); + } + + [Fact] + public void ValidateOnChange_ThrowingChangeListener_DoesNotFaultBackgroundRefresh() + { + var unobserved = new List(); + void OnUnobserved(object? sender, UnobservedTaskExceptionEventArgs e) + { + lock (unobserved) + { + unobserved.Add(e.Exception); + } + } + + TaskScheduler.UnobservedTaskException += OnUnobserved; + try + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator(); + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "seed"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + using var listenerRan = new ManualResetEventSlim(); + using IDisposable _ = monitor.OnChange((o, n) => + { + listenerRan.Set(); + throw new InvalidOperationException("listener boom"); + }); + + source.Trigger(); + Assert.True(listenerRan.Wait(TimeSpan.FromSeconds(30)), "The change listener did not run."); + Thread.Sleep(200); + + // Force finalization so a faulted, unobserved fire-and-forget refresh task would raise + // UnobservedTaskException. With the listener guarded, the task completes without faulting. + for (int i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + lock (unobserved) + { + Assert.DoesNotContain(unobserved, e => e.ToString().Contains("listener boom")); + } + } + finally + { + TaskScheduler.UnobservedTaskException -= OnUnobserved; + } + } + + [Fact] + public void ValidateOnChange_ThrowingOnErrorCallback_DoesNotFaultBackgroundRefresh() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator { Fail = true }; + var services = new ServiceCollection(); + using var errored = new ManualResetEventSlim(); + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(OptionsReloadValidationBehavior.KeepLastGood, (name, ex) => + { + errored.Set(); + throw new InvalidOperationException("onError boom"); + }); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "good"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + source.Trigger(); + + // A throwing OnError must be swallowed by the background refresh (no unobserved task fault); the last good + // value keeps being served. + Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "The error callback was not invoked."); + Assert.Equal("good", monitor.CurrentValue.Message); + } + + [Fact] + public void ValidateOnChange_InvalidReload_KeepLastGood_ServesLastGoodAndReportsError() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator { Fail = true }; + var services = new ServiceCollection(); + using var errored = new ManualResetEventSlim(); + Exception? reportedError = null; + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(OptionsReloadValidationBehavior.KeepLastGood, (name, ex) => { reportedError = ex; errored.Set(); }); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "good"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + bool changeFired = false; + using IDisposable _ = monitor.OnChange((o, n) => changeFired = true); + + source.Trigger(); + + Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "The error callback was not invoked."); + Assert.IsType(reportedError); + // The last validated value keeps being served, and no change notification is raised for a failed reload. + Assert.Equal("good", monitor.CurrentValue.Message); + Assert.False(changeFired); + } + + [Fact] + public void ValidateOnChange_InvalidReload_WithoutErrorCallback_EmitsEventSourceFailure() + { + using var listener = new OptionsEventSourceListener(); + var source = new ReloadSource(); + var validator = new ReloadTestValidator { Fail = true }; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "good"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + source.Trigger(); + + Assert.True(listener.FailureReported.Wait(TimeSpan.FromSeconds(30)), "The reload failure was not reported to the event source."); + // The failure is observable through the event source even though no onError callback was supplied. + Assert.Equal("good", monitor.CurrentValue.Message); + } + + [Fact] + public void ValidateOnChange_ReloadValidatorThrowsOwnCancellation_ReportsError() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator { ThrowOwnCancellation = true }; + var services = new ServiceCollection(); + using var errored = new ManualResetEventSlim(); + Exception? reportedError = null; + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(OptionsReloadValidationBehavior.KeepLastGood, (name, ex) => { reportedError = ex; errored.Set(); }); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "good"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + source.Trigger(); + + Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "A validator-originated cancellation was not reported as a failure."); + // A cancellation that does not originate from the monitor's own token is a genuine failure, not a supersession. + Assert.IsType(reportedError); + Assert.Equal("good", monitor.CurrentValue.Message); + } + + [Fact] + public void ValidateOnChange_InvalidReload_FailReads_NextReadThrows() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator { Fail = true }; + var services = new ServiceCollection(); + using var errored = new ManualResetEventSlim(); + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(OptionsReloadValidationBehavior.FailReads, (name, ex) => errored.Set()); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "good"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + source.Trigger(); + + Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "The error callback was not invoked."); + // FailReads dropped the cached value, so the next read re-creates and surfaces the failure. + Assert.Throws(() => monitor.CurrentValue); + } + + [Fact] + public void ValidateOnChange_NotOptedIn_MonitorUsesLazyRevalidation() + { + var source = new ReloadSource(); + int configureCount = 0; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = $"v{Interlocked.Increment(ref configureCount)}"); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + Assert.Equal("v1", monitor.CurrentValue.Message); + + // Without ValidateOnChange the default lazy behavior clears the cache and re-creates on the next read. + source.Trigger(); + Assert.Equal("v2", monitor.CurrentValue.Message); + } + + [Fact] + public void ValidateOnChange_RapidReloads_LatestWins() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator(); + var firstGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + validator.Gate = firstGate.Task; + int configureCount = 0; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = $"v{Interlocked.Increment(ref configureCount)}") + .ValidateOnChange(); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "seed"); + IOptionsMonitor monitor = sp.GetRequiredService>(); + + using var secondChanged = new ManualResetEventSlim(); + string? lastNotified = null; + using IDisposable _ = monitor.OnChange((o, n) => { lastNotified = o.Message; secondChanged.Set(); }); + + // First reload blocks in ValidateAsync on the gate. + source.Trigger(); + // Second reload supersedes the first and completes (its gate was cleared before it started). + validator.Gate = null; + source.Trigger(); + + Assert.True(secondChanged.Wait(TimeSpan.FromSeconds(30)), "The superseding reload did not publish."); + string published = monitor.CurrentValue.Message; + + // Release the superseded first reload; it must not overwrite the newer published value. + firstGate.SetResult(true); + Thread.Sleep(200); + + Assert.Equal(published, monitor.CurrentValue.Message); + Assert.Equal(published, lastNotified); + } + + [Fact] + public void ValidateOnChange_DisposeMonitor_CancelsInFlightReload() + { + var source = new ReloadSource(); + var validator = new ReloadTestValidator(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + validator.Gate = gate.Task; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "seed"); + var monitor = (OptionsMonitor)sp.GetRequiredService>(); + + source.Trigger(); // starts a background reload blocked on the gate + monitor.Dispose(); // cancels the in-flight reload + gate.SetResult(true); // release; the canceled reload must not publish + Thread.Sleep(200); + + var cache = (OptionsCache)sp.GetRequiredService>(); + Assert.True(cache.TryGetValue(Options.DefaultName, out FakeOptions? value)); + Assert.Equal("seed", value!.Message); + } + + [Fact] + public void ValidateOnChange_DisposeMonitor_NonCooperativeValidatorDoesNotPublishAfterDispose() + { + var source = new ReloadSource(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var validator = new ReloadTestValidator { Gate = gate.Task, IgnoreCancellation = true }; + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Message = "reloaded") + .ValidateOnChange(); + services.AddSingleton>(validator); + services.AddSingleton>(source); + using ServiceProvider sp = services.BuildServiceProvider(); + + SeedCache(sp, "seed"); + var monitor = (OptionsMonitor)sp.GetRequiredService>(); + + source.Trigger(); // starts a background reload blocked on the gate, ignoring cancellation + monitor.Dispose(); // cancels (ignored) and bumps the generation + gate.SetResult(true); // release; the completed reload must not publish after disposal + Thread.Sleep(200); + + var cache = (OptionsCache)sp.GetRequiredService>(); + Assert.True(cache.TryGetValue(Options.DefaultName, out FakeOptions? value)); + Assert.Equal("seed", value!.Message); + } + + private static void SeedCache(IServiceProvider sp, string message) + { + var cache = (OptionsCache)sp.GetRequiredService>(); + cache.AddOrReplace(Options.DefaultName, new FakeOptions { Message = message }); + } + + private sealed class ReloadSource : IOptionsChangeTokenSource + { + private readonly FakeChangeToken _token = new FakeChangeToken(); + public string? Name => null; + public IChangeToken GetChangeToken() => _token; + public void Trigger() => _token.InvokeChangeCallback(); + } + + private sealed class ReloadTestValidator : IValidateOptions, IAsyncValidateOptions + { + public bool Fail { get; set; } + public bool ThrowOwnCancellation { get; set; } + public bool IgnoreCancellation { get; set; } + public Task? Gate { get; set; } + + public ValidateOptionsResult Validate(string? name, FakeOptions options) + => ValidateOptionsResult.Fail("Synchronous validation is not supported."); + + public async Task ValidateAsync(string? name, FakeOptions options, CancellationToken cancellationToken) + { + Task? gate = Gate; + if (gate is not null) + { + if (IgnoreCancellation) + { + // Simulate a validator that does not observe the cancellation token. + await gate.ConfigureAwait(false); + } + else + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(static s => ((TaskCompletionSource)s!).TrySetCanceled(), tcs)) + { + await Task.WhenAny(gate, tcs.Task).ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); + } + } + + if (ThrowOwnCancellation) + { + // Simulate a validator that cancels for its own reasons (e.g. an internal timeout) using a token + // unrelated to the monitor's reload token. + throw new OperationCanceledException(new CancellationToken(canceled: true)); + } + + return Fail ? ValidateOptionsResult.Fail("async validation failed") : ValidateOptionsResult.Success; + } + } + private class CustomSyncOnlyValidator : IStartupValidator { public void Validate() { } } + + private sealed class TrackingAsyncStartupValidator : IAsyncStartupValidator + { + public bool Validated { get; private set; } + + public Task ValidateAsync(CancellationToken cancellationToken = default) + { + Validated = true; + return Task.CompletedTask; + } + } + + private sealed class SyncCapableAsyncValidator : IValidateOptions, IAsyncValidateOptions + { + public ValidateOptionsResult Validate(string? name, FakeOptions options) => ValidateOptionsResult.Success; + + public Task ValidateAsync(string? name, FakeOptions options, CancellationToken cancellationToken = default) + => Task.FromResult(ValidateOptionsResult.Success); + } + + private sealed class OptionsEventSourceListener : EventListener + { + public ManualResetEventSlim FailureReported { get; } = new ManualResetEventSlim(); + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (eventSource.Name == "Microsoft-Extensions-Options") + { + EnableEvents(eventSource, EventLevel.Error); + } + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (eventData.EventName == "ReloadValidationFailed") + { + FailureReported.Set(); + } + } + + public override void Dispose() + { + base.Dispose(); + FailureReported.Dispose(); + } + } + + private sealed class CapabilitySpyValidator : IValidateOptions, IAsyncValidateOptions + { + public bool SyncCalled { get; private set; } + public bool AsyncCalled { get; private set; } + + public ValidateOptionsResult Validate(string? name, FakeOptions options) + { + SyncCalled = true; + return ValidateOptionsResult.Success; + } + + public Task ValidateAsync(string? name, FakeOptions options, CancellationToken cancellationToken = default) + { + AsyncCalled = true; + return Task.FromResult(ValidateOptionsResult.Success); + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/Microsoft.Extensions.Options.Tests.csproj b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/Microsoft.Extensions.Options.Tests.csproj index d375c6390cb962..676c3b854bc1a1 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/Microsoft.Extensions.Options.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/Microsoft.Extensions.Options.Tests.csproj @@ -3,12 +3,14 @@ $(NetCoreAppCurrent);$(NetFrameworkCurrent) true + false false false + diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs index 3629ad9de262ad..bf66cab685360d 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs @@ -1046,7 +1046,7 @@ public async Task ValidateDataAnnotations_ValidateOnStart_AsyncStartupValidator_ .ValidateOnStart(); using ServiceProvider sp = services.BuildServiceProvider(); - var asyncValidator = sp.GetRequiredService(); + var asyncValidator = (IAsyncStartupValidator)sp.GetRequiredService(); await asyncValidator.ValidateAsync(CancellationToken.None); } diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs index a66a2f554b46dc..0b1eb805ed7ec5 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsMonitorTest.cs @@ -421,6 +421,73 @@ public ChangeTokenSource(IChangeToken changeToken) public IChangeToken GetChangeToken() => _changeToken; } + [Fact] + public void AddOrReplace_AddsWhenAbsentAndReplacesWhenPresent() + { + var cache = new OptionsCache(); + + cache.AddOrReplace("name", new FakeOptions { Message = "first" }); + Assert.True(cache.TryGetValue("name", out FakeOptions? added)); + Assert.Equal("first", added!.Message); + + // Unlike TryAdd, AddOrReplace overwrites an existing entry. + cache.AddOrReplace("name", new FakeOptions { Message = "second" }); + Assert.True(cache.TryGetValue("name", out FakeOptions? replaced)); + Assert.Equal("second", replaced!.Message); + + // TryAdd still reports the name already exists after a replace. + Assert.False(cache.TryAdd("name", new FakeOptions { Message = "third" })); + } + + [Fact] + public void AddOrReplace_NullNameUsesDefaultName() + { + var cache = new OptionsCache(); + + cache.AddOrReplace(null, new FakeOptions { Message = "value" }); + + Assert.True(cache.TryGetValue(Options.DefaultName, out FakeOptions? byDefault)); + Assert.Equal("value", byDefault!.Message); + Assert.True(cache.TryGetValue(null, out FakeOptions? byNull)); + Assert.Equal("value", byNull!.Message); + } + + [Fact] + public void AddOrReplace_DerivedCacheRoutesThroughVirtualMembers() + { + var cache = new CountingOptionsCache(); + + // First call: no existing entry, so only TryAdd runs. + cache.AddOrReplace("name", new FakeOptions { Message = "first" }); + Assert.Equal(1, cache.TryRemoveCalls); + Assert.Equal(1, cache.TryAddCalls); + + // Second call replaces: TryRemove then TryAdd both run through the overrides. + cache.AddOrReplace("name", new FakeOptions { Message = "second" }); + Assert.Equal(2, cache.TryRemoveCalls); + Assert.Equal(2, cache.TryAddCalls); + Assert.True(cache.TryGetValue("name", out FakeOptions? replaced)); + Assert.Equal("second", replaced!.Message); + } + + private sealed class CountingOptionsCache : OptionsCache + { + public int TryAddCalls { get; private set; } + public int TryRemoveCalls { get; private set; } + + public override bool TryAdd(string? name, FakeOptions options) + { + TryAddCalls++; + return base.TryAdd(name, options); + } + + public override bool TryRemove(string? name) + { + TryRemoveCalls++; + return base.TryRemove(name); + } + } + [Fact] public void CallsPublicGetOrAddForCustomOptionsCache() { diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs index 4815d029ebc4ae..5a9e1293209603 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Main.cs @@ -512,6 +512,113 @@ public void Validate(string name, FirstModel fm) Assert.Equal(DiagDescriptors.AlreadyImplementsValidateMethod.Id, diagnostics[0].Id); } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public async Task AlreadyImplementedAsync() + { + var (diagnostics, _) = await RunGenerator(@" + public class FirstModel + { + [Required] + public string One { get; set; } = string.Empty; + } + + [OptionsValidator] + public partial class FirstValidator : IAsyncValidateOptions + { + public System.Threading.Tasks.Task ValidateAsync(string? name, FirstModel options, System.Threading.CancellationToken cancellationToken = default) + => System.Threading.Tasks.Task.FromResult(ValidateOptionsResult.Success); + } + "); + + _ = Assert.Single(diagnostics); + Assert.Equal(DiagDescriptors.AlreadyImplementsValidateAsyncMethod.Id, diagnostics[0].Id); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public async Task AlreadyImplementedAsyncExplicitInterface() + { + var (diagnostics, _) = await RunGenerator(@" + public class FirstModel + { + [Required] + public string One { get; set; } = string.Empty; + } + + [OptionsValidator] + public partial class FirstValidator : IAsyncValidateOptions + { + System.Threading.Tasks.Task IAsyncValidateOptions.ValidateAsync(string? name, FirstModel options, System.Threading.CancellationToken cancellationToken) + => System.Threading.Tasks.Task.FromResult(ValidateOptionsResult.Success); + } + "); + + _ = Assert.Single(diagnostics); + Assert.Equal(DiagDescriptors.AlreadyImplementsValidateAsyncMethod.Id, diagnostics[0].Id); + } + +#if NET + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public async Task AsyncValidateMethodWithNoAwaitedWorkIsNotAsync() + { + // The model validates only synchronously (IValidatableObject, no attributes, no async children) but is validated + // by an IAsyncValidateOptions validator, so the generated ValidateAsync body contains no await and must be + // emitted as a non-async method returning Task.FromResult(...) (avoids CS1998). + var (diagnostics, generatedSources) = await RunGenerator(@" + using System.Collections.Generic; + + public class SyncSelfModel : IValidatableObject + { + public string? Name { get; set; } + public IEnumerable Validate(ValidationContext validationContext) + { + yield break; + } + } + + [OptionsValidator] + public partial class SyncSelfModelValidator : IAsyncValidateOptions + { + } + "); + + Assert.Empty(diagnostics); + _ = Assert.Single(generatedSources); + string emitted = generatedSources[0].SourceText.ToString(); + + // Task.FromResult(...) is emitted only on the no-await branch; the awaited branch returns builder.Build() directly. + Assert.Contains("global::System.Threading.Tasks.Task.FromResult", emitted); + // A non-async ValidateAsync contains neither the async modifier nor any await. + Assert.DoesNotContain("await ", emitted); + } +#endif // NET + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] + public async Task AsyncValidationRequiresNet11Diagnostic() + { + // A validator implementing IAsyncValidateOptions (available on every TFM) needs the async DataAnnotations + // APIs (IAsyncValidatableObject / Validator.TryValidateValueAsync), which are only present on .NET 11+. + var (diagnostics, _) = await RunGenerator(@" + public class FirstModel + { + [Required] + public string One { get; set; } = string.Empty; + } + + [OptionsValidator] + public partial class FirstValidator : IAsyncValidateOptions + { + } + "); + +#if NET + // .NET 11+: the async symbols are available, so no downlevel diagnostic is produced. + Assert.DoesNotContain(diagnostics, d => d.Id == DiagDescriptors.AsyncValidationRequiresNet11.Id); +#else + // Downlevel (async symbols absent): the generator reports SYSLIB1218 instead of emitting code that fails to compile. + Assert.Contains(diagnostics, d => d.Id == DiagDescriptors.AsyncValidationRequiresNet11.Id); +#endif // NET + } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.HasAssemblyFiles))] public async Task ShouldNotProduceInfoWhenTheClassHasABaseClass() { diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs index 736f8251b6bd60..612cafd3ef6b27 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/OptionsRuntimeTests.cs @@ -417,6 +417,213 @@ select Task.Factory.StartNew(() => Assert.True(result.Succeeded); }, TaskCreationOptions.LongRunning)).ToArray()); } + +#if NET + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestAsyncValidationSucceeds() + { + AsyncOptions options = new() + { + Name = "Valid", + Age = 30, + Nested = new() + { + Level = 5, + Id = "1", + Children = new() { new AsyncChildOptions { Name = "C1" } } + } + }; + + AsyncOptionsValidator validator = new(); + + ValidateOptionsResult asyncResult = await validator.ValidateAsync("AsyncOptions", options, default); + Assert.True(asyncResult.Succeeded); + + // The generated ValidateAsync must agree with the synchronous Validate for the same input. + ValidateOptionsResult syncResult = validator.Validate("AsyncOptions", options); + Assert.True(syncResult.Succeeded); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestAsyncValidationFailures() + { + AsyncOptions options = new() + { + Name = "Invalid", // trips self-validation + Age = 0, // out of [Range(1, 100)] + Nested = new() + { + Level = 50, // out of [Range(0, 10)] + Id = null, // [Required] + Children = new() { new AsyncChildOptions { Name = null } } // [Required] on enumerated item + } + }; + + AsyncOptionsValidator validator = new(); + + ValidateOptionsResult asyncResult = await validator.ValidateAsync("AsyncOptions", options, default); + Assert.True(asyncResult.Failed); + + // Attribute, nested object-member, enumerated-item failures, plus the async self-validation failure, + // are all surfaced. The self-validation entry ("Async self-validation failed.") proves the generated + // ValidateAsync dispatches to IAsyncValidatableObject.ValidateAsync (await foreach) rather than the + // synchronous IValidatableObject.Validate path. + Assert.Equal(new List + { + "Age: The field AsyncOptions.Age must be between 1 and 100.", + "Level: The field AsyncOptions.Nested.Level must be between 0 and 10.", + "Id: The AsyncOptions.Nested.Id field is required.", + "Name: The AsyncOptions.Nested.Children[0].Name field is required.", + "Async self-validation failed.", + }, + asyncResult.Failures); + + // The synchronous Validate path uses the synchronous self-validation instead, confirming the two code + // paths are genuinely distinct and the async test would not pass if ValidateAsync silently ran the sync path. + ValidateOptionsResult syncResult = validator.Validate("AsyncOptions", options); + Assert.True(syncResult.Failed); + Assert.Contains("Sync self-validation failed.", syncResult.Failures); + Assert.DoesNotContain("Async self-validation failed.", syncResult.Failures); + Assert.DoesNotContain("Sync self-validation failed.", asyncResult.Failures); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestAsyncValidationWithSharedSynthesizedValidator() + { + // The async AsyncOptionsValidator and the synchronous SyncRootReusingNestedOptionsValidator both nest + // AsyncNestedOptions, so they share a single synthesized child validator. Exercise both roots to confirm the + // shared child validates correctly from an async caller and a sync caller alike. + AsyncOptions asyncOptions = new() + { + Name = "Valid", + Age = 30, + Nested = new() { Level = 50, Id = null, Children = new() { new AsyncChildOptions { Name = "C1" } } } + }; + + ValidateOptionsResult asyncResult = await new AsyncOptionsValidator().ValidateAsync("AsyncOptions", asyncOptions, default); + Assert.True(asyncResult.Failed); + Assert.Contains("Level: The field AsyncOptions.Nested.Level must be between 0 and 10.", asyncResult.Failures); + Assert.Contains("Id: The AsyncOptions.Nested.Id field is required.", asyncResult.Failures); + + SyncRootReusingNestedOptions syncOptions = new() + { + Nested = new() { Level = 5, Id = "1", Children = new() { new AsyncChildOptions { Name = "C1" } } } + }; + + ValidateOptionsResult syncResult = new SyncRootReusingNestedOptionsValidator().Validate("SyncRoot", syncOptions); + Assert.True(syncResult.Succeeded); + + // Finding 3 regression: AsyncNestedOptions self-validates asynchronously. The async root must run that + // nested async self-validation, while the synchronous root sharing the same nested model type must not. + // Keying synthesized validators by model type alone would let the async root reuse a synchronous-only + // child (depending on discovery order) and silently skip nested async self-validation. + AsyncOptions asyncSelfValidating = new() + { + Name = "Valid", + Age = 30, + Nested = new() { Level = 5, Id = "trigger-async", Children = new() { new AsyncChildOptions { Name = "C1" } } } + }; + + ValidateOptionsResult nestedAsyncResult = await new AsyncOptionsValidator().ValidateAsync("AsyncOptions", asyncSelfValidating, default); + Assert.True(nestedAsyncResult.Failed); + Assert.Contains("Nested async self-validation failed.", nestedAsyncResult.Failures); + + SyncRootReusingNestedOptions syncSelfValidating = new() + { + Nested = new() { Level = 5, Id = "trigger-async", Children = new() { new AsyncChildOptions { Name = "C1" } } } + }; + + ValidateOptionsResult syncNestedResult = new SyncRootReusingNestedOptionsValidator().Validate("SyncRoot", syncSelfValidating); + Assert.True(syncNestedResult.Succeeded); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestExplicitTypeTransitiveAndEnumeratedAsyncValidatorsAreAwaited() + { + // Both the explicit-type transitive validator ([ValidateObjectMembers(typeof(...))]) and the explicit-type + // enumerated-items validator ([ValidateEnumeratedItems(typeof(...))]) implement IAsyncValidateOptions, so + // the async parent must await their ValidateAsync. AsyncNestedOptions only self-validates asynchronously, so + // if either child were dispatched through its synchronous Validate the failure would be silently skipped. + var options = new ExplicitAsyncRootOptions + { + Nested = new AsyncNestedOptions { Level = 5, Id = "trigger-async" }, + Items = new() { new AsyncNestedOptions { Level = 5, Id = "trigger-async" } } + }; + + ValidateOptionsResult result = await new ExplicitAsyncRootOptionsValidator().ValidateAsync("Root", options, default); + + Assert.True(result.Failed); + Assert.Equal(2, result.Failures.Count(f => f.Contains("Nested async self-validation failed."))); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestGeneratedValidateAsyncPropagatesCancellationToken() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var validator = new CancellationObservingOptionsValidator(); + + // The generated ValidateAsync threads its CancellationToken into the validation sinks; with an already + // canceled token, validation observes it and throws. A future emitter change that dropped the token would + // make this complete normally. + await Assert.ThrowsAnyAsync( + () => validator.ValidateAsync("opt", new CancellationObservingOptions { Name = "valid" }, cts.Token)); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestGeneratedValidateAsyncNoAwaitPathRunsSynchronously() + { + var validator = new SyncSelfValidatingOptionsValidator(); + + // The generated ValidateAsync for this model contains no await (non-async, returns Task.FromResult(...)). + // Its mere presence in this compiled assembly proves it compiles (no CS1998); assert it also runs correctly. + Task successTask = validator.ValidateAsync("opt", new SyncSelfValidatingOptions { Name = "ok" }, default); + Assert.True(successTask.IsCompletedSuccessfully); + Assert.True((await successTask).Succeeded); + + ValidateOptionsResult failure = await validator.ValidateAsync("opt", new SyncSelfValidatingOptions { Name = "bad" }, default); + Assert.True(failure.Failed); + Assert.Contains(failure.Failures, f => f.Contains("Sync self-validation failed.")); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public async Task TestGeneratedValidateAsyncMatchesValidatorParity() + { + var validator = new AsyncParityOptionsValidator(); + + var valid = new AsyncParityOptions { Name = "ok", Age = 30 }; + var validResults = new List(); + bool validatorValid = await Validator.TryValidateObjectAsync(valid, new ValidationContext(valid), validResults, validateAllProperties: true); + ValidateOptionsResult generatedValid = await validator.ValidateAsync("AsyncParityOptions", valid, default); + Assert.True(validatorValid); + Assert.True(generatedValid.Succeeded); + Assert.Empty(validResults); + + // Attribute-only failure (Age out of range, name is valid and not reserved). Note: Validator.TryValidateObjectAsync + // skips IAsyncValidatableObject.ValidateAsync once property validation fails, whereas the generated method always + // runs it; the two agree here only because the self-validation yields nothing for this input, so we deliberately + // avoid combining an attribute failure with a self-validation failure in one instance. + var attributeFailure = new AsyncParityOptions { Name = "ok", Age = 0 }; + var attributeResults = new List(); + bool validatorAttr = await Validator.TryValidateObjectAsync(attributeFailure, new ValidationContext(attributeFailure), attributeResults, validateAllProperties: true); + ValidateOptionsResult generatedAttr = await validator.ValidateAsync("AsyncParityOptions", attributeFailure, default); + Assert.False(validatorAttr); + Assert.True(generatedAttr.Failed); + Assert.Equal(attributeResults.Count, generatedAttr.Failures.Count()); + + // Self-validation-only failure (attributes pass, reserved name). Validator runs the async self-validation because + // property validation succeeds, so both report the same single failure. + var selfFailure = new AsyncParityOptions { Name = "reserved", Age = 30 }; + var selfResults = new List(); + bool validatorSelf = await Validator.TryValidateObjectAsync(selfFailure, new ValidationContext(selfFailure), selfResults, validateAllProperties: true); + ValidateOptionsResult generatedSelf = await validator.ValidateAsync("AsyncParityOptions", selfFailure, default); + Assert.False(validatorSelf); + Assert.True(generatedSelf.Failed); + Assert.Equal(selfResults.Count, generatedSelf.Failures.Count()); + Assert.Contains(generatedSelf.Failures, f => f.Contains("Name is reserved.")); + } +#endif // NET } public class FakeCount(int count) { public int Count { get { return count; } } } @@ -637,4 +844,195 @@ public class OptionsWithTimeSpanRangeAttribute public partial class TimeSpanRangeAttributeValidator : IValidateOptions { } + +#if NET + public class AsyncChildOptions + { + [Required] + public string? Name { get; set; } + } + + public class AsyncNestedOptions : IAsyncValidatableObject + { + [Range(0, 10)] + public int Level { get; set; } + + [Required] + public string? Id { get; set; } + + [ValidateEnumeratedItems] + public List? Children { get; set; } + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + [global::System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + if (Id == "trigger-async") + { + yield return new ValidationResult("Nested async self-validation failed."); + } + } + + public IEnumerable Validate(ValidationContext validationContext) + { + yield break; + } + } + + public class AsyncOptions : IAsyncValidatableObject + { + [Required] + public string? Name { get; set; } + + [Range(1, 100)] + public int Age { get; set; } + + [ValidateObjectMembers] + public AsyncNestedOptions? Nested { get; set; } + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + [global::System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + if (Name == "Invalid") + { + yield return new ValidationResult("Async self-validation failed."); + } + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Name == "Invalid") + { + yield return new ValidationResult("Sync self-validation failed."); + } + } + } + + [OptionsValidator] + public partial class AsyncOptionsValidator : IValidateOptions, IAsyncValidateOptions + { + } + + // Regression: a synchronous validator that nests the same type (AsyncNestedOptions) as the async + // AsyncOptionsValidator. Synthesized child validators are cached per model type and per capability + // (synchronous vs asynchronous), so the async root gets an async-capable child and the synchronous root gets a + // synchronous one regardless of discovery order. This guards against the async root emitting + // "await child.ValidateAsync(...)" against a synthesized child generated without a ValidateAsync method, and + // against the async root silently falling back to the synchronous child's Validate path. + public class SyncRootReusingNestedOptions + { + [ValidateObjectMembers] + public AsyncNestedOptions? Nested { get; set; } + } + + [OptionsValidator] + public partial class SyncRootReusingNestedOptionsValidator : IValidateOptions + { + } + + // An explicitly specified transitive/enumerated validator (typeof(...)) that implements + // IAsyncValidateOptions must be dispatched through ValidateAsync by an async parent. Otherwise the parent calls + // the child's synchronous Validate and silently skips the child's async-only validation. + public class ExplicitAsyncRootOptions + { + [ValidateObjectMembers(typeof(ExplicitAsyncNestedValidator))] + public AsyncNestedOptions? Nested { get; set; } + + [ValidateEnumeratedItems(typeof(ExplicitAsyncNestedValidator))] + public List? Items { get; set; } + } + + [OptionsValidator] + public partial class ExplicitAsyncNestedValidator : IAsyncValidateOptions + { + } + + [OptionsValidator] + public partial class ExplicitAsyncRootOptionsValidator : IAsyncValidateOptions + { + } + + // Flat model whose async self-validation observes the cancellation token, used to prove the generated ValidateAsync + // threads its CancellationToken into the validation sinks (TryValidateValueAsync / IAsyncValidatableObject.ValidateAsync). + public class CancellationObservingOptions : IAsyncValidatableObject + { + [Required] + public string? Name { get; set; } + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + [global::System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield break; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + yield break; + } + } + + [OptionsValidator] + public partial class CancellationObservingOptionsValidator : IAsyncValidateOptions + { + } + + // Model that validates only synchronously (IValidatableObject, no attributes, no async children) but is validated by + // an async validator, so the generated ValidateAsync body contains no await and must be emitted as a non-async + // method returning Task.FromResult(...) (avoids CS1998). + public class SyncSelfValidatingOptions : IValidatableObject + { + public string? Name { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Name == "bad") + { + yield return new ValidationResult("Sync self-validation failed.", new[] { nameof(Name) }); + } + } + } + + [OptionsValidator] + public partial class SyncSelfValidatingOptionsValidator : IAsyncValidateOptions + { + } + + // Flat model (attributes + async self-validation, no nested members) so the generated ValidateAsync can be compared + // for parity against Validator.TryValidateObjectAsync, which does not recurse into nested members. + public class AsyncParityOptions : IAsyncValidatableObject + { + [Required] + public string? Name { get; set; } + + [Range(1, 100)] + public int Age { get; set; } + + public async IAsyncEnumerable ValidateAsync( + ValidationContext validationContext, + [global::System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + if (Name == "reserved") + { + yield return new ValidationResult("Name is reserved.", new[] { nameof(Name) }); + } + } + + public IEnumerable Validate(ValidationContext validationContext) + { + yield break; + } + } + + [OptionsValidator] + public partial class AsyncParityOptionsValidator : IAsyncValidateOptions + { + } +#endif // NET } diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Resources/Strings.resx index 19dc7159a40c28..f6fce5fa8528db 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGeneration.Unit.Tests/Resources/Strings.resx @@ -189,6 +189,18 @@ Type {0} already implements the Validate method. + + A type already includes an implementation of the 'ValidateAsync' method. + + + Type {0} already implements the ValidateAsync method. + + + Asynchronous options validation requires targeting .NET 11 or later. + + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + 'OptionsValidatorAttribute' can't be applied to a static class. diff --git a/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/Resources/Strings.resx index 19dc7159a40c28..f6fce5fa8528db 100644 --- a/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Options/tests/SourceGenerationTests/Resources/Strings.resx @@ -189,6 +189,18 @@ Type {0} already implements the Validate method. + + A type already includes an implementation of the 'ValidateAsync' method. + + + Type {0} already implements the ValidateAsync method. + + + Asynchronous options validation requires targeting .NET 11 or later. + + + Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later. + 'OptionsValidatorAttribute' can't be applied to a static class.