Skip to content

[API Proposal]: Make it easier to create quality ValidateOptionsResult instances #77404

Closed
@geeknoid

Description

@geeknoid

Background and motivation

When writing implementations of the IValidateOptions.Validate, it is necessary to return a ValidateOptionsResult instance describing the result of validating input. Producing a quality instance is currently fairly clumsy, leading to 'peel the onion' validation errors. That is, validation usually terminates on the first validation error. This means as a user, you end up having to do the "edit/run/edit/run/edit/run" cycle multiple times until all the errors are addressed.

The proposal introduces a builder object that makes it simple for validation code to proceed incrementally and accumulate all the validation errors discovered.

API Proposal

namespace Microsoft.Extensions.Options;

/// <summary>
/// Builds <see cref="ValidateOptionsResult"/> with support for multiple error messages.
/// </summary>
[DebuggerDisplay("{_errors.Count} errors")]
public readonly struct ValidateOptionsResultBuilder
{
    /// <summary>
    /// Initializes a new instance of the <see cref="ValidateOptionsResultBuilder"/> struct.
    /// </summary>
    /// <param name="errors">An enumeration of error strings to initialize the builder with.</param>
    public ValidateOptionsResultBuilder(IEnumerable<string>? errors);

    /// <summary>
    /// Creates new instance of <see cref="ValidateOptionsResultBuilder"/> struct.
    /// </summary>
    /// <returns>New instance of <see cref="ValidateOptionsResultBuilder"/>.</returns>
    public static ValidateOptionsResultBuilder Create();

    /// <summary>
    /// Adds validation error.
    /// </summary>
    /// <param name="error">Content of error message.</param>
    public void AddError(string error);

    /// <summary>
    /// Adds validation error.
    /// </summary>
    /// <param name="propertyName">THe property in the option object which contains an error.</param>
    /// <param name="error">Content of error message.</param>
    public void AddError(string propertyName, string error);

    /// <summary>
    /// Adds any validation error carried by the <see cref="ValidationResult"/> instance to this instance.
    /// </summary>
    /// <param name="result">The instance to consume the errors from.</param>
    public void AddError(ValidationResult? result);

    /// <summary>
    /// Adds any validation error carried by the enumeration of <see cref="ValidationResult"/> instances to this instance.
    /// </summary>
    /// <param name="results">The enumeration to consume the errors from.</param>
    public void AddErrors(IEnumerable<ValidationResult>? results);

    /// <summary>
    /// Adds any validation errors carried by the <see cref="ValidateOptionsResult"/> instance to this instance.
    /// </summary>
    /// <param name="result">The instance to consume the errors from.</param>
    public void AddErrors(ValidateOptionsResult? result);

    /// <summary>
    /// Builds <see cref="ValidateOptionsResult"/> based on provided data.
    /// </summary>
    /// <returns>New instance of <see cref="ValidateOptionsResult"/>.</returns>
    public ValidateOptionsResult Build();
}

API Usage

internal sealed class HttpStandardResilienceOptionsCustomValidator : IValidateOptions<HttpStandardResilienceOptions>
{
    private const int CircuitBreakerTimeoutMultiplier = 2;

    public ValidateOptionsResult Validate(string name, HttpStandardResilienceOptions options)
    {
        var builder = ValidateOptionsResultBuilder.Create();

        if (options.AttemptTimeoutOptions.TimeoutInterval > options.TotalRequestTimeoutOptions.TimeoutInterval)
        {
            builder.AddError($"Total request timeout policy must have a greater timeout than the attempt timeout policy. " +
                $"Total Request Timeout: {options.TotalRequestTimeoutOptions.TimeoutInterval.TotalSeconds}s, " +
                $"Attempt Timeout: {options.AttemptTimeoutOptions.TimeoutInterval.TotalSeconds}s");
        }

        if (options.CircuitBreakerOptions.SamplingDuration < TimeSpan.FromMilliseconds(options.AttemptTimeoutOptions.TimeoutInterval.TotalMilliseconds * CircuitBreakerTimeoutMultiplier))
        {
            builder.AddError("The sampling duration of circuit breaker policy needs to be at least double of " +
                $"an attempt timeout policy’s timeout interval, in order to be effective. " +
                $"Sampling Duration: {options.CircuitBreakerOptions.SamplingDuration.TotalSeconds}s," +
                $"Attempt Timeout: {options.AttemptTimeoutOptions.TimeoutInterval.TotalSeconds}s");
        }

        if (options.RetryOptions.RetryCount != RetryPolicyOptions.InfiniteRetry)
        {
            TimeSpan retrySum = options.RetryOptions.GetRetryPolicyDelaySum();

            if (retrySum > options.TotalRequestTimeoutOptions.TimeoutInterval)
            {
                builder.AddError($"The cumulative delay of the retry policy cannot be larger than total request timeout policy interval. " +
                $"Cumulative Delay: {retrySum.TotalSeconds}s," +
                $"Total Request Timeout: {options.TotalRequestTimeoutOptions.TimeoutInterval.TotalSeconds}s");
            }
        }

        return builder.Build();
    }
}

Alternative Designs

No response

Risks

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-approvedAPI was approved in API review, it can be implementedapi-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-Extensions-Optionspartner-impactThis issue impacts a partner who needs to be kept updated

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions