Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for external class validators #67

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

ejsmith
Copy link

@ejsmith ejsmith commented Oct 14, 2024

Adds support for defining IValidator<T> classes to provide validation for class types outside of the model class where you are unable to alter the target model or would prefer to keep the model classes simple and not have the IValidatableObject interface implementation.

@niemyjski
Copy link

@DamianEdwards would this be something you would consider merging. We found this much easier to convert our existing fluent validation while leaving our models clean.

@niemyjski
Copy link

@DamianEdwards now that .NET 9 has shipped, would this be something you would accept?

@@ -532,6 +535,47 @@ private static async Task<bool> TryValidateImpl(
}
}

if (isValid)
{
var validators = (IEnumerable?)serviceProvider?.GetService(typeof(IEnumerable<>).MakeGenericType(typeof(IValidatable<>).MakeGenericType(targetType)));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better to cache as much of this reflection in the type cache while walking the type. We don't want to be calling MakeGenericType on every validate call if we can avoid it. The type cache could probably just do this discovery of validators from IServiceProvider up front and make those types available. Also you can check if the IServiceProvider supports IServiceProviderIsService and use that to check for validators rather than performing a lookup and checking for null (which can have side-effects), but if we move all this into the type cache that's probably not important anymore.

Comment on lines +548 to +556
var validatorMethod = validator.GetType().GetMethod(nameof(IValidatable<object>.ValidateAsync));
if (validatorMethod is null)
{
throw new InvalidOperationException(
$"The type {validators.GetType().Name} does not implement the required method 'Task<IEnumerable<ValidationResult>> ValidateAsync(object, ValidationContext)'.");
}

var validateTask = (Task<IEnumerable<ValidationResult>>?)validatorMethod.Invoke(validator,
new[] { target, validationContext });
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invoking this via reflection every time is not ideal. Would be good to figure out if there's a way to avoid this somehow but, e.g. generating a delegate in the type cache that can be invoked from here without the generic (the body of the generated method would just cast to the target type).

Pre-generating a delegate would mean we could avoid the checks here too on every call and just store the exception up front when the type cache is being built and generate the method to just throw.

/// Provides a way to add a validator for a type outside the class.
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IValidatable<in T>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not 100% sold on the name. Some alternatives to consider:

  • IValidate<TTarget>
  • IValidator<TTarget>
  • IValidatorFor<TTarget>

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vote for IValidate<T>.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.

/// <param name="instance">The object instance to validate.</param>
/// <param name="validationContext">The validation context.</param>
/// <returns>A collection that holds failed-validation information.</returns>
Task<IEnumerable<ValidationResult>> ValidateAsync(T instance, ValidationContext validationContext);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a non-async version? Or do we think given this is a net-new addition it's fine to just make it async-only? Do the non-async validate methods throw if an IValidatable is registered on the type you're trying to validate?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like this whole library could be simplified if it was async / valuetask only, but that would obviously be a breaking change and dataannotations doesn't support async either. I really wish .NET would modernize data annotations and make them more powerful. :-)

Anyway, it would be pretty trivial to add non-async support. Up to you.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for net-new stuff it's fine to say it's async only. This needs to add logic to throw if the non-async validate method is called on a type that has one of these registered.

@DamianEdwards
Copy link
Owner

@ejsmith @niemyjski thanks for this. I quite like the idea but have some suggested implementation changes and open questions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants