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),