Skip to content

Commit 70aa69e

Browse files
authored
Merge pull request #76 from zadykian/feature/params-validation
Parameters validation via attributes
2 parents d79a945 + ec736de commit 70aa69e

File tree

4 files changed

+158
-1
lines changed

4 files changed

+158
-1
lines changed

src/ConsoleAppFramework/ConsoleAppBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ IHostBuilder AddConsoleAppFramework(IHostBuilder builder, string[] args, Console
4141
configureOptions?.Invoke(ctx, options);
4242
options.CommandLineArguments = args;
4343
services.AddSingleton(options);
44+
services.AddSingleton<IParamsValidator, ParamsValidator>();
4445

4546
if (options.ReplaceToUseSimpleConsoleLogger)
4647
{

src/ConsoleAppFramework/ConsoleAppEngine.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
using Microsoft.Extensions.Logging;
33
using System;
44
using System.Collections.Generic;
5+
using System.Collections.Immutable;
56
using System.Collections.ObjectModel;
7+
using System.ComponentModel.DataAnnotations;
68
using System.Linq;
79
using System.Reflection;
810
using System.Text.Json;
@@ -18,13 +20,20 @@ internal class ConsoleAppEngine
1820
readonly CancellationToken cancellationToken;
1921
readonly ConsoleAppOptions options;
2022
readonly IServiceProviderIsService isService;
23+
readonly IParamsValidator paramsValidator;
2124
readonly bool isStrict;
2225

23-
public ConsoleAppEngine(ILogger<ConsoleApp> logger, IServiceProvider provider, ConsoleAppOptions options, IServiceProviderIsService isService, CancellationToken cancellationToken)
26+
public ConsoleAppEngine(ILogger<ConsoleApp> logger,
27+
IServiceProvider provider,
28+
ConsoleAppOptions options,
29+
IServiceProviderIsService isService,
30+
IParamsValidator paramsValidator,
31+
CancellationToken cancellationToken)
2432
{
2533
this.logger = logger;
2634
this.provider = provider;
2735
this.cancellationToken = cancellationToken;
36+
this.paramsValidator = paramsValidator;
2837
this.options = options;
2938
this.isService = isService;
3039
this.isStrict = options.StrictOption;
@@ -168,6 +177,13 @@ async Task RunCore(Type type, MethodInfo methodInfo, object? instance, string?[]
168177
invokeArgs = newInvokeArgs;
169178
}
170179

180+
var validationResult = paramsValidator.ValidateParameters(originalParameters.Zip(invokeArgs));
181+
if (validationResult != ValidationResult.Success)
182+
{
183+
await SetFailAsync(validationResult!.ErrorMessage!);
184+
return;
185+
}
186+
171187
try
172188
{
173189
if (instance == null && !type.IsAbstract && !methodInfo.IsStatic)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.Immutable;
4+
using System.ComponentModel.DataAnnotations;
5+
using System.Linq;
6+
using System.Reflection;
7+
8+
namespace ConsoleAppFramework
9+
{
10+
/// <summary>
11+
/// Validator of command parameters.
12+
/// </summary>
13+
public interface IParamsValidator
14+
{
15+
/// <summary>
16+
/// Validate <paramref name="parameters"/> of command based on validation attributes
17+
/// applied to method's parameters.
18+
/// </summary>
19+
ValidationResult? ValidateParameters(IEnumerable<(ParameterInfo Parameter, object? Value)> parameters);
20+
}
21+
22+
/// <inheritdoc />
23+
public class ParamsValidator : IParamsValidator
24+
{
25+
private readonly ConsoleAppOptions options;
26+
27+
public ParamsValidator(ConsoleAppOptions options) => this.options = options;
28+
29+
/// <inheritdoc />
30+
ValidationResult? IParamsValidator.ValidateParameters(
31+
IEnumerable<(ParameterInfo Parameter, object? Value)> parameters)
32+
{
33+
var invalidParameters = parameters
34+
.Select(tuple => (tuple.Parameter, tuple.Value, Result: Validate(tuple.Parameter, tuple.Value)))
35+
.Where(tuple => tuple.Result != ValidationResult.Success)
36+
.ToImmutableArray();
37+
38+
if (!invalidParameters.Any())
39+
{
40+
return ValidationResult.Success;
41+
}
42+
43+
var errorMessage = string.Join(Environment.NewLine,
44+
invalidParameters
45+
.Select(tuple =>
46+
$"{options.NameConverter(tuple.Parameter.Name!)} " +
47+
$"({tuple.Value}): " +
48+
$"{tuple.Result!.ErrorMessage}")
49+
);
50+
51+
return new ValidationResult($"Some parameters have invalid values:{Environment.NewLine}{errorMessage}");
52+
}
53+
54+
private static ValidationResult? Validate(ParameterInfo parameterInfo, object? value)
55+
{
56+
if (value is null) return ValidationResult.Success;
57+
58+
var validationContext = new ValidationContext(value, null, null);
59+
60+
var failedResults = GetValidationAttributes(parameterInfo)
61+
.Select(attribute => attribute.GetValidationResult(value, validationContext))
62+
.Where(result => result != ValidationResult.Success)
63+
.ToImmutableArray();
64+
65+
return failedResults.Any()
66+
? new ValidationResult(string.Join("; ", failedResults.Select(res => res?.ErrorMessage)))
67+
: ValidationResult.Success;
68+
}
69+
70+
private static IEnumerable<ValidationAttribute> GetValidationAttributes(ParameterInfo parameterInfo)
71+
=> parameterInfo
72+
.GetCustomAttributes()
73+
.OfType<ValidationAttribute>();
74+
}
75+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System;
2+
using System.ComponentModel.DataAnnotations;
3+
using FluentAssertions;
4+
using Xunit;
5+
6+
// ReSharper disable UnusedMember.Global
7+
// ReSharper disable UnusedParameter.Global
8+
9+
namespace ConsoleAppFramework.Integration.Test;
10+
11+
public class ValidationAttributeTests
12+
{
13+
/// <summary>
14+
/// Try to execute command with invalid option value.
15+
/// </summary>
16+
[Fact]
17+
public void Validate_String_Length_Test()
18+
{
19+
using var console = new CaptureConsoleOutput();
20+
21+
const string optionName = "arg";
22+
const string optionValue = "too-large-string-value";
23+
24+
var args = new[] { nameof(AppWithValidationAttributes.StrLength), $"--{optionName}", optionValue };
25+
ConsoleApp.Run<AppWithValidationAttributes>(args);
26+
27+
// Validation should fail, so StrLength command should not be executed.
28+
console.Output.Should().NotContain(AppWithValidationAttributes.Output);
29+
30+
console.Output.Should().Contain(optionName);
31+
console.Output.Should().Contain(optionValue);
32+
}
33+
34+
[Fact]
35+
public void Command_With_Multiple_Params()
36+
{
37+
using var console = new CaptureConsoleOutput();
38+
39+
var args = new[]
40+
{
41+
nameof(AppWithValidationAttributes.MultipleParams),
42+
"--second-arg", "10",
43+
"--first-arg", "invalid-email-address"
44+
};
45+
46+
ConsoleApp.Run<AppWithValidationAttributes>(args);
47+
48+
// Validation should fail, so StrLength command should not be executed.
49+
console.Output.Should().NotContain(AppWithValidationAttributes.Output);
50+
}
51+
52+
/// <inheritdoc />
53+
internal class AppWithValidationAttributes : ConsoleAppBase
54+
{
55+
public const string Output = $"hello from {nameof(AppWithValidationAttributes)}";
56+
57+
[Command(nameof(StrLength))]
58+
public void StrLength([StringLength(maximumLength: 8)] string arg) => Console.WriteLine(Output);
59+
60+
[Command(nameof(MultipleParams))]
61+
public void MultipleParams(
62+
[EmailAddress] string firstArg,
63+
[Range(0, 2)] int secondArg) => Console.WriteLine(Output);
64+
}
65+
}

0 commit comments

Comments
 (0)