Home > Mobile >  Dynamic validation of sub-options in ASP.NET Core 6
Dynamic validation of sub-options in ASP.NET Core 6

Time:11-21

Let's say I have the following options structure

using System.ComponentModel.DataAnnotations;

public record AzureOption
{
    public AzureGraphOption? Graph { get; init; }
}

public record AzureGraphOption
{
    public AzureGraphSecretOption? Secret { get; init; }
}

public record AzureGraphSecretOption
{
    [Required] public string TenantId { get; init; }
    [Required] public string ClientId { get; init; }
    [Required] public string ClientSecret { get; init; }
}

And an extension class:

using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Graph;

public static class AzureServiceExtensions
{
    // Add Azure services so we can query the Microsoft Graph and resolve membership for automatic group assignment
    public static IServiceCollection AddAzureServices(this IServiceCollection services)
    {
        services.AddAzureOptions();

        // Add a Azure Token Credentials based on "static" credentials
        // Requires a working Azure app and approvals for several permissions.
        // The app does not have to be operating on behalf of the user.
        services.AddScoped<TokenCredential>(provider =>
        {
            var azureOption = provider.GetRequiredService<IOptionsMonitor<AzureOption>>().CurrentValue;

            return new ClientSecretCredential(
                azureOption.Graph?.Secret?.TenantId,
                azureOption.Graph?.Secret?.ClientId,
                azureOption.Graph?.Secret?.ClientSecret,
                new TokenCredentialOptions
                {
                    AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
                });
        });

        // Add the Microsoft Graph Service, based on the Azure Token
        services.AddScoped(provider => new GraphServiceClient(provider.GetService<TokenCredential>(),
            new[] { "https://graph.microsoft.com/.default" }));
        
        return services;
    }

    public static IServiceCollection AddAzureOptions(this IServiceCollection services)
    {
        services.AddOptions<AzureOption>()
            .BindConfiguration("Azure")
            .ValidateDataAnnotations()
            .ValidateOnStart();

        return services;
    }
}

And a small utility extension class:

using Microsoft.Extensions.Configuration;

public static class ServiceCollectionExtensions
{
    public static IConfigurationBuilder AddSecretConfig(this IConfigurationBuilder config)
    {
        config.AddJsonFile("appsettings.Secret.json", true, true);
        return config;
    }
}

We can also bootstrap a little ASP.NET Core app for testing, if you will:

using WebApplication = Microsoft.AspNetCore.Builder.WebApplication;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddSecretConfig();

builder.Services
    .AddAzureServices();

var app = builder.Build();
app.Run();

I also have a small structure of secret file stored as appsettings.Secret.json in web root:

{
  "Azure": {
    "Graph": {
      "Secret": {
        "TenantId": "30cbfa3f-a625-436a-90ff-e90c3fe8bb8e",
        "ClientId": "37c72790-ee45-4090-a749-c3ff61c43df8",
        "ClientSecret": "qqBGA~5QYza42ABjeWx4o-kAQPJKAGD38wVXCR7Y"
      }
    }
  }
}

It all went good and well, until I decided to delete the secrets:

{
  "Azure": {
    "Graph": {
      "Secret": {
      }
    }
  }
}

However, the app still runs, and does not validate for the fields of Azure:Graph:Secret, because it is now null instead. Thus the Required validations attached on the fields never runs. If you have added a wrong property in Azure:Graph:Secret, this will happen too:

var azureOption = provider.GetRequiredService<IOptionsMonitor<AzureOption>>().CurrentValue;
var tenant = azureOption.Graph.Secret.TenantId; // What???! it is null???!

Of course, this is not desirable. I wanted to have this run dynamically:

var azureOption = provider.GetRequiredService<IOptionsMonitor<AzureOption>>().CurrentValue;
var tenant = azureOption.Graph.Secret.TenantId; // Throws an exception `Required value is not set` instead, while I don't have to handle nullable

One can always choose to add the nested options to match:

    public static IServiceCollection AddAzureOptions(this IServiceCollection services)
    {
        services.AddOptions<AzureOption>()
            .BindConfiguration("Azure")
            .ValidateDataAnnotations()
            .ValidateOnStart();

        services.AddOptions<AzureGraphSecretOption>()
            .BindConfiguration("Azure:Graph:Secret")
            .ValidateDataAnnotations()
            .ValidateOnStart();

        return services;
    }

But doing so is very tedious especially when you have tons of options.

CodePudding user response:

Validation does not run recursively.

When your App is run it tries validate AzureOption instance. Class AzureOption does not have constraints, in this case validation excute with Success result.

You can define new MyRecursiveValidationAttribute : ValidationAttribute that run validation of property value recurvively.

It seems like this (not very clean, but works):

Define new class

public class MyRecursiveValidationAttribute : ValidationAttribute
{
    public override bool IsValid(object? value)
    {
        var isValid = true;

        if (value == null)
        { return isValid; }

        isValid = Validator.TryValidateObject(value, new ValidationContext(value), null);

        return isValid;
    }
}

Modify your classes

public record AzureOption
{
    [MyRecursiveValidation]
    public AzureGraphOption? Graph { get; init; }
}

public record AzureGraphOption
{
    [MyRecursiveValidation]
    public AzureGraphSecretOption? Secret { get; init; }
}

public record AzureGraphSecretOption
{
    [Required] public string TenantId { get; init; }
    [Required] public string ClientId { get; init; }
    [Required] public string ClientSecret { get; init; }
}

CodePudding user response:

Based on some online resources and SO, I have made myself a reference answer, but I don't think this is the best way to do it, because it is "intrusive":

using System.ComponentModel.DataAnnotations;
using System.Collections.Immutable;

public class ValidateObjectAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
    {
        if (value == null)
        {
            var nullable = validationContext.ObjectType
                .GetMember(validationContext.MemberName!)
                .FirstOrDefault()?.CustomAttributes.Any(x => x.AttributeType.Name == "NullableAttribute") ?? false;
            return nullable ? ValidationResult.Success! : new ValidationResult($"{validationContext.DisplayName} is null");
        }

        var results = new List<ValidationResult>();
        Validator.TryValidateObject(value, new ValidationContext(value, null, null), results, true);

        if (results.Count > 0)
        {
            var compositeResults = new CompositeValidationResult($@"Validation for ""{validationContext.DisplayName}"" failed!");
            results.ForEach(compositeResults.AddResult);
            return compositeResults;
        }

        return ValidationResult.Success!;

    }
}

public class CompositeValidationResult : ValidationResult
{
    public IImmutableList<ValidationResult> Results { get; private set; } = ImmutableList<ValidationResult>.Empty;

    public CompositeValidationResult(string errorMessage) : base(errorMessage) { }
    public CompositeValidationResult(string errorMessage, IEnumerable<string> memberNames) : base(errorMessage, memberNames) { }
    protected CompositeValidationResult(ValidationResult validationResult) : base(validationResult) { }

    public void AddResult(ValidationResult validationResult) => Results = Results.Add(validationResult);
}

Then you use it like:

using System.ComponentModel.DataAnnotations;

public record GiteaOption
{
    [Required] public required string BaseUrl { get; init; }
    [ValidateObject]
    public GiteaSecretOption? Secret { get; init; } 
}

public record GiteaSecretOption
{ 
    [Required] public required string AccessToken { get; init; }
}

Now if Secret is not declared to have nullable and is not null, it will also validate the nested class as well.

  • Related