How to validate configuration settings using IValidateOptions in ASP.NET Core 2.2?

前端 未结 4 2172
[愿得一人]
[愿得一人] 2021-02-14 15:57

Microsoft\'s ASP.NET Core documentation briefly mentions that you can implement IValidateOptions to validate configuration settings from appsettings

相关标签:
4条回答
  • 2021-02-14 16:21

    Just build a lib for integrate FluentValidation with Microsoft.Extensions.Options.

    https://github.com/iron9light/FluentValidation.Extensions

    The nuget is here: https://www.nuget.org/packages/IL.FluentValidation.Extensions.Options/

    Sample:

    public class MyOptionsValidator : AbstractValidator<MyOptions> {
        // ...
    }
    
    using IL.FluentValidation.Extensions.Options;
    
    // Registration
    services.AddOptions<MyOptions>("optionalOptionsName")
        .Configure(o => { })
        .Validate<MyOptions, MyOptionsValidator>(); // ❗ Register validator type
    
    // Consumption
    var monitor = services.BuildServiceProvider()
        .GetService<IOptionsMonitor<MyOptions>>();
    
    try
    {
        var options = monitor.Get("optionalOptionsName");
    }
    catch (OptionsValidationException ex)
    {
    }
    
    0 讨论(0)
  • 2021-02-14 16:33

    Probably too late now, but for the benefit of anyone else that stumbles across this...

    Near the bottom of the documentation section (linked to in the question), this line appears

    Eager validation (fail fast at startup) is under consideration for a future release.

    On searching a little more for information on this, I came across this github issue, which provides an IStartupFilter, and an extension method for IOptions (which I've repeated below just incase the issue disappears)...

    This solution ensures that the options are validated ahead of the application "running".

    public static class EagerValidationExtensions {
        public static OptionsBuilder<TOptions> ValidateEagerly<TOptions>(this OptionsBuilder<TOptions> optionsBuilder)
            where TOptions : class, new()
        {
            optionsBuilder.Services.AddTransient<IStartupFilter, StartupOptionsValidation<TOptions>>();
            return optionsBuilder;
        }
    }
    
    public class StartupOptionsValidation<T>: IStartupFilter
    {
        public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
        {
            return builder =>
            {
                var options = builder.ApplicationServices.GetRequiredService(typeof(IOptions<>).MakeGenericType(typeof(T)));
                if (options != null)
                {
                    var optionsValue = ((IOptions<object>)options).Value;
                }
    
                next(builder);
            };
        }
    }
    

    I then have, an extension method called from within ConfigureServices that looks like this

    services
      .AddOptions<SomeOptions>()
      .Configure(options=>{ options.SomeProperty = "abcd" })
      .Validate(x=>
      {
          // do FluentValidation here
      })
      .ValidateEagerly();
    
    0 讨论(0)
  • 2021-02-14 16:43

    I eventually found an example of how this is done in the commit where the options validation feature was added. As with so many things in asp.net core, the answer is to add your validator to the DI container and it will automatically be used.

    With this approach the PolygonConfiguration goes into the DI container after validation and can be injected into the controllers that need it. I prefer this to injecting IOptions<PolygonConfiguration> into my controllers.

    It appears that the validation code runs the first time an instance of PolygonConfiguration is requested from the container (i.e. when the controller is instantiated). It might be nice to validate earlier during startup, but I'm satisfied with this for now.

    Here's what I ended up doing:

    public class Startup
    {
        public Startup(IConfiguration configuration, ILoggerFactory loggerFactory)
        {
            Configuration = configuration;
            Logger = loggerFactory.CreateLogger<Startup>();
        }
    
        public IConfiguration Configuration { get; }
        private ILogger<Startup> Logger { get; }
    
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    
            //Bind configuration settings
            services.Configure<PolygonConfiguration>(Configuration.GetSection(nameof(PolygonConfiguration)));
    
            //Add validator
            services.AddSingleton<IValidateOptions<PolygonConfiguration>, PolygonConfigurationValidator>();
    
            //Validate configuration and add to DI container
            services.AddSingleton<PolygonConfiguration>(container =>
            {
                try
                {
                    return container.GetService<IOptions<PolygonConfiguration>>().Value;
                }
                catch (OptionsValidationException ex)
                {
                    foreach (var validationFailure in ex.Failures)
                        Logger.LogError($"appSettings section '{nameof(PolygonConfiguration)}' failed validation. Reason: {validationFailure}");
    
                    throw;
                }
            });
        }
    
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
           ...
        }
    }
    
    

    appSettings.json with some valid and invalid values

    {
      "PolygonConfiguration": {
        "SupportedPolygons": [
          {
            "Description": "Triangle",
            "NumberOfSides": 3
          },
          {
            "Description": "Invalid",
            "NumberOfSides": -1
          },
          {
            "Description": "",
            "NumberOfSides": 6
          }
        ]
      }
    }
    

    The validator class itself

        public class PolygonConfigurationValidator : IValidateOptions<PolygonConfiguration>
        {
            public ValidateOptionsResult Validate(string name, PolygonConfiguration options)
            {
                if (options is null)
                    return ValidateOptionsResult.Fail("Configuration object is null.");
    
                if (options.SupportedPolygons is null || options.SupportedPolygons.Count == 0)
                    return ValidateOptionsResult.Fail($"{nameof(PolygonConfiguration.SupportedPolygons)} collection must contain at least one element.");
    
                foreach (var polygon in options.SupportedPolygons)
                {
                    if (string.IsNullOrWhiteSpace(polygon.Description))
                        return ValidateOptionsResult.Fail($"Property '{nameof(Polygon.Description)}' cannot be blank.");
    
                    if (polygon.NumberOfSides < 3)
                        return ValidateOptionsResult.Fail($"Property '{nameof(Polygon.NumberOfSides)}' must be at least 3.");
                }
    
                return ValidateOptionsResult.Success;
            }
        }
    

    And the configuration models

        public class Polygon
        {
            public string Description { get; set; }
            public int NumberOfSides { get; set; }
        }
    
        public class PolygonConfiguration
        {
            public List<Polygon> SupportedPolygons { get; set; }
        }
    
    0 讨论(0)
  • 2021-02-14 16:48

    One approach could be to add a trait IValidatable<T> to your configuration classes. Then you could use Data anootations to define what should be validated and what not. I'll provide an example on how to add a side project to your solution that would take care in the general case.

    Here we have the class that we want to validate: Configs/JwtConfig.cs

    using System.ComponentModel.DataAnnotations;
    using SettingValidation.Traits;
    
    namespace Configs
    {
        public class JwtConfig : IValidatable<JwtConfig>
        {
            [Required, StringLength(256, MinimumLength = 32)]
            public string Key { get; set; }
            [Required]
            public string Issuer { get; set; } = string.Empty;
            [Required]
            public string Audience { get; set; } = "*";
            [Range(1, 30)]
            public int ExpireDays { get; set; } = 30;
        }
    }
    
    

    This is the "trait interface" that adds the validation capability (in c# 8 this could be changed to an interface with default methods) SettingValidation/Traits/IValidatable.cs

    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.Linq;
    using Microsoft.Extensions.Logging;
    
    namespace SettingValidation.Traits
    {
        public interface IValidatable
        {
        }
    
        public interface IValidatable<T> : IValidatable
        {
    
        }
    
        public static class IValidatableTrait
        {
            public static void Validate(this IValidatable @this, ILogger logger)
            {
                var validation = new List<ValidationResult>();
                if (Validator.TryValidateObject(@this, new ValidationContext(@this), validation, validateAllProperties: true))
                {
                    logger.LogInformation($"{@this} Correctly validated.");
                }
                else
                {
                    logger.LogError($"{@this} Failed validation.{Environment.NewLine}{validation.Aggregate(new System.Text.StringBuilder(), (sb, vr) => sb.AppendLine(vr.ErrorMessage))}");
                    throw new ValidationException();
                }
            }
        }
    }
    
    

    Once you have this, you need to add a startup filter: SettingValidation/Filters/SettingValidationStartupFilter.cs

    using System.Collections.Generic;
    using Microsoft.Extensions.Logging;
    using SettingValidation.Traits;
    
    namespace SettingValidation.Filters
    {
        public class SettingValidationStartupFilter
        {
            public SettingValidationStartupFilter(IEnumerable<IValidatable> validatables, ILogger<SettingValidationStartupFilter> logger)
            {
                foreach (var validatable in validatables)
                {
                    validatable.Validate(logger);
                }
            }
        }
    }
    
    

    It's convention to add an extension method:

    SettingValidation/Extensions/IServiceCollectionExtensions.cs

    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Options;
    using SettingValidation.Filters;
    using SettingValidation.Traits;
    
    namespace SettingValidation.Extensions
    {
        public static class IServiceCollectionExtensions
        {
    
            public static IServiceCollection UseConfigurationValidation(this IServiceCollection services)
            {
                services.AddSingleton<SettingValidationStartupFilter>();
                using (var scope = services.BuildServiceProvider().CreateScope())
                {
                    // Do not remove this call.
                    // ReSharper disable once UnusedVariable
                    var validatorFilter = scope.ServiceProvider.GetRequiredService<SettingValidationStartupFilter>();
                }
                return services;
            }
    
            //
            // Summary:
            //     Registers a configuration instance which TOptions will bind against.
            //
            // Parameters:
            //   services:
            //     The Microsoft.Extensions.DependencyInjection.IServiceCollection to add the services
            //     to.
            //
            //   config:
            //     The configuration being bound.
            //
            // Type parameters:
            //   TOptions:
            //     The type of options being configured.
            //
            // Returns:
            //     The Microsoft.Extensions.DependencyInjection.IServiceCollection so that additional
            //     calls can be chained.
            public static IServiceCollection ConfigureAndValidate<T>(this IServiceCollection services, IConfiguration config)
                where T : class, IValidatable<T>, new()
            {
                services.Configure<T>(config);
                services.AddSingleton<IValidatable>(r => r.GetRequiredService<IOptions<T>>().Value);
                return services;
            }
        }
    }
    
    

    Finally enable the usage of the startup filter Startup.cs

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            ...
            services.ConfigureAndValidate<JwtConfig>(Configuration.GetSection("Jwt"));
            services.UseConfigurationValidation();
            ...
        }
    }
    

    I remember basing this code from some blog post in the internet I couldn't find right now, maybe it's the same you found, even if you dont use this solution, try refactoring what you did into a different project, so it can be reused in other ASP.NET Core solutions that you have.

    Have a nice day.

    0 讨论(0)
提交回复
热议问题