diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterContext.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterContext.cs index 4c40b4eb77..001a24dd99 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterContext.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterContext.cs @@ -36,6 +36,36 @@ namespace Microsoft.AspNetCore.Mvc.Formatters ModelStateDictionary modelState, ModelMetadata metadata, Func readerFactory) + : this(httpContext, modelName, modelState, metadata, readerFactory, treatEmptyInputAsDefaultValue: false) + { + } + + /// + /// Creates a new instance of . + /// + /// + /// The for the current operation. + /// + /// The name of the model. + /// + /// The for recording errors. + /// + /// + /// The of the model to deserialize. + /// + /// + /// A delegate which can create a for the request body. + /// + /// + /// A value for the property. + /// + public InputFormatterContext( + HttpContext httpContext, + string modelName, + ModelStateDictionary modelState, + ModelMetadata metadata, + Func 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; } + /// + /// Gets a flag to indicate whether the input formatter should allow no value to be provided. + /// If , the input formatter should handle empty input by returning + /// . If , the input + /// formatter should handle empty input by returning the default value for the type + /// . + /// + public bool TreatEmptyInputAsDefaultValue { get; } + /// /// Gets the associated with the current operation. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterResult.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterResult.cs index bcbd77e62c..2304cd5671 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterResult.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/Formatters/InputFormatterResult.cs @@ -10,17 +10,20 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// 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 _failureAsync = Task.FromResult(_failure); + private static readonly Task _noValueAsync = Task.FromResult(_noValue); - private InputFormatterResult() + private InputFormatterResult(bool hasError) { - HasError = true; + HasError = hasError; } private InputFormatterResult(object model) { Model = model; + IsModelSet = true; } /// @@ -28,6 +31,11 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// public bool HasError { get; } + /// + /// Gets an indication whether a value for the property was supplied. + /// + public bool IsModelSet { get; } + /// /// Gets the deserialized . /// @@ -89,5 +97,31 @@ namespace Microsoft.AspNetCore.Mvc.Formatters { return Task.FromResult(Success(model)); } + + /// + /// Returns an indicating the + /// operation produced no value. + /// + /// + /// An indicating the + /// operation produced no value. + /// + public static InputFormatterResult NoValue() + { + return _noValue; + } + + /// + /// Returns a that on completion provides an indicating + /// the operation produced no value. + /// + /// + /// A that on completion provides an indicating the + /// operation produced no value. + /// + public static Task NoValueAsync() + { + return _noValueAsync; + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs index 3a3bf23e12..f189da7094 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs @@ -24,6 +24,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// Default is "A value is required.". Func MissingKeyOrValueAccessor { get; } + /// + /// Error message the model binding system adds when no value is provided for the request body, + /// but a value is required. + /// + /// Default is "A non-empty request body is required.". + Func MissingRequestBodyRequiredValueAccessor { get; } + /// /// Error message the model binding system adds when a null value is bound to a /// non- property. diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/exceptions.net45.json b/src/Microsoft.AspNetCore.Mvc.Abstractions/exceptions.net45.json new file mode 100644 index 0000000000..bdcaa09241 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/exceptions.net45.json @@ -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 get_MissingRequestBodyRequiredValueAccessor()", + "Kind": "Addition" + } +] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/exceptions.netcore.json b/src/Microsoft.AspNetCore.Mvc.Abstractions/exceptions.netcore.json new file mode 100644 index 0000000000..bdcaa09241 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/exceptions.netcore.json @@ -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 get_MissingRequestBodyRequiredValueAccessor()", + "Kind": "Addition" + } +] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/InputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/InputFormatter.cs index c6fe0e94a1..2392b5a47c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/InputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/InputFormatter.cs @@ -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); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs index 9261c0e6f3..9dd2dd6513 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs @@ -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()); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinder.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinder.cs index 183e44b204..966d0d6563 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinder.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinder.cs @@ -22,6 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private readonly IList _formatters; private readonly Func _readerFactory; private readonly ILogger _logger; + private readonly MvcOptions _options; /// /// Creates a new . @@ -45,7 +46,29 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// instances for reading the request body. /// /// The . - public BodyModelBinder(IList formatters, IHttpRequestStreamReaderFactory readerFactory, ILoggerFactory loggerFactory) + public BodyModelBinder( + IList formatters, + IHttpRequestStreamReaderFactory readerFactory, + ILoggerFactory loggerFactory) + : this(formatters, readerFactory, loggerFactory, options: null) + { + } + + /// + /// Creates a new . + /// + /// The list of . + /// + /// The , used to create + /// instances for reading the request body. + /// + /// The . + /// The . + public BodyModelBinder( + IList formatters, + IHttpRequestStreamReaderFactory readerFactory, + ILoggerFactory loggerFactory, + MvcOptions options) { if (formatters == null) { @@ -64,6 +87,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { _logger = loggerFactory.CreateLogger(); } + + _options = options; } /// @@ -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) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinderProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinderProvider.cs index f8cc052621..7bd8b2f408 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinderProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinderProvider.cs @@ -18,6 +18,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders private readonly IList _formatters; private readonly IHttpRequestStreamReaderFactory _readerFactory; private readonly ILoggerFactory _loggerFactory; + private readonly MvcOptions _options; /// /// Creates a new . @@ -36,6 +37,22 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// The . /// The . public BodyModelBinderProvider(IList formatters, IHttpRequestStreamReaderFactory readerFactory, ILoggerFactory loggerFactory) + : this(formatters, readerFactory, loggerFactory, options: null) + { + } + + /// + /// Creates a new . + /// + /// The list of . + /// The . + /// The . + /// The . + public BodyModelBinderProvider( + IList 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; } /// @@ -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; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs index 950e059241..a4fee0832a 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs @@ -13,6 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata { private Func _missingBindRequiredValueAccessor; private Func _missingKeyOrValueAccessor; + private Func _missingRequestBodyRequiredValueAccessor; private Func _valueMustNotBeNullAccessor; private Func _attemptedValueIsInvalidAccessor; private Func _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 } } + /// + public Func MissingRequestBodyRequiredValueAccessor + { + get + { + return _missingRequestBodyRequiredValueAccessor; + } + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _missingRequestBodyRequiredValueAccessor = value; + } + } + /// public Func ValueMustNotBeNullAccessor { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs b/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs index 0b0165362e..77332ae7ea 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs @@ -34,6 +34,18 @@ namespace Microsoft.AspNetCore.Mvc ValueProviderFactories = new List(); } + /// + /// Gets or sets the flag which decides whether body model binding (for example, on an + /// action method parameter with ) should treat empty + /// input as valid. by default. + /// + /// + /// When , actions that model bind the request body (for example, + /// using ) will register an error in the + /// if the incoming request body is empty. + /// + public bool AllowEmptyInputInBodyModelBinding { get; set; } + /// /// Gets a Dictionary of CacheProfile Names, which are pre-defined settings for /// response caching. diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs index 955084fc27..044cb00dc2 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs @@ -808,6 +808,20 @@ namespace Microsoft.AspNetCore.Mvc.Core internal static string FormatModelBinding_MissingBindRequiredMember(object p0) => string.Format(CultureInfo.CurrentCulture, GetString("ModelBinding_MissingBindRequiredMember"), p0); + /// + /// A non-empty request body is required. + /// + internal static string ModelBinding_MissingRequestBodyRequiredMember + { + get => GetString("ModelBinding_MissingRequestBodyRequiredMember"); + } + + /// + /// A non-empty request body is required. + /// + internal static string FormatModelBinding_MissingRequestBodyRequiredMember() + => GetString("ModelBinding_MissingRequestBodyRequiredMember"); + /// /// The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx index 870a5904a1..1ba794f649 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx @@ -296,6 +296,9 @@ A value for the '{0}' property was not provided. + + + A non-empty request body is required. The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types. diff --git a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs index e146e7cce1..9428a7377e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs @@ -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(); diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs index d235bc9ea4..2ad5752053 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Formatters/InputFormatterTest.cs @@ -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 ReadRequestBodyAsync(InputFormatterContext context) diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs index fda1a0c936..f568ba7cde 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs @@ -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(); + mockInputFormatter.Setup(f => f.CanRead(It.IsAny())) + .Returns(true); + mockInputFormatter.Setup(o => o.ReadAsync(It.IsAny())) + .Returns(InputFormatterResult.NoValueAsync()); + var inputFormatter = mockInputFormatter.Object; + + var provider = new TestModelMetadataProvider(); + provider.ForType().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(); + mockInputFormatter.Setup(f => f.CanRead(It.IsAny())) + .Returns(true); + mockInputFormatter.Setup(o => o.ReadAsync(It.IsAny())) + .Returns(InputFormatterResult.NoValueAsync()) + .Verifiable(); + var inputFormatter = mockInputFormatter.Object; + + var provider = new TestModelMetadataProvider(); + provider.ForType().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(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 formatters) + private static BodyModelBinder CreateBinder(IList 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 diff --git a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs index 75dd819910..ca35c0f4e4 100644 --- a/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Formatters.Json.Test/JsonInputFormatterTest.cs @@ -342,6 +342,40 @@ namespace Microsoft.AspNetCore.Mvc.Formatters Assert.IsType(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.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() { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs index eb56bc0298..7c91c32e1c 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/InputFormatterTests.cs @@ -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")] diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BodyValidationIntegrationTests.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BodyValidationIntegrationTests.cs index 1f967fe98d..27e48d1500 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BodyValidationIntegrationTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/BodyValidationIntegrationTests.cs @@ -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>(); + 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>(); + 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>(); + 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(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] diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs index 248537fe04..62720b89fe 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs @@ -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>(); + 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); diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ExcludeBindingMetadataProviderIntegrationTest.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ExcludeBindingMetadataProviderIntegrationTest.cs index 4f1b02f7f8..88b665604b 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ExcludeBindingMetadataProviderIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ExcludeBindingMetadataProviderIntegrationTest.cs @@ -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 diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestContext.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestContext.cs index 2ae059bfd4..2457a8ca1e 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestContext.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestContext.cs @@ -8,5 +8,10 @@ namespace Microsoft.AspNetCore.Mvc.IntegrationTests public class ModelBindingTestContext : ControllerContext { public IModelMetadataProvider MetadataProvider { get; set; } + + public T GetService() + { + return (T)HttpContext.RequestServices.GetService(typeof(T)); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs index 0c801c7afc..8d340a29a6 100644 --- a/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs +++ b/test/Microsoft.AspNetCore.Mvc.IntegrationTests/ModelBindingTestHelper.cs @@ -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>(); + var options = mvcOptions != null + ? Options.Create(mvcOptions) + : services.GetRequiredService>(); if (binderProvider != null) { diff --git a/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs b/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs index fd8101faab..4fdafc2f66 100644 --- a/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs +++ b/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs @@ -45,6 +45,11 @@ namespace FormatterWebSite.Controllers [HttpPost] public IActionResult ReturnInput([FromBody]DummyClass dummyObject) { + if (!ModelState.IsValid) + { + return BadRequest(); + } + return Content(dummyObject.SampleInt.ToString()); }