Make [FromBody] treat empty request bodies as invalid (#4750)

This commit is contained in:
Steve Sanderson 2017-03-31 12:54:17 +01:00
parent 1e7972bd8f
commit 90acd055fe
24 changed files with 484 additions and 22 deletions

View File

@ -36,6 +36,36 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
ModelStateDictionary modelState,
ModelMetadata metadata,
Func<Stream, Encoding, TextReader> readerFactory)
: this(httpContext, modelName, modelState, metadata, readerFactory, treatEmptyInputAsDefaultValue: false)
{
}
/// <summary>
/// Creates a new instance of <see cref="InputFormatterContext"/>.
/// </summary>
/// <param name="httpContext">
/// The <see cref="Http.HttpContext"/> for the current operation.
/// </param>
/// <param name="modelName">The name of the model.</param>
/// <param name="modelState">
/// The <see cref="ModelStateDictionary"/> for recording errors.
/// </param>
/// <param name="metadata">
/// The <see cref="ModelMetadata"/> of the model to deserialize.
/// </param>
/// <param name="readerFactory">
/// A delegate which can create a <see cref="TextReader"/> for the request body.
/// </param>
/// <param name="treatEmptyInputAsDefaultValue">
/// A value for the <see cref="TreatEmptyInputAsDefaultValue"/> property.
/// </param>
public InputFormatterContext(
HttpContext httpContext,
string modelName,
ModelStateDictionary modelState,
ModelMetadata metadata,
Func<Stream, Encoding, TextReader> readerFactory,
bool treatEmptyInputAsDefaultValue)
{
if (httpContext == null)
{
@ -67,9 +97,19 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
ModelState = modelState;
Metadata = metadata;
ReaderFactory = readerFactory;
TreatEmptyInputAsDefaultValue = treatEmptyInputAsDefaultValue;
ModelType = metadata.ModelType;
}
/// <summary>
/// Gets a flag to indicate whether the input formatter should allow no value to be provided.
/// If <see langword="false"/>, the input formatter should handle empty input by returning
/// <see cref="InputFormatterResult.NoValueAsync()"/>. If <see langword="true"/>, the input
/// formatter should handle empty input by returning the default value for the type
/// <see cref="ModelType"/>.
/// </summary>
public bool TreatEmptyInputAsDefaultValue { get; }
/// <summary>
/// Gets the <see cref="Http.HttpContext"/> associated with the current operation.
/// </summary>

View File

@ -10,17 +10,20 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
/// </summary>
public class InputFormatterResult
{
private static readonly InputFormatterResult _failure = new InputFormatterResult();
private static readonly InputFormatterResult _failure = new InputFormatterResult(hasError: true);
private static readonly InputFormatterResult _noValue = new InputFormatterResult(hasError: false);
private static readonly Task<InputFormatterResult> _failureAsync = Task.FromResult(_failure);
private static readonly Task<InputFormatterResult> _noValueAsync = Task.FromResult(_noValue);
private InputFormatterResult()
private InputFormatterResult(bool hasError)
{
HasError = true;
HasError = hasError;
}
private InputFormatterResult(object model)
{
Model = model;
IsModelSet = true;
}
/// <summary>
@ -28,6 +31,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
/// </summary>
public bool HasError { get; }
/// <summary>
/// Gets an indication whether a value for the <see cref="Model"/> property was supplied.
/// </summary>
public bool IsModelSet { get; }
/// <summary>
/// Gets the deserialized <see cref="object"/>.
/// </summary>
@ -89,5 +97,31 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
{
return Task.FromResult(Success(model));
}
/// <summary>
/// Returns an <see cref="InputFormatterResult"/> indicating the <see cref="IInputFormatter.ReadAsync"/>
/// operation produced no value.
/// </summary>
/// <returns>
/// An <see cref="InputFormatterResult"/> indicating the <see cref="IInputFormatter.ReadAsync"/>
/// operation produced no value.
/// </returns>
public static InputFormatterResult NoValue()
{
return _noValue;
}
/// <summary>
/// Returns a <see cref="Task"/> that on completion provides an <see cref="InputFormatterResult"/> indicating
/// the <see cref="IInputFormatter.ReadAsync"/> operation produced no value.
/// </summary>
/// <returns>
/// A <see cref="Task"/> that on completion provides an <see cref="InputFormatterResult"/> indicating the
/// <see cref="IInputFormatter.ReadAsync"/> operation produced no value.
/// </returns>
public static Task<InputFormatterResult> NoValueAsync()
{
return _noValueAsync;
}
}
}

