Add support for model binding DateTime as UTC (#24893)

* Add support for model binding DateTime as UTC

Fixes https://github.com/dotnet/aspnetcore/issues/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
This commit is contained in:
Pranav K 2020-08-17 21:04:27 -07:00 committed by GitHub
parent 58a75925f7
commit 512a49c401
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 521 additions and 9 deletions

View File

@ -63,6 +63,7 @@ namespace Microsoft.AspNetCore.Mvc
options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider());
options.ModelBinderProviders.Add(new EnumTypeModelBinderProvider(options));
options.ModelBinderProviders.Add(new DateTimeModelBinderProvider());
options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider());
options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider());
options.ModelBinderProviders.Add(new ByteArrayModelBinderProvider());

View File

@ -0,0 +1,105 @@
// 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.Extensions.Logging;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
/// <summary>
/// An <see cref="IModelBinder"/> for <see cref="DateTime"/> and nullable <see cref="DateTime"/> models.
/// </summary>
public class DateTimeModelBinder : IModelBinder
{
private readonly DateTimeStyles _supportedStyles;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of <see cref="DateTimeModelBinder"/>.
/// </summary>
/// <param name="supportedStyles">The <see cref="DateTimeStyles"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public DateTimeModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory)
{
if (loggerFactory == null)
{
throw new ArgumentNullException(nameof(loggerFactory));
}
_supportedStyles = supportedStyles;
_logger = loggerFactory.CreateLogger<DateTimeModelBinder>();
}
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(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(DateTime))
{
model = DateTime.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;
}
}
}

View File

@ -0,0 +1,36 @@
// 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 Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
/// <summary>
/// An <see cref="IModelBinderProvider"/> for binding <see cref="DateTime" /> and nullable <see cref="DateTime"/> models.
/// </summary>
public class DateTimeModelBinderProvider : IModelBinderProvider
{
internal static readonly DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
/// <inheritdoc />
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var modelType = context.Metadata.UnderlyingOrModelType;
if (modelType == typeof(DateTime))
{
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
return new DateTimeModelBinder(SupportedStyles, loggerFactory);
}
return null;
}
}
}

View File

@ -63,7 +63,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
try
{
var value = valueProviderResult.FirstValue;
var culture = valueProviderResult.Culture;
object model;
if (string.IsNullOrWhiteSpace(value))
@ -73,7 +72,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
else if (type == typeof(decimal))
{
model = decimal.Parse(value, _supportedStyles, culture);
model = decimal.Parse(value, _supportedStyles, valueProviderResult.Culture);
}
else
{

View File

@ -63,7 +63,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
try
{
var value = valueProviderResult.FirstValue;
var culture = valueProviderResult.Culture;
object model;
if (string.IsNullOrWhiteSpace(value))
@ -73,7 +72,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
else if (type == typeof(double))
{
model = double.Parse(value, _supportedStyles, culture);
model = double.Parse(value, _supportedStyles, valueProviderResult.Culture);
}
else
{

View File

@ -63,7 +63,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
try
{
var value = valueProviderResult.FirstValue;
var culture = valueProviderResult.Culture;
object model;
if (string.IsNullOrWhiteSpace(value))
@ -73,7 +72,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
else if (type == typeof(float))
{
model = float.Parse(value, _supportedStyles, culture);
model = float.Parse(value, _supportedStyles, valueProviderResult.Culture);
}
else
{

View File

@ -46,6 +46,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
throw new ArgumentNullException(nameof(bindingContext));
}
_logger.AttemptingToBindModel(bindingContext);
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None)
{
@ -56,8 +58,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
return Task.CompletedTask;
}
_logger.AttemptingToBindModel(bindingContext);
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
try

View File

@ -463,6 +463,9 @@ Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexObjectModelBinderProvider.C
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinderProvider
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinderProvider.ComplexTypeModelBinderProvider() -> void
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinder
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider.DateTimeModelBinderProvider() -> void
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DecimalModelBinder
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DictionaryModelBinder<TKey, TValue>
Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DictionaryModelBinderProvider
@ -1464,6 +1467,9 @@ virtual Microsoft.AspNetCore.Mvc.ModelBinding.Validation.ValidationVisitor.Visit
~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
~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
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext bindingContext) -> System.Threading.Tasks.Task
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinder.DateTimeModelBinder(System.Globalization.DateTimeStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) -> void
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider.GetBinder(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBinderProviderContext context) -> Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DecimalModelBinder.BindModelAsync(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext bindingContext) -> System.Threading.Tasks.Task
~Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DecimalModelBinder.DecimalModelBinder(System.Globalization.NumberStyles supportedStyles, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) -> void
~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

View File

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class DateTimeModelBinderProviderTest
{
private readonly DateTimeModelBinderProvider _provider = new DateTimeModelBinderProvider();
[Theory]
[InlineData(typeof(string))]
[InlineData(typeof(DateTimeOffset))]
[InlineData(typeof(DateTimeOffset?))]
[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_ForDateTime_ReturnsBinder()
{
// Arrange
var context = new TestModelBinderProviderContext(typeof(DateTime));
// Act
var result = _provider.GetBinder(context);
// Assert
Assert.IsType<DateTimeModelBinder>(result);
}
[Fact]
public void Create_ForNullableDateTime_ReturnsBinder()
{
// Arrange
var context = new TestModelBinderProviderContext(typeof(DateTime?));
// Act
var result = _provider.GetBinder(context);
// Assert
Assert.IsType<DateTimeModelBinder>(result);
}
}
}

View File

@ -0,0 +1,222 @@
// 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.Extensions.Logging.Abstractions;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class DateTimeModelBinderTest
{
[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(DateTime));
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result);
Assert.Empty(bindingContext.ModelState);
}
[Fact]
public async Task BindModel_NullableDatetime_ReturnsFailed_IfValueProviderEmpty()
{
// Arrange
var bindingContext = GetBindingContext(typeof(DateTime?));
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(DateTime?));
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(DateTime))]
[InlineData(typeof(DateTime?))]
public async Task BindModel_ReturnsModel_IfAttemptedValueIsValid(Type type)
{
// Arrange
var expected = new DateTime(2019, 06, 14, 2, 30, 4, 0, DateTimeKind.Utc);
var bindingContext = GetBindingContext(type);
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
{
{ "theModelName", "2019-06-14T02:30:04.0000000Z" }
};
var binder = GetBinder();
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
var model = Assert.IsType<DateTime>(bindingContext.Result.Model);
Assert.Equal(expected, model);
Assert.Equal(DateTimeKind.Utc, model.Kind);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
[Fact]
public async Task UsesSpecifiedStyleToParseModel()
{
// Arrange
var bindingContext = GetBindingContext();
var expected = DateTime.Parse("2019-06-14T02:30:04.0000000Z");
bindingContext.ValueProvider = new SimpleValueProvider(new CultureInfo("fr-FR"))
{
{ "theModelName", "2019-06-14T02:30:04.0000000Z" }
};
var binder = GetBinder(DateTimeStyles.AssumeLocal);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
var model = Assert.IsType<DateTime>(bindingContext.Result.Model);
Assert.Equal(expected, model);
Assert.Equal(DateTimeKind.Local, model.Kind);
Assert.True(bindingContext.ModelState.ContainsKey("theModelName"));
}
private IModelBinder GetBinder(DateTimeStyles? dateTimeStyles = null)
{
return new DateTimeModelBinder(dateTimeStyles ?? DateTimeModelBinderProvider.SupportedStyles, NullLoggerFactory.Instance);
}
private static DefaultModelBindingContext GetBindingContext(Type modelType = null)
{
modelType ??= typeof(DateTime);
return new DefaultModelBindingContext
{
ModelMetadata = new EmptyModelMetadataProvider().GetMetadataForType(modelType),
ModelName = "theModelName",
ModelState = new ModelStateDictionary(),
ValueProvider = new SimpleValueProvider() // empty
};
}
}
}

