[Fixes #5859] Consider allowing binding header values to types other than string and string collections

This commit is contained in:
Kiran Challa 2018-01-23 05:21:42 -08:00
parent 19c89db48b
commit 0215740183
14 changed files with 1085 additions and 181 deletions

View File

@ -17,6 +17,19 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
/// <returns>An <see cref="IModelBinder"/>.</returns>
public abstract IModelBinder CreateBinder(ModelMetadata metadata);
/// <summary>
/// Creates an <see cref="IModelBinder"/> for the given <paramref name="metadata"/>
/// and <paramref name="bindingInfo"/>.
/// </summary>
/// <param name="metadata">The <see cref="ModelMetadata"/> for the model.</param>
/// <param name="bindingInfo">The <see cref="BindingInfo"/> that should be used
/// for creating the binder.</param>
/// <returns>An <see cref="IModelBinder"/>.</returns>
public virtual IModelBinder CreateBinder(ModelMetadata metadata, BindingInfo bindingInfo)
{
throw new NotSupportedException();
}
/// <summary>
/// Gets the <see cref="BindingInfo"/>.
/// </summary>

View File

@ -26,6 +26,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
if (Version >= CompatibilityVersion.Version_2_1)
{
values[nameof(MvcOptions.AllowCombiningAuthorizeFilters)] = true;
values[nameof(MvcOptions.AllowBindingHeaderValuesToNonStringModelTypes)] = true;
values[nameof(MvcOptions.InputFormatterExceptionPolicy)] = InputFormatterExceptionPolicy.MalformedInputExceptions;
values[nameof(MvcOptions.SuppressBindingUndefinedValueToEnumType)] = true;
}

View File

@ -107,6 +107,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
private static readonly Action<ILogger, Type, Exception> _cannotBindToComplexType;
private static readonly Action<ILogger, string, Type, Exception> _cannotBindToFilesCollectionDueToUnsupportedContentType;
private static readonly Action<ILogger, Type, Exception> _cannotCreateHeaderModelBinder;
private static readonly Action<ILogger, Type, Exception> _cannotCreateHeaderModelBinderCompatVersion_2_0;
private static readonly Action<ILogger, Exception> _noFilesFoundInRequest;
private static readonly Action<ILogger, string, string, Exception> _noNonIndexBasedFormatFoundForCollection;
private static readonly Action<ILogger, string, string, string, string, string, string, Exception> _attemptingToBindCollectionUsingIndices;
@ -490,7 +491,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
_cannotCreateHeaderModelBinder = LoggerMessage.Define<Type>(
LogLevel.Debug,
20,
"Could not create a binder for type '{ModelType}' as this binder only supports 'System.String' type or a collection of 'System.String'.");
"Could not create a binder for type '{ModelType}' as this binder only supports simple types (like string, int, bool, enum) or a collection of simple types.");
_noFilesFoundInRequest = LoggerMessage.Define(
LogLevel.Debug,
@ -597,6 +598,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal
LogLevel.Debug,
42,
"Done attempting to validate the bound property '{PropertyContainerType}.{PropertyName}' of type '{ModelType}'.");
_cannotCreateHeaderModelBinderCompatVersion_2_0 = LoggerMessage.Define<Type>(
LogLevel.Debug,
43,
"Could not create a binder for type '{ModelType}' as this binder only supports 'System.String' type or a collection of 'System.String'.");
}
public static void RegisteredOutputFormatters(this ILogger logger, IEnumerable<IOutputFormatter> outputFormatters)
@ -1169,6 +1175,11 @@ namespace Microsoft.AspNetCore.Mvc.Internal
_cannotBindToFilesCollectionDueToUnsupportedContentType(logger, bindingContext.ModelName, bindingContext.ModelType, null);
}
public static void CannotCreateHeaderModelBinderCompatVersion_2_0(this ILogger logger, Type modelType)
{
_cannotCreateHeaderModelBinderCompatVersion_2_0(logger, modelType, null);
}
public static void CannotCreateHeaderModelBinder(this ILogger logger, Type modelType)
{
_cannotCreateHeaderModelBinder(logger, modelType, null);

View File

@ -2,6 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Internal;
@ -21,11 +23,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
/// <summary>
/// <para>This constructor is obsolete and will be removed in a future version. The recommended alternative
/// is the overload that takes an <see cref="ILoggerFactory"/>.</para>
/// is the overload that takes an <see cref="ILoggerFactory"/> and an <see cref="IModelBinder"/>.</para>
/// <para>Initializes a new instance of <see cref="HeaderModelBinder"/>.</para>
/// </summary>
[Obsolete("This constructor is obsolete and will be removed in a future version. The recommended alternative"
+ " is the overload that takes an " + nameof(ILoggerFactory) + ".")]
+ " is the overload that takes an " + nameof(ILoggerFactory) + " and an " + nameof(IModelBinder) + ".")]
public HeaderModelBinder()
: this(NullLoggerFactory.Instance)
{
@ -36,35 +38,117 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public HeaderModelBinder(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<HeaderModelBinder>();
}
/// <summary>
/// Initializes a new instance of <see cref="HeaderModelBinder"/>.
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="innerModelBinder">The <see cref="IModelBinder"/> which does the actual
/// binding of values.</param>
public HeaderModelBinder(ILoggerFactory loggerFactory, IModelBinder innerModelBinder)
{
if (loggerFactory == null)
{
throw new ArgumentNullException(nameof(loggerFactory));
}
if (innerModelBinder == null)
{
throw new ArgumentNullException(nameof(innerModelBinder));
}
_logger = loggerFactory.CreateLogger<HeaderModelBinder>();
InnerModelBinder = innerModelBinder;
}
// to enable unit testing
internal IModelBinder InnerModelBinder { get; }
/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var request = bindingContext.HttpContext.Request;
_logger.AttemptingToBindModel(bindingContext);
// Property name can be null if the model metadata represents a type (rather than a property or parameter).
var headerName = bindingContext.FieldName;
_logger.AttemptingToBindModel(bindingContext);
// Do not set ModelBindingResult to Failed on not finding the value in the header as we want the inner
// modelbinder to do that. This would give a chance to the inner binder to add more useful information.
// For example, SimpleTypeModelBinder adds a model error when binding to let's say an integer and the
// model is null.
var request = bindingContext.HttpContext.Request;
if (!request.Headers.ContainsKey(headerName))
{
_logger.FoundNoValueInRequest(bindingContext);
}
if (InnerModelBinder == null)
{
BindWithoutInnerBinder(bindingContext);
return;
}
var headerValueProvider = GetHeaderValueProvider(headerName, bindingContext);
// Capture the top level object here as entering nested scope would make it 'false'.
var isTopLevelObject = bindingContext.IsTopLevelObject;
// Create a new binding scope in order to supply the HeaderValueProvider so that the binders like
// SimpleTypeModelBinder can find values from header.
ModelBindingResult result;
using (bindingContext.EnterNestedScope(
bindingContext.ModelMetadata,
fieldName: bindingContext.FieldName,
modelName: bindingContext.ModelName,
model: bindingContext.Model))
{
bindingContext.IsTopLevelObject = isTopLevelObject;
bindingContext.ValueProvider = headerValueProvider;
await InnerModelBinder.BindModelAsync(bindingContext);
result = bindingContext.Result;
}
bindingContext.Result = result;
_logger.DoneAttemptingToBindModel(bindingContext);
}
private HeaderValueProvider GetHeaderValueProvider(string headerName, ModelBindingContext bindingContext)
{
var request = bindingContext.HttpContext.Request;
// Prevent breaking existing users in scenarios where they are binding to a 'string' property
// and expect the whole comma separated string, if any, as a single string and not as a string array.
var values = Array.Empty<string>();
if (request.Headers.ContainsKey(headerName))
{
if (bindingContext.ModelMetadata.IsEnumerableType)
{
values = request.Headers.GetCommaSeparatedValues(headerName);
}
else
{
values = new[] { (string)request.Headers[headerName] };
}
}
return new HeaderValueProvider(values);
}
private void BindWithoutInnerBinder(ModelBindingContext bindingContext)
{
var headerName = bindingContext.FieldName;
var request = bindingContext.HttpContext.Request;
object model;
if (bindingContext.ModelType == typeof(string))
{
@ -101,7 +185,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
_logger.DoneAttemptingToBindModel(bindingContext);
return Task.CompletedTask;
}
private static object GetCompatibleCollection(ModelBindingContext bindingContext, string[] values)
@ -126,5 +209,34 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
return collection;
}
private class HeaderValueProvider : IValueProvider
{
private readonly string[] _values;
public HeaderValueProvider(string[] values)
{
Debug.Assert(values != null);
_values = values;
}
public bool ContainsPrefix(string prefix)
{
return _values.Length != 0;
}
public ValueProviderResult GetValue(string key)
{
if (_values.Length == 0)
{
return ValueProviderResult.None;
}
else
{
return new ValueProviderResult(_values, CultureInfo.InvariantCulture);
}
}
}
}
}

View File

@ -5,6 +5,7 @@ using System;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
@ -21,26 +22,65 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
throw new ArgumentNullException(nameof(context));
}
if (context.BindingInfo.BindingSource != null &&
context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Header))
var bindingInfo = context.BindingInfo;
if (bindingInfo.BindingSource == null ||
!bindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Header))
{
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<HeaderModelBinderProvider>();
return null;
}
// We only support strings and collections of strings. Some cases can fail
// at runtime due to collections we can't modify.
if (context.Metadata.ModelType == typeof(string) ||
context.Metadata.ElementType == typeof(string))
var modelMetadata = context.Metadata;
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<HeaderModelBinderProvider>();
var options = context.Services.GetRequiredService<IOptions<MvcOptions>>().Value;
if (!options.AllowBindingHeaderValuesToNonStringModelTypes)
{
if (modelMetadata.ModelType == typeof(string) ||
modelMetadata.ElementType == typeof(string))
{
return new HeaderModelBinder(loggerFactory);
}
else
{
logger.CannotCreateHeaderModelBinder(context.Metadata.ModelType);
logger.CannotCreateHeaderModelBinderCompatVersion_2_0(modelMetadata.ModelType);
}
return null;
}
return null;
if (!IsSimpleType(modelMetadata))
{
logger.CannotCreateHeaderModelBinder(modelMetadata.ModelType);
return null;
}
// Since we are delegating the binding of the current model type to other binders, modify the
// binding source of the current model type to a non-FromHeader binding source in order to avoid an
// infinite recursion into this binder provider.
var nestedBindingInfo = new BindingInfo(bindingInfo)
{
BindingSource = BindingSource.ModelBinding
};
var innerModelBinder = context.CreateBinder(
modelMetadata.GetMetadataForType(modelMetadata.ModelType),
nestedBindingInfo);
if (innerModelBinder == null)
{
return null;
}
return new HeaderModelBinder(loggerFactory, innerModelBinder);
}
// Support binding only to simple types or collection of simple types.
private bool IsSimpleType(ModelMetadata modelMetadata)
{
var metadata = modelMetadata.ElementMetadata ?? modelMetadata;
return !metadata.IsComplexType;
}
}
}

View File

@ -252,21 +252,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
private DefaultModelBinderProviderContext(
DefaultModelBinderProviderContext parent,
ModelMetadata metadata)
ModelMetadata metadata,
BindingInfo bindingInfo)
{
Metadata = metadata;
_factory = parent._factory;
MetadataProvider = parent.MetadataProvider;
Visited = parent.Visited;
BindingInfo = new BindingInfo()
{
BinderModelName = metadata.BinderModelName,
BinderType = metadata.BinderType,
BindingSource = metadata.BindingSource,
PropertyFilterProvider = metadata.PropertyFilterProvider,
};
BindingInfo = bindingInfo;
}
public override BindingInfo BindingInfo { get; }
@ -280,18 +274,36 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
public override IServiceProvider Services => _factory._serviceProvider;
public override IModelBinder CreateBinder(ModelMetadata metadata)
{
return CreateBinder(
metadata,
new BindingInfo()
{
BinderModelName = metadata.BinderModelName,
BinderType = metadata.BinderType,
BindingSource = metadata.BindingSource,
PropertyFilterProvider = metadata.PropertyFilterProvider,
});
}
public override IModelBinder CreateBinder(ModelMetadata metadata, BindingInfo bindingInfo)
{
if (metadata == null)
{
throw new ArgumentNullException(nameof(metadata));
}
if (bindingInfo == null)
{
throw new ArgumentNullException(nameof(bindingInfo));
}
// For non-root nodes we use the ModelMetadata as the cache token. This ensures that all non-root
// nodes with the same metadata will have the same binder. This is OK because for an non-root
// node there's no opportunity to customize binding info like there is for a parameter.
var token = metadata;
var nestedContext = new DefaultModelBinderProviderContext(this, metadata);
var nestedContext = new DefaultModelBinderProviderContext(this, metadata, bindingInfo);
return _factory.CreateBinderCoreCached(nestedContext, token);
}
}

View File

@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
@ -24,6 +25,7 @@ namespace Microsoft.AspNetCore.Mvc
private int _maxModelStateErrors = ModelStateDictionary.DefaultMaxAllowedErrors;
// See CompatibilitySwitch.cs for guide on how to implement these.
private readonly CompatibilitySwitch<bool> _allowBindingHeaderValuesToNonStringModelTypes;
private readonly CompatibilitySwitch<bool> _allowCombiningAuthorizeFilters;
private readonly CompatibilitySwitch<InputFormatterExceptionPolicy> _inputFormatterExceptionPolicy;
private readonly CompatibilitySwitch<bool> _suppressBindingUndefinedValueToEnumType;
@ -47,12 +49,14 @@ namespace Microsoft.AspNetCore.Mvc
ValueProviderFactories = new List<IValueProviderFactory>();
_allowCombiningAuthorizeFilters = new CompatibilitySwitch<bool>(nameof(AllowCombiningAuthorizeFilters));
_allowBindingHeaderValuesToNonStringModelTypes = new CompatibilitySwitch<bool>(nameof(AllowBindingHeaderValuesToNonStringModelTypes));
_inputFormatterExceptionPolicy = new CompatibilitySwitch<InputFormatterExceptionPolicy>(nameof(InputFormatterExceptionPolicy), InputFormatterExceptionPolicy.AllExceptions);
_suppressBindingUndefinedValueToEnumType = new CompatibilitySwitch<bool>(nameof(SuppressBindingUndefinedValueToEnumType));
_switches = new ICompatibilitySwitch[]
{
_allowCombiningAuthorizeFilters,
_allowCombiningAuthorizeFilters,
_allowBindingHeaderValuesToNonStringModelTypes,
_inputFormatterExceptionPolicy,
_suppressBindingUndefinedValueToEnumType,
};
@ -108,6 +112,38 @@ namespace Microsoft.AspNetCore.Mvc
set => _allowCombiningAuthorizeFilters.Value = value;
}
/// <summary>
/// Gets or sets a value that determines if <see cref="HeaderModelBinder"/> should bind to types other than
/// <see cref="String"/> or a collection of <see cref="String"/>. If set to <c>true</c>,
/// <see cref="HeaderModelBinder"/> would bind to simple types (like <see cref="String"/>, <see cref="Int32"/>,
/// <see cref="Enum"/>, <see cref="Boolean"/> etc.) or a collection of simple types. The default value of the
/// property is <c>false</c>.
/// </summary>
/// <remarks>
/// <para>
/// This property is associated with a compatibility switch and can provide a different behavior depending on
/// the configured compatibility version for the application. See <see cref="CompatibilityVersion"/> for
/// guidance and examples of setting the application's compatibility version.
/// </para>
/// <para>
/// Configuring the desired value of the compatibility switch by calling this property's setter will take precedence
/// over the value implied by the application's <see cref="CompatibilityVersion"/>.
/// </para>
/// <para>
/// If the application's compatibility version is set to <see cref="CompatibilityVersion.Version_2_0"/> then
/// this setting will have the value <c>false</c> unless explicitly configured.
/// </para>
/// <para>
/// If the application's compatibility version is set to <see cref="CompatibilityVersion.Version_2_1"/> or
/// higher then this setting will have the value <c>true</c> unless explicitly configured.
/// </para>
/// </remarks>
public bool AllowBindingHeaderValuesToNonStringModelTypes
{
get => _allowBindingHeaderValuesToNonStringModelTypes.Value;
set => _allowBindingHeaderValuesToNonStringModelTypes.Value = value;
}
/// <summary>
/// Gets a Dictionary of CacheProfile Names, <see cref="CacheProfile"/> which are pre-defined settings for
/// response caching.

View File

