Repository navigation
Closed
Labels
api-approvedAPI was approved in API review, it can be implementedAPI was approved in API review, it can be implementedarea-Extensions-OptionsblockingMarks issues that we want to fast track in order to unblock other important workMarks issues that we want to fast track in order to unblock other important workpartner-impactThis issue impacts a partner who needs to be kept updatedThis issue impacts a partner who needs to be kept updated
Milestone
Description
Background and motivation
In my current project, we have many option types which need to be validated on startup. To reduce startup overhead and improve validation feature set, we've implemented a source code generator that implements the validation logic. This is a general-purpose mechanism which could benefit the broader community.
API Proposal
namespace Microsoft.Extensions.Options;
/// <summary>
/// Triggers the automatic generation of the implementation of <see cref="Microsoft.Extensions.Options.IValidateOptions{T}" /> at compile time.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
[Conditional("CODE_GENERATION_ATTRIBUTES")]
public sealed class OptionsValidatorAttribute : Attribute
{
}
/// <summary>
/// Marks a field or property to be enumerated, and each enumerated object to be validated.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
[Conditional("CODE_GENERATION_ATTRIBUTES")]
public sealed class ValidateEnumerableAttribute : ValidationAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ValidateEnumerableAttribute"/> class.
/// </summary>
/// <remarks>
/// Using this constructor for a field/property tells the code generator to
/// generate validation for the individual members of the enumerable's type.
/// </remarks>
public ValidateEnumerableAttribute();
/// <summary>
/// Initializes a new instance of the <see cref="ValidateEnumerableAttribute"/> class.
/// </summary>
/// <param name="validator">A type that implements <see cref="IValidateOptions{T}" /> for the enumerable's type.</param>
/// <remarks>
/// Using this constructor for a field/property tells the code generator to use the given type to validate
/// the object held by the enumerable.
/// </remarks>
public ValidateEnumerableAttribute(Type validator);
/// <summary>
/// Gets the type to use to validate the enumerable's objects.
/// </summary>
public Type? Validator { get; }
}
/// <summary>
/// Marks a field or property to be validated transitively.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
[Conditional("CODE_GENERATION_ATTRIBUTES")]
public sealed class ValidateTransitivelyAttribute : ValidationAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ValidateTransitivelyAttribute"/> class.
/// </summary>
/// <remarks>
/// Using this constructor for a field/property tells the code generator to
/// generate validation for the individual members of the field/property's type.
/// </remarks>
public ValidateTransitivelyAttribute();
/// <summary>
/// Initializes a new instance of the <see cref="ValidateTransitivelyAttribute"/> class.
/// </summary>
/// <param name="validator">A type that implements <see cref="IValidateOptions{T}" /> for the field/property's type.</param>
/// <remarks>
/// Using this constructor for a field/property tells the code generator to use the given type to validate
/// the object held by the field/property.
/// </remarks>
public ValidateTransitivelyAttribute(Type validator);
/// <summary>
/// Gets the type to use to validate a field or property.
/// </summary>
public Type? Validator { get; }
}API Usage
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
using Microsoft.R9.Extensions.Options.Validation;
namespace Foo;
public class MyOptions
{
[Required]
public string Name { get; set; } = string.Empty;
[ValidateTransitively]
public NestedOptions? Nested { get; set; }
[ValidateEnumerable]
public IList<AccountOptions> Accounts { get; set; }
}
public class NestedOptions
{
[Range(0, 10)]
public int Value { get; set; }
}
public class AccountOptions
{
[Required]
public string Username { get; set; }
}
[OptionsValidator]
internal sealed partial class MyOptionsValidator : IValidateOptions<MyOptions>
{
// the implementation of this class is generated
}
[OptionsValidator]
internal sealed partial class NestedOptionsValidator : IValidateOptions<NestedOptions>
{
// the implementation of this class is generated
}
[OptionsValidator]
internal sealed partial class AccountOptionsValidator : IValidateOptions<AccountOptions>
{
// the implementation of this class is generated
}
//
// The app will need to register the IValidateOptions<MyOptions> interface to the DI to enable the generated validation. Here is some example
//
using Microsoft.Extensions.Options;
using OptionsValidationSample.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.Configure<MyOptions>(builder.Configuration.GetSection(...));
builder.Services.AddSingleton<IValidateOptions<MyOptions>, MyOptionsValidator>();With the above, the generated validator ensures that Name is specified, will perform transitive validation of the NestedOptions value, and will perform validation on all AccountOptions instances. Here's an example of the code that generator might produce:
// <auto-generated/>
#nullable enable
#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
#pragma warning disable CS0618 // Type or member is obsolete
namespace Foo
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
internal sealed partial class __AccountOptionsValidator__
{
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>Validation result.</returns>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
public static global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Foo.AccountOptions options)
{
var baseName = (string.IsNullOrEmpty(name) ? "AccountOptions" : name) + ".";
var builder = global::Microsoft.Extensions.Options.Validation.ValidateOptionsResultBuilder.Create();
var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
context.MemberName = "Username";
context.DisplayName = baseName + "Username";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A1.GetValidationResult(options.Username, context));
return builder.Build();
}
}
}
#pragma warning disable CS0618 // Type or member is obsolete
namespace Foo
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
internal sealed partial class __NestedOptionsValidator__
{
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>Validation result.</returns>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
public static global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Foo.NestedOptions options)
{
var baseName = (string.IsNullOrEmpty(name) ? "NestedOptions" : name) + ".";
var builder = global::Microsoft.Extensions.Options.Validation.ValidateOptionsResultBuilder.Create();
var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
context.MemberName = "Value";
context.DisplayName = baseName + "Value";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A2.GetValidationResult(options.Value, context));
return builder.Build();
}
}
}
#pragma warning disable CS0618 // Type or member is obsolete
namespace Foo
{
partial class AccountOptionsValidator
{
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>Validation result.</returns>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Foo.AccountOptions options)
{
var baseName = (string.IsNullOrEmpty(name) ? "AccountOptions" : name) + ".";
var builder = global::Microsoft.xtensions.Options.Validation.ValidateOptionsResultBuilder.Create();
var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
context.MemberName = "Username";
context.DisplayName = baseName + "Username";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A1.GetValidationResult(options.Username, context));
return builder.Build();
}
}
}
#pragma warning disable CS0618 // Type or member is obsolete
namespace Foo
{
partial class MyOptionsValidator
{
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>Validation result.</returns>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Foo.MyOptions options)
{
var baseName = (string.IsNullOrEmpty(name) ? "MyOptions" : name) + ".";
var builder = global::Microsoft.Extensions.Options.Validation.ValidateOptionsResultBuilder.Create();
var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
context.MemberName = "Name";
context.DisplayName = baseName + "Name";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A1.GetValidationResult(options.Name, context));
if (options.Nested != null)
{
builder.AddErrors(global::Foo.__NestedOptionsValidator__.Validate(baseName + "Nested", options.Nested));
}
if (options.Accounts != null)
{
var count = 0;
foreach (var o in options.Accounts)
{
if (o is not null)
{
builder.AddErrors(global::Foo.__AccountOptionsValidator__.Validate(baseName + $"Accounts[{count++}]", o));
}
else
{
builder.AddError(baseName + $"Accounts[{count++}] is null");
}
}
}
return builder.Build();
}
}
}
#pragma warning disable CS0618 // Type or member is obsolete
namespace Foo
{
partial class NestedOptionsValidator
{
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>Validation result.</returns>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Foo.NestedOptions options)
{
var baseName = (string.IsNullOrEmpty(name) ? "NestedOptions" : name) + ".";
var builder = global::Microsoft.Extensions.Options.Extensions.Options.Validation.ValidateOptionsResultBuilder.Create();
var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
context.MemberName = "Value";
context.DisplayName = baseName + "Value";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A2.GetValidationResult(options.Value, context));
return builder.Build();
}
}
}
#pragma warning disable CS0618 // Type or member is obsolete
namespace Microsoft.Extensions.Options.ClusterMetadata.ServiceFabric
{
partial class ServiceFabricMetadataValidator
{
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is <see langword="null" />).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>Validation result.</returns>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Microsoft.Extensions.Options.ClusterMetadata.ServiceFabric.ServiceFabricMetadata options)
{
var baseName = (string.IsNullOrEmpty(name) ? "ServiceFabricMetadata" : name) + ".";
var builder = global::Microsoft.Extensions.Options.Validation.ValidateOptionsResultBuilder.Create();
var context = new global::System.ComponentModel.DataAnnotations.ValidationContext(options);
context.MemberName = "ServiceName";
context.DisplayName = baseName + "ServiceName";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A1.GetValidationResult(options.ServiceName, context));
context.MemberName = "ReplicaOrInstanceId";
context.DisplayName = baseName + "ReplicaOrInstanceId";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A3.GetValidationResult(options.ReplicaOrInstanceId, context));
context.MemberName = "ApplicationName";
context.DisplayName = baseName + "ApplicationName";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A1.GetValidationResult(options.ApplicationName, context));
context.MemberName = "NodeName";
context.DisplayName = baseName + "NodeName";
builder.AddError(global::__OptionValidationStaticInstances.__Attributes.A1.GetValidationResult(options.NodeName, context));
return builder.Build();
}
}
}
namespace __OptionValidationStaticInstances
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
internal static class __Attributes
{
internal static readonly global::System.ComponentModel.DataAnnotations.RequiredAttribute A1 = new global::System.ComponentModel.DataAnnotations.RequiredAttribute();
internal static readonly global::System.ComponentModel.DataAnnotations.RangeAttribute A2 = new global::System.ComponentModel.DataAnnotations.RangeAttribute(
(int)0,
(int)10);
internal static readonly global::System.ComponentModel.DataAnnotations.RangeAttribute A3 = new global::System.ComponentModel.DataAnnotations.RangeAttribute(
(double)0,
(double)9.223372036854776E+18);
}
}
namespace __OptionValidationStaticInstances
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Options.Generators", "1.0.0.0")]
internal static class __Validators
{
}
}Note that the generated code shown here depends on #77404
Alternative Designs
No response
Risks
No response
Reacted by Maryam Ariyan, N0D4N and Paulus Pärssinen
Metadata
Metadata
Assignees
Labels
api-approvedAPI was approved in API review, it can be implementedAPI was approved in API review, it can be implementedarea-Extensions-OptionsblockingMarks issues that we want to fast track in order to unblock other important workMarks issues that we want to fast track in order to unblock other important workpartner-impactThis issue impacts a partner who needs to be kept updatedThis issue impacts a partner who needs to be kept updated
Activity
eerhardt commentedon Jan 10, 2023
Would it make sense to define this source generator at the
System.ComponentModel.DataAnnotationslibrary level? That way other libraries using DataAnnotations (e.g. ASP.NET model validation) can take advantage of it as well.tarekgh commentedon Jan 10, 2023
Would that need to introduce a dependency on
Microsoft.Extensions.OptionsfromSystem.ComponentModel.DataAnnotations?eerhardt commentedon Jan 10, 2023
No, that isn't the right layering. There is an Microsoft.Extensions.Options.DataAnnotations library that has a dependency on System.ComponentModel.DataAnnotations.
My thinking is that the core of this validation source generator would be written in terms of
System.ComponentModel.DataAnnotations, then each API that uses DataAnnotations underneath (MS.Ext.Options, ASP.NET, etc.) could reuse this core validation source generator logic.DamianEdwards commentedon Apr 4, 2023
We should consider having the generator emit code that uses the
Validator.TryValidateValuesmethod, rather than using theValidationAttribute.GetValidationResultmethod on each relevant attribute. This is to ensure the two-phase validation logic that first checks forRequiredAttributeis always run (which is contained inValidator).Additionally, we should ensure the generated code is linker and native AOT friendly. This could be as simple as suppressing the warning from
ValidationContext(object instance)as the generated code will root the instance type anyway, and the conditional reflection that bothValidationAttribute.GetValidationResultandValidator.TryValidateValuescan end up performing to discoverDisplayAttributeon validated members ifValidationContext.MemberNameis set will be avoided asValidationContext.DisplayNameis being set as well which disables that path.tarekgh commentedon Apr 6, 2023
@geeknoid
Is it anticipated that users will ever provide an implementation within classes designated with the
OptionsValidatorattribute? I ask because it seems peculiar to require users to provide an empty class every time just to generate the implementation code for theIValidateOptionsinterface.geeknoid commentedon Apr 7, 2023
@tarekgh No, in general there isn't anything particularly useful you can put as implementation logic in those classes. I surveyed our source base and out of 163 uses of the attribute, only one had a single constant defined in the class, which is used elsewhere in the project.
When originally designing this stuff, I had it so it was possible to provide some hand-written validation logic that the generator code would invoke. But then it turned out to be easier to just capture that in completely different validator types, so I took that feature out of the generator.
The generator needs four bits of state:
You can imagine this attribute pattern instead of the current approach:
Not great, but it could get better with some reasonable defaults:
Then you'd get:
which is indeed concise. If the validator is defined in the same assembly as the option type being validated (the 99.99% case), then you could also support annotating the option type itself:
Which is even more concise.
tarekgh commentedon Apr 9, 2023
@geeknoid Having both the assembly generic attribute and an attribute that can be directly applied to the Options type is great because it offers simplicity and flexibility in usage. Could you please update the proposal to reflect that?
tarekgh commentedon Apr 9, 2023
One more note, I am not good in naming in general, the design guidelines suggest not naming the classes as verbs.
Would keeping the name as
OptionsValidatorAttributeor something like that would be better? The same applies toValidateTransitivelyandValidateEnumerableAttribute. (Suggestions likeDependenciesValidatiorAttributeandEnumerableValidationAttribute).I see we already have
CompareAttributethough :-(DamianEdwards commentedon Apr 9, 2023
The
ValidateTransitelyattribute shouldn't be limited to options validation. Using a customValidationAttributeto implement recursive validation has been a common technique documented online for many years now. If we're going to introduce support for recursive validation via an attribute I suggest we do so by following that established pattern, so something specific to the options validator source generator.WRT to the design of the generator attributes, we seemingly have three established patterns for using source generators in the framework today:
partialclass from an in-framework base class and decorate that class with an attribute to opt into the source generator, e.g. theSystem.Text.Jsonsource generation patternpartialmethod of a specific signature and decorate that method with an attribute to opt into the source generator, e.g. theRegexsource generation patternMicrosoft.Extentions.Configurationbinding patternThe original proposed design for the options validation source generator seems to match the first pattern and leaves the user in control of aspects including the namespace, etc., but the subsequent proposals deviate from this and any other established pattern. Are we suggesting introducing a new BCL source generator pattern?
@stephentoub @eerhardt
tarekgh commentedon Apr 9, 2023
I am not sure we have to restrict ourselves to the options we have. We should do the right things regardless. Do you see any issue with the modified proposal? Do you see this can create any confusion? I am thinking more about the dev experience here.
tarekgh commentedon Apr 9, 2023
This is a good point. I think the pattern is to extend
ValidationAttributeand we can name the new attribute specific to Options. @jeffhandley do you have any feedback on this part? do we have patterns for IEnumerable attribute too?jeffhandley commentedon Apr 11, 2023
Great points on both topics, @DamianEdwards.
Source Generator Trigger
I was leaning toward the attribution on the options class, but heeding the point of not wanting to inadvertently introduce another way to trigger source generators, I lean back to the user declaring the partial class and annotating it as shown here.
That approach avoids challenges around naming collisions, access modifiers, and anything else where we then need to introduce a meta-model for giving us details for the type's definition. And it also leaves open the extensibility point (thus far unused, mind you) for user code to be incorporated into the generated code, perhaps overriding what the generator would do.
Transitive Validation
I think the topics that we would need to address for transitive validation to be handled in a general-purpose manner are:
For these reasons, I've shied away from trying to provide a general-purpose transitive validator. But with either a general-purpose or options-special-purpose transitive validator, I would prefer the approach of it being defined as a derivative of
ValidationAttribute.12 remaining items
terrajobst commentedon May 2, 2023
Video
OptionsValidatorAttributeIValidateOptions<T>which isMicrosoft.Extensions.OptionsSystem.ComponentModel.DataAnnotationsValidateTransitivelyAttributeValidateObjectMembersAttributeValidateEnumerableAttributeValidateEnumeratedItemsAttributeMicrosoft.Extensions.Optionsis in-boxMicrosoft.Extensions.Optionsfor people consuming it as suchDamianEdwards commentedon May 2, 2023
Something that we didn't explicitly discuss in API review was support for
IValidatableObjectin the options validation source generator and the new validation attributes. TheIValidatableObjectinterface exists in theSystem.ComponentModel.DataAnnotationsnamespace and thus I think it's reasonable to expect that the options validator source generator and the new attributes being introduced here (ValidateOptionsMembersandValidateEnumeratedItems) based onDataAnnotationsshould include support for types that implementIValidatableObject. That potentially introduces some complication WRT the nameValidateObjectMembers, e.g. the following:The source generator should suppotr the top level options type itself implementing
IValidatableObjecttoo, e.g.:tarekgh commentedon Jun 20, 2023
#87587