fairybread icon indicating copy to clipboard operation
fairybread copied to clipboard

Support for payload errors (MutationConventions, 6a)

Open FleloShe opened this issue 2 years ago • 12 comments

Currently there seems to be no support for the Mutation Conventions as described here in 9.0.0-rc.1.

I think I identified the following issues:

  • Validators are not applied to parameters wrapped by the (virtual) "input" parameter
  • For the Error-middleware to work
    • the ValidationMiddleware must be inserted after the Error-middleware
    • the ValidationErrorsHandler must throw an AggregateException that includes the ValidationErrors

I tried to adopt some of your code for PoC-tests for HotChocolate 12.7.0 and got it working so far. Maybe this helps a bit :)

Usage

            services
                .AddGraphQLServer()
                .AddMutationConventions(
                    new MutationConventionOptions
                    {
                        ...
                    })
                ...
                .AddFairyBread()
                .TryAddTypeInterceptor<MyValidationMiddlewareInjector>()

Validator

        public MyTypeValidator()
        {
            RuleFor(x => x.ARandomString).Must(x => x.Any()).WithState(err => new MyException() { ErrorCode = MyErrorEnum.FieldStringIsWhitespace });
        }

ValidationErrorsHandler

Simply get all states and throw all exceptions as AggregationException

Injector and Middleware

    internal class MyValidationMiddlewareInjector : TypeInterceptor
    {
        private FieldMiddlewareDefinition? _validationFieldMiddlewareDef;

        public override void OnBeforeCompleteType(
            ITypeCompletionContext completionContext,
            DefinitionBase? definition,
            IDictionary<string, object?> contextData)
        {
            if (definition is not ObjectTypeDefinition objTypeDef)
            {
                return;
            }

            var options = completionContext.Services.GetRequiredService<IFairyBreadOptions>();
            var validatorRegistry = completionContext.Services.GetRequiredService<IValidatorRegistry>();

            foreach (var fieldDef in objTypeDef.Fields)
            {
                // Don't add validation middleware unless:
                // 1. we have args
                var needsValidationMiddleware = false;

                if (fieldDef.Arguments.Count == 1)
                {
                    var type = fieldDef.ResolverMember as MethodInfo;
                    var parameters = type?.GetParameters();

                    var argDef = fieldDef.Arguments.First();
                    var argCoord = new FieldCoordinate(objTypeDef.Name, fieldDef.Name, argDef.Name);

                    if (parameters is null)
                        continue;


                    if (argDef.ContextData.ContainsKey(WellKnownContextData.ValidatorDescriptors))
                        continue;

                    // 2. the argument should be validated according to options func
                    if (!options.ShouldValidateArgument(objTypeDef, fieldDef, argDef))
                    {
                        continue;
                    }

                    // 3. there's validators for it
                    Dictionary<string, List<ValidatorDescriptor>> validatorDescs;
                    try
                    {
                        validatorDescs = DetermineValidatorsForArg(validatorRegistry, parameters);
                        if (validatorDescs.Count < 1)
                        {
                            continue;
                        }
                    }
                    catch (Exception ex)
                    {
                        throw new Exception(
                            $"Problem getting runtime type for argument '{argDef.Name}' " +
                            $"in field '{fieldDef.Name}' on object type '{objTypeDef.Name}'.",
                            ex);
                    }

                    validatorDescs.TrimExcess();
                    needsValidationMiddleware = true;
                    argDef.ContextData["MySolution.Validators.Params"] = validatorDescs;
                }

                if (needsValidationMiddleware)
                {
                    if (_validationFieldMiddlewareDef is null)
                    {
                        _validationFieldMiddlewareDef = new FieldMiddlewareDefinition(
                            FieldClassMiddlewareFactory.Create<ValidationMiddleware>());
                    }
                    fieldDef.MiddlewareDefinitions.Add(_validationFieldMiddlewareDef);
                }
                else // if fairybread mw + errors mw exist, move the fairybread mw behind the errors mw
                {
                    var firstMiddleWare = fieldDef.MiddlewareDefinitions.FirstOrDefault(m => m.Middleware.Target.GetType().GetGenericArguments().Any(x => x.FullName == "FairyBread.ValidationMiddleware"));
                    var errorMiddleware = fieldDef.MiddlewareDefinitions.LastOrDefault(x => x.Key == "HotChocolate.Types.Mutations.Errors");
                    if (firstMiddleWare != null && errorMiddleware != null)
                    {
                        fieldDef.MiddlewareDefinitions.Remove(firstMiddleWare);
                        var indexOfError = fieldDef.MiddlewareDefinitions.IndexOf(errorMiddleware);
                        fieldDef.MiddlewareDefinitions.Insert(indexOfError+1,firstMiddleWare);
                    }
                        ;
                }
            }
        }

        private static Dictionary<string, List<ValidatorDescriptor>> DetermineValidatorsForArg(IValidatorRegistry validatorRegistry, ParameterInfo[] parameters)
        {
            var validators = new Dictionary<string, List<ValidatorDescriptor>>();


            foreach (var parameter in parameters)
            {
                var paramVals = new List<ValidatorDescriptor>();
                // If validation is explicitly disabled, return none so validation middleware won't be added
                if (parameter.CustomAttributes.Any(x => x.AttributeType == typeof(DontValidateAttribute)))
                {
                    continue;
                }


                // Include implicit validator/s first (if allowed)
                if (!parameter.CustomAttributes.Any(x => x.AttributeType == typeof(DontValidateImplicitlyAttribute)))
                {
                    // And if we can figure out the arg's runtime type
                    var argRuntimeType = parameter.ParameterType;
                    if (argRuntimeType is not null)
                    {
                        if (validatorRegistry.Cache.TryGetValue(argRuntimeType, out var implicitValidators) &&
                            implicitValidators is not null)
                        {
                            paramVals.AddRange(implicitValidators);
                        }
                    }
                }

                // Include explicit validator/s (that aren't already added implicitly)
                var explicitValidators = parameter.GetCustomAttributes().Where(x => x.GetType() == typeof(ValidateAttribute)).Cast<ValidateAttribute>().ToList();
                if (explicitValidators.Any())
                {
                    var validatorTypes = explicitValidators.SelectMany(x => x.ValidatorTypes);
                    // TODO: Potentially check and throw if there's a validator being explicitly applied for the wrong runtime type

                    foreach (var validatorType in validatorTypes)
                    {
                        if (paramVals.Any(v => v.ValidatorType == validatorType))
                        {
                            continue;
                        }

                        paramVals.Add(new ValidatorDescriptor(validatorType));
                    }
                }
                if (paramVals.Any())
                    validators[parameter.Name] = paramVals;
            }
            return validators;
        }

        private static Type? TryGetArgRuntimeType(ArgumentDefinition argDef)
        {
            if (argDef.Parameter?.ParameterType is { } argRuntimeType)
            {
                return argRuntimeType;
            }

            if (argDef.Type is ExtendedTypeReference extTypeRef)
            {
                return TryGetRuntimeType(extTypeRef.Type);
            }

            return null;
        }

        private static Type? TryGetRuntimeType(IExtendedType extType)
        {
            // It's already a runtime type, .Type(typeof(int))
            if (extType.Kind == ExtendedTypeKind.Runtime)
            {
                return extType.Source;
            }

            // Array (though not sure what produces this scenario as seems to always be list)
            if (extType.IsArray)
            {
                if (extType.ElementType is null)
                {
                    return null;
                }

                var elementRuntimeType = TryGetRuntimeType(extType.ElementType);
                if (elementRuntimeType is null)
                {
                    return null;
                }

                return Array.CreateInstance(elementRuntimeType, 0).GetType();
            }

            // List
            if (extType.IsList)
            {
                if (extType.ElementType is null)
                {
                    return null;
                }

                var elementRuntimeType = TryGetRuntimeType(extType.ElementType);
                if (elementRuntimeType is null)
                {
                    return null;
                }

                return typeof(List<>).MakeGenericType(elementRuntimeType);
            }

            // Input object
            if (typeof(InputObjectType).IsAssignableFrom(extType))
            {
                var currBaseType = extType.Type.BaseType;
                while (currBaseType is not null &&
                    (!currBaseType.IsGenericType ||
                    currBaseType.GetGenericTypeDefinition() != typeof(InputObjectType<>)))
                {
                    currBaseType = currBaseType.BaseType;
                }

                if (currBaseType is null)
                {
                    return null;
                }

                return currBaseType!.GenericTypeArguments[0];
            }

            // Singular scalar
            if (typeof(ScalarType).IsAssignableFrom(extType))
            {
                var currBaseType = extType.Type.BaseType;
                while (currBaseType is not null &&
                    (!currBaseType.IsGenericType ||
                    currBaseType.GetGenericTypeDefinition() != typeof(ScalarType<>)))
                {
                    currBaseType = currBaseType.BaseType;
                }

                if (currBaseType is null)
                {
                    return null;
                }

                var argRuntimeType = currBaseType.GenericTypeArguments[0];
                if (argRuntimeType.IsValueType && extType.IsNullable)
                {
                    return typeof(Nullable<>).MakeGenericType(argRuntimeType);
                }

                return argRuntimeType;
            }

            return null;
        }
    }

    internal static class WellKnownContextData
    {
        public const string Prefix = "FairyBread";

        public const string DontValidate =
            Prefix + ".DontValidate";

        public const string DontValidateImplicitly =
            Prefix + ".DontValidateImplicitly";

        public const string ExplicitValidatorTypes =
            Prefix + ".ExplicitValidatorTypes";

        public const string ValidatorDescriptors =
            Prefix + ".Validators";
    }

    internal class ValidationMiddleware
    {
        private readonly FieldDelegate _next;
        private readonly IValidatorProvider _validatorProvider;
        private readonly IValidationErrorsHandler _validationErrorsHandler;

        public ValidationMiddleware(
            FieldDelegate next,
            IValidatorProvider validatorProvider,
            IValidationErrorsHandler validationErrorsHandler)
        {
            _next = next;
            _validatorProvider = validatorProvider;
            _validationErrorsHandler = validationErrorsHandler;
        }

        public async Task InvokeAsync(IMiddlewareContext context)
        {
            var arguments = context.Selection.Field.Arguments;

            var invalidResults = new List<ArgumentValidationResult>();
            var type = context.Selection.Field.ResolverMember as MethodInfo;
            var parameters = type.GetParameters();



            foreach (var argument in context.Selection.Field.Arguments)
            {
                if (argument == null)
                {
                    continue;
                }

                var resolvedValidators = GetValidatorsTest(context, argument).GroupBy(x => x.param);
                if (resolvedValidators.Count() > 0)
                {
                    foreach (var resolvedValidatorGroup in resolvedValidators)
                    {
                        try
                        {
                            var value = context.ArgumentValue<object?>(resolvedValidatorGroup.Key);
                            if (value == null)
                            {
                                continue;
                            }

                            foreach (var resolvedValidator in resolvedValidatorGroup)
                            {
                                var validationContext = new ValidationContext<object?>(value);
                                var validationResult = await resolvedValidator.resolver.Validator.ValidateAsync(
                                    validationContext,
                                    context.RequestAborted);
                                if (validationResult != null &&
                                    !validationResult.IsValid)
                                {
                                    invalidResults.Add(
                                        new ArgumentValidationResult(
                                            resolvedValidatorGroup.Key,
                                            resolvedValidator.resolver.Validator,
                                            validationResult));
                                }
                            }
                        }
                        finally
                        {
                            foreach (var resolvedValidator in resolvedValidatorGroup)
                            {
                                resolvedValidator.resolver.Scope?.Dispose();
                            }
                        }
                    }
                }
            }

            if (invalidResults.Any())
            {
                _validationErrorsHandler.Handle(context, invalidResults);
                return;
            }

            await _next(context);
        }

        IEnumerable<(string param, ResolvedValidator resolver)> GetValidatorsTest(IMiddlewareContext context, IInputField argument)
        {
            if (!argument.ContextData.TryGetValue("MySolution.Validators.Params", out var validatorDescriptorsRaw)
                || validatorDescriptorsRaw is not Dictionary<string, List<ValidatorDescriptor>> validatorDescriptors)
            {
                yield break;
            }

            foreach (var validatorDescriptor in validatorDescriptors)
            {
                foreach (var validatorDescriptorEntry in validatorDescriptor.Value)
                {
                    if (validatorDescriptorEntry.RequiresOwnScope)
                    {
                        var scope = context.Services.CreateScope(); // disposed by middleware
                        var validator = (IValidator)scope.ServiceProvider.GetRequiredService(validatorDescriptorEntry.ValidatorType);
                        yield return (validatorDescriptor.Key, new ResolvedValidator(validator, scope));
                    }
                    else
                    {
                        var validator = (IValidator)context.Services.GetRequiredService(validatorDescriptorEntry.ValidatorType);
                        yield return (validatorDescriptor.Key, new ResolvedValidator(validator));
                    }
                }
            }
        }
    }

