My team has heavily invested in our custom validation code which underneath uses DataAnnotations for validation. Specifically our custom validators (through much abstraction) depends on the ValidationAttribute.IsValid method and the fact that the ValidationContext parameter passed into it is itself an IServiceProvider. This has worked good for us in MVC.
We're currently integrating server side Blazor into an existing MVC app which has many validators already implemented with our custom validation (all based on DataAnnotations) and we'd like to take advantage of these within our Blazor validation. Although the argument of, "you're not supposed to do that" is probably valid, we are far beyond that option without major refactoring.
So I dug deeper and discovered that we could make a relatively small change to Microsoft's DataAnnotationsValidator.cs type located here. https://github.com/dotnet/aspnetcore/blob/master/src/Components/Forms/src/DataAnnotationsValidator.cs
The real change is actually in the EditContextDataAnnotationsExtensions.cs type that's located here: https://github.com/dotnet/aspnetcore/blob/master/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
Specifically, the EditContextDataAnnotationsExtensions method actually creates a new ValidationContext object but does NOT initialize the service provider. I've created a CustomValidator component to replace the DataAnnotationsValidator component and copied most of the flow (I changed the code to fit more with our style but the flow of things are the same).
In our CustomValidator I've included an initialization of the ValidationContext's service provider.
var validationContext = new ValidationContext(editContext.Model); validationContext.InitializeServiceProvider(type => this.serviceProvider.GetService(type));
Here's my code, slightly edited but the following should work out of the box.
public class CustomValidator : ComponentBase, IDisposable { private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> PropertyInfoCache = new ConcurrentDictionary<(Type, string), PropertyInfo>(); [CascadingParameter] EditContext CurrentEditContext { get; set; } [Inject] private IServiceProvider serviceProvider { get; set; } private ValidationMessageStore messages; protected override void OnInitialized() { if (CurrentEditContext == null) { throw new InvalidOperationException($"{nameof(CustomValidator)} requires a cascading " + $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(CustomValidator)} " + "inside an EditForm."); } this.messages = new ValidationMessageStore(CurrentEditContext); // Perform object-level validation on request CurrentEditContext.OnValidationRequested += validateModel; // Perform per-field validation on each field edit CurrentEditContext.OnFieldChanged += validateField; } private void validateModel(object sender, ValidationRequestedEventArgs e) { var editContext = (EditContext) sender; var validationContext = new ValidationContext(editContext.Model); validationContext.InitializeServiceProvider(type => this.serviceProvider.GetService(type)); var validationResults = new List<ValidationResult>(); Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true); // Transfer results to the ValidationMessageStore messages.Clear(); foreach (var validationResult in validationResults) { if (!validationResult.MemberNames.Any()) { messages.Add(new FieldIdentifier(editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage); continue; } foreach (var memberName in validationResult.MemberNames) { messages.Add(editContext.Field(memberName), validationResult.ErrorMessage); } } editContext.NotifyValidationStateChanged(); } private void validateField(object? sender, FieldChangedEventArgs e) { if (!TryGetValidatableProperty(e.FieldIdentifier, out var propertyInfo)) return; var propertyValue = propertyInfo.GetValue(e.FieldIdentifier.Model); var validationContext = new ValidationContext(CurrentEditContext.Model) {MemberName = propertyInfo.Name}; validationContext.InitializeServiceProvider(type => this.serviceProvider.GetService(type)); var results = new List<ValidationResult>(); Validator.TryValidateProperty(propertyValue, validationContext, results); messages.Clear(e.FieldIdentifier); messages.Add(e.FieldIdentifier, results.Select(result => result.ErrorMessage)); // We have to notify even if there were no messages before and are still no messages now, // because the "state" that changed might be the completion of some async validation task CurrentEditContext.NotifyValidationStateChanged(); } private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, [NotNullWhen(true)] out PropertyInfo propertyInfo) { var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName); if (PropertyInfoCache.TryGetValue(cacheKey, out propertyInfo)) return true; // DataAnnotations only validates public properties, so that's all we'll look for // If we can't find it, cache 'null' so we don't have to try again next time propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName); // No need to lock, because it doesn't matter if we write the same value twice PropertyInfoCache[cacheKey] = propertyInfo; return propertyInfo != null; } public void Dispose() { if (CurrentEditContext == null) return; CurrentEditContext.OnValidationRequested -= validateModel; CurrentEditContext.OnFieldChanged -= validateField; } }
All that is needed after you add this type is to use it instead of the DataAnnotationsValidator within your blazor/razor file.
So instead of this:
<DataAnnotationsValidator />
do this:
<CustomValidator />