Skip to content

Commit 9e08e07

Browse files
authored
[Blazor] Use the unified validation API for Blazor forms (#62045)
* Adds support for Microsoft.Extensions.Validation in `EditContextDataAnnotationsValidatorExtensions`
1 parent 5cb623c commit 9e08e07

22 files changed

+533
-61
lines changed

src/Components/Components.slnf

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@
154154
"src\\StaticAssets\\src\\Microsoft.AspNetCore.StaticAssets.csproj",
155155
"src\\StaticAssets\\test\\Microsoft.AspNetCore.StaticAssets.Tests.csproj",
156156
"src\\Testing\\src\\Microsoft.AspNetCore.InternalTesting.csproj",
157+
"src\\Validation\\gen\\Microsoft.Extensions.Validation.ValidationsGenerator.csproj",
158+
"src\\Validation\\src\\Microsoft.Extensions.Validation.csproj",
159+
"src\\Validation\\test\\Microsoft.Extensions.Validation.GeneratorTests\\Microsoft.Extensions.Validation.GeneratorTests.csproj",
160+
"src\\Validation\\test\\Microsoft.Extensions.Validation.Tests\\Microsoft.Extensions.Validation.Tests.csproj",
157161
"src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
158162
]
159163
}
160-
}
164+
}

src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
using System.Reflection;
88
using System.Reflection.Metadata;
99
using System.Runtime.InteropServices;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Options;
12+
using Microsoft.Extensions.Validation;
1013

1114
[assembly: MetadataUpdateHandler(typeof(Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions))]
1215

@@ -15,7 +18,7 @@ namespace Microsoft.AspNetCore.Components.Forms;
1518
/// <summary>
1619
/// Extension methods to add DataAnnotations validation to an <see cref="EditContext"/>.
1720
/// </summary>
18-
public static class EditContextDataAnnotationsExtensions
21+
public static partial class EditContextDataAnnotationsExtensions
1922
{
2023
/// <summary>
2124
/// Adds DataAnnotations validation support to the <see cref="EditContext"/>.
@@ -59,20 +62,31 @@ private static void ClearCache(Type[]? _)
5962
}
6063
#pragma warning restore IDE0051 // Remove unused private members
6164

62-
private sealed class DataAnnotationsEventSubscriptions : IDisposable
65+
private sealed partial class DataAnnotationsEventSubscriptions : IDisposable
6366
{
6467
private static readonly ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo?> _propertyInfoCache = new();
6568

6669
private readonly EditContext _editContext;
6770
private readonly IServiceProvider? _serviceProvider;
6871
private readonly ValidationMessageStore _messages;
72+
private readonly ValidationOptions? _validationOptions;
73+
#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.
74+
private readonly IValidatableInfo? _validatorTypeInfo;
75+
#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.
76+
private readonly Dictionary<string, FieldIdentifier> _validationPathToFieldIdentifierMapping = new();
6977

78+
[UnconditionalSuppressMessage("Trimming", "IL2066", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
7079
public DataAnnotationsEventSubscriptions(EditContext editContext, IServiceProvider serviceProvider)
7180
{
7281
_editContext = editContext ?? throw new ArgumentNullException(nameof(editContext));
7382
_serviceProvider = serviceProvider;
7483
_messages = new ValidationMessageStore(_editContext);
75-
84+
_validationOptions = _serviceProvider?.GetService<IOptions<ValidationOptions>>()?.Value;
85+
#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.
86+
_validatorTypeInfo = _validationOptions != null && _validationOptions.TryGetValidatableTypeInfo(_editContext.Model.GetType(), out var typeInfo)
87+
? typeInfo
88+
: null;
89+
#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.
7690
_editContext.OnFieldChanged += OnFieldChanged;
7791
_editContext.OnValidationRequested += OnValidationRequested;
7892

@@ -112,6 +126,18 @@ private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
112126
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
113127
{
114128
var validationContext = new ValidationContext(_editContext.Model, _serviceProvider, items: null);
129+
130+
if (!TryValidateTypeInfo(validationContext))
131+
{
132+
ValidateWithDefaultValidator(validationContext);
133+
}
134+
135+
_editContext.NotifyValidationStateChanged();
136+
}
137+
138+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
139+
private void ValidateWithDefaultValidator(ValidationContext validationContext)
140+
{
115141
var validationResults = new List<ValidationResult>();
116142
Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true);
117143

@@ -136,8 +162,62 @@ private void OnValidationRequested(object? sender, ValidationRequestedEventArgs
136162
_messages.Add(new FieldIdentifier(_editContext.Model, fieldName: string.Empty), validationResult.ErrorMessage!);
137163
}
138164
}
165+
}
139166

140-
_editContext.NotifyValidationStateChanged();
167+
#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.
168+
private bool TryValidateTypeInfo(ValidationContext validationContext)
169+
{
170+
if (_validatorTypeInfo is null)
171+
{
172+
return false;
173+
}
174+
175+
var validateContext = new ValidateContext
176+
{
177+
ValidationOptions = _validationOptions!,
178+
ValidationContext = validationContext,
179+
};
180+
try
181+
{
182+
validateContext.OnValidationError += AddMapping;
183+
184+
var validationTask = _validatorTypeInfo.ValidateAsync(_editContext.Model, validateContext, CancellationToken.None);
185+
if (!validationTask.IsCompleted)
186+
{
187+
throw new InvalidOperationException("Async validation is not supported");
188+
}
189+
190+
var validationErrors = validateContext.ValidationErrors;
191+
192+
// Transfer results to the ValidationMessageStore
193+
_messages.Clear();
194+
195+
if (validationErrors is not null && validationErrors.Count > 0)
196+
{
197+
foreach (var (fieldKey, messages) in validationErrors)
198+
{
199+
var fieldIdentifier = _validationPathToFieldIdentifierMapping[fieldKey];
200+
_messages.Add(fieldIdentifier, messages);
201+
}
202+
}
203+
}
204+
catch (Exception)
205+
{
206+
throw;
207+
}
208+
finally
209+
{
210+
validateContext.OnValidationError -= AddMapping;
211+
_validationPathToFieldIdentifierMapping.Clear();
212+
}
213+
214+
return true;
215+
216+
}
217+
private void AddMapping(ValidationErrorContext context)
218+
{
219+
_validationPathToFieldIdentifierMapping[context.Path] =
220+
new FieldIdentifier(context.Container ?? _editContext.Model, context.Name);
141221
}
142222

143223
public void Dispose()

src/Components/Forms/src/Microsoft.AspNetCore.Components.Forms.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
<ItemGroup>
1313
<Reference Include="Microsoft.AspNetCore.Components" />
14+
<Reference Include="Microsoft.Extensions.Validation" />
1415
</ItemGroup>
1516

1617
<ItemGroup>

src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj

Lines changed: 16 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
33
<!--
44
Skip building and running the Components E2E tests in CI unless explicitly configured otherwise via
55
EXECUTE_COMPONENTS_E2E_TESTS. At least build the Components E2E tests locally unless SkipTestBuild is set.
66
-->
77
<_BuildAndTest>false</_BuildAndTest>
8-
<_BuildAndTest
9-
Condition=" '$(ContinuousIntegrationBuild)' == 'true' AND '$(EXECUTE_COMPONENTS_E2E_TESTS)' == 'true' ">true</_BuildAndTest>
10-
<_BuildAndTest
11-
Condition=" '$(ContinuousIntegrationBuild)' != 'true' AND '$(SkipTestBuild)' != 'true' ">true</_BuildAndTest>
8+
<_BuildAndTest Condition=" '$(ContinuousIntegrationBuild)' == 'true' AND '$(EXECUTE_COMPONENTS_E2E_TESTS)' == 'true' ">true</_BuildAndTest>
9+
<_BuildAndTest Condition=" '$(ContinuousIntegrationBuild)' != 'true' AND '$(SkipTestBuild)' != 'true' ">true</_BuildAndTest>
1210
<ExcludeFromBuild Condition=" !$(_BuildAndTest) ">true</ExcludeFromBuild>
1311
<SkipTests Condition=" !$(_BuildAndTest) ">true</SkipTests>
1412

@@ -67,39 +65,19 @@
6765
</ItemGroup>
6866

6967
<ItemGroup Condition="'$(TestTrimmedOrMultithreadingApps)' == 'true'">
70-
<ProjectReference Include="..\..\benchmarkapps\Wasm.Performance\TestApp\Wasm.Performance.TestApp.csproj"
71-
Targets="Build;Publish"
72-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Wasm.Performance.TestApp\" />
73-
74-
<ProjectReference
75-
Include="..\testassets\BasicTestApp\BasicTestApp.csproj"
76-
Targets="Build;Publish"
77-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\BasicTestApp\" />
78-
79-
<ProjectReference
80-
Include="..\testassets\GlobalizationWasmApp\GlobalizationWasmApp.csproj"
81-
Targets="Build;Publish"
82-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\GlobalizationWasmApp\;" />
83-
84-
<ProjectReference
85-
Include="..\..\WebAssembly\testassets\StandaloneApp\StandaloneApp.csproj"
86-
Targets="Build;Publish"
87-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\StandaloneApp\;" />
88-
89-
<ProjectReference
90-
Include="..\..\WebAssembly\testassets\Wasm.Prerendered.Server\Wasm.Prerendered.Server.csproj"
91-
Targets="Build;Publish"
92-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Wasm.Prerendered.Server\;" />
93-
94-
<ProjectReference
95-
Include="..\..\WebAssembly\testassets\ThreadingApp\ThreadingApp.csproj"
96-
Targets="Build;Publish"
97-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=false;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\ThreadingApp\;" />
98-
99-
<ProjectReference
100-
Include="..\testassets\Components.TestServer\Components.TestServer.csproj"
101-
Targets="Build;Publish"
102-
Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Components.TestServer\;" />
68+
<ProjectReference Include="..\..\benchmarkapps\Wasm.Performance\TestApp\Wasm.Performance.TestApp.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Wasm.Performance.TestApp\" />
69+
70+
<ProjectReference Include="..\testassets\BasicTestApp\BasicTestApp.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\BasicTestApp\" />
71+
72+
<ProjectReference Include="..\testassets\GlobalizationWasmApp\GlobalizationWasmApp.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\GlobalizationWasmApp\;" />
73+
74+
<ProjectReference Include="..\..\WebAssembly\testassets\StandaloneApp\StandaloneApp.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\StandaloneApp\;" />
75+
76+
<ProjectReference Include="..\..\WebAssembly\testassets\Wasm.Prerendered.Server\Wasm.Prerendered.Server.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Wasm.Prerendered.Server\;" />
77+
78+
<ProjectReference Include="..\..\WebAssembly\testassets\ThreadingApp\ThreadingApp.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=false;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\ThreadingApp\;" />
79+
80+
<ProjectReference Include="..\testassets\Components.TestServer\Components.TestServer.csproj" Targets="Build;Publish" Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Components.TestServer\;" />
10381
</ItemGroup>
10482

10583
<!-- Shared testing infrastructure for running E2E tests using selenium -->
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Text;
7+
using Components.TestServer.RazorComponents;
8+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
9+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
10+
using Microsoft.AspNetCore.E2ETesting;
11+
using OpenQA.Selenium;
12+
using TestServer;
13+
using Xunit.Abstractions;
14+
15+
namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
16+
17+
public class AddValidationIntegrationTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>>>
18+
{
19+
public AddValidationIntegrationTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>> serverFixture, ITestOutputHelper output) : base(browserFixture, serverFixture, output)
20+
{
21+
}
22+
23+
protected override void InitializeAsyncCore()
24+
{
25+
Navigate("subdir/forms/add-validation-form");
26+
Browser.Exists(By.Id("is-interactive"));
27+
}
28+
29+
[Fact]
30+
public void FormWithNestedValidation_Works()
31+
{
32+
Browser.Exists(By.Id("submit-form")).Click();
33+
34+
Browser.Exists(By.Id("is-invalid"));
35+
36+
// Validation summary
37+
var messageElements = Browser.FindElements(By.CssSelector(".validation-errors > .validation-message"));
38+
39+
var messages = messageElements.Select(element => element.Text)
40+
.ToList();
41+
42+
var expected = new[]
43+
{
44+
"Order Name is required.",
45+
"Full Name is required.",
46+
"Email is required.",
47+
"Street is required.",
48+
"Zip Code is required.",
49+
"Product Name is required."
50+
};
51+
52+
Assert.Equal(expected, messages);
53+
54+
// Individual field messages
55+
var individual = Browser.FindElements(By.CssSelector(".mb-3 > .validation-message"))
56+
.Select(element => element.Text)
57+
.ToList();
58+
59+
Assert.Equal(expected, individual);
60+
}
61+
}

src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
1+
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
22

33
<PropertyGroup>
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
@@ -11,6 +11,9 @@
1111

1212
<!-- Project supports more than one language -->
1313
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
14+
15+
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
16+
1417
</PropertyGroup>
1518

1619
<PropertyGroup Condition="'$(TestTrimmedOrMultithreadingApps)' == 'true'">
@@ -47,10 +50,8 @@
4750
<ItemGroup>
4851
<ResolvedFileToPublish RelativePath="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('subdir\subdir', 'subdir').Replace('subdir/subdir', 'subdir'))" />
4952

50-
<ResolvedFileToPublish
51-
Include="@(ResolvedFileToPublish)"
52-
RelativePath="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('subdir\_content', '_content').Replace('subdir/subdir', '_content'))"
53-
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('subdir\_content', 'subdir/_content').Contains('subdir/_content'))" />
53+
<ResolvedFileToPublish Include="@(ResolvedFileToPublish)" RelativePath="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('subdir\_content', '_content').Replace('subdir/subdir', '_content'))" Condition="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('subdir\_content', 'subdir/_content').Contains('subdir/_content'))" />
54+
5455
</ItemGroup>
5556
</Target>
5657

