-
Notifications
You must be signed in to change notification settings - Fork 10.3k
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
base: main
Are you sure you want to change the base?
Conversation
…ontainer instances in Blazor validation
5f97328
to
4548c61
Compare
@@ -4,12 +4,21 @@ | |||
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> | |||
<IsShippingPackage>false</IsShippingPackage> | |||
<Nullable>enable</Nullable> | |||
|
|||
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces> |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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>
?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
@@ -11,6 +11,7 @@ | |||
|
|||
<ItemGroup> | |||
<Reference Include="Microsoft.AspNetCore.Components" /> | |||
<Reference Include="Microsoft.AspNetCore.Http.Abstractions" /> |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
{ | ||
// 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!; |
There was a problem hiding this comment.
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).
- Process the string into a
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 anEditForm
component. To opt-in into the new validation behavior, the user has to do the following:ValidationServiceCollectionExtensions.AddValidation
extension method during application setup.[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 toValidateAsync
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 usingEditContext
andValidationMessageStore
This mechanism usesFieldIdentifier
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 aDictionary<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:ValidationMessageStore
mechanism to not require container object references. This seems costly and there are reasons to avoid it.Dictionary<string, (string[], object?)>
, or something functionally equivalent.Proof-of-concept implementations of 3 and 4 are provided in this PR.
TODO
Fixes #28640