Skip to content

Commit 4d5eb6c

Browse files
committed
Add support for command validators
1 parent c5f4305 commit 4d5eb6c

File tree

10 files changed

+253
-3
lines changed

10 files changed

+253
-3
lines changed

src/CommandLineUtils/CommandLineApplication.Validation.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.ComponentModel.DataAnnotations;
7+
using McMaster.Extensions.CommandLineUtils.Validation;
68

79
namespace McMaster.Extensions.CommandLineUtils
810
{
@@ -20,6 +22,12 @@ public Func<ValidationResult, int> ValidationErrorHandler
2022
set => _validationErrorHandler = value ?? throw new ArgumentNullException(nameof(value));
2123
}
2224

25+
/// <summary>
26+
/// A collection of validators that execute before invoking <see cref="OnExecute(Func{int})"/>.
27+
/// When validation fails, <see cref="ValidationErrorHandler"/> is invoked.
28+
/// </summary>
29+
public ICollection<ICommandValidator> Validators { get; } = new List<ICommandValidator>();
30+
2331
/// <summary>
2432
/// Validates arguments and options.
2533
/// </summary>
@@ -38,6 +46,16 @@ internal ValidationResult GetValidationResult()
3846

3947
var factory = new CommandLineValidationContextFactory(this);
4048

