Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.AspNetCore.Components.Endpoints.DependencyInjection;
using Microsoft.AspNetCore.Components.Endpoints.Forms;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Forms.ClientValidation;
using Microsoft.AspNetCore.Components.Forms.Mapping;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.Routing;
Expand Down Expand Up @@ -94,7 +95,8 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<AntiforgeryStateProvider>(services, RenderMode.InteractiveAuto);
services.TryAddScoped<HttpContextFormDataProvider>();
services.TryAddScoped<IFormValueMapper, HttpContextFormValueMapper>();
services.AddClientValidation();
services.TryAddSingleton<ClientValidationCache>();
services.TryAddScoped<ClientValidationProvider, DataAnnotationsClientValidationProvider>();

if (configure != null)
{
Expand Down
170 changes: 170 additions & 0 deletions src/Components/Endpoints/src/Forms/ClientValidationCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// 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.Concurrent;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.HotReload;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Validation;

namespace Microsoft.AspNetCore.Components.Endpoints.Forms;

using FieldKey = (Type ModelType, string FieldName);

internal sealed class ClientValidationCache : IDisposable
{
private readonly ConcurrentDictionary<FieldKey, ClientValidationFieldMetadata?> _metadataCache = new();
private readonly ConcurrentDictionary<Type, bool> _typeHasValidatableInfo = new();
private readonly ConcurrentDictionary<FieldKey, bool> _propertyHasValidatableInfo = new();
private readonly ValidationOptions _validationOptions;

[UnconditionalSuppressMessage("Trimming", "IL2066",
Justification = "Preserves ValidationOptions's parameterless constructor used by Microsoft.Extensions.Options to materialize IOptions<ValidationOptions>.")]
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(ValidationOptions))]
public ClientValidationCache(IOptions<ValidationOptions> validationOptions)
{
_validationOptions = validationOptions.Value;

if (HotReloadManager.IsSupported)
{
HotReloadManager.Default.OnDeltaApplied += ClearCache;
}
}

