From f8b0dec3bb2936c40563809bd35aecb7d328418e Mon Sep 17 00:00:00 2001 From: liero Date: Mon, 18 Jul 2022 14:42:58 +0200 Subject: [PATCH 1/4] Extracted AssemblyScannerValidatorFactory and ServiceProviderValidatorFactory to separate classes for better reusability and granularity --- README.md | 2 +- .../AssemblyScannerValidatorFactory.cs | 52 +++++++++++++++++++ .../DefaultValidatorFactory.cs | 51 ++++-------------- .../ServiceProviderValidatorFactory.cs | 19 +++++++ 4 files changed, 83 insertions(+), 41 deletions(-) create mode 100644 vNext.BlazorComponents.FluentValidation/AssemblyScannerValidatorFactory.cs create mode 100644 vNext.BlazorComponents.FluentValidation/ServiceProviderValidatorFactory.cs diff --git a/README.md b/README.md index 41e35b5..0449d77 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ or per FluentValidationValidator component } ``` - See [DefaultValidatorFactory.cs](vNext.BlazorComponents.FluentValidation\DefaultValidatorFactory.cs) for more info. + See [DefaultValidatorFactory.cs](vNext.BlazorComponents.FluentValidation/DefaultValidatorFactory.cs) for more info. ## Validating Complex Models diff --git a/vNext.BlazorComponents.FluentValidation/AssemblyScannerValidatorFactory.cs b/vNext.BlazorComponents.FluentValidation/AssemblyScannerValidatorFactory.cs new file mode 100644 index 0000000..a487fee --- /dev/null +++ b/vNext.BlazorComponents.FluentValidation/AssemblyScannerValidatorFactory.cs @@ -0,0 +1,52 @@ +#nullable enable + +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace vNext.BlazorComponents.FluentValidation +{ + public class AssemblyScannerValidatorFactory : IValidatorFactory + { + static readonly List ScannedAssembly = new(); + static readonly List AssemblyScanResults = new(); + + public IValidator? CreateValidator(ValidatorFactoryContext context) + { + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(i => i.FullName is not null && !ScannedAssembly.Contains(i.FullName))) + { + try + { + AssemblyScanResults.AddRange(AssemblyScanner.FindValidatorsInAssembly(assembly, false)); + } + catch (Exception) + { + } + + ScannedAssembly.Add(assembly.FullName!); + } + + + Type modelType = context.Model.GetType(); + + static int CommonPrefixLength(string? str1, string? str2) => + (str1 ?? string.Empty).TakeWhile((c, i) => str2?.Length < i && c == str2[c]).Count(); + + + Type? modelValidatorType = AssemblyScanResults.Where(i => context.ValidatorType.IsAssignableFrom(i.InterfaceType)) + .OrderByDescending(e => e.ValidatorType.Assembly == modelType.Assembly) //prefer current assebly + .ThenByDescending(e => CommonPrefixLength(e.ValidatorType.FullName, modelType.FullName)) //prefer similar namespace + .ThenBy(e => e.ValidatorType.Namespace?.Length) + .FirstOrDefault()?.ValidatorType; + + if (modelValidatorType != null) + { + return (IValidator)ActivatorUtilities.CreateInstance(context.ServiceProvider, modelValidatorType); + } + return null; + } + } +} diff --git a/vNext.BlazorComponents.FluentValidation/DefaultValidatorFactory.cs b/vNext.BlazorComponents.FluentValidation/DefaultValidatorFactory.cs index 6ff53fb..befda20 100644 --- a/vNext.BlazorComponents.FluentValidation/DefaultValidatorFactory.cs +++ b/vNext.BlazorComponents.FluentValidation/DefaultValidatorFactory.cs @@ -1,64 +1,35 @@ #nullable enable using FluentValidation; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Linq; namespace vNext.BlazorComponents.FluentValidation { + /// /// Returns validator from ServiceProvider or by scanning assemlies /// public class DefaultValidatorFactory : IValidatorFactory { - static readonly List ScannedAssembly = new(); - static readonly List AssemblyScanResults = new(); + AssemblyScannerValidatorFactory? _assemblyScannerValidatorFactory; + ServiceProviderValidatorFactory? _serviceProviderValidatorFactory; public bool DisableAssemblyScanning { get; set; } + public bool DisableServiceProvider { get; set; } public IValidator? CreateValidator(ValidatorFactoryContext context) { - if (context.ServiceProvider.GetService(context.ValidatorType) is IValidator validator) + IValidator? result = null; + if (!DisableServiceProvider) { - return validator; + _serviceProviderValidatorFactory ??= new(); + result = _serviceProviderValidatorFactory.CreateValidator(context); } if (!DisableAssemblyScanning) { - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(i => i.FullName is not null && !ScannedAssembly.Contains(i.FullName))) - { - try - { - AssemblyScanResults.AddRange(AssemblyScanner.FindValidatorsInAssembly(assembly, false)); - } - catch (Exception) - { - } - - ScannedAssembly.Add(assembly.FullName!); - } - - - Type modelType = context.Model.GetType(); - - static int CommonPrefixLength(string? str1, string? str2) => - (str1 ?? string.Empty).TakeWhile((c, i) => str2?.Length < i && c == str2[c]).Count(); - - - Type? modelValidatorType = AssemblyScanResults.Where(i => context.ValidatorType.IsAssignableFrom(i.InterfaceType)) - .OrderByDescending(e => e.ValidatorType.Assembly == modelType.Assembly) //prefer current assebly - .ThenByDescending(e => CommonPrefixLength(e.ValidatorType.FullName, modelType.FullName)) //prefer similar namespace - .ThenBy(e => e.ValidatorType.Namespace?.Length) - .FirstOrDefault()?.ValidatorType; - - if (modelValidatorType != null) - { - return (IValidator)ActivatorUtilities.CreateInstance(context.ServiceProvider, modelValidatorType); - } + _assemblyScannerValidatorFactory ??= new(); + result ??= _assemblyScannerValidatorFactory.CreateValidator(context); } - - return null; + return result; } } } diff --git a/vNext.BlazorComponents.FluentValidation/ServiceProviderValidatorFactory.cs b/vNext.BlazorComponents.FluentValidation/ServiceProviderValidatorFactory.cs new file mode 100644 index 0000000..d5cfa52 --- /dev/null +++ b/vNext.BlazorComponents.FluentValidation/ServiceProviderValidatorFactory.cs @@ -0,0 +1,19 @@ +#nullable enable + +using FluentValidation; + +namespace vNext.BlazorComponents.FluentValidation +{ + public class ServiceProviderValidatorFactory: IValidatorFactory + { + public IValidator? CreateValidator(ValidatorFactoryContext context) + { + if (context.ServiceProvider.GetService(context.ValidatorType) is IValidator validator) + { + return validator; + } + + return null; + } + } +} From e3716a46d801c9efaa39581694d7faf97640a964 Mon Sep 17 00:00:00 2001 From: liero Date: Mon, 18 Jul 2022 15:43:05 +0200 Subject: [PATCH 2/4] Exposed ValidateFieldAsync and ValidateModelAsync and ValidationMessageStore Added overload to validate model without affecting validation messages --- .../FluentValidationValidator.cs | 126 +++++++++++++----- 1 file changed, 89 insertions(+), 37 deletions(-) diff --git a/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs b/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs index 01c5481..84278c1 100644 --- a/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs +++ b/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs @@ -8,11 +8,12 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; -using static FluentValidation.AssemblyScanner; namespace vNext.BlazorComponents.FluentValidation { @@ -28,7 +29,16 @@ public class FluentValidationValidator : ComponentBase [Parameter] public IValidator? Validator { get; set; } + /// + /// Minimum severity to be treated as an error. + /// For example, if Severity == Error, then any validation messages with Severity warning will be ignored + /// [Parameter] public Severity Severity { get; set; } = Severity.Info; + + /// + /// Determines how validator are resolved for , or in case of complex models + /// + /// [Parameter] public IValidatorFactory ValidatorFactory { get; set; } = default!; [Parameter] public Action>? ValidationStrategyOptions { get; set; } @@ -36,19 +46,44 @@ public class FluentValidationValidator : ComponentBase $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(FluentValidationValidator)} " + $"inside an {nameof(EditForm)}."); + public ValidationMessageStore ValidationMessageStore => _validationMessageStore ?? throw new InvalidOperationException("FluentValidationValidator not initialized."); public virtual async Task Validate() { return await EditContext.ValidateAsync(); } + public virtual Task ValidateModelAsync(bool updateValidationState = true) + => ValidateModel(ValidationMessageStore, updateValidationState); + + public virtual Task ValidateFieldAsync(Expression> accessor, bool updateValidationState = true) + => ValidateFieldAsync(FieldIdentifier.Create(accessor), updateValidationState); + + public virtual async Task ValidateFieldAsync(FieldIdentifier fieldIdentifier, bool updateValidationState = true) + => await ValidateField(ValidationMessageStore, fieldIdentifier, updateValidationState); + + public virtual void ClearMessages() { - _validationMessageStore!.Clear(); + _validationMessageStore?.Clear(); validationResults?.Errors?.Clear(); EditContext.NotifyValidationStateChanged(); } + /// + /// get validator for of . + /// If is default, return + /// + /// + public virtual IValidator? ResolveValidator(FieldIdentifier fieldIdentifier = default) + { + if (EditContext == null) throw new InvalidOperationException("EditContext is null"); + object model = fieldIdentifier.Model ?? EditContext.Model; + Type interfaceValidatorType = typeof(IValidator<>).MakeGenericType(model.GetType()); + var ctx = new ValidatorFactoryContext(interfaceValidatorType, ServiceProvider, EditContext, model, fieldIdentifier); + return ValidatorFactory.CreateValidator(ctx); + } + protected override void OnInitialized() { ValidatorFactory ??= ServiceProvider.GetService() ?? new DefaultValidatorFactory(); @@ -57,10 +92,10 @@ protected override void OnInitialized() EditContext.Properties["ValidationMessageStore"] = _validationMessageStore; EditContext.OnValidationRequested += - async (sender, eventArgs) => await ValidateModel(_validationMessageStore); + async (sender, eventArgs) => await ValidateModel(ValidationMessageStore, true); EditContext.OnFieldChanged += - async (sender, eventArgs) => await ValidateField(_validationMessageStore, eventArgs.FieldIdentifier); + async (sender, eventArgs) => await ValidateField(ValidationMessageStore, eventArgs.FieldIdentifier, true); } protected virtual string MapValidationFailureToMessage(ValidationFailure failure, ValidationResult result, ValidationContext validationContext) @@ -72,62 +107,79 @@ protected virtual string MapValidationFailureToMessage(ValidationFailure failure return $"[{failure.Severity}] {failure.ErrorMessage}"; } - protected virtual IValidator? GetValidator(FieldIdentifier fieldIdentifier = default) - { - if (EditContext == null) throw new InvalidOperationException("EditContext is null"); - object model = fieldIdentifier.Model ?? EditContext.Model; - Type interfaceValidatorType = typeof(IValidator<>).MakeGenericType(model.GetType()); - var ctx = new ValidatorFactoryContext(interfaceValidatorType, ServiceProvider, EditContext, model, fieldIdentifier); - return ValidatorFactory.CreateValidator(ctx); - } - - - protected virtual async Task ValidateModel(ValidationMessageStore messages) + protected virtual async Task ValidateModel(ValidationMessageStore messages, bool updateValidationState) { - if (EditContext == null) throw new InvalidOperationException("EditContext is null"); - - IValidator? validator = GetValidator(); + IValidator? validator = ResolveValidator(); if (validator is not null) { ValidationContext context = CreateValidationContext(validator); - Task validateAsyncTask = validator.ValidateAsync(context); - EditContext.Properties[EditContextExtensions.PROPERTY_VALIDATEASYNCTASK] = validateAsyncTask; - validationResults = await validateAsyncTask; - messages.Clear(); - foreach (var failure in validationResults.Errors.Where(f => f.Severity <= Severity)) + Task validateAsyncTask = validator.ValidateAsync(context); + if (updateValidationState) { - var fieldIdentifier = ToFieldIdentifier(EditContext, failure.PropertyName); - string errorMessage = MapValidationFailureToMessage(failure, validationResults, context); - messages.Add(fieldIdentifier, errorMessage); + EditContext.Properties[EditContextExtensions.PROPERTY_VALIDATEASYNCTASK] = validateAsyncTask; } + var validationResults = await validateAsyncTask; + if (updateValidationState) + { + this.validationResults = validationResults; + messages.Clear(); + foreach (var failure in validationResults.Errors.Where(f => f.Severity <= Severity)) + { + try + { + var fieldIdentifier = ToFieldIdentifier(EditContext, failure.PropertyName); + string errorMessage = MapValidationFailureToMessage(failure, validationResults, context); + messages.Add(fieldIdentifier, errorMessage); + } + catch (InvalidOperationException ex) + { + ServiceProvider.GetService>()?.LogError(ex, $"An error occured while parsing ValidationFailure(PropertyName={failure.PropertyName})"); + } + } - EditContext.NotifyValidationStateChanged(); + EditContext.NotifyValidationStateChanged(); + } + return validationResults; } + else + { + var emptyValidationResult = new ValidationResult(); + if (updateValidationState) + { + EditContext.Properties[EditContextExtensions.PROPERTY_VALIDATEASYNCTASK] = Task.FromResult(emptyValidationResult); + } + return emptyValidationResult; + } } - protected virtual async Task ValidateField(ValidationMessageStore messages, FieldIdentifier fieldIdentifier) + protected virtual async Task ValidateField(ValidationMessageStore messages, FieldIdentifier fieldIdentifier, bool updateValidationState) { var properties = new[] { fieldIdentifier.FieldName }; - IValidator? validator = GetValidator(fieldIdentifier); + IValidator? validator = ResolveValidator(fieldIdentifier); if (validator is not null) { var context = CreateValidationContext(validator, fieldIdentifier); var validationResults = await validator.ValidateAsync(context); - messages.Clear(fieldIdentifier); - var fieldMessages = validationResults.Errors - .Where(failure => failure.Severity <= Severity) - .Select(failure => MapValidationFailureToMessage(failure, validationResults, context)); + if (updateValidationState) + { + messages.Clear(fieldIdentifier); + var fieldMessages = validationResults.Errors + .Where(failure => failure.Severity <= Severity) + .Select(failure => MapValidationFailureToMessage(failure, validationResults, context)); - messages.Add(fieldIdentifier, fieldMessages); + messages.Add(fieldIdentifier, fieldMessages); + EditContext.NotifyValidationStateChanged(); + } - EditContext.NotifyValidationStateChanged(); + return validationResults; } + return new ValidationResult(); } protected virtual ValidationContext CreateValidationContext(IValidator validator, FieldIdentifier fieldIdentifier = default) @@ -145,12 +197,12 @@ protected virtual void ConfigureValidationStrategy(ValidationStrategy op { ValidationStrategyOptions(options); } - + if (fieldIdentifier.FieldName is not null) { options.IncludeProperties(fieldIdentifier.FieldName); } - + } protected static FieldIdentifier ToFieldIdentifier(EditContext editContext, string propertyPath) From acea8489032d2ed2e796b4b17047c5099ba47dcb Mon Sep 17 00:00:00 2001 From: liero Date: Mon, 18 Jul 2022 16:16:37 +0200 Subject: [PATCH 3/4] Added support for HashSet collections in complex models. Fixes "Could not find indexer on object of type HashSet`1" --- .../FluentValidationValidator.cs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs b/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs index 84278c1..be8b2f4 100644 --- a/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs +++ b/vNext.BlazorComponents.FluentValidation/FluentValidationValidator.cs @@ -136,8 +136,8 @@ protected virtual async Task ValidateModel(ValidationMessageSt } catch (InvalidOperationException ex) { - ServiceProvider.GetService>()?.LogError(ex, $"An error occured while parsing ValidationFailure(PropertyName={failure.PropertyName})"); - } + ServiceProvider.GetService>()?.LogError(ex, $"An error occured while parsing ValidationFailure(PropertyName={failure.PropertyName})"); + } } EditContext.NotifyValidationStateChanged(); @@ -152,7 +152,7 @@ protected virtual async Task ValidateModel(ValidationMessageSt EditContext.Properties[EditContextExtensions.PROPERTY_VALIDATEASYNCTASK] = Task.FromResult(emptyValidationResult); } return emptyValidationResult; - } + } } protected virtual async Task ValidateField(ValidationMessageStore messages, FieldIdentifier fieldIdentifier, bool updateValidationState) @@ -234,7 +234,9 @@ protected static FieldIdentifier ToFieldIdentifier(EditContext editContext, stri // It's an indexer // This code assumes C# conventions (one indexer named Item with one param) nextToken = nextToken.Substring(0, nextToken.Length - 1); - var prop = obj.GetType().GetProperty("Item"); + + var prop = obj.GetType().GetProperties().Where(e => e.Name == "Item" && e.GetIndexParameters().Length == 1).FirstOrDefault() + ?? obj.GetType().GetInterfaces().FirstOrDefault(e => e.IsGenericType && e.GetGenericTypeDefinition() == typeof(IReadOnlyList<>) || e.GetGenericTypeDefinition() == typeof(IList<>))?.GetProperty("Item"); //e.g. arrays if (prop is not null) { @@ -243,19 +245,13 @@ protected static FieldIdentifier ToFieldIdentifier(EditContext editContext, stri var indexerValue = Convert.ChangeType(nextToken, indexerType); newObj = prop.GetValue(obj, new object[] { indexerValue }); } + else if (obj is IEnumerable objEnumerable && int.TryParse(nextToken, out int indexerValue)) //e.g. hashset + { + newObj = objEnumerable.ElementAt(indexerValue); + } else { - // If there is no Item property - // Try to cast the object to array - if (obj is object[] array) - { - var indexerValue = Convert.ToInt32(nextToken); - newObj = array[indexerValue]; - } - else - { - throw new InvalidOperationException($"Could not find indexer on object of type {obj.GetType().FullName}."); - } + throw new InvalidOperationException($"Could not find indexer on object of type {obj.GetType().FullName}."); } } else From c57e45f4bf1a9aec159aa31e7289d2e1dcfe8fae Mon Sep 17 00:00:00 2001 From: liero Date: Mon, 18 Jul 2022 16:22:06 +0200 Subject: [PATCH 4/4] v1.1 --- README.md | 2 +- .../vNext.BlazorComponents.FluentValidation.csproj | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0449d77..760a684 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # FluentValidation -A library for using FluentValidation with Blazor that supports **async validation, severity levels** and more. +A battle tested library for using FluentValidation with Blazor that supports **async validation, severity levels** and more. For introduction, see this [blog post](https://blog.vyvojari.dev/advanced-validation-in-blazor-using-fluentvalidation/) diff --git a/vNext.BlazorComponents.FluentValidation/vNext.BlazorComponents.FluentValidation.csproj b/vNext.BlazorComponents.FluentValidation/vNext.BlazorComponents.FluentValidation.csproj index 2d4e410..b60d942 100644 --- a/vNext.BlazorComponents.FluentValidation/vNext.BlazorComponents.FluentValidation.csproj +++ b/vNext.BlazorComponents.FluentValidation/vNext.BlazorComponents.FluentValidation.csproj @@ -5,8 +5,8 @@ true true - 1.0.0 - 1.0.0 + 1.1.0 + 1.1.0 Liero; Daniel Turan vNext Software Consulting vNext.BlazorComponents.FluentValidation @@ -16,7 +16,7 @@ README.md https://github.com/Liero/vNext.BlazorComponents.FluentValidation git - A library for using FluentValidation with Blazor. + A battle tested library for using FluentValidation with Blazor. Supports async validation, validation severity, custom rulesets and is open for extensibility. Contributors are welcome!