Custom value validators (#87)

This commit is contained in:
Alexey Golub
2020-11-09 17:21:18 +02:00
committed by GitHub
14 changed files with 246 additions and 11 deletions

View File

@@ -252,6 +252,26 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString());
}
[Fact]
public async Task Command_parameter_custom_validator_must_implement_the_corresponding_interface()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<InvalidCustomValidatorParameterCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(Array.Empty<string>());
// Assert
exitCode.Should().NotBe(0);
stdErr.GetString().Should().NotBeNullOrWhiteSpace();
_output.WriteLine(stdErr.GetString());
}
[Fact]
public async Task Command_options_must_have_names_that_are_not_empty()
{
@@ -411,5 +431,25 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString());
}
[Fact]
public async Task Command_option_custom_validator_must_implement_the_corresponding_interface()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<InvalidCustomValidatorOptionCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(Array.Empty<string>());
// Assert
exitCode.Should().NotBe(0);
stdErr.GetString().Should().NotBeNullOrWhiteSpace();
_output.WriteLine(stdErr.GetString());
}
}
}

View File

@@ -0,0 +1,16 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command]
public class InvalidCustomValidatorOptionCommand : SelfSerializeCommandBase
{
[CommandOption('f', Validators = new[] { typeof(Validator) })]
public string? Option { get; set; }
public class Validator
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
}
}

View File

@@ -0,0 +1,16 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command]
public class InvalidCustomValidatorParameterCommand : SelfSerializeCommandBase
{
[CommandParameter(0, Validators = new[] { typeof(Validator) })]
public string? Param { get; set; }
public class Validator
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
}
}

View File

@@ -0,0 +1,18 @@
namespace CliFx
{
/// <summary>
/// A base type for custom validators.
/// </summary>
public abstract class ArgumentValueValidator<T> : IArgumentValueValidator
{
/// <summary>
/// Your validation logic have to be implemented in this method.
/// </summary>
public abstract ValidationResult Validate(T value);
/// <summary>
/// Non-generic method, will be called by the framework.
/// </summary>
public ValidationResult Validate(object value) => Validate((T) value);
}
}

View File

@@ -43,6 +43,11 @@ namespace CliFx.Attributes
/// </summary>
public Type? Converter { get; set; }
/// <summary>
/// Type of a converter to use for the option value evaluating.
/// </summary>
public Type[]? Validators { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>

View File

@@ -32,6 +32,11 @@ namespace CliFx.Attributes
/// </summary>
public Type? Converter { get; set; }
/// <summary>
/// Type of a converter to use for the option value evaluating.
/// </summary>
public Type[]? Validators { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandParameterAttribute"/>.
/// </summary>

View File

@@ -19,11 +19,14 @@ namespace CliFx.Domain
public Type? ConverterType { get; }
protected CommandArgumentSchema(PropertyInfo? property, string? description, Type? converterType)
public readonly Type[]? ValidatorTypes;
protected CommandArgumentSchema(PropertyInfo? property, string? description, Type? converterType = null, Type[]? validators = null)
{
Property = property;
Description = description;
ConverterType = converterType;
ValidatorTypes = validators;
}
private Type? TryGetEnumerableArgumentUnderlyingType() =>
@@ -132,8 +135,15 @@ namespace CliFx.Domain
}
}
public void BindOn(ICommand command, IReadOnlyList<string> values) =>
Property?.SetValue(command, Convert(values));
public void BindOn(ICommand command, IReadOnlyList<string> values)
{
var value = Convert(values);
if (ValidatorTypes.NotEmpty())
Validate(value);
Property?.SetValue(command, value);
}
public void BindOn(ICommand command, params string[] values) =>
BindOn(command, (IReadOnlyList<string>) values);
@@ -153,6 +163,25 @@ namespace CliFx.Domain
return Array.Empty<string>();
}
private void Validate(object? value)
{
if (value is null)
return;
var failed = new List<ValidationResult>();
foreach (var validator in ValidatorTypes!)
{
var result = validator.CreateInstance<IArgumentValueValidator>().Validate(value!);
if (result.IsValid)
continue;
failed.Add(result);
}
if (failed.NotEmpty())
throw CliFxException.ValueValidationFailed(this, failed.Select(x => x.ErrorMessage!));
}
}
internal partial class CommandArgumentSchema

View File

@@ -24,8 +24,9 @@ namespace CliFx.Domain
string? environmentVariableName,
bool isRequired,
string? description,
Type? converterType)
: base(property, description, converterType)
Type? converterType = null,
Type[]? validatorTypes = null)
: base(property, description, converterType, validatorTypes)
{
Name = name;
ShortName = shortName;
@@ -99,7 +100,8 @@ namespace CliFx.Domain
attribute.EnvironmentVariableName,
attribute.IsRequired,
attribute.Description,
attribute.Converter
attribute.Converter,
attribute.Validators
);
}
}
@@ -107,9 +109,9 @@ namespace CliFx.Domain
internal partial class CommandOptionSchema
{
public static CommandOptionSchema HelpOption { get; } =
new CommandOptionSchema(null, "help", 'h', null, false, "Shows help text.", null);
new CommandOptionSchema(null, "help", 'h', null, false, "Shows help text.", converterType: null, validatorTypes: null);
public static CommandOptionSchema VersionOption { get; } =
new CommandOptionSchema(null, "version", null, null, false, "Shows version information.", null);
new CommandOptionSchema(null, "version", null, null, false, "Shows version information.", converterType: null, validatorTypes: null);
}
}