View File

@ -24,6 +24,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
/// <value>Default <see cref="string"/> is "A value is required.".</value>
Func<string> MissingKeyOrValueAccessor { get; }
/// <summary>
/// Error message the model binding system adds when no value is provided for the request body,
/// but a value is required.
/// </summary>
/// <value>Default <see cref="string"/> is "A non-empty request body is required.".</value>
Func<string> MissingRequestBodyRequiredValueAccessor { get; }
/// <summary>
/// Error message the model binding system adds when a <c>null</c> value is bound to a
/// non-<see cref="Nullable"/> property.

View File

@ -0,0 +1,8 @@
[
{
"OldTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewMemberId": "System.Func<System.String> get_MissingRequestBodyRequiredValueAccessor()",
"Kind": "Addition"
}
]

View File

@ -0,0 +1,8 @@
[
{
"OldTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewMemberId": "System.Func<System.String> get_MissingRequestBodyRequiredValueAccessor()",
"Kind": "Addition"
}
]

View File

@ -105,7 +105,12 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
var request = context.HttpContext.Request;
if (request.ContentLength == 0)
{
return InputFormatterResult.SuccessAsync(GetDefaultValueForType(context.ModelType));
if (context.TreatEmptyInputAsDefaultValue)
{
return InputFormatterResult.SuccessAsync(GetDefaultValueForType(context.ModelType));
}
return InputFormatterResult.NoValueAsync();
}
return ReadRequestBodyAsync(context);

View File

@ -43,7 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
// Set up ModelBinding
options.ModelBinderProviders.Add(new BinderTypeModelBinderProvider());
options.ModelBinderProviders.Add(new ServicesModelBinderProvider());
options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory));
options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options));
options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider());
options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider());

View File

@ -22,6 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
private readonly IList<IInputFormatter> _formatters;
private readonly Func<Stream, Encoding, TextReader> _readerFactory;
private readonly ILogger _logger;
private readonly MvcOptions _options;
/// <summary>
/// Creates a new <see cref="BodyModelBinder"/>.
@ -45,7 +46,29 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
/// instances for reading the request body.
/// </param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BodyModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory, ILoggerFactory loggerFactory)
public BodyModelBinder(
IList<IInputFormatter> formatters,
IHttpRequestStreamReaderFactory readerFactory,
ILoggerFactory loggerFactory)
: this(formatters, readerFactory, loggerFactory, options: null)
{
}
/// <summary>
/// Creates a new <see cref="BodyModelBinder"/>.
/// </summary>
/// <param name="formatters">The list of <see cref="IInputFormatter"/>.</param>
/// <param name="readerFactory">
/// The <see cref="IHttpRequestStreamReaderFactory"/>, used to create <see cref="System.IO.TextReader"/>
/// instances for reading the request body.
/// </param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="options">The <see cref="MvcOptions"/>.</param>
public BodyModelBinder(
IList<IInputFormatter> formatters,
IHttpRequestStreamReaderFactory readerFactory,
ILoggerFactory loggerFactory,
MvcOptions options)
{
if (formatters == null)
{
@ -64,6 +87,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
_logger = loggerFactory.CreateLogger<BodyModelBinder>();
}
_options = options;
}
/// <inheritdoc />
@ -89,12 +114,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
var httpContext = bindingContext.HttpContext;
var allowEmptyInputInModelBinding = _options?.AllowEmptyInputInBodyModelBinding == true;
var formatterContext = new InputFormatterContext(
httpContext,
modelBindingKey,
bindingContext.ModelState,
bindingContext.ModelMetadata,
_readerFactory);
_readerFactory,
allowEmptyInputInModelBinding);
var formatter = (IInputFormatter)null;
for (var i = 0; i < _formatters.Count; i++)
@ -132,7 +160,24 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
return;
}
bindingContext.Result = ModelBindingResult.Success(model);
if (result.IsModelSet)
{
bindingContext.Result = ModelBindingResult.Success(model);
}
else
{
// If the input formatter gives a "no value" result, that's always a model state error,
// because BodyModelBinder implicitly regards input as being required for model binding.
// If instead the input formatter wants to treat the input as optional, it must do so by
// returning InputFormatterResult.Success(defaultForModelType), because input formatters
// are responsible for choosing a default value for the model type.
var message = bindingContext
.ModelMetadata
.ModelBindingMessageProvider
.MissingRequestBodyRequiredValueAccessor();
bindingContext.ModelState.AddModelError(modelBindingKey, message);
}
return;
}
catch (Exception ex)