src/Components/test/testassets/BasicTestApp/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public static async Task Main(string[] args)
2323
await SimulateErrorsIfNeededForTest();
2424

2525
var builder = WebAssemblyHostBuilder.CreateDefault(args);
26+
27+
builder.Services.AddValidation();
28+
2629
builder.RootComponents.Add<HeadOutlet>("head::after");
2730
builder.RootComponents.Add<Index>("root");
2831
builder.RootComponents.RegisterForJavaScript<DynamicallyAddedRootComponent>("my-dynamic-root-component");

src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
<Nullable>annotations</Nullable>
1111
<RazorLangVersion>latest</RazorLangVersion>
1212
<BlazorRoutingEnableRegexConstraint>true</BlazorRoutingEnableRegexConstraint>
13+
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated;Microsoft.Extensions.Validation.Generated</InterceptorsNamespaces>
1314
</PropertyGroup>
1415

16+
<ItemGroup>
17+
<ProjectReference Include="$(RepoRoot)/src/Validation/gen/Microsoft.Extensions.Validation.ValidationsGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
18+
</ItemGroup>
19+
1520
<ItemGroup>
1621
<Reference Include="Microsoft.AspNetCore" />
1722
<Reference Include="Microsoft.AspNetCore.Authentication.Cookies" />

src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public RazorComponentEndpointsStartup(IConfiguration configuration)
2828
// This method gets called by the runtime. Use this method to add services to the container.
2929
public void ConfigureServices(IServiceCollection services)
3030
{
31+
services.AddValidation();
32+
3133
services.AddRazorComponents(options =>
3234
{
3335
options.MaxFormMappingErrorCount = 10;

0 commit comments

Comments
 (0)