From cea619f5d838d1bbb22866761d21259653837877 Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Mon, 30 Jul 2018 11:52:40 -0700 Subject: [PATCH] Add support for options validation (#266) --- .../IValidateOptions.cs | 20 +++ .../OptionsBuilder.cs | 21 +++ .../OptionsFactory.cs | 31 +++- .../OptionsValidationException.cs | 26 +++ .../ValidateOptions.cs | 52 ++++++ .../ValidateOptionsResult.cs | 49 +++++ .../OptionsBuilderTest.cs | 170 ++++++++++++++++++ 7 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Extensions.Options/IValidateOptions.cs create mode 100644 src/Microsoft.Extensions.Options/OptionsValidationException.cs create mode 100644 src/Microsoft.Extensions.Options/ValidateOptions.cs create mode 100644 src/Microsoft.Extensions.Options/ValidateOptionsResult.cs diff --git a/src/Microsoft.Extensions.Options/IValidateOptions.cs b/src/Microsoft.Extensions.Options/IValidateOptions.cs new file mode 100644 index 0000000..a16a794 --- /dev/null +++ b/src/Microsoft.Extensions.Options/IValidateOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Options +{ + /// + /// Interface used to validate options. + /// + /// The options type to validate. + public interface IValidateOptions where TOptions : class + { + /// + /// Validates a specific named options instance (or all when name is null). + /// + /// The name of the options instance being validated. + /// The options instance. + /// The result. + ValidateOptionsResult Validate(string name, TOptions options); + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Options/OptionsBuilder.cs b/src/Microsoft.Extensions.Options/OptionsBuilder.cs index b1769aa..4721640 100644 --- a/src/Microsoft.Extensions.Options/OptionsBuilder.cs +++ b/src/Microsoft.Extensions.Options/OptionsBuilder.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Options @@ -255,5 +256,25 @@ public virtual OptionsBuilder PostConfigure(Action Validate(Func validation) + => Validate(name: Options.DefaultName, validation: validation, failureMessage: "A validation error has occured."); + + public virtual OptionsBuilder Validate(string name, Func validation) + => Validate(name: name, validation: validation, failureMessage: "A validation error has occured."); + + public virtual OptionsBuilder Validate(Func validation, string failureMessage) + => Validate(name: Options.DefaultName, validation: validation, failureMessage: failureMessage); + + public virtual OptionsBuilder Validate(string name, Func validation, string failureMessage) + { + if (validation == null) + { + throw new ArgumentNullException(nameof(validation)); + } + + Services.AddSingleton>(new ValidateOptions(name, validation, failureMessage)); + return this; + } } } \ No newline at end of file diff --git a/src/Microsoft.Extensions.Options/OptionsFactory.cs b/src/Microsoft.Extensions.Options/OptionsFactory.cs index 934a285..a5aee55 100644 --- a/src/Microsoft.Extensions.Options/OptionsFactory.cs +++ b/src/Microsoft.Extensions.Options/OptionsFactory.cs @@ -13,16 +13,27 @@ public class OptionsFactory : IOptionsFactory where TOptions { private readonly IEnumerable> _setups; private readonly IEnumerable> _postConfigures; + private readonly IEnumerable> _validations; /// /// Initializes a new instance with the specified options configurations. /// /// The configuration actions to run. /// The initialization actions to run. - public OptionsFactory(IEnumerable> setups, IEnumerable> postConfigures) + public OptionsFactory(IEnumerable> setups, IEnumerable> postConfigures) : this(setups, postConfigures, validations: null) + { } + + /// + /// Initializes a new instance with the specified options configurations. + /// + /// The configuration actions to run. + /// The initialization actions to run. + /// The validations to run. + public OptionsFactory(IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> validations) { _setups = setups; _postConfigures = postConfigures; + _validations = validations; } public TOptions Create(string name) @@ -43,6 +54,24 @@ public TOptions Create(string name) { post.PostConfigure(name, options); } + + if (_validations != null) + { + var failures = new List(); + foreach (var validate in _validations) + { + var result = validate.Validate(name, options); + if (result.Failed) + { + failures.Add(result.FailureMessage); + } + } + if (failures.Count > 0) + { + throw new OptionsValidationException(failures); + } + } + return options; } } diff --git a/src/Microsoft.Extensions.Options/OptionsValidationException.cs b/src/Microsoft.Extensions.Options/OptionsValidationException.cs new file mode 100644 index 0000000..0c5726a --- /dev/null +++ b/src/Microsoft.Extensions.Options/OptionsValidationException.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Options +{ + /// + /// Thrown when options validation fails. + /// + public class OptionsValidationException : Exception + { + /// + /// Constructor. + /// + /// The validation failure messages. + public OptionsValidationException(IEnumerable failureMessages) + => Failures = failureMessages ?? new List(); + + /// + /// The validation failures. + /// + public IEnumerable Failures { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Options/ValidateOptions.cs b/src/Microsoft.Extensions.Options/ValidateOptions.cs new file mode 100644 index 0000000..0d8e5b6 --- /dev/null +++ b/src/Microsoft.Extensions.Options/ValidateOptions.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Options +{ + /// + /// Implementation of + /// + /// The instance being validated. + public class ValidateOptions : IValidateOptions where TOptions : class + { + public ValidateOptions(string name, Func validation, string failureMessage) + { + Name = name; + Validation = validation; + FailureMessage = failureMessage; + } + + /// + /// The options name. + /// + public string Name { get; } + + /// + /// The validation action. + /// + public Func Validation { get; } + + /// + /// The error to return when validation fails. + /// + public string FailureMessage { get; } + + public ValidateOptionsResult Validate(string name, TOptions options) + { + // Null name is used to configure all named options. + if (Name == null || name == Name) + { + if ((Validation?.Invoke(options)).Value) + { + return ValidateOptionsResult.Success; + } + return ValidateOptionsResult.Fail(FailureMessage); + } + + // Ignored if not validating this instance. + return ValidateOptionsResult.Skip; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Options/ValidateOptionsResult.cs b/src/Microsoft.Extensions.Options/ValidateOptionsResult.cs new file mode 100644 index 0000000..4947f16 --- /dev/null +++ b/src/Microsoft.Extensions.Options/ValidateOptionsResult.cs @@ -0,0 +1,49 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Options +{ + /// + /// Represents the result of an options validation. + /// + public class ValidateOptionsResult + { + /// + /// Result when validation was skipped due to name not matching. + /// + public static readonly ValidateOptionsResult Skip = new ValidateOptionsResult() { Skipped = true }; + + /// + /// Validation was successful. + /// + public static readonly ValidateOptionsResult Success = new ValidateOptionsResult() { Skipped = true }; + + /// + /// True if validation was successful. + /// + public bool Succeeded { get; protected set; } + + /// + /// True if validation was not run. + /// + public bool Skipped { get; protected set; } + + /// + /// True if validation failed. + /// + public bool Failed { get; protected set; } + + /// + /// Used to describe why validation failed. + /// + public string FailureMessage { get; protected set; } + + /// + /// Returns a failure result. + /// + /// The reason for the failure. + /// The failure result. + public static ValidateOptionsResult Fail(string failureMessage) + => new ValidateOptionsResult { Failed = true, FailureMessage = failureMessage }; + } +} \ No newline at end of file diff --git a/test/Microsoft.Extensions.Options.Test/OptionsBuilderTest.cs b/test/Microsoft.Extensions.Options.Test/OptionsBuilderTest.cs index 1869d2a..415f7b7 100644 --- a/test/Microsoft.Extensions.Options.Test/OptionsBuilderTest.cs +++ b/test/Microsoft.Extensions.Options.Test/OptionsBuilderTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -233,5 +234,174 @@ public void CanNamedBindToNonPublicProperties(string property) Assert.Equal("stuff", options.GetType().GetProperty(property).GetValue(options)); } + [Fact] + public void CanValidateOptionsWithCustomError() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Boolean = false) + .Validate(Options.DefaultName, o => o.Boolean, "Boolean must be true."); + var sp = services.BuildServiceProvider(); + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + Assert.Equal("Boolean must be true.", error.Failures.First()); + } + + [Fact] + public void CanValidateOptionsWithDefaultError() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Boolean = false) + .Validate(o => o.Boolean); + var sp = services.BuildServiceProvider(); + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + Assert.Equal("A validation error has occured.", error.Failures.First()); + } + + [Fact] + public void CanValidateOptionsWithMultipleDefaultErrors() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => + { + o.Boolean = false; + o.Integer = 11; + }) + .Validate(o => o.Boolean) + .Validate(o => o.Integer > 12); + + var sp = services.BuildServiceProvider(); + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + var errors = error.Failures.ToArray(); + Assert.Equal(2, errors.Length); + Assert.Equal("A validation error has occured.", errors[0]); + Assert.Equal("A validation error has occured.", errors[1]); + } + + [Fact] + public void CanValidateOptionsWithMixedOverloads() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => + { + o.Boolean = false; + o.Integer = 11; + o.Virtual = "wut"; + }) + .Validate(o => o.Boolean) + .Validate(Options.DefaultName, o => o.Virtual == null, "Virtual") + .Validate(o => o.Integer > 12, "Integer"); + + var sp = services.BuildServiceProvider(); + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + var errors = error.Failures.ToArray(); + Assert.Equal(3, errors.Length); + Assert.Equal("A validation error has occured.", errors[0]); + Assert.Equal("Virtual", errors[1]); + Assert.Equal("Integer", errors[2]); + } + + private class MultiOptionValidator : IValidateOptions, IValidateOptions + { + private readonly string _allowed; + public MultiOptionValidator(string allowed) => _allowed = allowed; + + public ValidateOptionsResult Validate(string name, ComplexOptions options) + { + if (options.Virtual == _allowed) + { + return ValidateOptionsResult.Success; + } + return ValidateOptionsResult.Fail("Virtual != " + _allowed); + } + + public ValidateOptionsResult Validate(string name, FakeOptions options) + { + if (options.Message == _allowed) + { + return ValidateOptionsResult.Success; + } + return ValidateOptionsResult.Fail("Message != " + _allowed); + } + } + + [Fact] + public void CanValidateMultipleOptionsWithOneValidator() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Virtual = "wut"); + services.AddOptions("fake") + .Configure(o => o.Message = "real"); + + var validator = new MultiOptionValidator("real"); + services.AddSingleton>(validator); + services.AddSingleton>(validator); + + var sp = services.BuildServiceProvider(); + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + Assert.Single(error.Failures); + Assert.Equal("Virtual != real", error.Failures.First()); + + error = Assert.Throws(() => sp.GetRequiredService>().Value); + Assert.Single(error.Failures); + Assert.Equal("Message != real", error.Failures.First()); + + var fake = sp.GetRequiredService>().Get("fake"); + Assert.Equal("real", fake.Message); + } + + private class DependencyValidator : IValidateOptions + { + private readonly string _allowed; + public DependencyValidator(IOptions _fake) + { + _allowed = _fake.Value.Message; + } + + public ValidateOptionsResult Validate(string name, ComplexOptions options) + { + if (options.Virtual == _allowed) + { + return ValidateOptionsResult.Success; + } + return ValidateOptionsResult.Fail("Virtual != " + _allowed); + } + } + + [Fact] + public void CanValidateOptionsThatDependOnOptions() + { + var services = new ServiceCollection(); + services.AddOptions() + .Configure(o => o.Virtual = "default"); + services.AddOptions("yes") + .Configure(o => o.Virtual = "target"); + services.AddOptions("no") + .Configure(o => o.Virtual = "no"); + services.AddOptions() + .Configure(o => o.Message = "target"); + services.AddSingleton, DependencyValidator>(); + + var sp = services.BuildServiceProvider(); + + var error = Assert.Throws(() => sp.GetRequiredService>().Value); + Assert.Single(error.Failures); + Assert.Equal("Virtual != target", error.Failures.First()); + + error = Assert.Throws(() => sp.GetRequiredService>().Get(Options.DefaultName)); + Assert.Single(error.Failures); + Assert.Equal("Virtual != target", error.Failures.First()); + + error = Assert.Throws(() => sp.GetRequiredService>().Get("no")); + Assert.Single(error.Failures); + Assert.Equal("Virtual != target", error.Failures.First()); + + var op = sp.GetRequiredService>().Get("yes"); + Assert.Equal("target", op.Virtual); + } + } } \ No newline at end of file