diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index cc79f35b9b88..0ed65e49b0e2 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -154,7 +154,11 @@ "src\\StaticAssets\\src\\Microsoft.AspNetCore.StaticAssets.csproj", "src\\StaticAssets\\test\\Microsoft.AspNetCore.StaticAssets.Tests.csproj", "src\\Testing\\src\\Microsoft.AspNetCore.InternalTesting.csproj", + "src\\Validation\\gen\\Microsoft.Extensions.Validation.ValidationsGenerator.csproj", + "src\\Validation\\src\\Microsoft.Extensions.Validation.csproj", + "src\\Validation\\test\\Microsoft.Extensions.Validation.GeneratorTests\\Microsoft.Extensions.Validation.GeneratorTests.csproj", + "src\\Validation\\test\\Microsoft.Extensions.Validation.Tests\\Microsoft.Extensions.Validation.Tests.csproj", "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs index e15b90c0e42d..559c2881127d 100644 --- a/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs +++ b/src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs @@ -7,6 +7,9 @@ using System.Reflection; using System.Reflection.Metadata; using System.Runtime.InteropServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Validation; [assembly: MetadataUpdateHandler(typeof(Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions))] @@ -15,7 +18,7 @@ namespace Microsoft.AspNetCore.Components.Forms; /// /// Extension methods to add DataAnnotations validation to an . /// -public static class EditContextDataAnnotationsExtensions +public static partial class EditContextDataAnnotationsExtensions { /// /// Adds DataAnnotations validation support to the . @@ -59,20 +62,31 @@ private static void ClearCache(Type[]? _) } #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(); private readonly EditContext _editContext; private readonly IServiceProvider? _serviceProvider; private readonly ValidationMessageStore _messages; + private readonly ValidationOptions? _validationOptions; +#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 readonly IValidatableInfo? _validatorTypeInfo; +#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. + private readonly Dictionary _validationPathToFieldIdentifierMapping = new(); + [UnconditionalSuppressMessage("Trimming", "IL2066", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")] public DataAnnotationsEventSubscriptions(EditContext editContext, IServiceProvider serviceProvider) { _editContext = editContext ?? throw new ArgumentNullException(nameof(editContext)); _serviceProvider = serviceProvider; _messages = new ValidationMessageStore(_editContext); - + _validationOptions = _serviceProvider?.GetService>()?.Value; +#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. + _validatorTypeInfo = _validationOptions != null && _validationOptions.TryGetValidatableTypeInfo(_editContext.Model.GetType(), out var typeInfo) + ? typeInfo + : null; +#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. _editContext.OnFieldChanged += OnFieldChanged; _editContext.OnValidationRequested += OnValidationRequested; @@ -112,6 +126,18 @@ private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs) 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(); Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true); @@ -136,8 +162,62 @@ private void OnValidationRequested(object? sender, ValidationRequestedEventArgs _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) + { + if (_validatorTypeInfo is null) + { + return false; + } + + var validateContext = new ValidateContext + { + ValidationOptions = _validationOptions!, + ValidationContext = validationContext, + }; + try + { + validateContext.OnValidationError += AddMapping; + + var validationTask = _validatorTypeInfo.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) + { + var fieldIdentifier = _validationPathToFieldIdentifierMapping[fieldKey]; + _messages.Add(fieldIdentifier, messages); + } + } + } + catch (Exception) + { + throw; + } + finally + { + validateContext.OnValidationError -= AddMapping; + _validationPathToFieldIdentifierMapping.Clear(); + } + + return true; + + } + private void AddMapping(ValidationErrorContext context) + { + _validationPathToFieldIdentifierMapping[context.Path] = + new FieldIdentifier(context.Container ?? _editContext.Model, context.Name); } public void Dispose() diff --git a/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj b/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj index 654c4ac2d3fe..c92fe86008f3 100644 --- a/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj +++ b/src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj index dc8936cd9428..b147f6bc4877 100644 --- a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj +++ b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj @@ -1,14 +1,12 @@ - + <_BuildAndTest>false - <_BuildAndTest - Condition=" '$(ContinuousIntegrationBuild)' == 'true' AND '$(EXECUTE_COMPONENTS_E2E_TESTS)' == 'true' ">true - <_BuildAndTest - Condition=" '$(ContinuousIntegrationBuild)' != 'true' AND '$(SkipTestBuild)' != 'true' ">true + <_BuildAndTest Condition=" '$(ContinuousIntegrationBuild)' == 'true' AND '$(EXECUTE_COMPONENTS_E2E_TESTS)' == 'true' ">true + <_BuildAndTest Condition=" '$(ContinuousIntegrationBuild)' != 'true' AND '$(SkipTestBuild)' != 'true' ">true true true @@ -67,39 +65,19 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs new file mode 100644 index 000000000000..75aee6f07d08 --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/AddValidationIntegrationTest.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; + +public class AddValidationIntegrationTest : ServerTestBase>> +{ + public AddValidationIntegrationTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture> serverFixture, ITestOutputHelper output) : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate("subdir/forms/add-validation-form"); + Browser.Exists(By.Id("is-interactive")); + } + + [Fact] + public void FormWithNestedValidation_Works() + { + Browser.Exists(By.Id("submit-form")).Click(); + + Browser.Exists(By.Id("is-invalid")); + + // Validation summary + var messageElements = Browser.FindElements(By.CssSelector(".validation-errors > .validation-message")); + + var messages = messageElements.Select(element => element.Text) + .ToList(); + + var expected = new[] + { + "Order Name is required.", + "Full Name is required.", + "Email is required.", + "Street is required.", + "Zip Code is required.", + "Product Name is required." + }; + + Assert.Equal(expected, messages); + + // Individual field messages + var individual = Browser.FindElements(By.CssSelector(".mb-3 > .validation-message")) + .Select(element => element.Text) + .ToList(); + + Assert.Equal(expected, individual); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index 6d1f87776686..d3be5f099dfa 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -11,6 +11,9 @@ true + + true + @@ -47,10 +50,8 @@ - + + diff --git a/src/Components/test/testassets/BasicTestApp/Program.cs b/src/Components/test/testassets/BasicTestApp/Program.cs index 4b147896e621..554e65ba1ffe 100644 --- a/src/Components/test/testassets/BasicTestApp/Program.cs +++ b/src/Components/test/testassets/BasicTestApp/Program.cs @@ -23,6 +23,9 @@ public static async Task Main(string[] args) await SimulateErrorsIfNeededForTest(); var builder = WebAssemblyHostBuilder.CreateDefault(args); + + builder.Services.AddValidation(); + builder.RootComponents.Add("head::after"); builder.RootComponents.Add("root"); builder.RootComponents.RegisterForJavaScript("my-dynamic-root-component"); diff --git a/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj b/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj index b7d6d2b019b8..17ff57531d67 100644 --- a/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj +++ b/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj @@ -10,8 +10,13 @@ annotations latest true + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated;Microsoft.Extensions.Validation.Generated + + + + diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 4bb4bee5014c..bf77b9ae0d5c 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -28,6 +28,8 @@ public RazorComponentEndpointsStartup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + services.AddValidation(); + services.AddRazorComponents(options => { options.MaxFormMappingErrorCount = 10; diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ComplexValidationComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComplexValidationComponent.razor new file mode 100644 index 000000000000..2e4f4f958a69 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ComplexValidationComponent.razor @@ -0,0 +1,133 @@ +@using BasicTestApp.ValidationModels +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + +@if(RendererInfo.IsInteractive) { +

+} + +@if (_invalid) +{ +

+} + + + + +
+