@ -4,12 +4,65 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class HeaderModelBinderProviderTest
{
[Theory]
[InlineData(typeof(string))]
[InlineData(typeof(string[]))]
[InlineData(typeof(List<string>))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsBinder_ForStringTypes_And_CompatVersion_2_0(
Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(
modelType,
allowBindingHeaderValuesToNonStringModelTypes: false);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
Assert.IsType<HeaderModelBinder>(result);
}
[Theory]
[InlineData(typeof(int))]
[InlineData(typeof(int?))]
[InlineData(typeof(IEnumerable<int>))]
[InlineData(typeof(double))]
[InlineData(typeof(double?))]
[InlineData(typeof(IEnumerable<double>))]
[InlineData(typeof(CarEnumType))]
[InlineData(typeof(CarEnumType?))]
[InlineData(typeof(IEnumerable<CarEnumType>))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsNull_ForNonStringTypes_And_CompatVersion_2_0(
Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var context = GetTestModelBinderProviderContext(
modelType,
allowBindingHeaderValuesToNonStringModelTypes: false);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
Assert.Null(result);
}
public static TheoryData<BindingSource> NonHeaderBindingSources
{
get
@ -29,8 +82,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
// Arrange
var provider = new HeaderModelBinderProvider();
var context = new TestModelBinderProviderContext(typeof(string));
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(typeof(string));
context.OnCreatingBinder(modelMetadata => testBinder);
context.BindingInfo.BindingSource = source;
// Act
@ -40,52 +94,91 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.Null(result);
}
[Fact]
public void Create_WhenBindingSourceIsFromHeader_ReturnsBinder()
{
// Arrange
var provider = new HeaderModelBinderProvider();
var context = new TestModelBinderProviderContext(typeof(string));
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
Assert.IsType<HeaderModelBinder>(result);
}
[Theory]
[InlineData(typeof(string))]
[InlineData(typeof(IEnumerable<string>))]
[InlineData(typeof(string[]))]
[InlineData(typeof(Collection<string>))]
public void Create_WhenModelTypeIsSupportedType_ReturnsBinder(Type modelType)
[InlineData(typeof(bool))]
[InlineData(typeof(int))]
[InlineData(typeof(DateTime))]
[InlineData(typeof(double))]
[InlineData(typeof(CarEnumType))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsBinder_ForSimpleTypes(Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var context = new TestModelBinderProviderContext(modelType);
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(modelType);
context.OnCreatingBinder(modelMetadata => testBinder);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
Assert.IsType<HeaderModelBinder>(result);
var headerModelBinder = Assert.IsType<HeaderModelBinder>(result);
Assert.Same(testBinder, headerModelBinder.InnerModelBinder);
}
[Theory]
[InlineData(typeof(Dictionary<int, string>))]
[InlineData(typeof(Collection<int>))]
[InlineData(typeof(Person))]
public void Create_WhenModelTypeIsUnsupportedType_ReturnsNull(Type modelType)
[InlineData(typeof(bool?))]
[InlineData(typeof(int?))]
[InlineData(typeof(DateTime?))]
[InlineData(typeof(double?))]
[InlineData(typeof(CarEnumType?))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsBinder_ForNullableSimpleTypes(Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(modelType);
context.OnCreatingBinder(modelMetadata => testBinder);
context.BindingInfo.BindingSource = BindingSource.Header;
var context = new TestModelBinderProviderContext(modelType);
// Act
var result = provider.GetBinder(context);
// Assert
var headerModelBinder = Assert.IsType<HeaderModelBinder>(result);
Assert.Same(testBinder, headerModelBinder.InnerModelBinder);
}
[Theory]
[InlineData(typeof(string[]))]
[InlineData(typeof(IEnumerable<bool>))]
[InlineData(typeof(List<byte>))]
[InlineData(typeof(Collection<short>))]
[InlineData(typeof(float[]))]
[InlineData(typeof(IEnumerable<decimal>))]
[InlineData(typeof(List<double>))]
[InlineData(typeof(ICollection<CarEnumType>))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsBinder_ForCollectionOfSimpleTypes(Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(modelType);
context.OnCreatingBinder(modelMetadata => testBinder);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
var headerModelBinder = Assert.IsType<HeaderModelBinder>(result);
Assert.Same(testBinder, headerModelBinder.InnerModelBinder);
}
[Theory]
[InlineData(typeof(CustomerStruct))]
[InlineData(typeof(IEnumerable<CustomerStruct>))]
[InlineData(typeof(Person))]
[InlineData(typeof(IEnumerable<Person>))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsNull_ForNonSimpleModelType(Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(modelType);
context.OnCreatingBinder(modelMetadata => testBinder);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
@ -95,11 +188,89 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.Null(result);
}
[Theory]
[InlineData(typeof(ProductWithTypeConverter))]
[InlineData(typeof(IEnumerable<ProductWithTypeConverter>))]
[InlineData(typeof(CustomerStructWithTypeConverter))]
[InlineData(typeof(IEnumerable<CustomerStructWithTypeConverter>))]
public void Create_WhenBindingSourceIsFromHeader_ReturnsBinder_ForNonSimpleModelType_HavingTypeConverter(
Type modelType)
{
// Arrange
var provider = new HeaderModelBinderProvider();
var testBinder = Mock.Of<IModelBinder>();
var context = GetTestModelBinderProviderContext(modelType);
context.OnCreatingBinder(modelMetadata => testBinder);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
var headerModelBinder = Assert.IsType<HeaderModelBinder>(result);
Assert.Same(testBinder, headerModelBinder.InnerModelBinder);
}
[Fact]
public void Create_WhenBindingSourceIsFromHeader_NoInnerBinderAvailable_ReturnsNull()
{
// Arrange
var provider = new HeaderModelBinderProvider();
var context = GetTestModelBinderProviderContext(typeof(string));
context.OnCreatingBinder(modelMetadata => null);
context.BindingInfo.BindingSource = BindingSource.Header;
// Act
var result = provider.GetBinder(context);
// Assert
Assert.Null(result);
}
private TestModelBinderProviderContext GetTestModelBinderProviderContext(
Type modelType,
bool allowBindingHeaderValuesToNonStringModelTypes = true)
{
var context = new TestModelBinderProviderContext(modelType);
var options = context.Services.GetRequiredService<IOptions<MvcOptions>>().Value;
options.AllowBindingHeaderValuesToNonStringModelTypes = allowBindingHeaderValuesToNonStringModelTypes;
return context;
}
private enum CarEnumType
{
Sedan,
Coupe
}
private struct CustomerStruct
{
public string Name { get; set; }
}
[TypeConverter(typeof(CanConvertFromStringConverter))]
private struct CustomerStructWithTypeConverter
{
public string Name { get; set; }
}
private class Person
{
public string Name { get; set; }
}
public int Age { get; set; }
[TypeConverter(typeof(CanConvertFromStringConverter))]
private class ProductWithTypeConverter
{
public string Name { get; set; }
}
private class CanConvertFromStringConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string);
}
}
}
}

View File

@ -3,44 +3,46 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
public class HeaderModelBinderTests
{
[Theory]
[InlineData(typeof(string))]
[InlineData(typeof(string[]))]
[InlineData(typeof(object))]
[InlineData(typeof(int))]
[InlineData(typeof(int[]))]
[InlineData(typeof(BindingSource))]
public async Task BindModelAsync_ReturnsNonEmptyResult_ForAllTypes_WithHeaderBindingSource(Type type)
public static TheoryData<HeaderModelBinder> HeaderModelBinderWithoutInnerBinderData
{
// Arrange
var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
var bindingContext = GetBindingContext(type);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
get
{
var data = new TheoryData<HeaderModelBinder>();
#pragma warning disable CS0618
data.Add(new HeaderModelBinder());
#pragma warning restore CS0618
data.Add(new HeaderModelBinder(NullLoggerFactory.Instance));
return data;
}
}
[Fact]
public async Task HeaderBinder_BindsHeaders_ToStringCollection()
[Theory]
[MemberData(nameof(HeaderModelBinderWithoutInnerBinderData))]
public async Task HeaderBinder_BindsHeaders_ToStringCollection_WithoutInnerModelBinder(
HeaderModelBinder binder)
{
// Arrange
var type = typeof(string[]);
var header = "Accept";
var headerValue = "application/json,text/json";
var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
var bindingContext = GetBindingContext(type);
var bindingContext = CreateContext(type);
bindingContext.FieldName = header;
bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });
@ -53,15 +55,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.Equal(headerValue.Split(','), bindingContext.Result.Model);
}
[Fact]
public async Task HeaderBinder_BindsHeaders_ToStringType()
[Theory]
[MemberData(nameof(HeaderModelBinderWithoutInnerBinderData))]
public async Task HeaderBinder_BindsHeaders_ToStringType_WithoutInnerModelBinder(HeaderModelBinder binder)
{
// Arrange
var type = typeof(string);
var header = "User-Agent";
var headerValue = "UnitTest";
var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
var bindingContext = GetBindingContext(type);
var bindingContext = CreateContext(type);
bindingContext.FieldName = header;
bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });
@ -81,13 +83,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
[InlineData(typeof(List<string>))]
[InlineData(typeof(LinkedList<string>))]
[InlineData(typeof(StringList))]
public async Task HeaderBinder_BindsHeaders_ForCollectionsItCanCreate(Type destinationType)
public async Task HeaderBinder_BindsHeaders_ForCollectionsItCanCreate_WithoutInnerModelBinder(
Type destinationType)
{
// Arrange
var header = "Accept";
var headerValue = "application/json,text/json";
var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
var bindingContext = GetBindingContext(destinationType);
#pragma warning disable CS0618
var binder = new HeaderModelBinder();
#pragma warning restore CS0618
var bindingContext = CreateContext(destinationType);
bindingContext.FieldName = header;
bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });
@ -101,17 +106,146 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.Equal(headerValue.Split(','), bindingContext.Result.Model as IEnumerable<string>);
}
[Fact]
public async Task HeaderBinder_BindsHeaders_ToStringCollection()
{
// Arrange
var type = typeof(string[]);
var headerValue = "application/json,text/json";
var bindingContext = CreateContext(type);
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", new[] { headerValue });
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(headerValue.Split(','), bindingContext.Result.Model);
}
public static TheoryData<string, Type, object> BinderHeaderToSimpleTypesData
{
get
{
var guid = new Guid("3916A5B1-5FE4-4E09-9812-5CDC127FA5B1");
return new TheoryData<string, Type, object>()
{
{ "10", typeof(int), 10 },
{ "10.50", typeof(double), 10.50 },
{ "10.50", typeof(IEnumerable<double>), new List<double>() { 10.50 } },
{ "Sedan", typeof(CarType), CarType.Sedan },
{ "", typeof(CarType?), null },
{ "", typeof(string[]), Array.Empty<string>() },
{ null, typeof(string[]), Array.Empty<string>() },
{ "", typeof(IEnumerable<string>), new List<string>() },
{ null, typeof(IEnumerable<string>), new List<string>() },
{ guid.ToString(), typeof(Guid), guid },
{ "foo", typeof(string), "foo" },
{ "foo, bar", typeof(string), "foo, bar" },
{ "foo, bar", typeof(string[]), new[]{ "foo", "bar" } },
{ "foo, \"bar\"", typeof(string[]), new[]{ "foo", "bar" } },
{ "\"foo,bar\"", typeof(string[]), new[]{ "foo,bar" } }
};
}
}
[Theory]
[MemberData(nameof(BinderHeaderToSimpleTypesData))]
public async Task HeaderBinder_BindsHeaders_ToSimpleTypes(
string headerValue,
Type modelType,
object expectedModel)
{
// Arrange
var bindingContext = CreateContext(modelType);
var binder = CreateBinder(bindingContext);
if (headerValue != null)
{
bindingContext.HttpContext.Request.Headers.Add("Header", headerValue);
}
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal(expectedModel, bindingContext.Result.Model);
}
[Theory]
[InlineData(typeof(CarType?), null)]
[InlineData(typeof(int?), null)]
public async Task HeaderBinder_DoesNotSetModel_ForHeaderNotPresentOnRequest(
Type modelType,
object expectedModel)
{
// Arrange
var bindingContext = CreateContext(modelType);
var binder = CreateBinder(bindingContext);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
}
[Theory]
[InlineData(typeof(string[]), null)]
[InlineData(typeof(IEnumerable<string>), null)]
public async Task HeaderBinder_DoesNotCreateEmptyCollection_ForNonTopLevelObjects(
Type modelType,
object expectedModel)
{
// Arrange
var bindingContext = CreateContext(modelType);
bindingContext.IsTopLevelObject = false;
var binder = CreateBinder(bindingContext);
// No header on the request that the header value provider is looking for
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
}
[Theory]
[InlineData(typeof(IEnumerable<string>))]
[InlineData(typeof(ICollection<string>))]
[InlineData(typeof(IList<string>))]
[InlineData(typeof(List<string>))]
[InlineData(typeof(LinkedList<string>))]
[InlineData(typeof(StringList))]
public async Task HeaderBinder_BindsHeaders_ForCollectionsItCanCreate(Type destinationType)
{
// Arrange
var headerValue = "application/json,text/json";
var bindingContext = CreateContext(destinationType);
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", new[] { headerValue });
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.IsAssignableFrom(destinationType, bindingContext.Result.Model);
Assert.Equal(headerValue.Split(','), bindingContext.Result.Model as IEnumerable<string>);
}
[Fact]
public async Task HeaderBinder_ReturnsResult_ForReadOnlyDestination()
{
// Arrange
var header = "Accept";
var headerValue = "application/json,text/json";
var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
var bindingContext = GetBindingContextForReadOnlyArray();
bindingContext.FieldName = header;
bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });
var bindingContext = CreateContext(GetMetadataForReadOnlyArray());
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", "application/json,text/json");
// Act
await binder.BindModelAsync(bindingContext);
@ -122,66 +256,186 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
}
[Fact]
public async Task HeaderBinder_ReturnsFailedResult_ForCollectionsItCannotCreate()
public async Task HeaderBinder_ResetsTheBindingScope_GivingOriginalValueProvider()
{
// Arrange
var header = "Accept";
var headerValue = "application/json,text/json";
var binder = new HeaderModelBinder(NullLoggerFactory.Instance);
var bindingContext = GetBindingContext(typeof(ISet<string>));
bindingContext.FieldName = header;
bindingContext.HttpContext.Request.Headers.Add(header, new[] { headerValue });
var expectedValueProvider = Mock.Of<IValueProvider>();
var bindingContext = CreateContext(GetMetadataForType(typeof(string)), expectedValueProvider);
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", "application/json,text/json");
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.False(bindingContext.Result.IsModelSet);
Assert.Null(bindingContext.Result.Model);
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal("application/json,text/json", bindingContext.Result.Model);
Assert.Same(expectedValueProvider, bindingContext.ValueProvider);
}
private static DefaultModelBindingContext GetBindingContext(Type modelType)
[Fact]
public async Task HeaderBinder_UsesValues_OnlyFromHeaderValueProvider()
{
// Arrange
var testValueProvider = new Mock<IValueProvider>();
testValueProvider
.Setup(vp => vp.ContainsPrefix(It.IsAny<string>()))
.Returns(true);
testValueProvider
.Setup(vp => vp.GetValue(It.IsAny<string>()))
.Returns(new ValueProviderResult(new StringValues("foo,bar")));
var bindingContext = CreateContext(GetMetadataForType(typeof(string)), testValueProvider.Object);
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", "application/json,text/json");
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.True(bindingContext.Result.IsModelSet);
Assert.Equal("application/json,text/json", bindingContext.Result.Model);
Assert.Same(testValueProvider.Object, bindingContext.ValueProvider);
}
[Theory]
[InlineData(typeof(int), "not-an-integer")]
[InlineData(typeof(double), "not-an-double")]
[InlineData(typeof(CarType?), "boo")]
public async Task HeaderBinder_BindModelAsync_AddsErrorToModelState_OnInvalidInput(
Type modelType,
string headerValue)
{
// Arrange
var bindingContext = CreateContext(modelType);
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", headerValue);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
var entry = bindingContext.ModelState["someprefix.Header"];
Assert.NotNull(entry);
var error = Assert.Single(entry.Errors);
Assert.Equal($"The value '{headerValue}' is not valid.", error.ErrorMessage);
}
[Theory]
[InlineData(typeof(int[]), "a, b")]
[InlineData(typeof(IEnumerable<double>), "a, b")]
[InlineData(typeof(ICollection<CarType>), "a, b")]
public async Task HeaderBinder_BindModelAsync_AddsErrorToModelState_OnInvalid_CollectionInput(
Type modelType,
string headerValue)
{
// Arrange
var headerValues = headerValue.Split(',').Select(s => s.Trim()).ToArray();
var bindingContext = CreateContext(modelType);
var binder = CreateBinder(bindingContext);
bindingContext.HttpContext.Request.Headers.Add("Header", headerValue);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
var entry = bindingContext.ModelState["someprefix.Header"];
Assert.NotNull(entry);
Assert.Equal(2, entry.Errors.Count);
Assert.Equal($"The value '{headerValues[0]}' is not valid.", entry.Errors[0].ErrorMessage);
Assert.Equal($"The value '{headerValues[1]}' is not valid.", entry.Errors[1].ErrorMessage);
}
private static DefaultModelBindingContext CreateContext(
Type modelType,
bool allowBindingHeaderValuesToNonStringModelTypes = true)
{
return CreateContext(
metadata: GetMetadataForType(modelType),
valueProvider: null,
allowBindingHeaderValuesToNonStringModelTypes: allowBindingHeaderValuesToNonStringModelTypes);
}
private static DefaultModelBindingContext CreateContext(
ModelMetadata metadata,
IValueProvider valueProvider = null,
bool allowBindingHeaderValuesToNonStringModelTypes = true)
{
if (valueProvider == null)
{
valueProvider = Mock.Of<IValueProvider>();
}
var options = new MvcOptions()
{
AllowBindingHeaderValuesToNonStringModelTypes = allowBindingHeaderValuesToNonStringModelTypes
};
var setup = new MvcCoreMvcOptionsSetup(new TestHttpRequestStreamReaderFactory());
setup.Configure(options);
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(Options.Create(options));
var serviceProvider = services.BuildServiceProvider();
var headerName = "Header";
return new DefaultModelBindingContext()
{
IsTopLevelObject = true,
ModelMetadata = metadata,
BinderModelName = metadata.BinderModelName,
BindingSource = metadata.BindingSource,
// HeaderModelBinder must always use the field name when getting the values from header value provider
// but add keys into ModelState using the ModelName. This is for back compat reasons.
ModelName = $"somePrefix.{headerName}",
FieldName = headerName,
ValueProvider = valueProvider,
ModelState = new ModelStateDictionary(),
ActionContext = new ActionContext()
{
HttpContext = new DefaultHttpContext()
{
RequestServices = serviceProvider
}
},
};
}
private static IModelBinder CreateBinder(DefaultModelBindingContext bindingContext)
{
var factory = TestModelBinderFactory.Create(bindingContext.HttpContext.RequestServices);
var metadata = bindingContext.ModelMetadata;
return factory.CreateBinder(new ModelBinderFactoryContext()
{
Metadata = metadata,
BindingInfo = new BindingInfo()
{
BinderModelName = metadata.BinderModelName,
BinderType = metadata.BinderType,
BindingSource = metadata.BindingSource,
PropertyFilterProvider = metadata.PropertyFilterProvider,
},
});
}
private static ModelMetadata GetMetadataForType(Type modelType)
{
var metadataProvider = new TestModelMetadataProvider();
metadataProvider.ForType(modelType).BindingDetails(d => d.BindingSource = BindingSource.Header);
var modelMetadata = metadataProvider.GetMetadataForType(modelType);
return GetBindingContext(metadataProvider, modelMetadata);
return metadataProvider.GetMetadataForType(modelType);
}
private static DefaultModelBindingContext GetBindingContextForReadOnlyArray()
private static ModelMetadata GetMetadataForReadOnlyArray()
{
var metadataProvider = new TestModelMetadataProvider();
metadataProvider
.ForProperty<ModelWithReadOnlyArray>(nameof(ModelWithReadOnlyArray.ArrayProperty))
.BindingDetails(bd => bd.BindingSource = BindingSource.Header);
var modelMetadata = metadataProvider.GetMetadataForProperty(
return metadataProvider.GetMetadataForProperty(
typeof(ModelWithReadOnlyArray),
nameof(ModelWithReadOnlyArray.ArrayProperty));
return GetBindingContext(metadataProvider, modelMetadata);
}
private static DefaultModelBindingContext GetBindingContext(
IModelMetadataProvider metadataProvider,
ModelMetadata modelMetadata)
{
var bindingContext = new DefaultModelBindingContext
{
ActionContext = new ActionContext()
{
HttpContext = new DefaultHttpContext(),
},
ModelMetadata = modelMetadata,
ModelName = "modelName",
FieldName = "modelName",
ModelState = new ModelStateDictionary(),
BinderModelName = modelMetadata.BinderModelName,
BindingSource = modelMetadata.BindingSource,
};
return bindingContext;
}
private class ModelWithReadOnlyArray
@ -192,5 +446,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
private class StringList : List<string>
{
}
private enum CarType
{
Sedan,
Coupe
}
}
}

View File

@ -6,11 +6,14 @@ using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
public class TestModelBinderProviderContext : ModelBinderProviderContext
{
private BindingInfo _bindingInfo;
// Has to be internal because TestModelMetadataProvider is 'shared' code.
internal static readonly TestModelMetadataProvider CachedMetadataProvider = new TestModelMetadataProvider();
@ -18,10 +21,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
new List<Func<ModelMetadata, IModelBinder>>();
public TestModelBinderProviderContext(Type modelType)
: this(modelType, bindingInfo: null)
{
}
public TestModelBinderProviderContext(Type modelType, BindingInfo bindingInfo)
{
Metadata = CachedMetadataProvider.GetMetadataForType(modelType);
MetadataProvider = CachedMetadataProvider;
BindingInfo = new BindingInfo()
_bindingInfo = bindingInfo ?? new BindingInfo
{
BinderModelName = Metadata.BinderModelName,
BinderType = Metadata.BinderType,
@ -31,22 +39,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
Services = GetServices();
}
public TestModelBinderProviderContext(ModelMetadata metadata, BindingInfo bindingInfo)
{
Metadata = metadata;
BindingInfo = bindingInfo ?? new BindingInfo
{
BinderModelName = metadata.BinderModelName,
BinderType = metadata.BinderType,
BindingSource = metadata.BindingSource,
PropertyFilterProvider = metadata.PropertyFilterProvider,
};
MetadataProvider = CachedMetadataProvider;
Services = GetServices();
}
public override BindingInfo BindingInfo { get; }
public override BindingInfo BindingInfo => _bindingInfo;
public override ModelMetadata Metadata { get; }
@ -68,6 +61,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
return null;
}
public override IModelBinder CreateBinder(ModelMetadata metadata, BindingInfo bindingInfo)
{
_bindingInfo = bindingInfo;
return this.CreateBinder(metadata);
}
public void OnCreatingBinder(Func<ModelMetadata, IModelBinder> binderCreator)
{
_binderCreators.Add(binderCreator);
@ -82,6 +81,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(Options.Create(new MvcOptions()));
return services.BuildServiceProvider();
}
}

View File

@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
@ -31,7 +33,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task BindPropertyFromHeader_NoData_UsesFullPathAsKeyForModelStateErrors()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "Parameter1",
@ -43,7 +44,8 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
};
// Do not add any headers.
var testContext = ModelBindingTestHelper.GetTestContext();
var testContext = GetModelBindingTestContext();
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
var modelState = testContext.ModelState;
// Act
@ -70,7 +72,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task BindPropertyFromHeader_WithPrefix_GetsBound()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "Parameter1",
@ -81,8 +82,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
ParameterType = typeof(Person)
};
var testContext = ModelBindingTestHelper.GetTestContext(
var testContext = GetModelBindingTestContext(
request => request.Headers.Add("Header", new[] { "someValue" }));
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
var modelState = testContext.ModelState;
// Act
@ -106,7 +108,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Empty(entry.Value.Errors);
Assert.Equal(ModelValidationState.Valid, entry.Value.ValidationState);
Assert.Equal("someValue", entry.Value.AttemptedValue);
Assert.Equal(new string[] { "someValue" }, entry.Value.RawValue);
Assert.Equal("someValue", entry.Value.RawValue);
}
// The scenario is interesting as we to bind the top level model we fallback to empty prefix,
@ -115,7 +117,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task BindPropertyFromHeader_WithData_WithEmptyPrefix_GetsBound()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "Parameter1",
@ -123,8 +124,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
ParameterType = typeof(Person)
};
var testContext = ModelBindingTestHelper.GetTestContext(
var testContext = GetModelBindingTestContext(
request => request.Headers.Add("Header", new[] { "someValue" }));
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
var modelState = testContext.ModelState;
// Act
@ -148,7 +150,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Empty(entry.Value.Errors);
Assert.Equal(ModelValidationState.Valid, entry.Value.ValidationState);
Assert.Equal("someValue", entry.Value.AttemptedValue);
Assert.Equal(new string[] { "someValue" }, entry.Value.RawValue);
Assert.Equal("someValue", entry.Value.RawValue);
}
private class ListContainer1
@ -161,7 +163,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task BindCollectionPropertyFromHeader_WithData_IsBound()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
@ -169,8 +170,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
ParameterType = typeof(ListContainer1),
};
var testContext = ModelBindingTestHelper.GetTestContext(
var testContext = GetModelBindingTestContext(
request => request.Headers.Add("Header", new[] { "someValue" }));
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
var modelState = testContext.ModelState;
// Act
@ -195,7 +197,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Empty(modelStateEntry.Errors);
Assert.Equal(ModelValidationState.Valid, modelStateEntry.ValidationState);
Assert.Equal("someValue", modelStateEntry.AttemptedValue);
Assert.Equal(new[] { "someValue" }, modelStateEntry.RawValue);
Assert.Equal("someValue", modelStateEntry.RawValue);
}
private class ListContainer2
@ -208,7 +210,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task BindReadOnlyCollectionPropertyFromHeader_WithData_IsBound()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
@ -216,8 +217,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
ParameterType = typeof(ListContainer2),
};
var testContext = ModelBindingTestHelper.GetTestContext(
var testContext = GetModelBindingTestContext(
request => request.Headers.Add("Header", new[] { "someValue" }));
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
var modelState = testContext.ModelState;
// Act
@ -242,7 +244,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Empty(modelStateEntry.Errors);
Assert.Equal(ModelValidationState.Valid, modelStateEntry.ValidationState);
Assert.Equal("someValue", modelStateEntry.AttemptedValue);
Assert.Equal(new[] { "someValue" }, modelStateEntry.RawValue);
Assert.Equal("someValue", modelStateEntry.RawValue);
}
[Theory]
@ -251,20 +253,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task BindParameterFromHeader_WithData_WithPrefix_ModelGetsBound(Type modelType, string value)
{
// Arrange
object expectedValue;
string expectedAttemptedValue;
object expectedRawValue;
if (modelType == typeof(string))
{
expectedValue = value;
expectedRawValue = new string[] { value };
expectedAttemptedValue = value;
expectedRawValue = value;
}
else
{
expectedValue = value.Split(',').Select(v => v.Trim()).ToArray();
expectedRawValue = expectedValue;
expectedAttemptedValue = value.Replace(" ", "");
expectedRawValue = value.Split(',').Select(v => v.Trim()).ToArray();
}
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
@ -276,8 +277,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
ParameterType = modelType
};
Action<HttpRequest> action = r => r.Headers.Add("CustomParameter", new[] { value });
var testContext = ModelBindingTestHelper.GetTestContext(action);
Action<HttpRequest> action = r => r.Headers.Add("CustomParameter", new[] { expectedAttemptedValue });
var testContext = GetModelBindingTestContext(action);
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
// Do not add any headers.
var httpContext = testContext.HttpContext;
@ -301,8 +303,221 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.Equal("CustomParameter", entry.Key);
Assert.Empty(entry.Value.Errors);
Assert.Equal(ModelValidationState.Valid, entry.Value.ValidationState);
Assert.Equal(value, entry.Value.AttemptedValue);
Assert.Equal(expectedAttemptedValue, entry.Value.AttemptedValue);
Assert.Equal(expectedRawValue, entry.Value.RawValue);
}
[Fact]
public async Task BindPropertyFromHeader_WithPrefix_GetsBound_ForSimpleTypes()
{
// Arrange
var parameter = new ParameterDescriptor()
{
Name = "Parameter1",
BindingInfo = new BindingInfo()
{
BinderModelName = "prefix",
},
ParameterType = typeof(Product)
};
var testContext = GetModelBindingTestContext(
request =>
{
request.Headers.Add("NoCommaString", "someValue");
request.Headers.Add("OneCommaSeparatedString", "one, two, three");
request.Headers.Add("IntProperty", "10");
request.Headers.Add("NullableIntProperty", "300");
request.Headers.Add("ArrayOfString", "first, second");
request.Headers.Add("EnumerableOfDouble", "10.51, 45.44");
request.Headers.Add("ListOfEnum", "Sedan, Coupe");
request.Headers.Add("ListOfOrderWithTypeConverter", "10");
});
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices);
var modelState = testContext.ModelState;
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
// ModelBindingResult
Assert.True(modelBindingResult.IsModelSet);
// Model
var product = Assert.IsType<Product>(modelBindingResult.Model);
Assert.NotNull(product);
Assert.NotNull(product.Manufacturer);
Assert.Equal("someValue", product.Manufacturer.NoCommaString);
Assert.Equal("one, two, three", product.Manufacturer.OneCommaSeparatedStringProperty);
Assert.Equal(10, product.Manufacturer.IntProperty);
Assert.Equal(300, product.Manufacturer.NullableIntProperty);
Assert.Null(product.Manufacturer.NullableLongProperty);
Assert.Equal(new[] { "first", "second" }, product.Manufacturer.ArrayOfString);
Assert.Equal(new double[] { 10.51, 45.44 }, product.Manufacturer.EnumerableOfDoubleProperty);
Assert.Equal(new CarType[] { CarType.Sedan, CarType.Coupe }, product.Manufacturer.ListOfEnum);
var orderWithTypeConverter = Assert.Single(product.Manufacturer.ListOfOrderWithTypeConverterProperty);
Assert.Equal(10, orderWithTypeConverter.Id);
// ModelState
Assert.True(modelState.IsValid);
Assert.Collection(
modelState.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("prefix.Manufacturer.ArrayOfString", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("first,second", entry.AttemptedValue);
Assert.Equal(new[] { "first", "second" }, entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.EnumerableOfDouble", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("10.51,45.44", entry.AttemptedValue);
Assert.Equal(new[] { "10.51", "45.44" }, entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.IntProperty", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("10", entry.AttemptedValue);
Assert.Equal("10", entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.ListOfEnum", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("Sedan,Coupe", entry.AttemptedValue);
Assert.Equal(new[] { "Sedan", "Coupe" }, entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.ListOfOrderWithTypeConverter", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("10", entry.AttemptedValue);
Assert.Equal("10", entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.NoCommaString", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("someValue", entry.AttemptedValue);
Assert.Equal("someValue", entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.NullableIntProperty", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("300", entry.AttemptedValue);
Assert.Equal("300", entry.RawValue);
},
kvp =>
{
Assert.Equal("prefix.Manufacturer.OneCommaSeparatedString", kvp.Key);
var entry = kvp.Value;
Assert.Empty(entry.Errors);
Assert.Equal(ModelValidationState.Valid, entry.ValidationState);
Assert.Equal("one, two, three", entry.AttemptedValue);
Assert.Equal("one, two, three", entry.RawValue);
});
}
private ModelBindingTestContext GetModelBindingTestContext(
Action<HttpRequest> updateRequest = null,
Action<MvcOptions> updateOptions = null)
{
if (updateOptions == null)
{
updateOptions = o =>
{
o.AllowBindingHeaderValuesToNonStringModelTypes = true;
};
}
return ModelBindingTestHelper.GetTestContext(updateRequest, updateOptions);
}
private class Product
{
public Manufacturer Manufacturer { get; set; }
}
private class Manufacturer
{
[FromHeader]
public string NoCommaString { get; set; }
[FromHeader(Name = "OneCommaSeparatedString")]
public string OneCommaSeparatedStringProperty { get; set; }
[FromHeader]
public int IntProperty { get; set; }
[FromHeader]
public int? NullableIntProperty { get; set; }
[FromHeader]
public long? NullableLongProperty { get; set; }
[FromHeader]
public string[] ArrayOfString { get; set; }
[FromHeader(Name = "EnumerableOfDouble")]
public IEnumerable<double> EnumerableOfDoubleProperty { get; set; }
[FromHeader]
public List<CarType> ListOfEnum { get; set; }
[FromHeader(Name = "ListOfOrderWithTypeConverter")]
public List<OrderWithTypeConverter> ListOfOrderWithTypeConverterProperty { get; set; }
}
private enum CarType
{
Coupe,
Sedan
}
[TypeConverter(typeof(CanConvertFromStringConverter))]
private class OrderWithTypeConverter : IEquatable<OrderWithTypeConverter>
{
public int Id { get; set; }
public int ItemCount { get; set; }
public bool Equals(OrderWithTypeConverter other)
{
return Id == other.Id;
}
}
private class CanConvertFromStringConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
var id = value.ToString();
return new OrderWithTypeConverter() { Id = int.Parse(id) };
}
}
}
}

View File

@ -60,6 +60,18 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
}
}
public static ParameterBinder GetParameterBinder(IServiceProvider serviceProvider)
{
var metadataProvider = serviceProvider.GetRequiredService<IModelMetadataProvider>();
var options = serviceProvider.GetRequiredService<IOptions<MvcOptions>>();
return new ParameterBinder(
metadataProvider,
new ModelBinderFactory(metadataProvider, options, serviceProvider),
new CompositeModelValidatorProvider(GetModelValidatorProviders(options)),
NullLoggerFactory.Instance);
}
public static ParameterBinder GetParameterBinder(
IModelMetadataProvider metadataProvider,
IModelBinderProvider binderProvider = null,
@ -77,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
return new ParameterBinder(
metadataProvider,
GetModelBinderFactory(metadataProvider, options),
new ModelBinderFactory(metadataProvider, options, services),
new CompositeModelValidatorProvider(GetModelValidatorProviders(options)),
NullLoggerFactory.Instance);
}
@ -130,7 +142,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
return httpContext;
}
private static IServiceProvider GetServices(Action<MvcOptions> updateOptions = null)
public static IServiceProvider GetServices(Action<MvcOptions> updateOptions = null)
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddAuthorization();

