From 7e10dd9639e4d1f983f1b64ae800da2a3f5e57ba Mon Sep 17 00:00:00 2001 From: Ben McCallum Date: Sat, 21 Nov 2020 17:24:36 +0100 Subject: [PATCH] feat: Ability to filter handling of a ValidationException by the error filter --- README.md | 12 ++ src/Directory.Build.props | 2 +- src/FairyBread.Tests/CustomizationTests.cs | 2 +- .../DefaultValidationErrorFilterTests.cs | 80 +++++++++++ .../InputValidationMiddlewareTests.cs | 4 +- .../RequiresOwnScopeValidatorTests.cs | 2 +- .../ValidateDescriptorTests.cs | 4 +- src/FairyBread/DefaultFairyBreadOptions.cs | 133 ++++++++++++------ src/FairyBread/IFairyBreadOptions.cs | 8 +- ...rrorFilter.cs => ValidationErrorFilter.cs} | 24 +++- 10 files changed, 219 insertions(+), 52 deletions(-) create mode 100644 src/FairyBread.Tests/DefaultValidationErrorFilterTests.cs rename src/FairyBread/{DefaultValidationErrorFilter.cs => ValidationErrorFilter.cs} (62%) diff --git a/README.md b/README.md index 8d1f65a..63e53ea 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,11 @@ Some of the default implementations are defined publicly on the default options Errors will be written out into the GraphQL execution result in the `Errors` property. By default, extra information about the validation will be written out in the Extensions property. +This is handled by the `ValidationErrorFilter`. + +If you need to selectively ignore some `ValidationException` instances, +you can provide your own predicate function to `ValidationErrorFilter` via the constructor. + For example: > (note, this is the serialized `IExecutionResult`, not a GraphQL server response) @@ -140,6 +145,13 @@ public class UserInputValidator : AbstractValidator, IRequiresOwnScop } ``` +### Using MediatR for firing validation? + +If you're using [MediatR](https://github.com/jbogard/MediatR) for firing validation, no worries! + +If your MediatR pipeline behaviour throws a `FluentValidation.ValidationException` you can still use FairyBread's +`DefaultValidationErrorFilter` as mentioned earlier to rewrite it on the way out into a friendlier error for the client. + ### Where to next? For more examples, please see the tests. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2824115..38af217 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,7 +14,7 @@ $(MSBuildProjectName) https://github.com/benmccallum/fairybread/raw/master/logo-400x400.png - 1.0.0-preview.9 + 1.0.0 10.5.2 diff --git a/src/FairyBread.Tests/CustomizationTests.cs b/src/FairyBread.Tests/CustomizationTests.cs index 114e3f1..eb6e4aa 100644 --- a/src/FairyBread.Tests/CustomizationTests.cs +++ b/src/FairyBread.Tests/CustomizationTests.cs @@ -46,7 +46,7 @@ private IQueryExecutor InitQueryExecutor(Action preBuildProv { builder .UseDefaultPipeline() - .AddErrorFilter(); + .AddErrorFilter(); }); } diff --git a/src/FairyBread.Tests/DefaultValidationErrorFilterTests.cs b/src/FairyBread.Tests/DefaultValidationErrorFilterTests.cs new file mode 100644 index 0000000..0372568 --- /dev/null +++ b/src/FairyBread.Tests/DefaultValidationErrorFilterTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using FluentValidation; +using HotChocolate; +using Moq; +using Xunit; + +namespace FairyBread.Tests +{ + public class DefaultValidationErrorFilterTests + { + [Fact] + public void Should_Handle_ValidationException() + { + // Arrange + var error = new AnError().WithException(new ValidationException("some msg")); + + var defaultValidationErrorFilter = new ValidationErrorFilter(); + + // Act / Assert + Assert.Throws(() => defaultValidationErrorFilter.OnError(error)); + } + + [Fact] + public void Should_Ignore_OtherException() + { + // Arrange + var error = new AnError().WithException(new Exception("some msg")); + + var defaultValidationErrorFilter = new ValidationErrorFilter(); + + // Act / Assert + defaultValidationErrorFilter.OnError(error); + } + + [Fact] + public void Should_Ignore_CertainValidationException() + { + // Arrange + var error = new AnError().WithException(new Exception("some msg")); + + var defaultValidationErrorFilter = new ValidationErrorFilter(ex => false); + + // Act / Assert + defaultValidationErrorFilter.OnError(error); + } + + class AnError : IError + { + public string Message => throw new NotImplementedException(); + + public string Code => throw new NotImplementedException(); + + public IReadOnlyList Path => throw new NotImplementedException(); + + public IReadOnlyList Locations => throw new NotImplementedException(); + + private Exception? _exception; + public Exception? Exception => _exception; + + public IReadOnlyDictionary Extensions => throw new NotImplementedException(); + + public IError AddExtension(string key, object value) => throw new NotImplementedException(); + public IError RemoveException() => throw new NotImplementedException(); + public IError RemoveExtension(string key) => throw new NotImplementedException(); + public IError WithCode(string code) => throw new NotImplementedException(); + public IError WithException(Exception exception) + { + _exception = exception; + return this; + } + + public IError WithExtensions(IReadOnlyDictionary extensions) => throw new NotImplementedException(); + public IError WithLocations(IReadOnlyList locations) => throw new NotImplementedException(); + public IError WithMessage(string message) => throw new NotImplementedException(); + public IError WithPath(Path path) => throw new NotImplementedException(); + public IError WithPath(IReadOnlyList path) => throw new NotImplementedException(); + } + } +} diff --git a/src/FairyBread.Tests/InputValidationMiddlewareTests.cs b/src/FairyBread.Tests/InputValidationMiddlewareTests.cs index 6fa9749..f1a4695 100644 --- a/src/FairyBread.Tests/InputValidationMiddlewareTests.cs +++ b/src/FairyBread.Tests/InputValidationMiddlewareTests.cs @@ -43,7 +43,7 @@ private IQueryExecutor GetQueryExecutor(Action? configureOpt { builder .UseDefaultPipeline() - .AddErrorFilter(); + .AddErrorFilter(); }); } @@ -133,7 +133,7 @@ public async Task Mutation_Works(CaseData caseData) { builder .UseDefaultPipeline() - .AddErrorFilter(); + .AddErrorFilter(); }); // Act diff --git a/src/FairyBread.Tests/RequiresOwnScopeValidatorTests.cs b/src/FairyBread.Tests/RequiresOwnScopeValidatorTests.cs index 9ba3640..8c91130 100644 --- a/src/FairyBread.Tests/RequiresOwnScopeValidatorTests.cs +++ b/src/FairyBread.Tests/RequiresOwnScopeValidatorTests.cs @@ -43,7 +43,7 @@ private IQueryExecutor InitQueryExecutor(Action preBuildProv { builder .UseDefaultPipeline() - .AddErrorFilter(); + .AddErrorFilter(); }); } diff --git a/src/FairyBread.Tests/ValidateDescriptorTests.cs b/src/FairyBread.Tests/ValidateDescriptorTests.cs index ccd8672..1f06a60 100644 --- a/src/FairyBread.Tests/ValidateDescriptorTests.cs +++ b/src/FairyBread.Tests/ValidateDescriptorTests.cs @@ -21,7 +21,7 @@ private IQueryExecutor InitQueryExecutor() services.AddFairyBread(options => { options.AssembliesToScanForValidators = new[] { typeof(FooInputDtoValidator).Assembly }; - options.ShouldValidate = DefaultFairyBreadOptions.ShouldValidateBasedOnValidateDescriptorImplementation; + options.ShouldValidate = DefaultFairyBreadOptions.DefaultImplementations.ShouldValidateBasedOnValidateDescriptor; }); var serviceProvider = services.BuildServiceProvider(); @@ -42,7 +42,7 @@ private IQueryExecutor InitQueryExecutor() { builder .UseDefaultPipeline() - .AddErrorFilter(); + .AddErrorFilter(); }); } diff --git a/src/FairyBread/DefaultFairyBreadOptions.cs b/src/FairyBread/DefaultFairyBreadOptions.cs index 6429030..d5ca836 100644 --- a/src/FairyBread/DefaultFairyBreadOptions.cs +++ b/src/FairyBread/DefaultFairyBreadOptions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -15,61 +14,113 @@ public class DefaultFairyBreadOptions : IFairyBreadOptions public virtual bool ThrowIfNoValidatorsFound { get; set; } = true; - public virtual Func ShouldValidate { get; set; } = ShouldValidateImplementation; + /// + public virtual Func ShouldValidate { get; set; } + = DefaultImplementations.ShouldValidate; - public static bool ShouldValidateImplementation(IMiddlewareContext context, Argument argument) + /// + /// Default implementations of some things that can be re-composed as needed. + /// + public static class DefaultImplementations { - // If a mutation operation and this is an input object - if (context.Operation.Operation == OperationType.Mutation && - argument.Type.InnerType() is InputObjectType) + /// + /// By default, FairyBread will validate any argument that: + /// + /// + /// + /// is an InputObjectType on a mutation operation, + /// + /// + /// + /// + /// is manually opted-in at the field level with: + /// + /// + /// [Validate] on the resolver method argument in pure code first + /// + /// + /// .UseValidation() on the argument definition in code first + /// + /// + /// + /// + /// + /// + /// is manually opted-in at the input type level with: + /// + /// + /// [Validate] on the CLR backing type in pure code first + /// + /// + /// .UseValidation() on the InputObjectType descriptor in code first + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// More at: https://github.com/benmccallum/fairybread#when-validation-will-fire + /// + public static bool ShouldValidate(IMiddlewareContext context, Argument argument) { - return true; + // If a mutation operation and this is an input object + if (context.Operation.Operation == OperationType.Mutation && + argument.Type.InnerType() is InputObjectType) + { + return true; + } + + if (ShouldValidateBasedOnValidateDescriptor(context, argument)) + { + return true; + } + + return false; } - if (ShouldValidateBasedOnValidateDescriptorImplementation(context, argument)) + public static bool ShouldValidateBasedOnValidateDescriptor(IMiddlewareContext context, Argument argument) { - return true; - } + // If argument itself was annotated + if (IsValidateDescriptorApplied(argument.ContextData)) + { + return true; + } - return false; - } + // If argument's input type was annotated + if (argument.Type.InnerType() is InputObjectType inputType && + IsValidateDescriptorApplied(inputType)) + { + return true; + } - public static bool ShouldValidateBasedOnValidateDescriptorImplementation(IMiddlewareContext context, Argument argument) - { - // If argument itself was annotated - if (IsValidateDescriptorApplied(argument.ContextData)) - { - return true; + // If argument's clr type was annotated + if (ClrTypesMarkedWithValidate.Cache.GetOrAdd( + argument.ClrType, + clrType => clrType.GetCustomAttribute(inherit: true) != null)) + { + return true; + } + + return false; } - // If argument's input type was annotated - if (argument.Type.InnerType() is InputObjectType inputType && - IsValidateDescriptorApplied(inputType)) + private static bool IsValidateDescriptorApplied(IHasContextData thing) { - return true; + return IsValidateDescriptorApplied(thing.ContextData); } - // If argument's clr type was annotated - if (ClrTypesMarkedWithValidate.Cache.GetOrAdd( - argument.ClrType, - clrType => clrType.GetCustomAttribute(inherit: true) != null)) + private static bool IsValidateDescriptorApplied(IReadOnlyDictionary contextData) { - return true; + return contextData.TryGetValue(ValidateAttribute.ValidateContextDataKey, out var isValidateDescriptorAppliedRaw) && + isValidateDescriptorAppliedRaw is bool isValidateDescriptorApplied && + isValidateDescriptorApplied; } - - return false; - } - - private static bool IsValidateDescriptorApplied(IHasContextData thing) - { - return IsValidateDescriptorApplied(thing.ContextData); - } - - private static bool IsValidateDescriptorApplied(IReadOnlyDictionary contextData) - { - return contextData.TryGetValue(ValidateAttribute.ValidateContextDataKey, out var isValidateDescriptorAppliedRaw) && - isValidateDescriptorAppliedRaw is bool isValidateDescriptorApplied && - isValidateDescriptorApplied; } } } diff --git a/src/FairyBread/IFairyBreadOptions.cs b/src/FairyBread/IFairyBreadOptions.cs index 205ed82..1c77717 100644 --- a/src/FairyBread/IFairyBreadOptions.cs +++ b/src/FairyBread/IFairyBreadOptions.cs @@ -1,4 +1,5 @@ -using HotChocolate.Resolvers; +using FluentValidation; +using HotChocolate.Resolvers; using HotChocolate.Types; using System; using System.Collections.Generic; @@ -12,6 +13,11 @@ public interface IFairyBreadOptions bool ThrowIfNoValidatorsFound { get; set; } + /// + /// Function used to determine if an argument should be validated by + /// FairyBread's . + /// The default implementation is + /// Func ShouldValidate { get; set; } } } diff --git a/src/FairyBread/DefaultValidationErrorFilter.cs b/src/FairyBread/ValidationErrorFilter.cs similarity index 62% rename from src/FairyBread/DefaultValidationErrorFilter.cs rename to src/FairyBread/ValidationErrorFilter.cs index 710fb97..9b385c0 100644 --- a/src/FairyBread/DefaultValidationErrorFilter.cs +++ b/src/FairyBread/ValidationErrorFilter.cs @@ -1,16 +1,34 @@ -using System.Linq; +using System; +using System.Linq; using FluentValidation; using FluentValidation.Results; using HotChocolate; namespace FairyBread { - public class DefaultValidationErrorFilter : IErrorFilter + public class ValidationErrorFilter : IErrorFilter { + /// + /// Function used to determine if an with a + /// should be handled and rewritten. + /// + private Func _shouldHandleValidationExceptionPredicate { get; set; } = ex => true; + + public ValidationErrorFilter() + { + + } + + public ValidationErrorFilter(Func shouldHandleValidationExceptionPredicate) + { + _shouldHandleValidationExceptionPredicate = shouldHandleValidationExceptionPredicate; + } + public IError OnError(IError error) { if (error.Exception != null && - error.Exception is ValidationException validationException) + error.Exception is ValidationException validationException && + _shouldHandleValidationExceptionPredicate(validationException)) { return OnValidationException(error, validationException); }