Background
As you most likely know ASP.NET Core supports validating options in the appsettings.json
file. This is a very nice feature, especially when the application contains a lot of configuration options and there could be a risk that the user may provide invalid configuration and cause the application to not work properly. The validation uses DataAnnotations
or direct validation implementations to set restrictions on the values that can be provided for an option. For more information on how to start with validating options please read the official documentation here.
Current version of ASP.NET Core (5.0) does support complex validation, but it comes with some limitations, for instance if your configuration has a complex structure like object containing object or array of objects then you will lose the possibility of using DataAnnotations
. Basically, you can use DataAnnotations
but they will not be validated, and the only way suggested by Microsoft is to use IValidateOptions
to implement Validate
function for the properties in your configuration object. In this blog post I will show you how to get DataAnnotations
back and even show you how to implement more complex DataAnnotations
for your project.
Get Data Annotations back
The simplest way I found to get complex objects to get fully validated, meaning DataAnnotations
in sub-objects and array of objects being effective, was to create a base options implementation that would iterate through all properties and call the validate on them one by one. The base option needs to implement IValidatableObject
so that the inner Validate
function gets triggered when validation is called on the object. Here is the implementation of the base class:
public abstract class BaseOptions<T> : IValidatableObject where T : BaseOptions<T>, new()
{
public virtual IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var results = new List<ValidationResult>();
foreach (var propertyInfo in GetType().GetProperties())
{
var option = propertyInfo.GetValue(this, null);
switch (option)
{
case null:
case string _:
continue;
case IEnumerable list:
{
ValidateList(list, propertyInfo.Name, validationContext, results);
continue;
}
default:
ValidateOption(option, validationContext, results);
continue;
}
}
return results;
}
private static void ValidateList(IEnumerable list, string propertyInfoName, ValidationContext validationContext,
List<ValidationResult> results)
{
foreach (var optionItem in list)
{
if (optionItem == null)
{
results.Add(new ValidationResult($"{propertyInfoName} contains null in the list."));
}
else
{
ValidateOption(optionItem, validationContext, results);
}
}
}
private static void ValidateOption(object option, ValidationContext validationContext,
List<ValidationResult> results)
{
Validator.TryValidateObject(option,
new ValidationContext(option, validationContext, validationContext.Items),
results, true);
}
}
The Validate
function in the BaseOptions
iterates through each property of the object and gets the value of that specific property, then it checks a list of conditions:
- If the value is
null
orstring
, the value is ignored - If the value is of type
IEnumerable
, each item in the list will be validated individually, as if each item is an object that containsDataAnnotations
. - Otherwise, the value will be validated as if it is an object that contains
DataAnnotations
in it.
Be aware that if the property's value is not an object or a list of objects, Validator.TryValidateObject
will ignore the value, so it is not a problem that the Validate
function may validate these values.
Now that we have the BaseOptions
it is time to create a configuration object that will represent a section of the appsettings.json
file. It is important that all configuration objects must implement BaseOptions
to enforce full validation of the configuration options. Below is a simple example which is an expansion of the example in Microsoft documentation. In this example MyConfigOptions
object contains Key3
which is a KeyContainer
object and Key4
which is an IEnumerable<KeyContainer>
object, and finally KeyContainer
contains a DataAnnotations
in it.
public class MyConfigOptions : BaseOptions<MyConfigOptions>
{
public const string MyConfig = "MyConfig";
[RegularExpression(@"^[a-zA-Z''-'\s]{1,40}$")]
public string Key1 { get; set; }
[Range(0, 1000, ErrorMessage = "Value for {0} must be between {1} and {2}.")]
public int Key2 { get; set; }
public KeyContainer Key3 { get; set; }
public IEnumerable<KeyContainer> Key4 { get; set; }
}
public class KeyContainer: BaseOptions<KeyContainer>
{
[Range(0, 1000,
ErrorMessage = "Value for {0} must be between {1} and {2}.")]
public int Key5 { get; set; }
}
Here is the section of appsettings.json
file that contains the configuration options for MyConfigOptions
object.
"MyConfig": {
"Key1": "My Key One",
"Key2": 10,
"Key3": {
"key5": 11
},
"Key4": [
{
"key5": 12
},
{
"key5": 13
}
]
}
Custom DataAnnotation attributes
Now that we have the complex validation in place, we have the opportunity of creating more complex data annotation attributes that can be used across the applications options on properties or classes. In this section I will show two examples on how to create custom data annotation attributes.
Class level data annotation attribute
Here I will demonstrate how to create an attribute that will validate and make sure that only one or none of the properties in an object has a value, this could be a usual case in an application where a specific feature can be configured in multiple ways, for instance configuring how to load a certificate to the application. You could specify the certificate path or pass it in as a base 64 string or specify its thumbprint. Here is how the configuration section would look like:
"MyConfig": {
"Certificate": {
//There are 3 ways to specify the certificate:
// 1) If the certificate is a pfx file provide the path and password
"Path": "",
"Password": "",
// 2) If the certificate is a base 64 encoded string it can be specified here
"Base64": "",
// 3) If the certificate is registered in the Windows/Linux certificate store provide the thumbprint
"Thumbprint": ""
}
}
Now we need a data annotation attribute that would make sure that only one or none of the three ways of specifying the certificate has been specified. For this we need a class level attribute like below that will check all the specified properties values and make sure only one or none of them have a value.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class EnsureOnlyOneOrNonePropertyAttribute : ValidationAttribute
{
private string[] PropertyList { get; }
public EnsureOnlyOneOrNonePropertyAttribute(params string[] propertyList)
{
PropertyList = propertyList;
}
public override bool IsValid(object value)
{
return PropertyList.Select(propertyName => value.GetType().GetProperty(propertyName))
.Count(propertyInfo => IsNotNullObjectOrEmptyString(propertyInfo, value)) < 2;
}
private bool IsNotNullObjectOrEmptyString([CanBeNull] PropertyInfo propertyInfo, object value)
{
var propValue = propertyInfo?.GetValue(value, null);
return propValue != null && (!(propValue is string stringValue) || !string.IsNullOrWhiteSpace(stringValue));
}
}
And here is how to use it on an option object.
public class MyConfigOptions : BaseOptions<MyConfigOptions>
{
public const string MyConfig = "MyConfig";
public CertificateOptions Certificate { get; set; }
}
[EnsureOnlyOneOrNoneProperty(nameof(Path), nameof(Base64), nameof(Thumbprint),
ErrorMessage = "Only one way of specifying certificate is allowed.")]
public class CertificateOptions : BaseOptions<CertificateOptions>
{
public string Path { get; set; }
public string Password { get; set; }
public string Base64 { get; set; }
public string Thumbprint { get; set; }
}
Property level data annotation attribute
Now let's expand on the above example and add a property level annotation that will make sure when we provide a base 64 string as the certificate, it is a valid base 64 string. Note that if the value is not specified it should succeed. here is the attribute for it:
[AttributeUsage(AttributeTargets.Property)]
public class EnsureBase64StringAttribute : ValidationAttribute
{
public override bool IsValid(object value)
{
return value switch
{
null => true,
string base64 => Convert.TryFromBase64String(base64, new Span<byte>(new byte[base64.Length]), out _),
_ => false
};
}
}
And here is the final version of the CertificateOptions
class:
[EnsureOnlyOneOrNoneProperty(nameof(Path), nameof(Base64), nameof(Thumbprint),
ErrorMessage = "Only one way of specifying certificate is allowed.")]
public class CertificateOptions : BaseOptions<CertificateOptions>
{
public string Path { get; set; }
public string Password { get; set; }
[EnsureBase64String(ErrorMessage = "Must be a base 64 encoded string.")]
public string Base64 { get; set; }
public string Thumbprint { get; set; }
}
Conclusion
There are a lot of complex data annotations that can be created and thanks to this solution you can be sure that they are validated and allow you to have a robust configuration file for your application. Creating the validations as attributes makes it easier to reuse the same validation in different option objects and I think it is the right approach to add reusable complex validation. I hope that this post has been useful.
Comments