fairybread
fairybread copied to clipboard
Support for payload errors (MutationConventions, 6a)
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));
}
}
}
}
}
#75 is kind of related to this, I guess.
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 :)
Sadly, my time is highly limited this month. However I will try to provide you a PR as soon as possible.
any news on this? 😊
Sorry @wondering639 , I'm waiting on @FleloShe to do a PR so I can have a look at the changes
Sorry for the delay, I just came back from a trip. I hope I can provide a PR within the next days :)
See #80 Hope this helps ;)
@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. 🙂
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 :)
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.
@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.
FYI all that this is coming soon on HCv13.x. Just need to find some time 😅