View File

@ -34,6 +34,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest
// Assert
Assert.False(mvcOptions.AllowCombiningAuthorizeFilters);
Assert.False(mvcOptions.AllowBindingHeaderValuesToNonStringModelTypes);
Assert.False(mvcOptions.SuppressBindingUndefinedValueToEnumType);
Assert.Equal(InputFormatterExceptionPolicy.AllExceptions, mvcOptions.InputFormatterExceptionPolicy);
Assert.False(jsonOptions.AllowInputFormatterExceptionMessages);
@ -57,6 +58,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest
// Assert
Assert.True(mvcOptions.AllowCombiningAuthorizeFilters);
Assert.True(mvcOptions.AllowBindingHeaderValuesToNonStringModelTypes);
Assert.True(mvcOptions.SuppressBindingUndefinedValueToEnumType);
Assert.Equal(InputFormatterExceptionPolicy.MalformedInputExceptions, mvcOptions.InputFormatterExceptionPolicy);
Assert.True(jsonOptions.AllowInputFormatterExceptionMessages);
@ -80,6 +82,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTest
// Assert
Assert.True(mvcOptions.AllowCombiningAuthorizeFilters);
Assert.True(mvcOptions.AllowBindingHeaderValuesToNonStringModelTypes);
Assert.True(mvcOptions.SuppressBindingUndefinedValueToEnumType);
Assert.Equal(InputFormatterExceptionPolicy.MalformedInputExceptions, mvcOptions.InputFormatterExceptionPolicy);
Assert.True(jsonOptions.AllowInputFormatterExceptionMessages);

View File

@ -3,15 +3,24 @@
using System;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
public class TestModelBinderFactory : ModelBinderFactory
{
public static TestModelBinderFactory Create(IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<IOptions<MvcOptions>>();
return new TestModelBinderFactory(
TestModelMetadataProvider.CreateDefaultProvider(),
options,
serviceProvider);
}
public static TestModelBinderFactory Create(params IModelBinderProvider[] providers)
{
return Create(null, providers);
@ -58,14 +67,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
}
protected TestModelBinderFactory(IModelMetadataProvider metadataProvider, IOptions<MvcOptions> options)
: base(metadataProvider, options, GetServices())
: this(metadataProvider, options, GetServices(options))
{
}
private static IServiceProvider GetServices()
protected TestModelBinderFactory(
IModelMetadataProvider metadataProvider,
IOptions<MvcOptions> options,
IServiceProvider serviceProvider)
: base(metadataProvider, options, serviceProvider)
{
}
private static IServiceProvider GetServices(IOptions<MvcOptions> options)
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(options);
return services.BuildServiceProvider();
}
}