-
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?
Changes from all commits
005956f
cb649a9
bb3bf86
f741541
7cc7699
7eea69d
4a7f943
4548c61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,18 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System.Collections; | ||
using System.Collections.Concurrent; | ||
using System.ComponentModel.DataAnnotations; | ||
using System.Diagnostics.CodeAnalysis; | ||
using System.Linq; | ||
using System.Reflection; | ||
using System.Reflection.Metadata; | ||
using System.Runtime.InteropServices; | ||
using System.Text.RegularExpressions; | ||
using Microsoft.AspNetCore.Http.Validation; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Microsoft.Extensions.Options; | ||
|
||
[assembly: MetadataUpdateHandler(typeof(Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions))] | ||
|
||
|
@@ -15,7 +21,7 @@ | |
/// <summary> | ||
/// Extension methods to add DataAnnotations validation to an <see cref="EditContext"/>. | ||
/// </summary> | ||
public static class EditContextDataAnnotationsExtensions | ||
public static partial class EditContextDataAnnotationsExtensions | ||
{ | ||
/// <summary> | ||
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>. | ||
|
@@ -59,7 +65,7 @@ | |
} | ||
#pragma warning restore IDE0051 // Remove unused private members | ||
|
||
private sealed class DataAnnotationsEventSubscriptions : IDisposable | ||
private sealed partial class DataAnnotationsEventSubscriptions : IDisposable | ||
{ | ||
private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new(); | ||
|
||
|
@@ -82,6 +88,7 @@ | |
} | ||
} | ||
|
||
// TODO(OR): Should this also use ValidatablePropertyInfo.ValidateAsync? | ||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] | ||
private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs) | ||
{ | ||
|
@@ -112,6 +119,18 @@ | |
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e) | ||
{ | ||
var validationContext = new ValidationContext(_editContext.Model, _serviceProvider, items: null); | ||
|
||
if (!TryValidateTypeInfo(validationContext)) | ||
{ | ||
ValidateWithDefaultValidator(validationContext); | ||
} | ||
|
||
_editContext.NotifyValidationStateChanged(); | ||
} | ||
|
||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] | ||
private void ValidateWithDefaultValidator(ValidationContext validationContext) | ||
{ | ||
var validationResults = new List<ValidationResult>(); | ||
Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true); | ||
|
||
|
@@ -136,8 +155,125 @@ | |
_messages.Add(new FieldIdentifier(_editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!); | ||
} | ||
} | ||
} | ||
|
||
_editContext.NotifyValidationStateChanged(); | ||
#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; | ||
|
||
if (options == null || !options.TryGetValidatableTypeInfo(_editContext.Model.GetType(), out var typeInfo)) | ||
{ | ||
return false; | ||
} | ||
|
||
var validateContext = new ValidateContext | ||
{ | ||
ValidationOptions = options, | ||
ValidationContext = validationContext, | ||
}; | ||
|
||
var containerMapping = new Dictionary<string, object?>(); | ||
|
||
validateContext.OnValidationError += (key, _, container) => containerMapping[key] = container; | ||
|
||
var validationTask = typeInfo.ValidateAsync(_editContext.Model, validateContext, CancellationToken.None); | ||
|
||
if (!validationTask.IsCompleted) | ||
{ | ||
throw new InvalidOperationException("Async validation is not supported"); | ||
} | ||
|
||
var validationErrors = validateContext.ValidationErrors; | ||
|
||
// Transfer results to the ValidationMessageStore | ||
_messages.Clear(); | ||
|
||
if (validationErrors is not null && validationErrors.Count > 0) | ||
{ | ||
foreach (var (fieldKey, messages) in validationErrors) | ||
{ | ||
// Reverse mapping based on storing references during validation. | ||
// With this approach, we could skip iterating over ValidateContext.ValidationErrors and pass the errors | ||
// directly to ValidationMessageStore in the OnValidationError handler. | ||
var fieldContainer = containerMapping[fieldKey] ?? _editContext.Model; | ||
|
||
// Alternative: Reverse mapping based on object graph walk. | ||
//var fieldContainer = GetFieldContainer(_editContext.Model, fieldKey); | ||
|
||
var lastDotIndex = fieldKey.LastIndexOf('.'); | ||
var fieldName = lastDotIndex >= 0 ? fieldKey[(lastDotIndex + 1)..] : fieldKey; | ||
|
||
_messages.Add(new FieldIdentifier(fieldContainer, fieldName), messages); | ||
} | ||
} | ||
|
||
return true; | ||
} | ||
#pragma warning restore ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. | ||
|
||
// TODO(OR): Replace this with a more robust implementation or a different approach. E.g. collect references during the validation process itself. | ||
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] | ||
private static object GetFieldContainer(object obj, string fieldKey) | ||
Check failure on line 217 in src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
|
||
{ | ||
// 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!; | ||
Comment on lines
+218
to
+276
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
} | ||
|
||
public void Dispose() | ||
|
@@ -174,5 +310,11 @@ | |
{ | ||
_propertyInfoCache.Clear(); | ||
} | ||
|
||
private static readonly Regex _pathSegmentRegex = PathSegmentRegexGen(); | ||
|
||
// Regex to parse "PropertyName" or "PropertyName[index]" | ||
[GeneratedRegex(@"^([a-zA-Z_]\w*)(?:\[(\d+)\])?$", RegexOptions.Compiled)] | ||
private static partial Regex PathSegmentRegexGen(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 commentThe reason will be displayed to describe this comment to others. Learn more. This is problematic. We can't depend on stuff that brings in This is more of a question for @captainsafia There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Per our offline discussion, let's create a new 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... |
||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 commentThe 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. |
||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="..\BlazorUnitedApp.Client\BlazorUnitedApp.Client.csproj" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="$(RepoRoot)/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Microsoft.AspNetCore.Http.ValidationsGenerator.csproj" | ||
OutputItemType="Analyzer" | ||
ReferenceOutputAssembly="false" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<Reference Include="Microsoft.AspNetCore" /> | ||
<Reference Include="Microsoft.AspNetCore.Components.Endpoints" /> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
@page "/complex-form" | ||
@rendermode InteractiveServer | ||
|
||
@using System.ComponentModel.DataAnnotations | ||
@using BlazorUnitedApp.Validation | ||
|
||
<PageTitle>Validated Order Form</PageTitle> | ||
|
||
<EditForm Model="@order" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit"> | ||
<DataAnnotationsValidator /> | ||
|
||
<div class="container mt-4"> | ||
<h4>Order Details</h4> | ||
<div class="mb-3"> | ||
<label for="orderName" class="form-label">Order Name</label> | ||
<InputText id="orderName" @bind-Value="order.OrderName" class="form-control" /> | ||
<ValidationMessage For="@(() => order.OrderName)" /> | ||
</div> | ||
|
||
<hr /> | ||
|
||
<h4>Customer Details</h4> | ||
<div class="card mb-3"> | ||
<div class="card-body"> | ||
<div class="mb-3"> | ||
<label for="customerFullName" class="form-label">Full Name</label> | ||
<InputText id="customerFullName" @bind-Value="order.CustomerDetails.FullName" class="form-control" /> | ||
<ValidationMessage For="@(() => order.CustomerDetails.FullName)" /> | ||
</div> | ||
<div class="mb-3"> | ||
<label for="customerEmail" class="form-label">Email</label> | ||
<InputText id="customerEmail" @bind-Value="order.CustomerDetails.Email" class="form-control" /> | ||
<ValidationMessage For="@(() => order.CustomerDetails.Email)" /> | ||
</div> | ||
|
||
<h5>Shipping Address</h5> | ||
<div class="card mb-3"> | ||
<div class="card-body"> | ||
<div class="row"> | ||
<div class="mb-3 col-sm-8"> | ||
<label for="shippingStreet" class="form-label">Street</label> | ||
<InputText id="shippingStreet" @bind-Value="order.CustomerDetails.ShippingAddress.Street" class="form-control" /> | ||
<ValidationMessage For="@(() => order.CustomerDetails.ShippingAddress.Street)" /> | ||
</div> | ||
<div class="mb-3 col-sm"> | ||
<label for="shippingZipCode" class="form-label">Zip Code</label> | ||
<InputText id="shippingZipCode" @bind-Value="order.CustomerDetails.ShippingAddress.ZipCode" class="form-control" /> | ||
<ValidationMessage For="@(() => order.CustomerDetails.ShippingAddress.ZipCode)" /> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
|
||
<hr /> | ||
|
||
<h4>Order Items</h4> | ||
@if (order.OrderItems.Any()) | ||
{ | ||
for (int i = 0; i < order.OrderItems.Count; i++) | ||
{ | ||
var itemIndex = i; | ||
<div class="card mb-3"> | ||
<div class="card-header d-flex justify-content-between align-items-center"> | ||
<span>Item @(itemIndex + 1)</span> | ||
<button type="button" class="btn btn-sm btn-danger" @onclick="() => RemoveOrderItem(itemIndex)">Remove</button> | ||
</div> | ||
<div class="card-body"> | ||
<div class="row"> | ||
<div class="mb-3 col-sm-8"> | ||
<label for="@($"productName_{itemIndex}")" class="form-label">Product Name</label> | ||
<InputText id="@($"productName_{itemIndex}")" @bind-Value="order.OrderItems[itemIndex].ProductName" class="form-control" /> | ||
<ValidationMessage For="@(() => order.OrderItems[itemIndex].ProductName)" /> | ||
</div> | ||
<div class="mb-3 col-sm"> | ||
<label for="@($"quantity_{itemIndex}")" class="form-label">Quantity</label> | ||
<InputNumber id="@($"quantity_{itemIndex}")" @bind-Value="order.OrderItems[itemIndex].Quantity" class="form-control" /> | ||
<ValidationMessage For="@(() => order.OrderItems[itemIndex].Quantity)" /> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
} | ||
} | ||
else | ||
{ | ||
<p>No order items. Add one below.</p> | ||
} | ||
|
||
<button type="button" class="btn btn-success mb-3" @onclick="AddOrderItem">Add Order Item</button> | ||
|
||
<hr /> | ||
|
||
<div class="mb-3"> | ||
<button type="submit" class="btn btn-primary">Submit Order</button> | ||
</div> | ||
|
||
<ValidationSummary /> | ||
</div> | ||
</EditForm> | ||
|
||
@if (submitted) | ||
{ | ||
<div class="mt-4 alert alert-success" role="alert"> | ||
<h4>Form Submitted Successfully!</h4> | ||
<p>Order Name: @order.OrderName</p> | ||
<p>Customer: @order.CustomerDetails.FullName (@order.CustomerDetails.Email)</p> | ||
<h5>Order Items:</h5> | ||
<ul> | ||
@foreach (var item in order.OrderItems) | ||
{ | ||
<li>@item.Quantity x @item.ProductName</li> | ||
} | ||
</ul> | ||
</div> | ||
} | ||
|
||
@if (submitFailed) | ||
{ | ||
<div class="mt-4 alert alert-danger" role="alert"> | ||
<h4>Form Submission Failed!</h4> | ||
<p>Please correct the validation errors and try again.</p> | ||
</div> | ||
} | ||
|
||
|
||
@code { | ||
private OrderModel order = new OrderModel(); | ||
private bool submitted = false; | ||
private bool submitFailed = false; | ||
|
||
private void HandleValidSubmit() | ||
{ | ||
Console.WriteLine("Form submitted successfully!"); | ||
submitted = true; | ||
submitFailed = false; | ||
} | ||
|
||
private void HandleInvalidSubmit() | ||
{ | ||
Console.WriteLine("Form submission failed due to validation errors."); | ||
submitted = false; | ||
submitFailed = true; | ||
} | ||
|
||
private void AddOrderItem() | ||
{ | ||
order.OrderItems.Add(new OrderItemModel()); | ||
submitted = false; | ||
submitFailed = false; | ||
} | ||
|
||
private void RemoveOrderItem(int index) | ||
{ | ||
if (index >= 0 && index < order.OrderItems.Count) | ||
{ | ||
order.OrderItems.RemoveAt(index); | ||
} | ||
submitted = false; | ||
submitFailed = false; | ||
} | ||
} |
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