Skip to content

Commit

Permalink
feat(normalize): move normalization to parser, such that validator is…
Browse files Browse the repository at this point in the history
… always validating strict. Add Strict-property (default true) to IbanAttribute and FluentValidator extension. When Strict=true, the input must strictly match the IBAN format rules. When Strict=false, whitespace is ignored and strict character casing enforcement is disabled (meaning, the user can input in lower and uppercase). This mode is a bit more forgiving when dealing with user-input. However it does require after successful validation, that you parse the user input with IIbanParser to normalize/sanitize the input and to be able to format the IBAN in correct electronic format.

See #93
  • Loading branch information
skwasjer committed Oct 4, 2022
1 parent ea7f247 commit 700b04a
Show file tree
Hide file tree
Showing 22 changed files with 335 additions and 142 deletions.
17 changes: 15 additions & 2 deletions src/IbanNet.DataAnnotations/IbanAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using IbanNet.Internal;

namespace IbanNet.DataAnnotations
{
Expand All @@ -17,6 +18,14 @@ public IbanAttribute()
{
}

/// <summary>
/// Gets or sets whether to perform strict validation. When true, the input must strictly match the IBAN format rules.
/// When false, whitespace is ignored and strict character casing enforcement is disabled (meaning, the user can input in lower and uppercase). This mode is a bit more forgiving when dealing with user-input. However it does require after successful validation, that you parse the user input with <see cref="IIbanParser" /> to normalize/sanitize the input and to be able to format the IBAN in correct electronic format.
///
/// <para>Default is <see langword="true" />. (this may change in future major release)</para>
/// </summary>
public bool Strict { get; init; } = true;

/// <inheritdoc />
protected override System.ComponentModel.DataAnnotations.ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
Expand All @@ -31,7 +40,11 @@ public IbanAttribute()
}

IIbanValidator ibanValidator = GetValidator(validationContext);
ValidationResult result = ibanValidator.Validate(strValue);
ValidationResult result = ibanValidator.Validate(
Strict
? strValue
: InputNormalization.NormalizeOrNull(strValue)
);
if (result.IsValid)
{
return System.ComponentModel.DataAnnotations.ValidationResult.Success;
Expand All @@ -54,7 +67,7 @@ public IbanAttribute()
/// <summary>
/// Gets the validator from IoC container.
/// </summary>
private static IIbanValidator GetValidator(IServiceProvider serviceProvider)
private static IIbanValidator GetValidator(IServiceProvider? serviceProvider)
{
var ibanValidator = (IIbanValidator?)serviceProvider?.GetService(typeof(IIbanValidator));
if (ibanValidator is null)
Expand Down
5 changes: 5 additions & 0 deletions src/IbanNet.DataAnnotations/IbanNet.DataAnnotations.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\IbanNet\Extensions\CharExtensions.cs" Link="Internal\Extensions\CharExtensions.cs" />
<Compile Include="..\IbanNet\Internal\InputNormalization.cs" Link="Internal\InputNormalization.cs" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
Expand Down
19 changes: 16 additions & 3 deletions src/IbanNet.FluentValidation/FluentIbanValidator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentValidation;
using FluentValidation.Validators;
using IbanNet.Internal;

namespace IbanNet.FluentValidation
{
Expand All @@ -19,6 +20,14 @@ public FluentIbanValidator(IIbanValidator ibanValidator)
_ibanValidator = ibanValidator ?? throw new ArgumentNullException(nameof(ibanValidator));
}

/// <summary>
/// Gets or sets whether to perform strict validation. When true, the input must strictly match the IBAN format rules.
/// When false, whitespace is ignored and strict character casing enforcement is disabled (meaning, the user can input in lower and uppercase). This mode is a bit more forgiving when dealing with user-input. However it does require after successful validation, that you parse the user input with <see cref="IIbanParser" /> to normalize/sanitize the input and to be able to format the IBAN in correct electronic format.
///
/// <para>Default is <see langword="true" />. (this may change in future major release)</para>
/// </summary>
public bool Strict { get; init; } = true;

/// <inheritdoc />
protected override string GetDefaultMessageTemplate(string errorCode)
{
Expand All @@ -28,16 +37,20 @@ protected override string GetDefaultMessageTemplate(string errorCode)
/// <inheritdoc />
public override bool IsValid(ValidationContext<T> context, string value)
{
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (value is null)
{
return true;
}

ValidationResult result = _ibanValidator.Validate(value);
ValidationResult result = _ibanValidator.Validate(
Strict
? value
: InputNormalization.NormalizeOrNull(value)
);
if (result.Error is not null)
{
// ReSharper disable once ConstantConditionalAccessQualifier
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
context?.MessageFormatter.AppendArgument("Error", result.Error);
}

Expand Down
5 changes: 5 additions & 0 deletions src/IbanNet.FluentValidation/IbanNet.FluentValidation.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\IbanNet\Extensions\CharExtensions.cs" Link="Internal\Extensions\CharExtensions.cs" />
<Compile Include="..\IbanNet\Internal\InputNormalization.cs" Link="Internal\InputNormalization.cs" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
Expand Down
12 changes: 10 additions & 2 deletions src/IbanNet.FluentValidation/RuleBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,26 @@ public static class RuleBuilderExtensions
/// <typeparam name="T">Type of object being validated</typeparam>
/// <param name="ruleBuilder">The rule builder on which the validator should be defined</param>
/// <param name="ibanValidator">The <see cref="IIbanValidator" /> instance to use for validation.</param>
/// <param name="strict">
/// When true, the input must strictly match the IBAN format rules.
/// When false, whitespace is ignored and strict character casing enforcement is disabled (meaning, the user can input in lower and uppercase). This mode is a bit more forgiving when dealing with user-input. However it does require after successful validation, that you parse the user input with <see cref="IIbanParser" /> to normalize/sanitize the input and to be able to format the IBAN in correct electronic format.
///
/// <para>Default is <see langword="true" />. (this may change in future major release)</para>
/// </param>
/// <returns></returns>
public static IRuleBuilderOptions<T, string> Iban<T>
(
this IRuleBuilder<T, string> ruleBuilder,
IIbanValidator ibanValidator)
IIbanValidator ibanValidator,
bool strict = true
)
{
if (ruleBuilder is null)
{
throw new ArgumentNullException(nameof(ruleBuilder));
}

return ruleBuilder.SetValidator(new FluentIbanValidator<T>(ibanValidator));
return ruleBuilder.SetValidator(new FluentIbanValidator<T>(ibanValidator) { Strict = strict });
}
}
}
61 changes: 7 additions & 54 deletions src/IbanNet/Iban.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using IbanNet.Extensions;
using IbanNet.Internal;
using IbanNet.Registry;
using IbanNet.TypeConverters;

Expand All @@ -23,7 +23,7 @@ public sealed class Iban
/// The maximum length of any IBAN, from any country.
/// </summary>
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
internal const int MaxLength = 34;
public const int MaxLength = 34;

[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private static readonly Func<IIbanValidator> DefaultFactory = () => new IbanValidator();
Expand Down Expand Up @@ -51,9 +51,12 @@ public static IIbanValidator Validator
: new Lazy<IIbanValidator>(() => value, true);
}

internal Iban(string iban, IbanCountry ibanCountry)
internal Iban(string iban, IbanCountry ibanCountry, bool skipNormalize = false)
{
_iban = NormalizeOrNull(iban) ?? throw new ArgumentNullException(nameof(iban));
_iban = (skipNormalize
? iban
: InputNormalization.NormalizeOrNull(iban)
) ?? throw new ArgumentNullException(nameof(iban));
Country = ibanCountry ?? throw new ArgumentNullException(nameof(ibanCountry));
}

Expand Down Expand Up @@ -221,56 +224,6 @@ public override int GetHashCode()
return !Equals(left, right);
}