public IEnumerable<(string renderedName, ClientValidationFieldMetadata metadata)> GetValidatableFieldMetadata(
IReadOnlyDictionary<FieldIdentifier, string> fields,
object formModel)
{
// The form model type determines which validation pipeline (DataAnnotations.Validator vs MEV) the server uses.
var formHasValidatableInfo = HasValidatableTypeInfo(formModel.GetType());

foreach (var (fieldIdentifier, renderedName) in fields)
{
// Don't enable client-side validation for fields that would not get server-side validation
// to help developers avoid security mistakes.
if (!IsServerValidatable(fieldIdentifier, formModel, formHasValidatableInfo))
{
continue;
}

var fieldKey = (fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
var cachedMetadata = _metadataCache.GetOrAdd(
fieldKey,
static key => BuildFieldMetadata(key.ModelType, key.FieldName));

if (cachedMetadata is { } fieldMetadata)
{
yield return (renderedName, fieldMetadata);
}
}
}

/// <summary>
/// Determines whether the server will validate the specified field on submit, which is the
/// condition under which client-side rules may be safely emitted.
/// </summary>
/// <param name="fieldIdentifier">The field an input was rendered for.</param>
/// <param name="formModel">The form's top-level model (<see cref="EditContext.Model"/>).</param>
/// <param name="formHasValidatableInfo">
/// Whether the form model type is recognized by MEV. Computed once per form by the caller via
/// <see cref="HasValidatableTypeInfo"/> and threaded in so the per-field path does no extra
/// type-level lookup.
/// </param>
private bool IsServerValidatable(in FieldIdentifier fieldIdentifier, object formModel, bool formHasValidatableInfo)
{
if (formHasValidatableInfo)
{
// MEV submit path (ValidateAsync) recurses. A field is validated iff MEV has
// ValidatablePropertyInfo for it on its owner type. This also naturally excludes
// members/types filtered by [SkipValidation], matching the server.
return HasValidatablePropertyInfo(fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
}
else
{
// DataAnnotations submit path (Validator.TryValidateObject) validates only the form
// model's top-level properties and does not recurse. A field is top-level iff its owner
// is the form model instance.
return ReferenceEquals(fieldIdentifier.Model, formModel);
}
}

#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates.
/// <summary>
/// Returns whether MEV recognizes <paramref name="type"/> as a validatable type. Cached.
/// Returns <see langword="false"/> when MEV is not configured.
/// </summary>
private bool HasValidatableTypeInfo(Type type) =>
_validationOptions.Resolvers.Count > 0
&& _typeHasValidatableInfo.GetOrAdd(type,
key => _validationOptions.TryGetValidatableTypeInfo(key, out _));

private bool HasValidatablePropertyInfo(Type ownerType, string fieldName) =>
_validationOptions.Resolvers.Count > 0
&& _propertyHasValidatableInfo.GetOrAdd((ownerType, fieldName),
key => _validationOptions.TryGetValidatableTypeInfo(key.ModelType, out var typeInfo)
&& typeInfo.TryFindProperty(key.FieldName, _validationOptions, out _));
#pragma warning restore ASP0029

private void ClearCache()
{
_metadataCache.Clear();
_typeHasValidatableInfo.Clear();
_propertyHasValidatableInfo.Clear();
}

public void Dispose()
{
if (HotReloadManager.IsSupported)
{
HotReloadManager.Default.OnDeltaApplied -= ClearCache;
}
}

// Builds reflection metadata for a single field. Culture-independent; localized text is
// resolved per call by the provider. At most one of ResourceDisplayAttribute and
// LiteralDisplayName is non-null; both null means the property has no display attribute.
[UnconditionalSuppressMessage("Trimming", "IL2070",
Justification = "Model types are application code and are preserved by default.")]
private static ClientValidationFieldMetadata? BuildFieldMetadata(Type modelType, string propertyName)
{
var property = modelType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);

if (property is null)
{
return null;
}

var validationAttributes = property.GetCustomAttributes<ValidationAttribute>(inherit: true).ToArray();

if (validationAttributes.Length == 0)
{
return null;
}

var displayAttribute = property.GetCustomAttribute<DisplayAttribute>(inherit: true);
DisplayAttribute? resourceDisplayAttribute = null;
string? literalDisplayName = null;

if (displayAttribute is { ResourceType: not null, Name: not null })
{
resourceDisplayAttribute = displayAttribute;
}
else
{
literalDisplayName = displayAttribute?.Name
?? property.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName;
}

return new ClientValidationFieldMetadata(
propertyName: property.Name,
validationAttributes,
declaringType: property.DeclaringType,
resourceDisplayAttribute,
literalDisplayName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Text;
using System.Text.Json;

namespace Microsoft.AspNetCore.Components.Endpoints.Forms;

internal static class ClientValidationDataSerializer
{
public static string Serialize(ClientValidationFormDescriptor form)
{
var buffer = new ArrayBufferWriter<byte>(initialCapacity: 256);
using (var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
writer.WritePropertyName("fields"u8);
writer.WriteStartArray();
foreach (var field in form.Fields)
{
WriteField(writer, field);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
// Utf8JsonWriter's default encoder escapes <, >, &, ' - safe for HTML text content.
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}

private static void WriteField(Utf8JsonWriter writer, ClientValidationFieldDescriptor field)
{
writer.WriteStartObject();
writer.WriteString("name"u8, field.Name);
writer.WritePropertyName("rules"u8);
writer.WriteStartArray();
foreach (var rule in field.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
}

private static void WriteRule(Utf8JsonWriter writer, ClientValidationRuleDescriptor rule)
{
writer.WriteStartObject();
writer.WriteString("name"u8, rule.Name);
writer.WriteString("message"u8, rule.ErrorMessage);
if (rule.Parameters is { Count: > 0 } parameters)
{
writer.WritePropertyName("params"u8);
writer.WriteStartObject();
foreach (var kvp in parameters)
{
writer.WriteString(kvp.Key, kvp.Value);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Endpoints.Forms;

/// <summary>
/// Describes the client-side validation rules for one field within a form.
/// </summary>
internal sealed class ClientValidationFieldDescriptor
{
/// <summary>
/// Creates a field descriptor.
/// </summary>
/// <param name="name">The field name as it appears in form posts. Should use dotted path for nested fields (e.g. <c>Address.Street</c>).</param>
/// <param name="rules">The ordered list of client-side validation rules for this field.</param>
public ClientValidationFieldDescriptor(
string name,
IReadOnlyList<ClientValidationRuleDescriptor> rules)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(rules);
Name = name;
Rules = rules;
}

/// <summary>
/// Field name as it appears in form posts.
/// Dotted path for nested fields (e.g. <c>Address.Street</c>).
/// </summary>
public string Name { get; }

/// <summary>Ordered list of client-side validation rules.</summary>
public IReadOnlyList<ClientValidationRuleDescriptor> Rules { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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 Microsoft.AspNetCore.Components.Endpoints.Forms;

// Per-field reflection results. Culture-independent; localized text is resolved per call.
// At most one of ResourceDisplayAttribute and LiteralDisplayName is non-null; both null means
// the field property has no display attribute.
internal readonly struct ClientValidationFieldMetadata(
string propertyName,
ValidationAttribute[] validationAttributes,
Type? declaringType,
DisplayAttribute? resourceDisplayAttribute,
string? literalDisplayName)
{
public string PropertyName { get; } = propertyName;

public ValidationAttribute[] ValidationAttributes { get; } = validationAttributes;

public Type? DeclaringType { get; } = declaringType;

// [Display(Name=..., ResourceType=...)]
public DisplayAttribute? ResourceDisplayAttribute { get; } = resourceDisplayAttribute;

// [Display(Name="X")] (no ResourceType) or [DisplayName("X")].
public string? LiteralDisplayName { get; } = literalDisplayName;
}
Original file line number Diff line number Diff line change
@@ -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.

namespace Microsoft.AspNetCore.Components.Endpoints.Forms;

/// <summary>
/// Describes the client-side validation data for one form: the set of validated fields
/// and their rules.
/// </summary>
internal sealed class ClientValidationFormDescriptor
{
/// <summary>
/// Creates a descriptor with the given field descriptors.
/// </summary>
public ClientValidationFormDescriptor(IReadOnlyList<ClientValidationFieldDescriptor> fields)
{
ArgumentNullException.ThrowIfNull(fields);
Fields = fields;
}

/// <summary>
/// Per-field client-side validation data.
/// </summary>
public IReadOnlyList<ClientValidationFieldDescriptor> Fields { get; }
}
44 changes: 44 additions & 0 deletions src/Components/Endpoints/src/Forms/ClientValidationRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Forms;

/// <summary>
/// Describes a single client-side validation rule produced by an <see cref="IClientValidationAdapter"/>.
/// </summary>
public sealed class ClientValidationRule
{
/// <summary>
/// Creates a rule with the specified name and optional parameters.
/// </summary>
/// <param name="name">
/// The rule name. Must be non-empty and must match the name registered with the JS
/// validator via <c>Blazor.formValidation.addValidator(name, ...)</c>.
/// </param>
/// <param name="parameters">
/// Optional parameters passed to the JS validator at runtime. All values are strings on the
/// wire; validators that need numeric or boolean values parse them at validation time
/// (<c>parseInt</c>, <c>parseFloat</c>, etc.). When <see langword="null"/> or empty, the
/// <c>params</c> object is omitted from the wire format.
/// </param>
public ClientValidationRule(
string name,
IReadOnlyDictionary<string, string>? parameters = null)
{
ArgumentException.ThrowIfNullOrEmpty(name);

Name = name;
Parameters = parameters;
}

/// <summary>
/// Gets the rule name. Matches the name registered with the JS validator.
/// </summary>
public string Name { get; }

/// <summary>
/// Gets the parameters passed to the JS validator at runtime. <see langword="null"/> when
/// no parameters apply.
/// </summary>
public IReadOnlyDictionary<string, string>? Parameters { get; }
}
Loading
Loading