Skip to content

Commit 512a49c

Browse files
authored
Add support for model binding DateTime as UTC (#24893)
* Add support for model binding DateTime as UTC Fixes #11584 * Make test work in other TZs * Changes per PR comments * Cleanup unused exception code path, fix doc comments * Clean up usage of variables * Adjust logging to be consistent * Apply suggestions from code review
1 parent 58a7592 commit 512a49c

13 files changed

+521
-9
lines changed

src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public void Configure(MvcOptions options)
6363
options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
6464
options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider());
6565
options.ModelBinderProviders.Add(new EnumTypeModelBinderProvider(options));
66+
options.ModelBinderProviders.Add(new DateTimeModelBinderProvider());
6667
options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider());
6768
options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider());
6869
options.ModelBinderProviders.Add(new ByteArrayModelBinderProvider());
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Globalization;
6+
using System.Threading.Tasks;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
10+
{
11+
/// <summary>
12+
/// An <see cref="IModelBinder"/> for <see cref="DateTime"/> and nullable <see cref="DateTime"/> models.
13+
/// </summary>
14+
public class DateTimeModelBinder : IModelBinder
15+
{
16+
private readonly DateTimeStyles _supportedStyles;
17+
private readonly ILogger _logger;
18+
19+
/// <summary>
20+
/// Initializes a new instance of <see cref="DateTimeModelBinder"/>.
21+
/// </summary>
22+
/// <param name="supportedStyles">The <see cref="DateTimeStyles"/>.</param>
23+
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
24+
public DateTimeModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory)
25+
{
26+
if (loggerFactory == null)
27+
{
28+
throw new ArgumentNullException(nameof(loggerFactory));
29+
}
30+
31+
_supportedStyles = supportedStyles;
32+
_logger = loggerFactory.CreateLogger<DateTimeModelBinder>();
33+
}
34+
35+
/// <inheritdoc />
36+
public Task BindModelAsync(ModelBindingContext bindingContext)
37+
{
38+
if (bindingContext == null)
39+
{
40+
throw new ArgumentNullException(nameof(bindingContext));
41+
}
42+
43+
_logger.AttemptingToBindModel(bindingContext);
44+
45+
var modelName = bindingContext.ModelName;
46+
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
47+
if (valueProviderResult == ValueProviderResult.None)
48+
{
49+
_logger.FoundNoValueInRequest(bindingContext);
50+
51+
// no entry
52+
_logger.DoneAttemptingToBindModel(bindingContext);
53+
return Task.CompletedTask;
54+
}
55+
56+
var modelState = bindingContext.ModelState;
57+
modelState.SetModelValue(modelName, valueProviderResult);
58+
59+
var metadata = bindingContext.ModelMetadata;
60+
var type = metadata.UnderlyingOrModelType;
61+
try
62+
{
63+
var value = valueProviderResult.FirstValue;
64+
65+
object model;
66+
if (string.IsNullOrWhiteSpace(value))
67+
{
68+
// Parse() method trims the value (with common DateTimeSyles) then throws if the result is empty.
69+
model = null;
70+
}
71+
else if (type == typeof(DateTime))
72+
{
73+
model = DateTime.Parse(value, valueProviderResult.Culture, _supportedStyles);
74+
}
75+
else
76+
{
77+
throw new NotSupportedException();
78+
}
79+
80+
// When converting value, a null model may indicate a failed conversion for an otherwise required
81+
// model (can't set a ValueType to null). This detects if a null model value is acceptable given the
82+
// current bindingContext. If not, an error is logged.
83+
if (model == null && !metadata.IsReferenceOrNullableType)
84+
{
85+
modelState.TryAddModelError(
86+
modelName,
87+
metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
88+
valueProviderResult.ToString()));
89+
}
90+
else
91+
{
92+
bindingContext.Result = ModelBindingResult.Success(model);
93+
}
94+
}
95+
catch (Exception exception)
96+
{
97+
// Conversion failed.
98+
modelState.TryAddModelError(modelName, exception, metadata);
99+
}
100+
101+
_logger.DoneAttemptingToBindModel(bindingContext);
102+
return Task.CompletedTask;
103+
}
104+
}
105+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Globalization;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
10+
{
11+
/// <summary>
12+
/// An <see cref="IModelBinderProvider"/> for binding <see cref="DateTime" /> and nullable <see cref="DateTime"/> models.
13+
/// </summary>
14+
public class DateTimeModelBinderProvider : IModelBinderProvider
15+
{
16+
internal static readonly DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
17+
18+
/// <inheritdoc />
19+
public IModelBinder GetBinder(ModelBinderProviderContext context)
20+
{
21+
if (context == null)
22+
{
23+
throw new ArgumentNullException(nameof(context));
24+
}
25+
26+
var modelType = context.Metadata.UnderlyingOrModelType;
27+
if (modelType == typeof(DateTime))
28+
{
29+
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
30+
return new DateTimeModelBinder(SupportedStyles, loggerFactory);
31+
}
32+
33+
return null;
34+
}
35+
}
36+
}

