diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs index 9dd2dd6513..400e97c76a 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs @@ -45,6 +45,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal options.ModelBinderProviders.Add(new ServicesModelBinderProvider()); options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options)); options.ModelBinderProviders.Add(new HeaderModelBinderProvider()); + options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider()); options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider()); options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider()); options.ModelBinderProviders.Add(new ByteArrayModelBinderProvider()); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DecimalModelBinder.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DecimalModelBinder.cs new file mode 100644 index 0000000000..0c9b2e58c9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DecimalModelBinder.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// An for and where T is + /// . + /// + public class DecimalModelBinder : IModelBinder + { + private readonly NumberStyles _supportedStyles; + + public DecimalModelBinder(NumberStyles supportedStyles) + { + _supportedStyles = supportedStyles; + } + + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + // no entry + return Task.CompletedTask; + } + + var modelState = bindingContext.ModelState; + modelState.SetModelValue(modelName, valueProviderResult); + + var metadata = bindingContext.ModelMetadata; + var type = metadata.UnderlyingOrModelType; + try + { + var value = valueProviderResult.FirstValue; + var culture = valueProviderResult.Culture; + + object model; + if (string.IsNullOrWhiteSpace(value)) + { + // Parse() method trims the value (with common NumberStyles) then throws if the result is empty. + model = null; + } + else if (type == typeof(decimal)) + { + model = decimal.Parse(value, _supportedStyles, culture); + } + else + { + // unreachable + throw new NotSupportedException(); + } + + // When converting value, a null model may indicate a failed conversion for an otherwise required + // model (can't set a ValueType to null). This detects if a null model value is acceptable given the + // current bindingContext. If not, an error is logged. + if (model == null && !metadata.IsReferenceOrNullableType) + { + modelState.TryAddModelError( + modelName, + metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( + valueProviderResult.ToString())); + + return Task.CompletedTask; + } + else + { + bindingContext.Result = ModelBindingResult.Success(model); + return Task.CompletedTask; + } + } + catch (Exception exception) + { + var isFormatException = exception is FormatException; + if (!isFormatException && exception.InnerException != null) + { + // Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve + // this code in case a cursory review of the CoreFx code missed something. + exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException; + } + + modelState.TryAddModelError(modelName, exception, metadata); + + // Conversion failed. + return Task.CompletedTask; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DoubleModelBinder.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DoubleModelBinder.cs new file mode 100644 index 0000000000..c09d7cf0ec --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/DoubleModelBinder.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// An for and where T is + /// . + /// + public class DoubleModelBinder : IModelBinder + { + private readonly NumberStyles _supportedStyles; + + public DoubleModelBinder(NumberStyles supportedStyles) + { + _supportedStyles = supportedStyles; + } + + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + // no entry + return Task.CompletedTask; + } + + var modelState = bindingContext.ModelState; + modelState.SetModelValue(modelName, valueProviderResult); + + var metadata = bindingContext.ModelMetadata; + var type = metadata.UnderlyingOrModelType; + try + { + var value = valueProviderResult.FirstValue; + var culture = valueProviderResult.Culture; + + object model; + if (string.IsNullOrWhiteSpace(value)) + { + // Parse() method trims the value (with common NumberStyles) then throws if the result is empty. + model = null; + } + else if (type == typeof(double)) + { + model = double.Parse(value, _supportedStyles, culture); + } + else + { + // unreachable + throw new NotSupportedException(); + } + + // When converting value, a null model may indicate a failed conversion for an otherwise required + // model (can't set a ValueType to null). This detects if a null model value is acceptable given the + // current bindingContext. If not, an error is logged. + if (model == null && !metadata.IsReferenceOrNullableType) + { + modelState.TryAddModelError( + modelName, + metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( + valueProviderResult.ToString())); + + return Task.CompletedTask; + } + else + { + bindingContext.Result = ModelBindingResult.Success(model); + return Task.CompletedTask; + } + } + catch (Exception exception) + { + var isFormatException = exception is FormatException; + if (!isFormatException && exception.InnerException != null) + { + // Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve + // this code in case a cursory review of the CoreFx code missed something. + exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException; + } + + modelState.TryAddModelError(modelName, exception, metadata); + + // Conversion failed. + return Task.CompletedTask; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FloatModelBinder.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FloatModelBinder.cs new file mode 100644 index 0000000000..370b1a692a --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FloatModelBinder.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// An for and where T is + /// . + /// + public class FloatModelBinder : IModelBinder + { + private readonly NumberStyles _supportedStyles; + + public FloatModelBinder(NumberStyles supportedStyles) + { + _supportedStyles = supportedStyles; + } + + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + // no entry + return Task.CompletedTask; + } + + var modelState = bindingContext.ModelState; + modelState.SetModelValue(modelName, valueProviderResult); + + var metadata = bindingContext.ModelMetadata; + var type = metadata.UnderlyingOrModelType; + try + { + var value = valueProviderResult.FirstValue; + var culture = valueProviderResult.Culture; + + object model; + if (string.IsNullOrWhiteSpace(value)) + { + // Parse() method trims the value (with common NumberStyles) then throws if the result is empty. + model = null; + } + else if (type == typeof(float)) + { + model = float.Parse(value, _supportedStyles, culture); + } + else + { + // unreachable + throw new NotSupportedException(); + } + + // When converting value, a null model may indicate a failed conversion for an otherwise required + // model (can't set a ValueType to null). This detects if a null model value is acceptable given the + // current bindingContext. If not, an error is logged. + if (model == null && !metadata.IsReferenceOrNullableType) + { + modelState.TryAddModelError( + modelName, + metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( + valueProviderResult.ToString())); + + return Task.CompletedTask; + } + else + { + bindingContext.Result = ModelBindingResult.Success(model); + return Task.CompletedTask; + } + } + catch (Exception exception) + { + var isFormatException = exception is FormatException; + if (!isFormatException && exception.InnerException != null) + { + // Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve + // this code in case a cursory review of the CoreFx code missed something. + exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException; + } + + modelState.TryAddModelError(modelName, exception, metadata); + + // Conversion failed. + return Task.CompletedTask; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FloatingPointTypeModelBinderProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FloatingPointTypeModelBinderProvider.cs new file mode 100644 index 0000000000..438bde541d --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/FloatingPointTypeModelBinderProvider.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// An for binding , , + /// , and their wrappers. + /// + public class FloatingPointTypeModelBinderProvider : IModelBinderProvider + { + // SimpleTypeModelBinder uses DecimalConverter and similar. Those TypeConverters default to NumberStyles.Float. + // Internal for testing. + internal static readonly NumberStyles SupportedStyles = NumberStyles.Float | NumberStyles.AllowThousands; + + /// + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var modelType = context.Metadata.UnderlyingOrModelType; + if (modelType == typeof(decimal)) + { + return new DecimalModelBinder(SupportedStyles); + } + + if (modelType == typeof(double)) + { + return new DoubleModelBinder(SupportedStyles); + } + + if (modelType == typeof(float)) + { + return new FloatModelBinder(SupportedStyles); + } + + return null; + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DecimalModelBinderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DecimalModelBinderTest.cs new file mode 100644 index 0000000000..ad0ba31f59 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DecimalModelBinderTest.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Globalization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public class DecimalModelBinderTest : FloatingPointTypeModelBinderTest + { + protected override decimal Twelve => 12M; + + protected override decimal TwelvePointFive => 12.5M; + + protected override decimal ThirtyTwoThousand => 32_000M; + + protected override decimal ThirtyTwoThousandPointOne => 32_000.1M; + + protected override IModelBinder GetBinder(NumberStyles numberStyles) + { + return new DecimalModelBinder(numberStyles); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DoubleModelBinderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DoubleModelBinderTest.cs new file mode 100644 index 0000000000..e0d28c3836 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/DoubleModelBinderTest.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Globalization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public class DoubleModelBinderTest : FloatingPointTypeModelBinderTest + { + protected override double Twelve => 12.0; + + protected override double TwelvePointFive => 12.5; + + protected override double ThirtyTwoThousand => 32_000.0; + + protected override double ThirtyTwoThousandPointOne => 32_000.1; + + protected override IModelBinder GetBinder(NumberStyles numberStyles) + { + return new DoubleModelBinder(numberStyles); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatModelBinderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatModelBinderTest.cs new file mode 100644 index 0000000000..d0a7a16fc8 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatModelBinderTest.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Globalization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public class FloatModelBinderTest : FloatingPointTypeModelBinderTest + { + protected override float Twelve => 12.0F; + + protected override float TwelvePointFive => 12.5F; + + protected override float ThirtyTwoThousand => 32_000.0F; + + protected override float ThirtyTwoThousandPointOne => 32_000.1F; + + protected override IModelBinder GetBinder(NumberStyles numberStyles) + { + return new FloatModelBinder(numberStyles); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderProviderTest.cs new file mode 100644 index 0000000000..7da624a7ed --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderProviderTest.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public class FloatingPointTypeModelBinderProviderTest + { + [Theory] + [InlineData(typeof(object))] + [InlineData(typeof(Calendar))] + [InlineData(typeof(TestClass))] + [InlineData(typeof(List))] + public void Create_ForCollectionOrComplexTypes_ReturnsNull(Type modelType) + { + // Arrange + var provider = new FloatingPointTypeModelBinderProvider(); + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData(typeof(int))] + [InlineData(typeof(int?))] + [InlineData(typeof(char))] + [InlineData(typeof(string))] + [InlineData(typeof(DateTime))] + [InlineData(typeof(DateTime?))] + public void Create_ForUnsupportedSimpleTypes_ReturnsNull(Type modelType) + { + // Arrange + var provider = new FloatingPointTypeModelBinderProvider(); + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData(typeof(decimal))] + [InlineData(typeof(decimal?))] + public void Create_ForDecimalTypes_ReturnsBinder(Type modelType) + { + // Arrange + var provider = new FloatingPointTypeModelBinderProvider(); + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + [Theory] + [InlineData(typeof(double))] + [InlineData(typeof(double?))] + public void Create_ForDoubleTypes_ReturnsBinder(Type modelType) + { + // Arrange + var provider = new FloatingPointTypeModelBinderProvider(); + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + [Theory] + [InlineData(typeof(float))] + [InlineData(typeof(float?))] + public void Create_ForFloatTypes_ReturnsBinder(Type modelType) + { + // Arrange + var provider = new FloatingPointTypeModelBinderProvider(); + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + private class TestClass + { + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs new file mode 100644 index 0000000000..fbcb7e5893 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/FloatingPointTypeModelBinderTestOfT.cs @@ -0,0 +1,387 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public abstract class FloatingPointTypeModelBinderTest where TFloatingPoint: struct + { + public static TheoryData ConvertableTypeData + { + get + { + return new TheoryData + { + typeof(TFloatingPoint), + typeof(TFloatingPoint?), + }; + } + } + + protected abstract TFloatingPoint Twelve { get; } + + protected abstract TFloatingPoint TwelvePointFive { get; } + + protected abstract TFloatingPoint ThirtyTwoThousand { get; } + + protected abstract TFloatingPoint ThirtyTwoThousandPointOne { get; } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "some-value" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed(Type destinationType) + { + // Arrange + var message = "The value 'not a number' is not valid."; + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "not a number" }, + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + Assert.Null(bindingContext.Result.Model); + Assert.False(bindingContext.ModelState.IsValid); + + var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors); + Assert.Equal(message, error.ErrorMessage); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "12_5" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + Assert.Null(bindingContext.Result.Model); + + var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors); + Assert.Equal("The value '12_5' is not valid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedWhitespace(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", " 12" } + }; + var binder = GetBinder(NumberStyles.Float & ~NumberStyles.AllowLeadingWhite); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + Assert.Null(bindingContext.Result.Model); + + var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors); + Assert.Equal("The value ' 12' is not valid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedDecimal(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "12.5" } + }; + var binder = GetBinder(NumberStyles.Float & ~NumberStyles.AllowDecimalPoint); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + Assert.Null(bindingContext.Result.Model); + + var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors); + Assert.Equal("The value '12.5' is not valid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_CreatesError_IfAttemptedValueContainsDisallowedThousandsSeparator(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "32,000" } + }; + var binder = GetBinder(NumberStyles.Float); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + Assert.Null(bindingContext.Result.Model); + + var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors); + Assert.Equal("The value '32,000' is not valid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsFailed_IfValueProviderEmpty(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result); + Assert.Empty(bindingContext.ModelState); + } + + [Theory] + [InlineData("")] + [InlineData(" \t \r\n ")] + public async Task BindModel_CreatesError_IfTrimmedAttemptedValueIsEmpty_NonNullableDestination(string value) + { + // Arrange + var message = $"The value '{value}' is invalid."; + var bindingContext = GetBindingContext(typeof(TFloatingPoint)); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", value }, + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + Assert.Null(bindingContext.Result.Model); + + var error = Assert.Single(bindingContext.ModelState["theModelName"].Errors); + Assert.Equal(message, error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + } + + [Theory] + [InlineData("")] + [InlineData(" \t \r\n ")] + public async Task BindModel_ReturnsNull_IfTrimmedAttemptedValueIsEmpty_NullableDestination(string value) + { + // Arrange + var bindingContext = GetBindingContext(typeof(TFloatingPoint?)); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", value } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Null(bindingContext.Result.Model); + var entry = Assert.Single(bindingContext.ModelState); + Assert.Equal("theModelName", entry.Key); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_Twelve(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "12" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(Twelve, bindingContext.Result.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + [ReplaceCulture("en-GB", "en-GB")] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_TwelvePointFive(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", "12.5" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(TwelvePointFive, bindingContext.Result.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_FrenchTwelvePointFive(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR")) + { + { "theModelName", "12,5" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(TwelvePointFive, bindingContext.Result.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_ThirtyTwoThousand(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "32,000" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(ThirtyTwoThousand, bindingContext.Result.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_ThirtyTwoThousandPointOne(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "32,000.1" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(ThirtyTwoThousandPointOne, bindingContext.Result.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + [Theory] + [MemberData(nameof(ConvertableTypeData))] + public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid_FrenchThirtyTwoThousandPointOne(Type destinationType) + { + // Arrange + var bindingContext = GetBindingContext(destinationType); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR")) + { + { "theModelName", "32 000,1" } + }; + var binder = GetBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal(ThirtyTwoThousandPointOne, bindingContext.Result.Model); + Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); + } + + protected abstract IModelBinder GetBinder(NumberStyles numberStyles); + + private IModelBinder GetBinder() + { + return GetBinder(FloatingPointTypeModelBinderProvider.SupportedStyles); + } + + private static DefaultModelBindingContext GetBindingContext(Type modelType) + { + return new DefaultModelBindingContext + { + ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType), + ModelName = "theModelName", + ModelState = new ModelStateDictionary(), + ValueProvider = new SimpleValueProvider() // empty + }; + } + + private sealed class TestClass + { + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs index 231c907750..e46c61aa41 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs @@ -109,13 +109,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders } [Theory] - [InlineData(typeof(byte))] - [InlineData(typeof(short))] - [InlineData(typeof(int))] - [InlineData(typeof(long))] - [InlineData(typeof(Guid))] - [InlineData(typeof(double))] - [InlineData(typeof(DayOfWeek))] + [MemberData(nameof(ConvertableTypeData))] public async Task BindModel_CreatesError_WhenTypeConversionIsNull(Type destinationType) { // Arrange @@ -262,6 +256,55 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.True(bindingContext.ModelState.ContainsKey("theModelName")); } + public static TheoryData BiggerNumericTypes + { + get + { + // Data set does not include bool, byte, sbyte, or char because they do not need thousands separators. + return new TheoryData + { + typeof(decimal), + typeof(double), + typeof(float), + typeof(int), + typeof(long), + typeof(short), + typeof(uint), + typeof(ulong), + typeof(ushort), + }; + } + } + + [Theory] + [MemberData(nameof(BiggerNumericTypes))] + public async Task BindModel_ThousandsSeparators_LeadToErrors(Type type) + { + // Arrange + var bindingContext = GetBindingContext(type); + bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB")) + { + { "theModelName", "32,000" } + }; + + var binder = new SimpleTypeModelBinder(type); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.False(bindingContext.Result.IsModelSet); + + var entry = Assert.Single(bindingContext.ModelState); + Assert.Equal("theModelName", entry.Key); + Assert.Equal("32,000", entry.Value.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.Value.ValidationState); + + var error = Assert.Single(entry.Value.Errors); + Assert.Equal("The value '32,000' is not valid.", error.ErrorMessage); + Assert.Null(error.Exception); + } + [Fact] public async Task BindModel_ValidValueProviderResultWithProvidedCulture_ReturnsModel() { @@ -349,11 +392,49 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Assert.Equal(IntEnum.Value1, boundModel); } + public static TheoryData EnumValues + { + get + { + return new TheoryData + { + { "0", 0 }, + { "1", 1 }, + { "13", 13 }, + { "Value1", 1 }, + { "Value1, Value2", 3 }, + // These two values look like big integers but are treated as two separate enum values that are + // or'd together. + { "32,015", 47 }, + { "32,128", 160 }, + }; + } + } + [Theory] - [InlineData("0", 0)] - [InlineData("1", 1)] - [InlineData("13", 13)] - [InlineData("Value1", 1)] + [MemberData(nameof(EnumValues))] + public async Task BindModel_BindsIntEnumModels(string flagsEnumValue, int expected) + { + // Arrange + var bindingContext = GetBindingContext(typeof(IntEnum)); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "theModelName", flagsEnumValue } + }; + + var binder = new SimpleTypeModelBinder(typeof(IntEnum)); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + var boundModel = Assert.IsType(bindingContext.Result.Model); + Assert.Equal((IntEnum)expected, boundModel); + } + + [Theory] + [MemberData(nameof(EnumValues))] [InlineData("Value8, Value4", 12)] public async Task BindModel_BindsFlagsEnumModels(string flagsEnumValue, int expected) { @@ -396,13 +477,14 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders Value1 = 1, Value2 = 2, Value4 = 4, - Value8 = 8 + Value8 = 8, } private enum IntEnum { Value0 = 0, Value1 = 1, + Value2 = 2, MaxValue = int.MaxValue } } diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs index 96d4fc1437..906450a3fa 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/SimpleTypeModelBinderIntegrationTest.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Primitives; using Xunit; @@ -152,6 +153,50 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState); } + [Fact] + [ReplaceCulture("en-GB", "en-GB")] + public async Task BindDecimalParameter_WithData_GetsBound() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor + { + Name = "Parameter1", + BindingInfo = new BindingInfo(), + ParameterType = typeof(decimal), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Parameter1", "32,000.99"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + + // ModelBindingResult + Assert.True(modelBindingResult.IsModelSet); + + // Model + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(32000.99M, model); + + // ModelState + Assert.True(modelState.IsValid); + + Assert.Equal(1, modelState.Keys.Count()); + var key = Assert.Single(modelState.Keys); + Assert.Equal("Parameter1", key); + Assert.Equal("32,000.99", modelState[key].AttemptedValue); + Assert.Equal("32,000.99", modelState[key].RawValue); + Assert.Empty(modelState[key].Errors); + Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState); + } + [Fact] public async Task BindParameter_WithMultipleValues_GetsBoundToFirstValue() { diff --git a/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs b/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs index a985e1a0ac..5cd8b88d0b 100644 --- a/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Test/MvcOptionsSetupTest.cs @@ -58,6 +58,7 @@ namespace Microsoft.AspNetCore.Mvc binder => Assert.IsType(binder), binder => Assert.IsType(binder), binder => Assert.IsType(binder), + binder => Assert.IsType(binder), binder => Assert.IsType(binder), binder => Assert.IsType(binder), binder => Assert.IsType(binder),