View File

@ -18,6 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
private readonly IList<IInputFormatter> _formatters;
private readonly IHttpRequestStreamReaderFactory _readerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly MvcOptions _options;
/// <summary>
/// Creates a new <see cref="BodyModelBinderProvider"/>.
@ -36,6 +37,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
/// <param name="readerFactory">The <see cref="IHttpRequestStreamReaderFactory"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BodyModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory, ILoggerFactory loggerFactory)
: this(formatters, readerFactory, loggerFactory, options: null)
{
}
/// <summary>
/// Creates a new <see cref="BodyModelBinderProvider"/>.
/// </summary>
/// <param name="formatters">The list of <see cref="IInputFormatter"/>.</param>
/// <param name="readerFactory">The <see cref="IHttpRequestStreamReaderFactory"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="options">The <see cref="MvcOptions"/>.</param>
public BodyModelBinderProvider(
IList<IInputFormatter> formatters,
IHttpRequestStreamReaderFactory readerFactory,
ILoggerFactory loggerFactory,
MvcOptions options)
{
if (formatters == null)
{
@ -50,6 +67,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
_formatters = formatters;
_readerFactory = readerFactory;
_loggerFactory = loggerFactory;
_options = options;
}
/// <inheritdoc />
@ -71,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
typeof(IInputFormatter).FullName));
}
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory);
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory, _options);
}
return null;

View File

@ -13,6 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
private Func<string, string> _missingBindRequiredValueAccessor;
private Func<string> _missingKeyOrValueAccessor;
private Func<string> _missingRequestBodyRequiredValueAccessor;
private Func<string, string> _valueMustNotBeNullAccessor;
private Func<string, string, string> _attemptedValueIsInvalidAccessor;
private Func<string, string> _unknownValueIsInvalidAccessor;
@ -26,6 +27,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
{
MissingBindRequiredValueAccessor = Resources.FormatModelBinding_MissingBindRequiredMember;
MissingKeyOrValueAccessor = Resources.FormatKeyValuePair_BothKeyAndValueMustBePresent;
MissingRequestBodyRequiredValueAccessor = Resources.FormatModelBinding_MissingRequestBodyRequiredMember;
ValueMustNotBeNullAccessor = Resources.FormatModelBinding_NullValueNotValid;
AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid;
UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid;
@ -47,6 +49,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
MissingBindRequiredValueAccessor = originalProvider.MissingBindRequiredValueAccessor;
MissingKeyOrValueAccessor = originalProvider.MissingKeyOrValueAccessor;
MissingRequestBodyRequiredValueAccessor = originalProvider.MissingRequestBodyRequiredValueAccessor;
ValueMustNotBeNullAccessor = originalProvider.ValueMustNotBeNullAccessor;
AttemptedValueIsInvalidAccessor = originalProvider.AttemptedValueIsInvalidAccessor;
UnknownValueIsInvalidAccessor = originalProvider.UnknownValueIsInvalidAccessor;
@ -90,6 +93,24 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
}
}
/// <inheritdoc/>
public Func<string> MissingRequestBodyRequiredValueAccessor
{
get
{
return _missingRequestBodyRequiredValueAccessor;
}
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_missingRequestBodyRequiredValueAccessor = value;
}
}
/// <inheritdoc/>
public Func<string, string> ValueMustNotBeNullAccessor
{

View File

@ -34,6 +34,18 @@ namespace Microsoft.AspNetCore.Mvc
ValueProviderFactories = new List<IValueProviderFactory>();
}
/// <summary>
/// Gets or sets the flag which decides whether body model binding (for example, on an
/// action method parameter with <see cref="FromBodyAttribute"/>) should treat empty
/// input as valid. <see langword="false"/> by default.
/// </summary>
/// <example>
/// When <see langword="false"/>, actions that model bind the request body (for example,
/// using <see cref="FromBodyAttribute"/>) will register an error in the
/// <see cref="ModelStateDictionary"/> if the incoming request body is empty.
/// </example>
public bool AllowEmptyInputInBodyModelBinding { get; set; }
/// <summary>
/// Gets a Dictionary of CacheProfile Names, <see cref="CacheProfile"/> which are pre-defined settings for
/// response caching.

View File

@ -808,6 +808,20 @@ namespace Microsoft.AspNetCore.Mvc.Core
internal static string FormatModelBinding_MissingBindRequiredMember(object p0)
=> string.Format(CultureInfo.CurrentCulture, GetString("ModelBinding_MissingBindRequiredMember"), p0);
/// <summary>
/// A non-empty request body is required.
/// </summary>
internal static string ModelBinding_MissingRequestBodyRequiredMember
{
get => GetString("ModelBinding_MissingRequestBodyRequiredMember");
}
/// <summary>
/// A non-empty request body is required.
/// </summary>
internal static string FormatModelBinding_MissingRequestBodyRequiredMember()
=> GetString("ModelBinding_MissingRequestBodyRequiredMember");
/// <summary>
/// The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types.
/// </summary>

View File

@ -296,6 +296,9 @@
</data>
<data name="ModelBinding_MissingBindRequiredMember" xml:space="preserve">
<value>A value for the '{0}' property was not provided.</value>
</data>
<data name="ModelBinding_MissingRequestBodyRequiredMember" xml:space="preserve">
<value>A non-empty request body is required.</value>
</data>
<data name="ValueProviderResult_NoConverterExists" xml:space="preserve">
<value>The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types.</value>

View File

@ -158,7 +158,18 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
if (successful)
{
return InputFormatterResult.SuccessAsync(model);
if (model == null && !context.TreatEmptyInputAsDefaultValue)
{
// Some nonempty inputs might deserialize as null, for example whitespace,
// or the JSON-encoded value "null". The upstream BodyModelBinder needs to
// be notified that we don't regard this as a real input so it can register
// a model binding error.
return InputFormatterResult.NoValueAsync();
}
else
{
return InputFormatterResult.SuccessAsync(model);
}
}
return InputFormatterResult.FailureAsync();

View File