FleloShe avatar Apr 19 '22 11:04 FleloShe

#75 is kind of related to this, I guess.

FleloShe avatar Apr 19 '22 11:04 FleloShe

Thanks @FleloShe , are you able to put your code in a branch and submit a draft PR? Just want to see what's changed more clearly :)

benmccallum avatar Apr 22 '22 06:04 benmccallum

Sadly, my time is highly limited this month. However I will try to provide you a PR as soon as possible.

FleloShe avatar May 03 '22 16:05 FleloShe

any news on this? 😊

wondering639 avatar Jun 07 '22 22:06 wondering639

Sorry @wondering639 , I'm waiting on @FleloShe to do a PR so I can have a look at the changes

benmccallum avatar Jun 08 '22 02:06 benmccallum

Sorry for the delay, I just came back from a trip. I hope I can provide a PR within the next days :)

FleloShe avatar Jun 08 '22 06:06 FleloShe

See #80 Hope this helps ;)

FleloShe avatar Jun 08 '22 10:06 FleloShe

@benmccallum

Does this look like something that will require large changes? I'm just getting started with HC & FB, using mutation conventions, and this issue has just hit me. 🙂

glen-84 avatar Jun 16 '22 18:06 glen-84

Thanks @FleloShe ! I'll try take a look soon but am currently very busy (buying a property).

@glen-84, awesome to have you, welcome! I think I need to digest the PR that FleloShe's done and see whetehr it's the way to go. #75 has my original thoughts on this one, but I think if there's a way to support both we can certainly give it a go :)

benmccallum avatar Jun 17 '22 09:06 benmccallum

Michael mentioned on Slack that v13 will have a way to raise errors without throwing an exception – is that required, or should there be a temporary (?) solution to allow exceptions to be thrown?

I'd want to map the Fluent Validation errors/exceptions to my custom error interface in Hot Chocolate, so that everything ends up with the same shape.

glen-84 avatar Jun 25 '22 15:06 glen-84

@benmccallum Sorry to be a pain, but do you think that you'll have time soon to look into this? It would be great if validation errors could be surfaced using the same interface as other user errors.

glen-84 avatar Jul 18 '22 13:07 glen-84

FYI all that this is coming soon on HCv13.x. Just need to find some time 😅

benmccallum avatar Apr 11 '23 02:04 benmccallum