up
This commit is contained in:
@@ -8,7 +8,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.DependencyInjection.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring fail-fast options validation on application startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail-fast validation ensures configuration errors are detected immediately at startup
|
||||
/// rather than at first use, following the principle of "fail early, fail loudly."
|
||||
/// </remarks>
|
||||
public static class FailFastOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds options with a validator and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure.</typeparam>
|
||||
/// <typeparam name="TValidator">The validator type that implements <see cref="IValidateOptions{TOptions}"/>.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithValidation<TOptions, TValidator>(
|
||||
this IServiceCollection services,
|
||||
string sectionName)
|
||||
where TOptions : class
|
||||
where TValidator : class, IValidateOptions<TOptions>
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds options with a validator instance and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <param name="validator">The validator instance.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithValidation<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string sectionName,
|
||||
IValidateOptions<TOptions> validator)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton(validator);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds options with inline validation and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <param name="validate">The validation function. Returns null/empty for success, or error messages for failure.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithValidation<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string sectionName,
|
||||
Func<TOptions, IEnumerable<string>?> validate)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.Validate(options =>
|
||||
{
|
||||
var errors = validate(options);
|
||||
return errors == null || !errors.Any();
|
||||
}, "Configuration validation failed. See logs for details.")
|
||||
.ValidateOnStart();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds options with data annotation validation and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure. Must have DataAnnotations attributes.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithDataAnnotations<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string sectionName)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an existing options configuration to validate on start.
|
||||
/// Use when options are already registered but need fail-fast validation added.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection ValidateOptionsOnStart<TOptions>(this IServiceCollection services)
|
||||
where TOptions : class
|
||||
{
|
||||
// Force options validation at startup by resolving IOptions<TOptions>
|
||||
services.AddHostedService<OptionsValidationHostedService<TOptions>>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.DependencyInjection.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted service that validates options at application startup.
|
||||
/// This ensures configuration errors are detected early rather than at first use.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to validate.</typeparam>
|
||||
internal sealed class OptionsValidationHostedService<TOptions> : IHostedService
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly IOptions<TOptions> _options;
|
||||
|
||||
public OptionsValidationHostedService(IOptions<TOptions> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Accessing .Value triggers validation if IValidateOptions<TOptions> is registered
|
||||
_ = _options.Value;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.DependencyInjection.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for implementing options validators with a fluent error collection pattern.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to validate.</typeparam>
|
||||
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the configuration section prefix used in error messages.
|
||||
/// Override to customize the prefix (e.g., "MyModule:Settings").
|
||||
/// </summary>
|
||||
protected virtual string SectionPrefix => typeof(TOptions).Name.Replace("Options", string.Empty);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, TOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var context = new ValidationContext(SectionPrefix);
|
||||
ValidateOptions(options, context);
|
||||
|
||||
return context.HasErrors
|
||||
? ValidateOptionsResult.Fail(context.Errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override this method to implement validation logic.
|
||||
/// Use the context to add errors.
|
||||
/// </summary>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <param name="context">The validation context for collecting errors.</param>
|
||||
protected abstract void ValidateOptions(TOptions options, ValidationContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Provides a fluent interface for collecting validation errors.
|
||||
/// </summary>
|
||||
protected sealed class ValidationContext
|
||||
{
|
||||
private readonly List<string> _errors = new();
|
||||
private readonly string _sectionPrefix;
|
||||
|
||||
internal ValidationContext(string sectionPrefix)
|
||||
{
|
||||
_sectionPrefix = sectionPrefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collected errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors => _errors;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any errors have been added.
|
||||
/// </summary>
|
||||
public bool HasErrors => _errors.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a validation error for a specific property.
|
||||
/// </summary>
|
||||
/// <param name="propertyName">The property name (e.g., "MaxRetries").</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddError(string propertyName, string message)
|
||||
{
|
||||
_errors.Add($"{_sectionPrefix}:{propertyName} {message}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a validation error for a nested property.
|
||||
/// </summary>
|
||||
/// <param name="parentProperty">The parent property name.</param>
|
||||
/// <param name="childProperty">The child property name.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddError(string parentProperty, string childProperty, string message)
|
||||
{
|
||||
_errors.Add($"{_sectionPrefix}:{parentProperty}:{childProperty} {message}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a general validation error.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddGeneralError(string message)
|
||||
{
|
||||
_errors.Add($"{_sectionPrefix}: {message}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditionally adds an error if the condition is true.
|
||||
/// </summary>
|
||||
/// <param name="condition">The condition to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddErrorIf(bool condition, string propertyName, string message)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
AddError(propertyName, message);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a string property is not null or whitespace.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequireNotEmpty(string? value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
AddError(propertyName, "is required and cannot be empty.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a numeric property is greater than zero.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequirePositive(int value, string propertyName)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
AddError(propertyName, "must be greater than zero.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a TimeSpan property is greater than zero.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequirePositive(TimeSpan value, string propertyName)
|
||||
{
|
||||
if (value <= TimeSpan.Zero)
|
||||
{
|
||||
AddError(propertyName, "must be greater than zero.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a value is within a specified range.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <param name="min">The minimum allowed value (inclusive).</param>
|
||||
/// <param name="max">The maximum allowed value (inclusive).</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequireInRange<T>(T value, string propertyName, T min, T max)
|
||||
where T : IComparable<T>
|
||||
{
|
||||
if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0)
|
||||
{
|
||||
AddError(propertyName, $"must be between {min} and {max}.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user