49+
var commandContext = factory.Create(this);
50+
foreach (var validator in Validators)
51+
{
52+
var result = validator.GetValidationResult(this, commandContext);
53+
if (result != ValidationResult.Success)
54+
{
55+
return result;
56+
}
57+
}
58+
4159
foreach (var argument in Arguments)
4260
{
4361
var context = factory.Create(argument);

src/CommandLineUtils/Conventions/ConventionBuilderExtensions.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static IConventionBuilder UseDefaultConventions(this IConventionBuilder b
3030
.SetSubcommandPropertyOnModel()
3131
.SetParentPropertyOnModel()
3232
.UseOnExecuteMethodFromModel()
33+
.UseOnValidateMethodFromModel()
3334
.UseOnValidationErrorMethodFromModel()
3435
.UseConstructorInjection();
3536
}
@@ -149,7 +150,15 @@ public static IConventionBuilder UseSubcommandAttributes(this IConventionBuilder
149150
=> builder.AddConvention(new SubcommandAttributeConvention());
150151

151152
/// <summary>
152-
/// Invokes a method named "OnValidationError" on the model type.
153+
/// Invokes a method named "OnValidate" on the model type after parsing.
154+
/// </summary>
155+
/// <param name="builder">The builder.</param>
156+
/// <returns>The builder.</returns>
157+
public static IConventionBuilder UseOnValidateMethodFromModel(this IConventionBuilder builder)
158+
=> builder.AddConvention(new ValidateMethodConvention());
159+
160+
/// <summary>
161+
/// Invokes a method named "OnValidationError" on the model type when validation fails.
153162
/// </summary>
154163
/// <param name="builder">The builder.</param>
155164
/// <returns>The builder.</returns>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.ComponentModel.DataAnnotations;
6+
using System.Reflection;
7+
using McMaster.Extensions.CommandLineUtils.Abstractions;
8+
using McMaster.Extensions.CommandLineUtils.Conventions;
9+
10+
namespace McMaster.Extensions.CommandLineUtils
11+
{
12+
/// <summary>
13+
/// Invokes a method named "OnValidate" on the model type after parsing.
14+
/// </summary>
15+
public class ValidateMethodConvention : IConvention
16+
{
17+
/// <inheritdoc />
18+
public void Apply(ConventionContext context)
19+
{
20+
if (context.ModelType == null)
21+
{
22+
return;
23+
}
24+
25+
const BindingFlags MethodFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
26+
27+
var method = context.ModelType
28+
.GetTypeInfo()
29+
.GetMethod("OnValidate", MethodFlags);
30+
31+
if (method == null)
32+
{
33+
return;
34+
}
35+
36+
if (method.ReturnType != typeof(ValidationResult))
37+
{
38+
throw new InvalidOperationException(Strings.InvalidOnValidateReturnType(context.ModelType));
39+
}
40+
41+
var accessor = context.ModelAccessor;
42+
var methodParams = method.GetParameters();
43+
context.Application.OnValidate(ctx =>
44+
{
45+
var arguments = new object[methodParams.Length];
46+
47+
for (var i = 0; i < methodParams.Length; i++)
48+
{
49+
var methodParam = methodParams[i];
50+
51+
if (typeof(ValidationContext).GetTypeInfo().IsAssignableFrom(methodParam.ParameterType))
52+
{
53+
arguments[i] = ctx;
54+
}
55+
else if (typeof(CommandLineContext).GetTypeInfo().IsAssignableFrom(methodParam.ParameterType))
56+
{
57+
arguments[i] = context.Application._context;
58+
}
59+
else
60+
{
61+
throw new InvalidOperationException(Strings.UnsupportedParameterTypeOnMethod(method.Name, methodParam));
62+
}
63+
}
64+
65+
return (ValidationResult)method.Invoke(accessor.GetModel(), arguments);
66+
});
67+
}
68+
}
69+
}

src/CommandLineUtils/Internal/CommandLineValidationContext.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Generic;
65
using System.ComponentModel.DataAnnotations;
7-
using McMaster.Extensions.CommandLineUtils.Abstractions;
86

97
namespace McMaster.Extensions.CommandLineUtils
108
{
@@ -17,6 +15,9 @@ public CommandLineValidationContextFactory(CommandLineApplication app)
1715
_app = app ?? throw new ArgumentNullException(nameof(app));
1816
}
1917

18+
public ValidationContext Create(CommandLineApplication app)
19+
=> new ValidationContext(app, _app, null);
20+
2021
public ValidationContext Create(CommandArgument argument)
2122
=> new ValidationContext(argument, _app, null);
2223

src/CommandLineUtils/Properties/Strings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.ComponentModel.DataAnnotations;
56
using System.Reflection;
67

78
namespace McMaster.Extensions.CommandLineUtils
@@ -30,6 +31,9 @@ public const string NoOnExecuteMethodFound
3031
public static string InvalidOnExecuteReturnType(string methodName)
3132
=> methodName + " must have a return type of int or void, or if the method is async, Task<int> or Task.";
3233

34+
public static string InvalidOnValidateReturnType(Type modelType)
35+
=> $"The OnValidate method on {modelType.FullName} must return {typeof(ValidationResult).FullName}";
36+
3337
public static string CannotDetermineOptionType(PropertyInfo member)
3438
=> $"Could not automatically determine the {nameof(CommandOptionType)} for type {member.PropertyType.FullName}. " +
3539
$"Set the {nameof(OptionAttribute.OptionType)} on the {nameof(OptionAttribute)} declaration for {member.DeclaringType.FullName}.{member.Name}.";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.ComponentModel.DataAnnotations;
6+
7+
namespace McMaster.Extensions.CommandLineUtils.Validation
8+
{
9+
/// <summary>
10+
/// Implements a validator with an anonymous function
11+
/// </summary>
12+
public class DelegateValidator : ICommandValidator, IArgumentValidator, IOptionValidator
13+
{
14+
private readonly Func<ValidationContext, ValidationResult> _validator;
15+
16+
/// <summary>
17+
/// Initializes an instance of <see cref="DelegateValidator"/>.
18+
/// </summary>
19+
/// <param name="validator"></param>
20+
public DelegateValidator(Func<ValidationContext, ValidationResult> validator)
21+
{
22+
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
23+
}
24+
25+
ValidationResult ICommandValidator.GetValidationResult(CommandLineApplication command, ValidationContext context)
26+
=> _validator(context);
27+
28+
ValidationResult IArgumentValidator.GetValidationResult(CommandArgument argument, ValidationContext context)
29+
=> _validator(context);
30+
31+
ValidationResult IOptionValidator.GetValidationResult(CommandOption option, ValidationContext context)
32+
=> _validator(context);
33+
}
34+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.ComponentModel.DataAnnotations;
5+
6+
namespace McMaster.Extensions.CommandLineUtils.Validation
7+
{
8+
/// <summary>
9+
/// Provides validation on a command
10+
/// </summary>
11+
public interface ICommandValidator
12+
{
13+
/// <summary>
14+
/// Validates a command
15+
/// </summary>
16+
/// <param name="command">The command.</param>
17+
/// <param name="context">The validation context.</param>
18+
/// <returns>The validation result. Returns <see cref="ValidationResult.Success"/> if the values pass validation.</returns>
19+
ValidationResult GetValidationResult(CommandLineApplication command, ValidationContext context);
20+
}
21+
}

src/CommandLineUtils/Validation/ValidationExtensions.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,42 @@ public static IValidationBuilder<double> Range(this IValidationBuilder<double> b
336336
return builder;
337337
}
338338

339+
/// <summary>
340+
/// Adds a validator that runs after parsing is complete and before command execution.
341+
/// </summary>
342+
/// <param name="command">The command.</param>
343+
/// <param name="validate">The callback. Return <see cref="ValidationResult.Success"/> if there is no error.</param>
344+
/// <returns></returns>
345+
public static CommandLineApplication OnValidate(this CommandLineApplication command, Func<ValidationContext, ValidationResult> validate)
346+
{
347+
command.Validators.Add(new DelegateValidator(validate));
348+
return command;
349+
}
350+
351+
/// <summary>
352+
/// Adds a validator that runs after parsing is complete and before command execution.
353+
/// </summary>
354+
/// <param name="argument">The argument.</param>
355+
/// <param name="validate">The callback. Return <see cref="ValidationResult.Success"/> if there is no error.</param>
356+
/// <returns></returns>
357+
public static CommandArgument OnValidate(this CommandArgument argument, Func<ValidationContext, ValidationResult> validate)
358+
{
359+
argument.Validators.Add(new DelegateValidator(validate));
360+
return argument;
361+
}
362+
363+
/// <summary>
364+
/// Adds a validator that runs after parsing is complete and before command execution.
365+
/// </summary>
366+
/// <param name="option">The option.</param>
367+
/// <param name="validate">The callback. Return <see cref="ValidationResult.Success"/> if there is no error.</param>
368+
/// <returns></returns>
369+
public static CommandOption OnValidate(this CommandOption option, Func<ValidationContext, ValidationResult> validate)
370+
{
371+
option.Validators.Add(new DelegateValidator(validate));
372+
return option;
373+
}
374+
339375
private static T GetValidationAttr<T>(string errorMessage, object[] ctorArgs = null)
340376
where T : ValidationAttribute
341377
{
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.ComponentModel.DataAnnotations;
6+
using McMaster.Extensions.CommandLineUtils.Abstractions;
7+
using Xunit;
8+
9+
namespace McMaster.Extensions.CommandLineUtils.Tests
10+
{
11+
public class ValidateMethodConventionTests
12+
{
13+
private class ProgramWithValidate
14+
{
15+
private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext)
16+
{
17+
return new ValidationResult("Failed");
18+
}
19+
}
20+
21+
[Fact]
22+
public void ValidatorAddedViaConvention()
23+
{
24+
var app = new CommandLineApplication<ProgramWithValidate>();
25+
app.Conventions.UseOnValidateMethodFromModel();
26+
var result = app.GetValidationResult();
27+
Assert.NotEqual(ValidationResult.Success, result);
28+
Assert.Equal("Failed", result.ErrorMessage);
29+
}
30+
31+
private class ProgramWithBadOnValidate
32+
{
33+
private void OnValidate() { }
34+
}
35+
36+
[Fact]
37+
public void ConventionThrowsIfOnValidateDoesNotReturnValidationresult()
38+
{
39+
var app = new CommandLineApplication<ProgramWithBadOnValidate>();
40+
var ex = Assert.Throws<InvalidOperationException>(() => app.Conventions.UseOnValidateMethodFromModel());
41+
Assert.Equal(Strings.InvalidOnValidateReturnType(typeof(ProgramWithBadOnValidate)), ex.Message);
42+
}
43+
}
44+
}

test/CommandLineUtils.Tests/ValidationTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ public void ValidationHandlerOfSubcommandIsInvoked()
4242
Assert.True(called, "Validation on subcommand should be called");
4343
}
4444

45+
[Fact]
46+
public void ValidatorInvoked()
47+
{
48+
var app = new CommandLineApplication();
49+
var called = false;
50+
app.OnValidate(_ =>
51+
{
52+
called = true;
53+
return ValidationResult.Success;
54+
});
55+
Assert.Equal(0, app.Execute());
56+
Assert.True(called);
57+
}
58+
4559
[Theory]
4660
[InlineData(CommandOptionType.NoValue, new string[0], false)]
4761
[InlineData(CommandOptionType.SingleValue, new string[0], false)]

0 commit comments

Comments
 (0)