Skip to content

Use the unified validation API for Blazor forms #62045

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

oroztocil
Copy link
Member

@oroztocil oroztocil commented May 21, 2025

Integrates the validation API based on #46349 into Blazor. This adds the ability to validate nested objects and collection items in the form model.

Usage in Blazor

Form validation is provided via the existing DataAnnotationsValidator component in the context of an EditForm component. To opt-in into the new validation behavior, the user has to do the following:

  1. Call the ValidationServiceCollectionExtensions.AddValidation extension method during application setup.
  2. Declare the form model types in a .cs file (i.e. not a .razor file).
  3. Annotate the root model class with the [ValidatableType] attribute.

Otherwise, the validation behavior will be the same as in previous versions.

Type discoverability limitations in Razor files

The requirement for declaring model types in .cs files is due to the fact that the new validation infrastructure uses a source generator to discover validatable types and generate the validation code during build. However, the Razor component compiler is also implemented as a source generator and there is currently the limitation that a source generator cannot take output of another generator as its input. This means that types declared in .razor files cannot be discovered by the validation generator.

If #61220 is implemented, a run-time discovery of form model types could be used instead of the source generator based discovery, or as a fallback.

Support for async validations

Although the entrypoint method in the new validation API (IValidatableInfo.ValidateAsync) is async, the Blazor integration only supports validation operations which are actually synchronous. That is, we do a non-awaited call to ValidateAsync which has to complete immediately, or an exception gets thrown. We do this to avoid the need to rewrite the existing validation infrastructure in Blazor which is synchronous. (And making it meaningfully async in the context of an UI framework is a more complex task than just marking the APIs with async.)

Error reporting

Currently, validation errors are propagated to the EditForm component via a fairly complex mechanism using EditContext and ValidationMessageStore This mechanism uses FieldIdentifier instances to map the individual errors to the relevant model properties (and consequently the relevant form input elements).

FieldIdentifier requires a reference to the immediate parent object (container) which contains the property. However, the new API returns validation errors only as a Dictionary<string, string[]> where the key is the concatenated path to the property (e.g. A.B[2].C). We have to decide, how to integrate these two solutions. Some possible options are:

  1. Modify the ValidationMessageStore mechanism to not require container object references. This seems costly and there are reasons to avoid it.
  2. Modify the new validation API to include the container object reference in the result. E.g. Dictionary<string, (string[], object?)>, or something functionally equivalent.
  3. Modify the new validation API to raise an event when a validation error is found. At that point the validation code has the container object reference available and can expose it via the event.
  4. Do not modify either of the two existing implementations but do a reverse mapping from the string key to the actual container object reference in the model instance.

Proof-of-concept implementations of 3 and 4 are provided in this PR.

TODO

  • Choose and implement a solution for the issue described in the Error reporting section.
  • Add tests.

Fixes #28640

@github-actions github-actions bot added the area-blazor Includes: Blazor, Razor Components label May 21, 2025
@oroztocil oroztocil force-pushed the oroztocil/unified-validations-for-blazor branch from 5f97328 to 4548c61 Compare May 21, 2025 15:26
@@ -4,12 +4,21 @@
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<IsShippingPackage>false</IsShippingPackage>
<Nullable>enable</Nullable>

<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully, you won't need this soon once we take a new SDK update and absorb dotnet/sdk#48891.

/// <summary>
/// Optional event raised when a validation error is reported.
/// </summary>
public event Action<string, string[], object?>? OnValidationError;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit cautious about making this an action with multiple parameters. Perhaps we should introduce a lightweight context object here a la Action<ValidationErrorContext>?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this was just hacked up for illustration. I would either do what you suggest or declare a typed delegate with named parameters, if we were to go this route.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @captainsafia I think a ValidationErrorContext would be best.

@oroztocil oroztocil added feature-blazor-form-validation This issue is related to forms validation in Blazor feature-validation Issues related to model validation in minimal and controller-based APIs labels May 21, 2025
@@ -11,6 +11,7 @@

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components" />
<Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is problematic.

We can't depend on stuff that brings in HttpContext and a bunch of other framework related types. Is there anything in the validation stuff that requires things from Microsoft.AspNetCore.Http?

This is more of a question for @captainsafia

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per our offline discussion, let's create a new Microsoft.Extensions.Validation package under src/Validation and move things into it there.

Happy to help if you need any guidance moving the testing infrastructure...

We'll also want to consider whether the source generator goes into the extensions package or not. My inclination is to say it does. In that case, we need to add more sanity checks to it to handle cases where the package might be referenced outside the context of ASP.NET Core. Specifically, this would just be better fallbacks for places where it looks for symbols that are defined in ASP.NET Core namespaces...

#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private bool TryValidateTypeInfo(ValidationContext validationContext)
{
var options = _serviceProvider?.GetService<IOptions<ValidationOptions>>()?.Value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be wrong, but I don't think we need the null check here.

I believe IOptions will always resolve a non-null value (we can assume that services.AddOptions has been called as its a dependency we have). I also believe that .Value will always be populated with a default instance

Comment on lines +218 to +276
{
// The method does not check all possible null access and index bound errors as the path is constructed internally and assumed to be correct.
var dotSegments = fieldKey.Split('.')[..^1];
var currentObject = obj;

for (int i = 0; i < dotSegments.Length; i++)
{
string segment = dotSegments[i];

if (currentObject == null)
{
string traversedPath = string.Join(".", dotSegments.Take(i));
throw new ArgumentException($"Cannot access segment '{segment}' because the path '{traversedPath}' resolved to null.");
}

Match match = _pathSegmentRegex.Match(segment);
if (!match.Success)
{
throw new ArgumentException($"Invalid path segment: '{segment}'.");
}

string propertyName = match.Groups[1].Value;
string? indexStr = match.Groups[2].Success ? match.Groups[2].Value : null;

Type currentType = currentObject.GetType();
PropertyInfo propertyInfo = currentType!.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)!;
object propertyValue = propertyInfo!.GetValue(currentObject)!;

if (indexStr == null) // Simple property access
{
currentObject = propertyValue;
}
else // Indexed access
{
if (!int.TryParse(indexStr, out int index))
{
throw new ArgumentException($"Invalid index '{indexStr}' in segment '{segment}'.");
}

if (propertyValue is Array array)
{
currentObject = array.GetValue(index)!;
}
else if (propertyValue is IList list)
{
currentObject = list[index]!;
}
else if (propertyValue is IEnumerable enumerable)
{
currentObject = enumerable.Cast<object>().ElementAt(index);
}
else
{
throw new ArgumentException($"Property '{propertyName}' is not an array, list, or enumerable. Cannot access by index in segment '{segment}'.");
}
}

}
return currentObject!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a prototype this is "ok", but we essentially have two options here. Either:

  • We are able to get the container from the validation process as we walk the tree.
  • We do something like what MVC does.
    • Process the string into a System.Linq.Expression, compile it and cache it (and I really don't want us to do this if we can avoid it).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components feature-blazor-form-validation This issue is related to forms validation in Blazor feature-validation Issues related to model validation in minimal and controller-based APIs
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Consider shipping ObjectGraphDataAnnotationsValidator / ValidateComplexTypeAttribute
3 participants