Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API Proposal]: Introduce a code generator to handle option validation #85475

Closed
Tracked by #45910 ...
geeknoid opened this issue Oct 24, 2022 · 23 comments
Closed
Tracked by #45910 ...

[API Proposal]: Introduce a code generator to handle option validation #85475

geeknoid opened this issue Oct 24, 2022 · 23 comments
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-Extensions-Options blocking Marks issues that we want to fast track in order to unblock other important work partner-impact This issue impacts a partner who needs to be kept updated
Milestone

Comments

@geeknoid
Copy link
Member

geeknoid commented Oct 24, 2022

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

@dotnet-issue-labeler
Copy link

I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

@ghost
Copy link

ghost commented Oct 24, 2022

Tagging subscribers to this area: @dotnet/area-extensions-options
See info in area-owners.md if you want to be subscribed.

Issue Details

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 : Attribute
{
    /// <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 : Attribute
{
    /// <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
}

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.R9.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.R9.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.R9.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.R9.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.R9.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.R9.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.R9.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.R9.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
{
    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.R9.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.R9.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.R9.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.R9.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.R9.Extensions.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.R9.Generators", "1.0.0.0")]
        public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Microsoft.R9.Extensions.ClusterMetadata.ServiceFabric.ServiceFabricMetadata options)
        {
            var baseName = (string.IsNullOrEmpty(name) ? "ServiceFabricMetadata" : name) + ".";
            var builder = global::Microsoft.R9.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.R9.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.R9.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

Author: geeknoid
Assignees: -
Labels:

api-suggestion, untriaged, area-Extensions-Options

Milestone: -

@eerhardt
Copy link
Member

Would it make sense to define this source generator at the System.ComponentModel.DataAnnotations library level? That way other libraries using DataAnnotations (e.g. ASP.NET model validation) can take advantage of it as well.

@tarekgh
Copy link
Member

tarekgh commented Jan 10, 2023

Would that need to introduce a dependency on Microsoft.Extensions.Options from System.ComponentModel.DataAnnotations?

@eerhardt
Copy link
Member

Would that need to introduce a dependency on Microsoft.Extensions.Options from System.ComponentModel.DataAnnotations?

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
Copy link
Member

We should consider having the generator emit code that uses the Validator.TryValidateValues method, rather than using the ValidationAttribute.GetValidationResult method on each relevant attribute. This is to ensure the two-phase validation logic that first checks for RequiredAttribute is always run (which is contained in Validator).

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 both ValidationAttribute.GetValidationResult and Validator.TryValidateValues can end up performing to discover DisplayAttribute on validated members if ValidationContext.MemberName is set will be avoided as ValidationContext.DisplayName is being set as well which disables that path.

@tarekgh
Copy link
Member

tarekgh commented Apr 6, 2023

@geeknoid

[OptionsValidator]
internal sealed partial class MyOptionsValidator : IValidateOptions<MyOptions>
{
    // the implementation of this class is generated
}

Is it anticipated that users will ever provide an implementation within classes designated with the OptionsValidator attribute? I ask because it seems peculiar to require users to provide an empty class every time just to generate the implementation code for the IValidateOptions interface.

@geeknoid
Copy link
Member Author

geeknoid commented 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:

  • The type to validate
  • The name of the generated type
  • The namespace and/or parent type of the generated type
  • The visibility modifier of the generated type

You can imagine this attribute pattern instead of the current approach:

[assembly: CreateOptionsValidator(typeof(MyOptions), Name = "Namespace1.Namespace2.MyParentType+MyOptionsValidator", Visibility="internal")]

Not great, but it could get better with some reasonable defaults:

  • The generated type will default to the same namespace, parent type, and name as the type being validated, with a Validator suffix tacked on.
  • The generated type will default to "internal" visibility.
  • We use generic attributes.

Then you'd get:

[assembly: CreateOptionsValidator<MyOptions>]

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:

[CreateOptionsValidator]
public class MyOptions
{
}

Which is even more concise.

@tarekgh
Copy link
Member

tarekgh commented 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
Copy link
Member

tarekgh commented Apr 9, 2023

One more note, I am not good in naming in general, the design guidelines suggest not naming the classes as verbs.

✔️ DO name classes and structs with nouns or noun phrases, using PascalCasing.

Would keeping the name as OptionsValidatorAttribute or something like that would be better? The same applies to ValidateTransitively and ValidateEnumerableAttribute. (Suggestions like DependenciesValidatiorAttribute and EnumerableValidationAttribute).
I see we already have CompareAttribute though :-(

@DamianEdwards
Copy link
Member

The ValidateTransitely attribute shouldn't be limited to options validation. Using a custom ValidationAttribute to 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:

  1. Require user code to declare a partial class from an in-framework base class and decorate that class with an attribute to opt into the source generator, e.g. the System.Text.Json source generation pattern
  2. Require user code to declare a partial method of a specific signature and decorate that method with an attribute to opt into the source generator, e.g. the Regex source generation pattern
  3. Use call-site replacement techniques (global extension methods or pending language feature) to replace original call with generated code, reusing parameter values from user code invocation, e.g. the ASP.NET Core Request Delegate Generator and Microsoft.Extentions.Configuration binding pattern

The 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
Copy link
Member

tarekgh commented Apr 9, 2023

The 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?

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
Copy link
Member

tarekgh commented Apr 9, 2023

Using a custom ValidationAttribute to 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.

This is a good point. I think the pattern is to extend ValidationAttribute and 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
Copy link
Member

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:

  1. Member Name syntax. By keeping the transitive validation operation as an app-level or app-framework-level concern, we've avoided needing to define a general-purpose syntax for nested member names in validation results. For child properties, it's easy to assume dot-notation. But for enumerables, the syntax is less obvious.
  2. Graph Walk Algorithm. Different scenarios require different graph walk approaches. When should short-circuiting occur? Is there a depth limit? What happens when we detect cycles (and already-validated objects have validation results)?

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.

@tarekgh
Copy link
Member

tarekgh commented Apr 11, 2023

Thanks @jeffhandley.

Per feedback,

We'll keep the original proposal mentioned in the description #85475 with the following changes:

  • Make ValidateEnumerableAttribute and ValidateTransitivelyAttribute derive from ValidationAttribute
  • Figure out if the attributes naming is good enough or need to change that. We may discuss that in the design review.

I prefer to list the option listed in the comment #85475 as alternative design option and we can discuss that in the design review too. Just in case we get more feedback on that.

@geeknoid could you please apply the minor change to the proposal? or do you want me to help with that?

@eerhardt @davidfowl @stephentoub please let's know if you have any feedback before we can go ahead and schedule that for design review.

@geeknoid
Copy link
Member Author

@tarekgh What's the implication on the model to make ValidateEnumerableAttribute and ValidateTransitivelyAttribute derive from ValidationAttribute? ValidationAttribute has semantics associated with it (GetValidationResult, IsValid, etc). What would be their purpose/behavior?

@jeffhandley
Copy link
Member

By deriving from ValidationAttribute, the transitive/enumerable validator will plug into the existing validation execution that Validator has in place. That prevents us from having to deviate from or augment Validator's behavior.

It does limit us to producing a single validation result, which I think is the only drawback. If that's a major hurdle though, I'd rather revisit the possibility of a single attribute producing multiple results instead of needing to rely on the consumer augmenting Validator behavior.

@jeffhandley
Copy link
Member

@tarekgh asked a follow-up question about the ValidationAttribute derivation with regard to source generators specifically, where Validator isn't fully responsible for execution of the validation.

I would still promote a ValidationAttribute-based approach because then if the model is validated through Validator, the same results would be produced as what the source generator accomplishes. I expect the source generator to produce code that uses Validator as much as possible but just sidestepping the reflection usage for gathering the metadata of the model.

We would not want traditional- and source-gen-based validation to behave differently, and we would not want the source generator to produce code that diverges from Validator's behavior.

@DamianEdwards
Copy link
Member

I expect the source generator to produce code that uses Validator as much as possible

I think this is the key point. It also makes things like flowing IServiceProvider to validators possible, along with supporting scenarios like an options that implements IValidatableObject.

@tarekgh tarekgh transferred this issue from dotnet/runtime Apr 24, 2023
@ericstj ericstj transferred this issue from another repository Apr 27, 2023
@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Apr 27, 2023
@ghost ghost added the untriaged New issue has not been triaged by the area owner label Apr 27, 2023
@ericstj ericstj transferred this issue from another repository Apr 27, 2023
@ericstj ericstj added area-Extensions-Options and removed untriaged New issue has not been triaged by the area owner needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Apr 27, 2023
@ericstj ericstj assigned tarekgh and unassigned joperezr Apr 27, 2023
@ghost
Copy link

ghost commented Apr 27, 2023

Tagging subscribers to this area: @dotnet/area-extensions-options
See info in area-owners.md if you want to be subscribed.

Issue Details

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 : Attribute
{
    /// <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 : Attribute
{
    /// <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
}

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.R9.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.R9.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.R9.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.R9.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.R9.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.R9.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.R9.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.R9.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
{
    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.R9.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.R9.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.R9.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.R9.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.R9.Extensions.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.R9.Generators", "1.0.0.0")]
        public global::Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, global::Microsoft.R9.Extensions.ClusterMetadata.ServiceFabric.ServiceFabricMetadata options)
        {
            var baseName = (string.IsNullOrEmpty(name) ? "ServiceFabricMetadata" : name) + ".";
            var builder = global::Microsoft.R9.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.R9.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.R9.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

Author: geeknoid
Assignees: joperezr
Labels:

area-Extensions-Options

Milestone: -

@ericstj ericstj added this to the 8.0.0 milestone Apr 27, 2023
@tarekgh tarekgh added partner-impact This issue impacts a partner who needs to be kept updated blocking Marks issues that we want to fast track in order to unblock other important work api-ready-for-review API is ready for review, it is NOT ready for implementation labels Apr 27, 2023
@terrajobst
Copy link
Member

terrajobst commented May 2, 2023

Video

  • OptionsValidatorAttribute
    • This belongs with IValidateOptions<T> which is Microsoft.Extensions.Options
  • The other two attributes seem to belong to System.ComponentModel.DataAnnotations
  • Can we make the code generation a bit simpler so that people don't have to write stubs for the generated types?
  • ValidateTransitivelyAttribute
    • Most customers use the term recursively
    • ValidateObjectMembersAttribute
  • ValidateEnumerableAttribute
    • We're not validating the container.
    • ValidateEnumeratedItemsAttribute
  • The source generator should live in two places
    • The ref pack where Microsoft.Extensions.Options is in-box
    • The package Microsoft.Extensions.Options for people consuming it as such
  • Remove the conditionals
namespace Microsoft.Extensions.Options;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class OptionsValidatorAttribute : Attribute
{
}

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ValidateEnumerableAttribute : ValidationAttribute
{
    public ValidateEnumerableAttribute();
    public ValidateEnumerableAttribute(Type validator);
    public Type? Validator { get; }
}

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class ValidateTransitivelyAttribute : ValidationAttribute
{
    public ValidateTransitivelyAttribute();
    public ValidateTransitivelyAttribute(Type validator);
    public Type? Validator { get; }
}

@terrajobst terrajobst added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels May 2, 2023
@DamianEdwards
Copy link
Member

Something that we didn't explicitly discuss in API review was support for IValidatableObject in the options validation source generator and the new validation attributes. The IValidatableObject interface exists in the System.ComponentModel.DataAnnotations namespace and thus I think it's reasonable to expect that the options validator source generator and the new attributes being introduced here (ValidateOptionsMembers and ValidateEnumeratedItems) based on DataAnnotations should include support for types that implement IValidatableObject. That potentially introduces some complication WRT the name ValidateObjectMembers, e.g. the following:

public class MyFeatureOptions
{
    [ValidateObjectMembers] // This name isn't such a great fit in this scenario
    public MyCustomOptions { get; set; }
}

public class MyCustomOptions : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // Custom validation logic here...
    }
}

The source generator should suppotr the top level options type itself implementing IValidatableObject too, e.g.:

public class MyCustomOptions : IValidatableObject
{
    [Required]
    public required string SomeRequiredSetting { get; set; } 

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // Custom validation logic here...
    }
}

[OptionsValidator]
internal sealed partial class MyCustomOptionsValidator : IValidateOptions<MyCustomOptions>
{

}

@tarekgh
Copy link
Member

tarekgh commented Jun 20, 2023

#87587

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-Extensions-Options blocking Marks issues that we want to fast track in order to unblock other important work partner-impact This issue impacts a partner who needs to be kept updated
Projects
None yet
Development

No branches or pull requests

8 participants