Reputation: 267
We're on .NET Core 3.1.5 and this is a Blazor Server application.
We have a ValidationAttribute and need access to an external Service to validate the objects.
ValidationAttribute has the IsValid method:
protected override ValidationResult IsValid(object value, ValidationContext validationContext) ValidationContext has a GetService method which delegates to an instance of ServiceProvider. Unfortunately, the service provider field is never initialized and so we cannot retrieve any Services.
This was raised (and fixed) back in Mvc: aspnet/Mvc#6346 But our Validator is called via one of these two:
https://github.com/dotnet/aspnetcore/blob/master/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs#L47 https://github.com/dotnet/aspnetcore/blob/master/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs#L75 And later down the stack the service provider is also never set. I hesitated to open a bug (but can do so) but this seems wrong to me (or at least should be documented).
Any Google search eventually ends up at this Blog post but as I just mentioned this doesn't work.
So our question is: What's the correct way of injecting services into a ValidationAttribute or more general what is the proper way to validate a field of a model that requires a call to an external service?
In statup.cs
:
services.AddTransient<IMarktTypDaten, MarktTypDaten>();
Class where we are trying to inject the service and apply the validation.
public class MarktTypNameValidation : ValidationAttribute {
protected override ValidationResult IsValid(object value, ValidationContext validationContext) {
var service = (IMarktTypDaten) validationContext.GetRequiredService(typeof(IMarktTypDaten));
...some code...
return ValidationResult.Success;
}
}
ExceptionMessage when calling GetRequiredService
: 'No service for type 'DataAccessLibrary.Interfaces.IMarktTypDaten' has been registered.
It's also posted on Github: https://github.com/dotnet/aspnetcore/discussions/23305
Also: I'm using C#/.NET for the first time in 15 or so years, please be gentle ;-)
Upvotes: 10
Views: 5481
Reputation: 1950
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 />
Upvotes: 9
Reputation: 45626
As suggested by Steven in the comments section, you shouldn't do that that way. Instead you can do that as described in the following code snippet, part of which is only pseudocode to point out what you need to do... It is not suppose to work as is.
You can overrides the EditContext's FieldChanged method for this.
Suppose you have this form with an input field for the email address, and you want to check if this email is already being used by another user... To check the availability of the entered email address you must perform a call to your data store and verify this. Note that some of the actions described in the FieldChanged method can be moved to a separate validation service...
<EditForm EditContext="@EditContext"
OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<div class="form-group">
<label for="name">Name: </label>
<InputText Id="name" Class="form-control" @bind-
Value="@Model.Name"></InputText>
<ValidationMessage For="@(() => Model.Name)" />
</div>
<div class="form-group">
<label for="body">Text: </label>
<InputText Id="body" Class="form-control" @bind-Value="@Model.Text"></InputText>
<ValidationMessage For="@(() => Model.Text)" />
</div>
<div class="form-group">
<label for="body">Email: </label>
<InputText Id="body" Class="form-control" @bind-Value="@Model.EmailAddress"></InputText>
<ValidationMessage For="@(() => Model.EmailAddress)" />
</div>
<p>
<button type="submit">Save</button>
</p>
</EditForm>
@code
{
private EditContext EditContext;
private Comment Model = new Comment();
ValidationMessageStore messages;
protected override void OnInitialized()
{
EditContext = new EditContext(Model);
EditContext.OnFieldChanged += EditContext_OnFieldChanged;
messages = new ValidationMessageStore(EditContext);
base.OnInitialized();
}
// Note: The OnFieldChanged event is raised for each field in the
// model. Here you should validate the email address
private void EditContext_OnFieldChanged(object sender,
FieldChangedEventArgs e)
{
// Call your database to check if the email address is
// available
// Retrieve the value of the input field for email
// Pseudocode...
var email = "[email protected]";
var exists = VerifyEmail(email);
messages.Clear();
// If exists is true, form a message about this, and add it
// to the messages object so that it is displayed in the
// ValidationMessage component for email
}
}
Hope this helps...
Upvotes: 5