@ -390,7 +390,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
var formatter = new BadConfigurationFormatter();
var context = new InputFormatterContext(
new DefaultHttpContext(),
"",
string.Empty,
new ModelStateDictionary(),
new EmptyModelMetadataProvider().GetMetadataForType(typeof(object)),
(s, e) => new StreamReader(s, e));
@ -410,6 +410,31 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
() => formatter.GetSupportedContentTypes("application/json", typeof(object)));
}
[Theory]
[InlineData(true, true)]
[InlineData(false, false)]
public async Task ReadAsync_WithEmptyRequest_ReturnsNoValueResultWhenExpected(bool allowEmptyInputValue, bool expectedIsModelSet)
{
// Arrange
var formatter = new TestFormatter();
var context = new InputFormatterContext(
new DefaultHttpContext(),
string.Empty,
new ModelStateDictionary(),
new EmptyModelMetadataProvider().GetMetadataForType(typeof(object)),
(s, e) => new StreamReader(s, e),
allowEmptyInputValue);
context.HttpContext.Request.ContentLength = 0;
// Act
var result = await formatter.ReadAsync(context);
// Assert
Assert.False(result.HasError);
Assert.Null(result.Model);
Assert.Equal(expectedIsModelSet, result.IsModelSet);
}
private class BadConfigurationFormatter : InputFormatter
{
public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
@ -115,6 +116,79 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
Assert.False(bindingContext.Result.IsModelSet);
}
[Fact]
public async Task BindModel_NoValueResult_SetsModelStateError()
{
// Arrange
var mockInputFormatter = new Mock<IInputFormatter>();
mockInputFormatter.Setup(f => f.CanRead(It.IsAny<InputFormatterContext>()))
.Returns(true);
mockInputFormatter.Setup(o => o.ReadAsync(It.IsAny<InputFormatterContext>()))
.Returns(InputFormatterResult.NoValueAsync());
var inputFormatter = mockInputFormatter.Object;
var provider = new TestModelMetadataProvider();
provider.ForType<Person>().BindingDetails(d =>
{
d.BindingSource = BindingSource.Body;
d.ModelBindingMessageProvider.MissingRequestBodyRequiredValueAccessor =
() => "Customized error message";
});
var bindingContext = GetBindingContext(
typeof(Person),
metadataProvider: provider);
bindingContext.BinderModelName = "custom";
var binder = CreateBinder(new[] { inputFormatter });
// Act
await binder.BindModelAsync(bindingContext);
// Assert
Assert.Null(bindingContext.Result.Model);
Assert.False(bindingContext.Result.IsModelSet);
Assert.False(bindingContext.ModelState.IsValid);
// Key is the bindermodelname because this was a top-level binding.
var entry = Assert.Single(bindingContext.ModelState);
Assert.Equal("custom", entry.Key);
Assert.Equal("Customized error message", entry.Value.Errors.Single().ErrorMessage);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task BindModel_PassesAllowEmptyInputOptionViaContext(bool treatEmptyInputAsDefaultValueOption)
{
// Arrange
var mockInputFormatter = new Mock<IInputFormatter>();
mockInputFormatter.Setup(f => f.CanRead(It.IsAny<InputFormatterContext>()))
.Returns(true);
mockInputFormatter.Setup(o => o.ReadAsync(It.IsAny<InputFormatterContext>()))
.Returns(InputFormatterResult.NoValueAsync())
.Verifiable();
var inputFormatter = mockInputFormatter.Object;
var provider = new TestModelMetadataProvider();
provider.ForType<Person>().BindingDetails(d => d.BindingSource = BindingSource.Body);
var bindingContext = GetBindingContext(
typeof(Person),
metadataProvider: provider);
bindingContext.BinderModelName = "custom";
var binder = CreateBinder(new[] { inputFormatter }, treatEmptyInputAsDefaultValueOption);
// Act
await binder.BindModelAsync(bindingContext);
// Assert
mockInputFormatter.Verify(formatter => formatter.ReadAsync(
It.Is<InputFormatterContext>(ctx => ctx.TreatEmptyInputAsDefaultValue == treatEmptyInputAsDefaultValueOption)),
Times.Once);
}
[Fact]
public async Task CustomFormatterDeserializationException_AddedToModelState()
{
@ -316,11 +390,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
return bindingContext;
}
private static BodyModelBinder CreateBinder(IList<IInputFormatter> formatters)
private static BodyModelBinder CreateBinder(IList<IInputFormatter> formatters, bool treatEmptyInputAsDefaultValueOption = false)
{
var sink = new TestSink();
var loggerFactory = new TestLoggerFactory(sink, enabled: true);
return new BodyModelBinder(formatters, new TestHttpRequestStreamReaderFactory(), loggerFactory);
var options = new MvcOptions { AllowEmptyInputInBodyModelBinding = treatEmptyInputAsDefaultValueOption };
return new BodyModelBinder(formatters, new TestHttpRequestStreamReaderFactory(), loggerFactory, options);
}
private class Person

View File

@ -342,6 +342,40 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
Assert.IsType<TooManyModelErrorsException>(error.Exception);
}
[Theory]
[InlineData("null", true, true)]
[InlineData("null", false, false)]
[InlineData(" ", true, true)]
[InlineData(" ", false, false)]
public async Task ReadAsync_WithInputThatDeserializesToNull_SetsModelOnlyIfAllowingEmptyInput(string content, bool allowEmptyInput, bool expectedIsModelSet)
{
// Arrange
var logger = GetLogger();
var formatter =
new JsonInputFormatter(logger, _serializerSettings, ArrayPool<char>.Shared, _objectPoolProvider);
var contentBytes = Encoding.UTF8.GetBytes(content);
var modelState = new ModelStateDictionary();
var httpContext = GetHttpContext(contentBytes);
var provider = new EmptyModelMetadataProvider();
var metadata = provider.GetMetadataForType(typeof(object));
var context = new InputFormatterContext(
httpContext,
modelName: string.Empty,
modelState: modelState,
metadata: metadata,
readerFactory: new TestHttpRequestStreamReaderFactory().CreateReader,
treatEmptyInputAsDefaultValue: allowEmptyInput);
// Act
var result = await formatter.ReadAsync(context);
// Assert
Assert.False(result.HasError);
Assert.Equal(expectedIsModelSet, result.IsModelSet);
Assert.Null(result.Model);
}
[Fact]
public void Constructor_UsesSerializerSettings()
{

View File

@ -79,6 +79,24 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal(expectedSampleIntValue.ToString(), responseBody);
}
[Theory]
[InlineData("application/json", "")]
[InlineData("application/json", " ")]
public async Task JsonInputFormatter_ReturnsBadRequest_ForEmptyRequestBody(
string requestContentType,
string jsonInput)
{
// Arrange
var content = new StringContent(jsonInput, Encoding.UTF8, requestContentType);
// Act
var response = await Client.PostAsync("http://localhost/JsonFormatter/ReturnInput/", content);
var responseBody = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Theory]
[InlineData("\"I'm a JSON string!\"")]
[InlineData("true")]