/// <summary>
/// Normalizes an IBAN by removing whitespace, removing non-alphanumerics and upper casing each character.
/// </summary>
/// <param name="value">The input value to normalize.</param>
/// <returns>The normalized IBAN.</returns>
internal static string? NormalizeOrNull([NotNullIfNotNull("value")] string? value)
{
if (value is null)
{
return null;
}

int length = value.Length;
#if USE_SPANS
// Use stack but clamp to avoid excessive stackalloc buffer.
const int stackallocMaxSize = MaxLength + 6;
Span<char> buffer = length <= stackallocMaxSize
? stackalloc char[length]
: new char[length];
#else
char[] buffer = new char[length];
#endif
int pos = 0;
// ReSharper disable once ForCanBeConvertedToForeach - justification : performance
for (int i = 0; i < length; i++)
{
char ch = value[i];
if (ch.IsWhitespace())
{
continue;
}

if (ch.IsAsciiLetter())
{
// Inline upper case.
buffer[pos++] = (char)(ch & ~' ');
}
else
{
buffer[pos++] = ch;
}
}

#if USE_SPANS
return new string(buffer[..pos]);
#else
return new string(buffer, 0, pos);
#endif
}

private string? Extract(StructureSection? structure)
{
if (structure?.Pattern is null or NullPattern)
Expand Down
7 changes: 5 additions & 2 deletions src/IbanNet/IbanParser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using IbanNet.Internal;
using IbanNet.Registry;

namespace IbanNet
Expand Down Expand Up @@ -77,9 +78,11 @@ private bool TryParse(
iban = null;
exceptionThrown = null;

string? normalizedValue = InputNormalization.NormalizeOrNull(value);

try
{
validationResult = _ibanValidator.Validate(value);
validationResult = _ibanValidator.Validate(normalizedValue);
}
catch (Exception ex)
{
Expand All @@ -93,7 +96,7 @@ private bool TryParse(
return false;
}

iban = new Iban(validationResult.AttemptedValue!, validationResult.Country!);
iban = new Iban(normalizedValue!, validationResult.Country!, true);
return true;
}
}
Expand Down
6 changes: 2 additions & 4 deletions src/IbanNet/IbanValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,7 @@ internal IbanValidator(IbanValidatorOptions options, IValidationRuleResolver val
/// <returns>a validation result, indicating if the IBAN is valid or not</returns>
public ValidationResult Validate(string? iban)
{
string? normalizedIban = Iban.NormalizeOrNull(iban);

var context = new ValidationRuleContext(normalizedIban ?? string.Empty);
var context = new ValidationRuleContext(iban ?? string.Empty);
ErrorResult? error = null;

foreach (IIbanValidationRule rule in _rules)
Expand All @@ -101,7 +99,7 @@ public ValidationResult Validate(string? iban)

return new ValidationResult
{
AttemptedValue = normalizedIban,
AttemptedValue = iban,
Country = context.Country,
Error = error
};
Expand Down
57 changes: 57 additions & 0 deletions src/IbanNet/Internal/InputNormalization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Diagnostics.CodeAnalysis;
using IbanNet.Extensions;

namespace IbanNet.Internal;

internal static class InputNormalization
{
/// <summary>
/// Normalizes an IBAN by removing whitespace, removing non-alphanumerics and upper casing each character.
/// </summary>
/// <param name="value">The input value to normalize.</param>
/// <returns>The normalized IBAN.</returns>
internal static string? NormalizeOrNull([NotNullIfNotNull("value")] string? value)
{
if (value is null)
{
return null;
}

int length = value.Length;
#if USE_SPANS
// Use stack but clamp to avoid excessive stackalloc buffer.
const int stackallocMaxSize = Iban.MaxLength + 6;
Span<char> buffer = length <= stackallocMaxSize
? stackalloc char[length]
: new char[length];
#else
char[] buffer = new char[length];
#endif
int pos = 0;
// ReSharper disable once ForCanBeConvertedToForeach - justification : performance
for (int i = 0; i < length; i++)
{
char ch = value[i];
if (ch.IsWhitespace())
{
continue;
}

if (ch.IsAsciiLetter())
{
// Inline upper case.
buffer[pos++] = (char)(ch & ~' ');
}
else
{
buffer[pos++] = ch;
}
}

#if USE_SPANS
return new string(buffer[..pos]);
#else
return new string(buffer, 0, pos);
#endif
}
}
10 changes: 5 additions & 5 deletions test/IbanNet.DataAnnotations.Tests/AspNetIntegrationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,21 @@ public async Task Given_valid_iban_when_posting_with_attribute_validation_it_sho
using HttpClient client = _fixture.TestServer.CreateClient();

// Act
HttpResponseMessage response = await client.SendAsync(CreateSaveRequest(validIban));
HttpResponseMessage response = await client.SendAsync(CreateSaveRequest(validIban, false));

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
(await response.Content.ReadAsStringAsync()).Should().Be($"\"{validIban}\"");
}

[Fact]
public async Task Given_invalid_iban_when_posting_with_attribute_validation_it_should_validate()
public async Task Given_invalid_iban_when_posting_with_attribute_validation_it_should_not_validate()
{
const string invalidIban = "invalid-iban";
using HttpClient client = _fixture.TestServer.CreateClient();

// Act
HttpResponseMessage response = await client.SendAsync(CreateSaveRequest(invalidIban));
HttpResponseMessage response = await client.SendAsync(CreateSaveRequest(invalidIban, false));

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
Expand All @@ -58,9 +58,9 @@ public async Task Given_invalid_iban_when_posting_with_attribute_validation_it_s
.Contain("The field 'BankAccountNumber' is not a valid IBAN.");
}

private static HttpRequestMessage CreateSaveRequest(string iban)
private static HttpRequestMessage CreateSaveRequest(string iban, bool strict)
{
return new(HttpMethod.Post, "test/save")
return new(HttpMethod.Post, "test/save" + (strict ? "-strict" : ""))
{
Headers =
{
Expand Down
5 changes: 2 additions & 3 deletions test/IbanNet.DataAnnotations.Tests/AspNetWebHostFixture.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
#if ASPNET_INTEGRATION_TESTS
using IbanNet.DependencyInjection.ServiceProvider;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;

namespace IbanNet.DataAnnotations
{
Expand All @@ -14,7 +13,7 @@ public void ConfigureServices(IServiceCollection services)
#pragma warning restore CA1822 // Mark members as static
{
services
.AddSingleton<IIbanValidator, IbanValidator>()
.AddIbanNet()
.AddMvc()
.AddControllersAsServices();
}
Expand Down
Loading

0 comments on commit 700b04a

Please sign in to comment.