diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
index 5ae0d2c9105e..3be747c018ac 100644
--- a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
+++ b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs
@@ -74,6 +74,8 @@ public void Configure(MvcOptions options)
options.ModelBinderProviders.Add(new ArrayModelBinderProvider());
options.ModelBinderProviders.Add(new CollectionModelBinderProvider());
options.ModelBinderProviders.Add(new ComplexObjectModelBinderProvider());
+ options.ModelBinderProviders.Add(new DateOnlyModelBinderProvider());
+ options.ModelBinderProviders.Add(new TimeOnlyModelBinderProvider());
// Set up filters
options.Filters.Add(new UnsupportedContentTypeFilter());
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs
new file mode 100644
index 000000000000..20dfc90ccbd1
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinder.cs
@@ -0,0 +1,98 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+///
+/// An for and nullable models.
+///
+public class DateOnlyModelBinder : IModelBinder
+{
+ private readonly DateTimeStyles _supportedStyles;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The .
+ /// The .
+ public DateOnlyModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory)
+ {
+ ArgumentNullException.ThrowIfNull(loggerFactory, nameof(loggerFactory));
+
+ _supportedStyles = supportedStyles;
+ _logger = loggerFactory.CreateLogger();
+ }
+
+ ///
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ ArgumentNullException.ThrowIfNull(bindingContext, nameof(bindingContext));
+
+ _logger.AttemptingToBindModel(bindingContext);
+
+ var modelName = bindingContext.ModelName;
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
+ if (valueProviderResult == ValueProviderResult.None)
+ {
+ _logger.FoundNoValueInRequest(bindingContext);
+
+ // no entry
+ _logger.DoneAttemptingToBindModel(bindingContext);
+ return Task.CompletedTask;
+ }
+
+ var modelState = bindingContext.ModelState;
+ modelState.SetModelValue(modelName, valueProviderResult);
+
+ var metadata = bindingContext.ModelMetadata;
+ var type = metadata.UnderlyingOrModelType;
+ try
+ {
+ var value = valueProviderResult.FirstValue;
+
+ object? model;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ // Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty.
+ model = null;
+ }
+ else if (type == typeof(DateOnly))
+ {
+ model = DateOnly.Parse(value, valueProviderResult.Culture, _supportedStyles);
+ }
+ else
+ {
+ 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()));
+ }
+ else
+ {
+ bindingContext.Result = ModelBindingResult.Success(model);
+ }
+ }
+ catch (Exception exception)
+ {
+ // Conversion failed.
+ modelState.TryAddModelError(modelName, exception, metadata);
+ }
+
+ _logger.DoneAttemptingToBindModel(bindingContext);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs
new file mode 100644
index 000000000000..2614abdc76a2
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/DateOnlyModelBinderProvider.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System.Globalization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+///
+/// An for binding and nullable models.
+///
+public class DateOnlyModelBinderProvider : IModelBinderProvider
+{
+ internal const DateTimeStyles SupportedStyles = DateTimeStyles.AllowWhiteSpaces;
+
+ ///
+ public IModelBinder? GetBinder(ModelBinderProviderContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context, nameof(context));
+
+ var modelType = context.Metadata.UnderlyingOrModelType;
+ if (modelType == typeof(DateOnly))
+ {
+ var loggerFactory = context.Services.GetRequiredService();
+ return new DateOnlyModelBinder(SupportedStyles, loggerFactory);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs
new file mode 100644
index 000000000000..e3abfa29b123
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinder.cs
@@ -0,0 +1,98 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+///
+/// An for and nullable models.
+///
+public class TimeOnlyModelBinder : IModelBinder
+{
+ private readonly DateTimeStyles _supportedStyles;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The .
+ /// The .
+ public TimeOnlyModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory)
+ {
+ ArgumentNullException.ThrowIfNull(loggerFactory, nameof(loggerFactory));
+
+ _supportedStyles = supportedStyles;
+ _logger = loggerFactory.CreateLogger();
+ }
+
+ ///
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ ArgumentNullException.ThrowIfNull(bindingContext, nameof(bindingContext));
+
+ _logger.AttemptingToBindModel(bindingContext);
+
+ var modelName = bindingContext.ModelName;
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
+ if (valueProviderResult == ValueProviderResult.None)
+ {
+ _logger.FoundNoValueInRequest(bindingContext);
+
+ // no entry
+ _logger.DoneAttemptingToBindModel(bindingContext);
+ return Task.CompletedTask;
+ }
+
+ var modelState = bindingContext.ModelState;
+ modelState.SetModelValue(modelName, valueProviderResult);
+
+ var metadata = bindingContext.ModelMetadata;
+ var type = metadata.UnderlyingOrModelType;
+ try
+ {
+ var value = valueProviderResult.FirstValue;
+
+ object? model;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ // Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty.
+ model = null;
+ }
+ else if (type == typeof(TimeOnly))
+ {
+ model = TimeOnly.Parse(value, valueProviderResult.Culture, _supportedStyles);
+ }
+ else
+ {
+ 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()));
+ }
+ else
+ {
+ bindingContext.Result = ModelBindingResult.Success(model);
+ }
+ }
+ catch (Exception exception)
+ {
+ // Conversion failed.
+ modelState.TryAddModelError(modelName, exception, metadata);
+ }
+
+ _logger.DoneAttemptingToBindModel(bindingContext);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs
new file mode 100644
index 000000000000..8a994c8d56ac
--- /dev/null
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/TimeOnlyModelBinderProvider.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System.Globalization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+///
+/// An for binding and nullable models.
+///
+public class TimeOnlyModelBinderProvider : IModelBinderProvider
+{
+ internal const DateTimeStyles SupportedStyles = DateTimeStyles.AllowWhiteSpaces;
+
+ ///
+ public IModelBinder? GetBinder(ModelBinderProviderContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context, nameof(context));
+
+ var modelType = context.Metadata.UnderlyingOrModelType;
+ if (modelType == typeof(TimeOnly))
+ {
+ var loggerFactory = context.Services.GetRequiredService();
+ return new TimeOnlyModelBinder(SupportedStyles, loggerFactory);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
index c2c8fdf41a5a..17e0567d0d1e 100644
--- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
+++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
@@ -36,3 +36,15 @@ virtual Microsoft.AspNetCore.Mvc.Infrastructure.ConfigureCompatibilityOptions Microsoft.AspNetCore.Mvc.EmptyResult!
*REMOVED*virtual Microsoft.AspNetCore.Mvc.ModelBinding.DefaultPropertyFilterProvider.PropertyIncludeExpressions.get -> System.Collections.Generic.IEnumerable!>!>?
virtual Microsoft.AspNetCore.Mvc.ModelBinding.DefaultPropertyFilterProvider.PropertyIncludeExpressions.get -> System.Collections.Generic.IEnumerable!>!>?
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinder
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext! bindingContext) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinder.DateOnlyModelBinder(System.Globalization.DateTimeStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinderProvider
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinderProvider.DateOnlyModelBinderProvider() -> void
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateOnlyModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext! context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder?
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinder
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext! bindingContext) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinder.TimeOnlyModelBinder(System.Globalization.DateTimeStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinderProvider
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinderProvider.TimeOnlyModelBinderProvider() -> void
+Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TimeOnlyModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext! context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder?
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs
new file mode 100644
index 000000000000..98e1dfa5d293
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderProviderTest.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+public class DateOnlyModelBinderProviderTest
+{
+ private readonly DateOnlyModelBinderProvider _provider = new DateOnlyModelBinderProvider();
+
+ [Theory]
+ [InlineData(typeof(string))]
+ [InlineData(typeof(DateTimeOffset))]
+ [InlineData(typeof(DateTimeOffset?))]
+ [InlineData(typeof(DateTime))]
+ [InlineData(typeof(DateTime?))]
+ [InlineData(typeof(TimeSpan))]
+ public void Create_ForNonDateOnly_ReturnsNull(Type modelType)
+ {
+ // Arrange
+ var context = new TestModelBinderProviderContext(modelType);
+
+ // Act
+ var result = _provider.GetBinder(context);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Create_ForDateOnly_ReturnsBinder()
+ {
+ // Arrange
+ var context = new TestModelBinderProviderContext(typeof(DateOnly));
+
+ // Act
+ var result = _provider.GetBinder(context);
+
+ // Assert
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Create_ForNullableDateOnly_ReturnsBinder()
+ {
+ // Arrange
+ var context = new TestModelBinderProviderContext(typeof(DateOnly?));
+
+ // Act
+ var result = _provider.GetBinder(context);
+
+ // Assert
+ Assert.IsType(result);
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderTest.cs
new file mode 100644
index 000000000000..c6db62a58be5
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DateOnlyModelBinderTest.cs
@@ -0,0 +1,216 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+public class DateOnlyModelBinderTest
+{
+ [Fact]
+ public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext();
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "theModelName", "some-value" }
+ };
+ var binder = GetBinder();
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.False(bindingContext.Result.IsModelSet);
+ }
+
+ [Fact]
+ public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed()
+ {
+ // Arrange
+ var message = "The value 'not a date' is not valid.";
+ var bindingContext = GetBindingContext();
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "theModelName", "not a date" },
+ };
+ 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);
+ }
+
+ [Fact]
+ public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext();
+ bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
+ {
+ { "theModelName", "2020-08-not-a-date" }
+ };
+ 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 '2020-08-not-a-date' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
+ Assert.Null(error.Exception);
+ }
+
+ [Fact]
+ public async Task BindModel_ReturnsFailed_IfValueProviderEmpty()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext(typeof(DateOnly));
+ var binder = GetBinder();
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result);
+ Assert.Empty(bindingContext.ModelState);
+ }
+
+ [Fact]
+ public async Task BindModel_NullableDateOnly_ReturnsFailed_IfValueProviderEmpty()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext(typeof(DateOnly?));
+ 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();
+ 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(DateOnly?));
+ 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]
+ [InlineData(typeof(DateOnly))]
+ [InlineData(typeof(DateOnly?))]
+ public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid(Type type)
+ {
+ // Arrange
+ var expected = new DateOnly(2019, 06, 14);
+ var bindingContext = GetBindingContext(type);
+ bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
+ {
+ { "theModelName", "2019-06-14" }
+ };
+ var binder = GetBinder();
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ var model = Assert.IsType(bindingContext.Result.Model);
+ Assert.Equal(expected, model);
+ Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
+ }
+
+ [Fact]
+ public async Task UsesSpecifiedStyleToParseModel()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext();
+ var expected = DateOnly.Parse("2019-06-14", CultureInfo.InvariantCulture);
+ bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
+ {
+ { "theModelName", " 2019-06-14" }
+ };
+ var binder = GetBinder(DateTimeStyles.AllowLeadingWhite);
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ var model = Assert.IsType(bindingContext.Result.Model);
+ Assert.Equal(expected, model);
+ Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
+ }
+
+ private IModelBinder GetBinder(DateTimeStyles? dateTimeStyles = null)
+ {
+ return new DateOnlyModelBinder(dateTimeStyles ?? DateOnlyModelBinderProvider.SupportedStyles, NullLoggerFactory.Instance);
+ }
+
+ private static DefaultModelBindingContext GetBindingContext(Type modelType = null)
+ {
+ modelType ??= typeof(DateOnly);
+ return new DefaultModelBindingContext
+ {
+ ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType),
+ ModelName = "theModelName",
+ ModelState = new ModelStateDictionary(),
+ ValueProvider = new SimpleValueProvider() // empty
+ };
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderProviderTest.cs
new file mode 100644
index 000000000000..6eaf5bd39215
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderProviderTest.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+public class TimeOnlyModelBinderProviderTest
+{
+ private readonly TimeOnlyModelBinderProvider _provider = new TimeOnlyModelBinderProvider();
+
+ [Theory]
+ [InlineData(typeof(string))]
+ [InlineData(typeof(DateTimeOffset))]
+ [InlineData(typeof(DateTimeOffset?))]
+ [InlineData(typeof(DateTime))]
+ [InlineData(typeof(DateTime?))]
+ [InlineData(typeof(TimeSpan))]
+ public void Create_ForNonDateTime_ReturnsNull(Type modelType)
+ {
+ // Arrange
+ var context = new TestModelBinderProviderContext(modelType);
+
+ // Act
+ var result = _provider.GetBinder(context);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Create_ForTimeOnly_ReturnsBinder()
+ {
+ // Arrange
+ var context = new TestModelBinderProviderContext(typeof(TimeOnly));
+
+ // Act
+ var result = _provider.GetBinder(context);
+
+ // Assert
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Create_ForNullableTimeOnly_ReturnsBinder()
+ {
+ // Arrange
+ var context = new TestModelBinderProviderContext(typeof(TimeOnly?));
+
+ // Act
+ var result = _provider.GetBinder(context);
+
+ // Assert
+ Assert.IsType(result);
+ }
+}
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderTest.cs
new file mode 100644
index 000000000000..eb2fab9cf7ed
--- /dev/null
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/TimeOnlyModelBinderTest.cs
@@ -0,0 +1,216 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Globalization;
+
+namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+
+public class TimeOnlyModelBinderTest
+{
+ [Fact]
+ public async Task BindModel_ReturnsFailure_IfAttemptedValueCannotBeParsed()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext();
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "theModelName", "some-value" }
+ };
+ var binder = GetBinder();
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.False(bindingContext.Result.IsModelSet);
+ }
+
+ [Fact]
+ public async Task BindModel_CreatesError_IfAttemptedValueCannotBeParsed()
+ {
+ // Arrange
+ var message = "The value 'not a time' is not valid.";
+ var bindingContext = GetBindingContext();
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "theModelName", "not a time" },
+ };
+ 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);
+ }
+
+ [Fact]
+ public async Task BindModel_CreatesError_IfAttemptedValueCannotBeCompletelyParsed()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext();
+ bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("en-GB"))
+ {
+ { "theModelName", "11:05:08-not-a-time" }
+ };
+ 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 '11:05:08-not-a-time' is not valid.", error.ErrorMessage, StringComparer.Ordinal);
+ Assert.Null(error.Exception);
+ }
+
+ [Fact]
+ public async Task BindModel_ReturnsFailed_IfValueProviderEmpty()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext(typeof(TimeOnly));
+ var binder = GetBinder();
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result);
+ Assert.Empty(bindingContext.ModelState);
+ }
+
+ [Fact]
+ public async Task BindModel_NullableTimeOnly_ReturnsFailed_IfValueProviderEmpty()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext(typeof(TimeOnly?));
+ 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();
+ 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(TimeOnly?));
+ 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]
+ [InlineData(typeof(TimeOnly))]
+ [InlineData(typeof(TimeOnly?))]
+ public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid(Type type)
+ {
+ // Arrange
+ var expected = new TimeOnly(2, 30, 4, 0);
+ var bindingContext = GetBindingContext(type);
+ bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
+ {
+ { "theModelName", "02:30:04.0000000" }
+ };
+ var binder = GetBinder();
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ var model = Assert.IsType(bindingContext.Result.Model);
+ Assert.Equal(expected, model);
+ Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
+ }
+
+ [Fact]
+ public async Task UsesSpecifiedStyleToParseModel()
+ {
+ // Arrange
+ var bindingContext = GetBindingContext();
+ var expected = TimeOnly.Parse("02:30:04.0000000", CultureInfo.InvariantCulture);
+ bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
+ {
+ { "theModelName", " 02:30:04.0000000" }
+ };
+ var binder = GetBinder(DateTimeStyles.AllowLeadingWhite);
+
+ // Act
+ await binder.BindModelAsync(bindingContext);
+
+ // Assert
+ Assert.True(bindingContext.Result.IsModelSet);
+ var model = Assert.IsType(bindingContext.Result.Model);
+ Assert.Equal(expected, model);
+ Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
+ }
+
+ private IModelBinder GetBinder(DateTimeStyles? dateTimeStyles = null)
+ {
+ return new TimeOnlyModelBinder(dateTimeStyles ?? TimeOnlyModelBinderProvider.SupportedStyles, NullLoggerFactory.Instance);
+ }
+
+ private static DefaultModelBindingContext GetBindingContext(Type modelType = null)
+ {
+ modelType ??= typeof(TimeOnly);
+ return new DefaultModelBindingContext
+ {
+ ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType),
+ ModelName = "theModelName",
+ ModelState = new ModelStateDictionary(),
+ ValueProvider = new SimpleValueProvider() // empty
+ };
+ }
+}
diff --git a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs
index 79c83b8ec421..cdb3a37f6072 100644
--- a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs
+++ b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs
@@ -62,7 +62,9 @@ public void Setup_SetsUpModelBinderProviders()
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));
}
[Fact]