View File

@ -10,6 +10,7 @@ using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.IntegrationTests
@ -356,10 +357,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
}
[Fact]
public async Task FromBodyAndRequiredOnProperty_EmptyBody_AddsModelStateError()
public async Task FromBodyAllowingEmptyInputAndRequiredOnProperty_EmptyBody_AddsModelStateError()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "Parameter1",
@ -381,6 +381,11 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
var addressRequired = ValidationAttributeUtil.GetRequiredErrorMessage("Address");
var optionsAccessor = testContext.GetService<IOptions<MvcOptions>>();
optionsAccessor.Value.AllowEmptyInputInBodyModelBinding = true;
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(optionsAccessor.Value);
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
@ -396,10 +401,9 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
}
[Fact]
public async Task FromBodyOnActionParameter_EmptyBody_BindsToNullValue()
public async Task FromBodyAllowingEmptyInputOnActionParameter_EmptyBody_BindsToNullValue()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
@ -421,6 +425,11 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
var httpContext = testContext.HttpContext;
var modelState = testContext.ModelState;
var optionsAccessor = testContext.GetService<IOptions<MvcOptions>>();
optionsAccessor.Value.AllowEmptyInputInBodyModelBinding = true;
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(optionsAccessor.Value);
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
@ -584,6 +593,63 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
Assert.NotEmpty(error.Exception.Message);
}
[Theory]
[InlineData(false, false)]
[InlineData(true, true)]
public async Task FromBodyWithEmptyBody_JsonFormatterAddsModelErrorWhenExpected(
bool allowEmptyInputInBodyModelBindingSetting, bool expectedModelStateIsValid)
{
// Arrange
var parameter = new ParameterDescriptor
{
Name = "Parameter1",
BindingInfo = new BindingInfo
{
BinderModelName = "CustomParameter",
},
ParameterType = typeof(Person5)
};
var testContext = ModelBindingTestHelper.GetTestContext(
request =>
{
request.Body = new MemoryStream(Encoding.UTF8.GetBytes(string.Empty));
request.ContentType = "application/json";
});
var optionsAccessor = testContext.GetService<IOptions<MvcOptions>>();
optionsAccessor.Value.AllowEmptyInputInBodyModelBinding = allowEmptyInputInBodyModelBindingSetting;
var modelState = testContext.ModelState;
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(optionsAccessor.Value);
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext);
// Assert
Assert.True(modelBindingResult.IsModelSet);
var boundPerson = Assert.IsType<Person5>(modelBindingResult.Model);
Assert.NotNull(boundPerson);
if (expectedModelStateIsValid)
{
Assert.True(modelState.IsValid);
}
else
{
Assert.False(modelState.IsValid);
var entry = Assert.Single(modelState);
Assert.Equal("CustomParameter.Address", entry.Key);
var street = entry.Value;
Assert.Equal(ModelValidationState.Invalid, street.ValidationState);
var error = Assert.Single(street.Errors);
// Since the message doesn't come from DataAnnotations, we don't have a way to get the
// exact string, so just check it's nonempty.
Assert.NotEmpty(error.ErrorMessage);
}
}
private class Person2
{
[FromBody]

View File

@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Xunit;
@ -134,7 +135,6 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData()
{
// Arrange
var parameterBinder = ModelBindingTestHelper.GetParameterBinder();
var parameter = new ParameterDescriptor()
{
Name = "parameter",
@ -148,9 +148,14 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
request.ContentType = "application/json";
});
var optionsAccessor = testContext.GetService<IOptions<MvcOptions>>();
optionsAccessor.Value.AllowEmptyInputInBodyModelBinding = true;
var modelState = testContext.ModelState;
var valueProvider = await CompositeValueProvider.CreateAsync(testContext);
var parameterBinder = ModelBindingTestHelper.GetParameterBinder(optionsAccessor.Value);
// Act
var modelBindingResult = await parameterBinder.BindModelAsync(testContext, valueProvider, parameter);

View File

@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
{
public class ExcludeBindingMetadataProviderIntegrationTest
{
[Fact]
[Fact(Skip = "See issue #6110")]
public async Task BindParameter_WithTypeProperty_IsNotBound()
{
// Arrange

View File

@ -8,5 +8,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
public class ModelBindingTestContext : ControllerContext
{
public IModelMetadataProvider MetadataProvider { get; set; }
public T GetService<T>()
{
return (T)HttpContext.RequestServices.GetService(typeof(T));
}
}
}

View File

@ -53,16 +53,19 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests
else
{
var metadataProvider = TestModelMetadataProvider.CreateProvider(options.ModelMetadataDetailsProviders);
return GetParameterBinder(metadataProvider, binderProvider);
return GetParameterBinder(metadataProvider, binderProvider, options);
}
}
public static ParameterBinder GetParameterBinder(
IModelMetadataProvider metadataProvider,
IModelBinderProvider binderProvider = null)
IModelBinderProvider binderProvider = null,
MvcOptions mvcOptions = null)
{
var services = GetServices();
var options = services.GetRequiredService<IOptions<MvcOptions>>();
var options = mvcOptions != null
? Options.Create(mvcOptions)
: services.GetRequiredService<IOptions<MvcOptions>>();
if (binderProvider != null)
{

View File

@ -45,6 +45,11 @@ namespace FormatterWebSite.Controllers
[HttpPost]
public IActionResult ReturnInput([FromBody]DummyClass dummyObject)
{
if (!ModelState.IsValid)
{
return BadRequest();
}
return Content(dummyObject.SampleInt.ToString());
}