src/Mvc/Mvc.Core/src/ModelBinding/Binders/DecimalModelBinder.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
6363
try
6464
{
6565
var value = valueProviderResult.FirstValue;
66-
var culture = valueProviderResult.Culture;
6766

6867
object model;
6968
if (string.IsNullOrWhiteSpace(value))
@@ -73,7 +72,7 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
7372
}
7473
else if (type == typeof(decimal))
7574
{
76-
model = decimal.Parse(value, _supportedStyles, culture);
75+
model = decimal.Parse(value, _supportedStyles, valueProviderResult.Culture);
7776
}
7877
else
7978
{

src/Mvc/Mvc.Core/src/ModelBinding/Binders/DoubleModelBinder.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
6363
try
6464
{
6565
var value = valueProviderResult.FirstValue;
66-
var culture = valueProviderResult.Culture;
6766

6867
object model;
6968
if (string.IsNullOrWhiteSpace(value))
@@ -73,7 +72,7 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
7372
}
7473
else if (type == typeof(double))
7574
{
76-
model = double.Parse(value, _supportedStyles, culture);
75+
model = double.Parse(value, _supportedStyles, valueProviderResult.Culture);
7776
}
7877
else
7978
{

src/Mvc/Mvc.Core/src/ModelBinding/Binders/FloatModelBinder.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
6363
try
6464
{
6565
var value = valueProviderResult.FirstValue;
66-
var culture = valueProviderResult.Culture;
6766

6867
object model;
6968
if (string.IsNullOrWhiteSpace(value))
@@ -73,7 +72,7 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
7372
}
7473
else if (type == typeof(float))
7574
{
76-
model = float.Parse(value, _supportedStyles, culture);
75+
model = float.Parse(value, _supportedStyles, valueProviderResult.Culture);
7776
}
7877
else
7978
{

src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
4646
throw new ArgumentNullException(nameof(bindingContext));
4747
}
4848

49+
_logger.AttemptingToBindModel(bindingContext);
50+
4951
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
5052
if (valueProviderResult == ValueProviderResult.None)
5153
{
@@ -56,8 +58,6 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
5658
return Task.CompletedTask;
5759
}
5860

59-
_logger.AttemptingToBindModel(bindingContext);
60-
6161
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
6262

6363
try

src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,9 @@ Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexObjectModelBinderProvider.C
463463
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder
464464
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinderProvider
465465
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinderProvider.ComplexTypeModelBinderProvider() -> void
466+
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinder
467+
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider
468+
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider.DateTimeModelBinderProvider() -> void
466469
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DecimalModelBinder
467470
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DictionaryModelBinder<TKey, TValue>
468471
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DictionaryModelBinderProvider
@@ -1464,6 +1467,9 @@ virtual Microsoft.AspNetCore.Mvc.ModelBinding.Validation.ValidationVisitor.Visit
14641467
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder.ComplexTypeModelBinder(System.Collections.Generic.IDictionary<Microsoft.AspNetCore.Mvc.ModelBinding.ModelMetadata, Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder> propertyBinders, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) -> void
14651468
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder.ComplexTypeModelBinder(System.Collections.Generic.IDictionary<Microsoft.AspNetCore.Mvc.ModelBinding.ModelMetadata, Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder> propertyBinders, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, bool allowValidatingTopLevelNodes) -> void
14661469
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder
1470+
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext bindingContext) -> System.Threading.Tasks.Task
1471+
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinder.DateTimeModelBinder(System.Globalization.DateTimeStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) -> void
1472+
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder
14671473
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DecimalModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext bindingContext) -> System.Threading.Tasks.Task
14681474
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DecimalModelBinder.DecimalModelBinder(System.Globalization.NumberStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) -> void
14691475
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DictionaryModelBinder<TKey, TValue>.DictionaryModelBinder(Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder keyBinder, Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder valueBinder, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) -> void
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Xunit;
7+
8+
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
9+
{
10+
public class DateTimeModelBinderProviderTest
11+
{
12+
private readonly DateTimeModelBinderProvider _provider = new DateTimeModelBinderProvider();
13+
14+
[Theory]
15+
[InlineData(typeof(string))]
16+
[InlineData(typeof(DateTimeOffset))]
17+
[InlineData(typeof(DateTimeOffset?))]
18+
[InlineData(typeof(TimeSpan))]
19+
public void Create_ForNonDateTime_ReturnsNull(Type modelType)
20+
{
21+
// Arrange
22+
var context = new TestModelBinderProviderContext(modelType);
23+
24+
// Act
25+
var result = _provider.GetBinder(context);
26+
27+
// Assert
28+
Assert.Null(result);
29+
}
30+
31+
[Fact]
32+
public void Create_ForDateTime_ReturnsBinder()
33+
{
34+
// Arrange
35+
var context = new TestModelBinderProviderContext(typeof(DateTime));
36+
37+
// Act
38+
var result = _provider.GetBinder(context);
39+
40+
// Assert
41+
Assert.IsType<DateTimeModelBinder>(result);
42+
}
43+
44+
[Fact]
45+
public void Create_ForNullableDateTime_ReturnsBinder()
46+
{
47+
// Arrange
48+
var context = new TestModelBinderProviderContext(typeof(DateTime?));
49+
50+
// Act
51+
var result = _provider.GetBinder(context);
52+
53+
// Assert
54+
Assert.IsType<DateTimeModelBinder>(result);
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)