Order Details

+
+ + + +
+ +
+ +

Customer Details

+
+
+
+ + + +
+
+ + + +
+ +
Shipping Address
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+
+
+ +
+ +

Order Items

+ @if (order.OrderItems.Any()) + { + for (int i = 0; i < order.OrderItems.Count; i++) + { + var itemIndex = i; +
+
+ Item @(itemIndex + 1) + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+ } + } + else + { +

No order items. Add one below.

+ } + + + +
+ +
+ +
+ + +
+
+ +@code { + private OrderModel order = new OrderModel(); + private bool _invalid; + + private void HandleValidSubmit() + { + } + + private void HandleInvalidSubmit() + { + _invalid = true; + } + + private void AddOrderItem() + { + order.OrderItems.Add(new OrderItemModel()); + } + + private void RemoveOrderItem(int index) + { + if (index >= 0 && index < order.OrderItems.Count) + { + order.OrderItems.RemoveAt(index); + } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AddValidationCapableForm.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AddValidationCapableForm.razor new file mode 100644 index 000000000000..88c19cd11103 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AddValidationCapableForm.razor @@ -0,0 +1,6 @@ +@page "/forms/add-validation-form" +@rendermode RenderMode.InteractiveServer + +

This form demostrates using `AddValidation` from Microsoft.Extensions.Validation

+ + diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/AddressModel.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/AddressModel.cs new file mode 100644 index 000000000000..ec34d35e3beb --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/AddressModel.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; + +namespace BasicTestApp.ValidationModels; + +public class AddressModel +{ + [Required(ErrorMessage = "Street is required.")] + public string Street { get; set; } + + [Required(ErrorMessage = "Zip Code is required.")] + [StringLength(10, MinimumLength = 5, ErrorMessage = "Zip Code must be between 5 and 10 characters.")] + public string ZipCode { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/CustomerModel.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/CustomerModel.cs new file mode 100644 index 000000000000..408216cae8fd --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/CustomerModel.cs @@ -0,0 +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.ComponentModel.DataAnnotations; + +namespace BasicTestApp.ValidationModels; + +public class CustomerModel +{ + [Required(ErrorMessage = "Full Name is required.")] + public string FullName { get; set; } + + [Required(ErrorMessage = "Email is required.")] + [EmailAddress(ErrorMessage = "Invalid Email Address.")] + public string Email { get; set; } + + public AddressModel ShippingAddress { get; set; } = new AddressModel(); +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/OrderItemModel.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/OrderItemModel.cs new file mode 100644 index 000000000000..177da9e5ff04 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/OrderItemModel.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; + +namespace BasicTestApp.ValidationModels; + +public class OrderItemModel +{ + [Required(ErrorMessage = "Product Name is required.")] + public string ProductName { get; set; } + + [Range(1, 100, ErrorMessage = "Quantity must be between 1 and 100.")] + public int Quantity { get; set; } = 1; +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/OrderModel.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/OrderModel.cs new file mode 100644 index 000000000000..fe165d05f72c --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/ValidationModels/OrderModel.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Validation; + +namespace BasicTestApp.ValidationModels; +#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. +[ValidatableType] +#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. +public class OrderModel +{ + [Required(ErrorMessage = "Order Name is required.")] + [StringLength(100, ErrorMessage = "Order Name cannot be longer than 100 characters.")] + public string OrderName { get; set; } + + public CustomerModel CustomerDetails { get; set; } = new CustomerModel(); + + public List OrderItems { get; set; } = new List(); + + public OrderModel() + { + OrderItems.Add(new OrderItemModel()); + } +} diff --git a/src/Validation/src/PublicAPI.Unshipped.txt b/src/Validation/src/PublicAPI.Unshipped.txt index ce3b07db31b0..d7f657e38875 100644 --- a/src/Validation/src/PublicAPI.Unshipped.txt +++ b/src/Validation/src/PublicAPI.Unshipped.txt @@ -18,6 +18,7 @@ Microsoft.Extensions.Validation.ValidateContext.CurrentDepth.get -> int Microsoft.Extensions.Validation.ValidateContext.CurrentDepth.set -> void Microsoft.Extensions.Validation.ValidateContext.CurrentValidationPath.get -> string! Microsoft.Extensions.Validation.ValidateContext.CurrentValidationPath.set -> void +Microsoft.Extensions.Validation.ValidateContext.OnValidationError -> System.Action? Microsoft.Extensions.Validation.ValidateContext.ValidateContext() -> void Microsoft.Extensions.Validation.ValidateContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext! Microsoft.Extensions.Validation.ValidateContext.ValidationContext.set -> void @@ -25,6 +26,16 @@ Microsoft.Extensions.Validation.ValidateContext.ValidationErrors.get -> System.C Microsoft.Extensions.Validation.ValidateContext.ValidationErrors.set -> void Microsoft.Extensions.Validation.ValidateContext.ValidationOptions.get -> Microsoft.Extensions.Validation.ValidationOptions! Microsoft.Extensions.Validation.ValidateContext.ValidationOptions.set -> void +Microsoft.Extensions.Validation.ValidationErrorContext +Microsoft.Extensions.Validation.ValidationErrorContext.Container.get -> object? +Microsoft.Extensions.Validation.ValidationErrorContext.Container.init -> void +Microsoft.Extensions.Validation.ValidationErrorContext.Errors.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.Extensions.Validation.ValidationErrorContext.Errors.init -> void +Microsoft.Extensions.Validation.ValidationErrorContext.Name.get -> string! +Microsoft.Extensions.Validation.ValidationErrorContext.Name.init -> void +Microsoft.Extensions.Validation.ValidationErrorContext.Path.get -> string! +Microsoft.Extensions.Validation.ValidationErrorContext.Path.init -> void +Microsoft.Extensions.Validation.ValidationErrorContext.ValidationErrorContext() -> void Microsoft.Extensions.Validation.ValidationOptions Microsoft.Extensions.Validation.ValidationOptions.MaxDepth.get -> int Microsoft.Extensions.Validation.ValidationOptions.MaxDepth.set -> void diff --git a/src/Validation/src/ValidatableParameterInfo.cs b/src/Validation/src/ValidatableParameterInfo.cs index 8481887ff8ce..dca5df305914 100644 --- a/src/Validation/src/ValidatableParameterInfo.cs +++ b/src/Validation/src/ValidatableParameterInfo.cs @@ -77,7 +77,7 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null) { var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}"; - context.AddValidationError(key, [result.ErrorMessage]); + context.AddValidationError(Name, key, [result.ErrorMessage], null); return; } } @@ -92,13 +92,13 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null) { var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}"; - context.AddOrExtendValidationErrors(key, [result.ErrorMessage]); + context.AddOrExtendValidationErrors(Name, key, [result.ErrorMessage], null); } } catch (Exception ex) { var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}"; - context.AddValidationError(key, [ex.Message]); + context.AddValidationError(Name, key, [ex.Message], null); } } diff --git a/src/Validation/src/ValidatablePropertyInfo.cs b/src/Validation/src/ValidatablePropertyInfo.cs index b73a8968fd62..94d7eb606fa3 100644 --- a/src/Validation/src/ValidatablePropertyInfo.cs +++ b/src/Validation/src/ValidatablePropertyInfo.cs @@ -85,14 +85,14 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null) { - context.AddValidationError(context.CurrentValidationPath, [result.ErrorMessage]); + context.AddValidationError(Name, context.CurrentValidationPath, [result.ErrorMessage], value); context.CurrentValidationPath = originalPrefix; // Restore prefix return; } } // Validate any other attributes - ValidateValue(propertyValue, context.CurrentValidationPath, validationAttributes); + ValidateValue(propertyValue, Name, context.CurrentValidationPath, validationAttributes, value); // Check if we've reached the maximum depth before validating complex properties if (context.CurrentDepth >= context.ValidationOptions.MaxDepth) @@ -150,7 +150,7 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, context.CurrentValidationPath = originalPrefix; } - void ValidateValue(object? val, string errorPrefix, ValidationAttribute[] validationAttributes) + void ValidateValue(object? val, string name, string errorPrefix, ValidationAttribute[] validationAttributes, object? container) { for (var i = 0; i < validationAttributes.Length; i++) { @@ -160,12 +160,14 @@ void ValidateValue(object? val, string errorPrefix, ValidationAttribute[] valida var result = attribute.GetValidationResult(val, context.ValidationContext); if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null) { - context.AddOrExtendValidationErrors(errorPrefix.TrimStart('.'), [result.ErrorMessage]); + var key = errorPrefix.TrimStart('.'); + context.AddOrExtendValidationErrors(name, key, [result.ErrorMessage], container); } } catch (Exception ex) { - context.AddOrExtendValidationErrors(errorPrefix.TrimStart('.'), [ex.Message]); + var key = errorPrefix.TrimStart('.'); + context.AddOrExtendValidationErrors(name, key, [ex.Message], container); } } } diff --git a/src/Validation/src/ValidatableTypeInfo.cs b/src/Validation/src/ValidatableTypeInfo.cs index 936a30d52d3b..8852f674a7e0 100644 --- a/src/Validation/src/ValidatableTypeInfo.cs +++ b/src/Validation/src/ValidatableTypeInfo.cs @@ -109,13 +109,13 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context, var key = string.IsNullOrEmpty(originalPrefix) ? memberName : $"{originalPrefix}.{memberName}"; - context.AddOrExtendValidationError(key, validationResult.ErrorMessage); + context.AddOrExtendValidationError(memberName, key, validationResult.ErrorMessage, value); } if (!validationResult.MemberNames.Any()) { // If no member names are specified, then treat this as a top-level error - context.AddOrExtendValidationError(string.Empty, validationResult.ErrorMessage); + context.AddOrExtendValidationError(string.Empty, string.Empty, validationResult.ErrorMessage, value); } } } diff --git a/src/Validation/src/ValidateContext.cs b/src/Validation/src/ValidateContext.cs index b27408ce4844..5cce279c4be9 100644 --- a/src/Validation/src/ValidateContext.cs +++ b/src/Validation/src/ValidateContext.cs @@ -60,14 +60,26 @@ public sealed class ValidateContext ///
public int CurrentDepth { get; set; } - internal void AddValidationError(string key, string[] error) + /// + /// Optional event raised when a validation error is reported. + /// + public event Action? OnValidationError; + + internal void AddValidationError(string propertyName, string key, string[] error, object? container) { ValidationErrors ??= []; ValidationErrors[key] = error; + OnValidationError?.Invoke(new ValidationErrorContext + { + Name = propertyName, + Path = key, + Errors = error, + Container = container + }); } - internal void AddOrExtendValidationErrors(string key, string[] errors) + internal void AddOrExtendValidationErrors(string propertyName, string key, string[] errors, object? container) { ValidationErrors ??= []; @@ -82,9 +94,17 @@ internal void AddOrExtendValidationErrors(string key, string[] errors) { ValidationErrors[key] = errors; } + + OnValidationError?.Invoke(new ValidationErrorContext + { + Name = propertyName, + Path = key, + Errors = errors, + Container = container + }); } - internal void AddOrExtendValidationError(string key, string error) + internal void AddOrExtendValidationError(string name, string key, string error, object? container) { ValidationErrors ??= []; @@ -96,5 +116,13 @@ internal void AddOrExtendValidationError(string key, string error) { ValidationErrors[key] = [error]; } + + OnValidationError?.Invoke(new ValidationErrorContext + { + Name = name, + Path = key, + Errors = [error], + Container = container + }); } } diff --git a/src/Validation/src/ValidationErrorContext.cs b/src/Validation/src/ValidationErrorContext.cs new file mode 100644 index 000000000000..b159ce2c6209 --- /dev/null +++ b/src/Validation/src/ValidationErrorContext.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.Validation; + +/// +/// Represents the context of a validation error. +/// +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public readonly struct ValidationErrorContext +{ + /// + /// Gets the name of the property or parameter that caused the validation error. + /// + public required string Name { get; init; } + + /// + /// Gets the full path from the root object to the property or parameter that caused the validation error. + /// + public required string Path { get; init; } + + /// + /// Gets the list of error messages associated with the validation error. + /// + public required IReadOnlyList Errors { get; init; } + + /// + /// Gets a reference to the container object of the validated property. + /// + public required object? Container { get; init; } + + private string GetDebuggerDisplay() + { + return $"{Path}: {string.Join(",", Errors)}"; + } +} diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs index 775ad0e947ee..002ebb1582b0 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/ValidatableTypeInfoTests.cs @@ -15,6 +15,8 @@ public class ValidatableTypeInfoTests public async Task Validate_ValidatesComplexType_WithNestedProperties() { // Arrange + List validationErrors = []; + var personType = new TestValidatableTypeInfo( typeof(Person), [ @@ -52,6 +54,8 @@ [new RequiredAttribute()]) ValidationContext = new ValidationContext(personWithMissingRequiredFields) }; + context.OnValidationError += validationErrors.Add; + // Act await personType.ValidateAsync(personWithMissingRequiredFields, context, default); @@ -78,12 +82,43 @@ [new RequiredAttribute()]) Assert.Equal("Address.City", kvp.Key); Assert.Equal("The City field is required.", kvp.Value.First()); }); + + Assert.Collection(validationErrors, + context => + { + Assert.Equal("Name", context.Name); + Assert.Equal("Name", context.Path); + Assert.Equal("The Name field is required.", context.Errors.Single()); + Assert.Same(context.Container, personWithMissingRequiredFields); + }, + context => + { + Assert.Equal("Age", context.Name); + Assert.Equal("Age", context.Path); + Assert.Equal("The field Age must be between 0 and 120.", context.Errors.Single()); + Assert.Same(context.Container, personWithMissingRequiredFields); + }, + context => + { + Assert.Equal("Street", context.Name); + Assert.Equal("Address.Street", context.Path); + Assert.Equal("The Street field is required.", context.Errors.Single()); + Assert.Same(context.Container, personWithMissingRequiredFields.Address); + }, + context => + { + Assert.Equal("City", context.Name); + Assert.Equal("Address.City", context.Path); + Assert.Equal("The City field is required.", context.Errors.Single()); + Assert.Same(context.Container, personWithMissingRequiredFields.Address); + }); } [Fact] public async Task Validate_HandlesIValidatableObject_Implementation() { // Arrange + var validationErrors = new List(); var employeeType = new TestValidatableTypeInfo( typeof(Employee), [ @@ -110,6 +145,8 @@ [new RequiredAttribute()]), ValidationContext = new ValidationContext(employee) }; + context.OnValidationError += validationErrors.Add; + // Act await employeeType.ValidateAsync(employee, context, default); @@ -118,6 +155,12 @@ [new RequiredAttribute()]), var error = Assert.Single(context.ValidationErrors); Assert.Equal("Salary", error.Key); Assert.Equal("Salary must be a positive value.", error.Value.First()); + + var errorContext = Assert.Single(validationErrors); + Assert.Equal("Salary", errorContext.Name); + Assert.Equal("Salary", errorContext.Path); + Assert.Equal("Salary must be a positive value.", errorContext.Errors.Single()); + Assert.Same(errorContext.Container, employee); } [Fact]