View File

@ -194,7 +194,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
// Assert
Assert.Equal(ModelBindingResult.Failed(), bindingContext.Result);
Assert.Empty(bindingContext.ModelState);
Assert.Equal(2, sink.Writes.Count());
Assert.Equal(3, sink.Writes.Count());
}
[Theory]

View File

@ -58,6 +58,7 @@ namespace Microsoft.AspNetCore.Mvc
binder => Assert.IsType<HeaderModelBinderProvider>(binder),
binder => Assert.IsType<FloatingPointTypeModelBinderProvider>(binder),
binder => Assert.IsType<EnumTypeModelBinderProvider>(binder),
binder => Assert.IsType<DateTimeModelBinderProvider>(binder),
binder => Assert.IsType<SimpleTypeModelBinderProvider>(binder),
binder => Assert.IsType<CancellationTokenModelBinderProvider>(binder),
binder => Assert.IsType<ByteArrayModelBinderProvider>(binder),

View File

@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
@ -229,6 +231,91 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState);
}
[Fact]
[ReplaceCulture("en-GB", "en-GB")]
public async Task BindDateTimeParameter_WithData_GetsBound()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
ParameterType = typeof(DateTime),
BindingInfo = new BindingInfo(),
};
var testContext = ModelBindingTestHelper.GetTestContext(request =>
{
request.QueryString = QueryString.Create("Parameter1", "2020-02-01");
});
var modelState = testContext.ModelState;
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
// ModelBindingResult
Assert.True(modelBindingResult.IsModelSet);
// Model
var model = Assert.IsType<DateTime>(modelBindingResult.Model);
Assert.Equal(new DateTime(2020, 02, 01, 0, 0, 0, DateTimeKind.Utc), model);
// ModelState
Assert.True(modelState.IsValid);
Assert.Single(modelState.Keys);
var key = Assert.Single(modelState.Keys);
Assert.Equal("Parameter1", key);
Assert.Equal("2020-02-01", modelState[key].AttemptedValue);
Assert.Equal("2020-02-01", modelState[key].RawValue);
Assert.Empty(modelState[key].Errors);
Assert.Equal(ModelValidationState.Valid, modelState[key].ValidationState);
}
[Fact]
[ReplaceCulture("en-GB", "en-GB")]
public async Task BindDateTimeParameter_WithDataFromBody_GetsBound()
{
// Arrange
var input = "\"2020-02-01\"";
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
ParameterType = typeof(DateTime),
BindingInfo = new BindingInfo
{
BindingSource = BindingSource.Body,
}
};
var testContext = ModelBindingTestHelper.GetTestContext(request =>
{
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(input));
request.ContentType = "application/json";
});
var modelState = testContext.ModelState;
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
// ModelBindingResult
Assert.True(modelBindingResult.IsModelSet);
// Model
var model = Assert.IsType<DateTime>(modelBindingResult.Model);
Assert.Equal(new DateTime(2020, 02, 01, 0, 0, 0, DateTimeKind.Utc), model);
// ModelState
Assert.True(modelState.IsValid);
}
[Fact]
public async Task BindParameter_WithMultipleValues_GetsBoundToFirstValue()
{