View File

@@ -17,8 +17,9 @@ namespace CliFx.Domain
int order,
string name,
string? description,
Type? converterType)
: base(property, description, converterType)
Type? converterType = null,
Type[]? validatorTypes = null)
: base(property, description, converterType, validatorTypes)
{
Order = order;
Name = name;
@@ -57,7 +58,8 @@ namespace CliFx.Domain
attribute.Order,
name,
attribute.Description,
attribute.Converter
attribute.Converter,
attribute.Validators
);
}
}

View File

@@ -126,6 +126,18 @@ namespace CliFx.Domain
invalidConverterParameters
);
}
var invalidValidatorParameters = command.Parameters
.Where(p => p.ValidatorTypes != null && !p.ValidatorTypes.All(x => x.Implements(typeof(IArgumentValueValidator))))
.ToArray();
if (invalidValidatorParameters.Any())
{
throw CliFxException.ParametersWithInvalidValidators(
command,
invalidValidatorParameters
);
}
}
private static void ValidateOptions(CommandSchema command)
@@ -208,6 +220,18 @@ namespace CliFx.Domain
invalidConverterOptions
);
}
var invalidValidatorOptions = command.Options
.Where(o => o.ValidatorTypes != null && !o.ValidatorTypes.All(x => x.Implements(typeof(IArgumentValueValidator))))
.ToArray();
if (invalidValidatorOptions.Any())
{
throw CliFxException.OptionsWithInvalidValidators(
command,
invalidValidatorOptions
);
}
}
private static void ValidateCommands(IReadOnlyList<CommandSchema> commands)

View File

@@ -185,6 +185,19 @@ Specified converter must implement {typeof(IArgumentValueConverter).FullName}.";
return new CliFxException(message.Trim());
}
internal static CliFxException ParametersWithInvalidValidators(
CommandSchema command,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid value validators:
{invalidParameters.JoinToString(Environment.NewLine)}
Specified validator(s) must inherit from {typeof(ArgumentValueValidator<>).FullName}.";
return new CliFxException(message.Trim());
}
internal static CliFxException OptionsWithNoName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
@@ -269,6 +282,19 @@ Specified converter must implement {typeof(IArgumentValueConverter).FullName}.";
return new CliFxException(message.Trim());
}
internal static CliFxException OptionsWithInvalidValidators(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid validators:
{invalidOptions.JoinToString(Environment.NewLine)}
Specified validators must inherit from {typeof(IArgumentValueValidator).FullName}.";
return new CliFxException(message.Trim());
}
}
// End-user-facing exceptions
@@ -415,5 +441,13 @@ Unrecognized options provided:
return new CliFxException(message.Trim());
}
internal static CliFxException ValueValidationFailed(CommandArgumentSchema argument, IEnumerable<string> errors)
{
var message = $@"
The validation of the provided value for {argument.Property!.Name} is failed because: {errors.JoinToString(Environment.NewLine)}";
return new CliFxException(message.Trim());
}
}
}

View File

@@ -0,0 +1,7 @@
namespace CliFx
{
internal interface IArgumentValueValidator
{
ValidationResult Validate(object value);
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
namespace CliFx.Internal.Extensions
{
@@ -9,5 +10,11 @@ namespace CliFx.Internal.Extensions
foreach (var item in items)
source.Remove(item);
}
public static bool IsNullOrEmpty<T>(this IEnumerable<T>? source) =>
!source?.Any() ?? true;
public static bool NotEmpty<T>(this IEnumerable<T>? source) =>
!source.IsNullOrEmpty();
}
}

30
CliFx/ValidationResult.cs Normal file
View File

@@ -0,0 +1,30 @@
namespace CliFx
{
/// <summary>
/// A tiny object that represents a result of the validation.
/// </summary>
public class ValidationResult
{
/// <summary>
/// False if there is no error message, otherwise - true.
/// </summary>
public bool IsValid => ErrorMessage == null;
/// <summary>
/// Contains an information about the reasons of failed validation.
/// </summary>
public string? ErrorMessage { get; private set; }
private ValidationResult() { }
/// <summary>
/// Creates Ok result, means that the validation is passed.
/// </summary>
public static ValidationResult Ok() => new ValidationResult() { };
/// <summary>
/// Creates Error result, means that the validation failed.
/// </summary>
public static ValidationResult Error(string message) => new ValidationResult() { ErrorMessage = message };
}
}