Add support for optional FromBody parameters (#22634)

* Add support for optional FromBody parameters

Fixes https://github.com/dotnet/aspnetcore/issues/6878

* Fixup nullable

* Changes per API review
This commit is contained in:
Pranav K 2020-06-22 12:11:55 -07:00 committed by GitHub
parent c2bfbf5a04
commit f7d2fac8a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 245 additions and 19 deletions

View File

@ -160,6 +160,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
public partial class ApiParameterDescription
{
public ApiParameterDescription() { }
public Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo BindingInfo { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public object DefaultValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public bool IsRequired { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public Microsoft.AspNetCore.Mvc.ModelBinding.ModelMetadata ModelMetadata { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
@ -462,6 +463,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
public string BinderModelName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public System.Type BinderType { get { throw null; } set { } }
public Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource BindingSource { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior EmptyBodyBehavior { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public Microsoft.AspNetCore.Mvc.ModelBinding.IPropertyFilterProvider PropertyFilterProvider { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public System.Func<Microsoft.AspNetCore.Mvc.ActionContext, bool> RequestPredicate { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public static Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo GetBindingInfo(System.Collections.Generic.IEnumerable<object> attributes) { throw null; }
@ -500,6 +502,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
public override bool CanAcceptDataFrom(Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource bindingSource) { throw null; }
public static Microsoft.AspNetCore.Mvc.ModelBinding.CompositeBindingSource Create(System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource> bindingSources, string displayName) { throw null; }
}
public enum EmptyBodyBehavior
{
Default = 0,
Allow = 1,
Disallow = 2,
}
[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
public readonly partial struct EnumGroupAndName
{

View File

@ -32,6 +32,11 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// </summary>
public BindingSource Source { get; set; }
/// <summary>
/// Gets or sets the <see cref="BindingInfo"/>.
/// </summary>
public BindingInfo BindingInfo { get; set; }
/// <summary>
/// Gets or sets the parameter type.
/// </summary>

View File

@ -38,6 +38,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
BinderType = other.BinderType;
PropertyFilterProvider = other.PropertyFilterProvider;
RequestPredicate = other.RequestPredicate;
EmptyBodyBehavior = other.EmptyBodyBehavior;
}
/// <summary>
@ -87,6 +88,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
/// </summary>
public Func<ActionContext, bool> RequestPredicate { get; set; }
/// <summary>
/// Gets or sets the value which decides if empty bodies are treated as valid inputs.
/// </summary>
public EmptyBodyBehavior EmptyBodyBehavior { get; set; }
/// <summary>
/// Constructs a new instance of <see cref="BindingInfo"/> from the given <paramref name="attributes"/>.
/// <para>
@ -160,6 +166,13 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
}
}
foreach (var configureEmptyBodyBehavior in attributes.OfType<IConfigureEmptyBodyBehavior>())
{
isBindingInfoPresent = true;
bindingInfo.EmptyBodyBehavior = configureEmptyBodyBehavior.EmptyBodyBehavior;
break;
}
return isBindingInfoPresent ? bindingInfo : null;
}
@ -235,6 +248,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
PropertyFilterProvider = modelMetadata.PropertyFilterProvider;
}
// There isn't a ModelMetadata feature to configure AllowEmptyInputInBodyModelBinding,
// so nothing to infer from it.
return isBindingInfoPresent;
}

View File

@ -0,0 +1,27 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
/// <summary>
/// Determines the behavior for processing empty bodies during input formatting.
/// </summary>
public enum EmptyBodyBehavior
{
/// <summary>
/// Uses the framework default behavior for processing empty bodies.
/// This is typically configured using <c>MvcOptions.AllowEmptyInputInBodyModelBinding</c>
/// </summary>
Default,
/// <summary>
/// Empty bodies are treated as valid inputs.
/// </summary>
Allow,
/// <summary>
/// Empty bodies are treated as invalid inputs.
/// </summary>
Disallow,
}
}

View File

@ -0,0 +1,10 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Mvc.ModelBinding
{
internal interface IConfigureEmptyBodyBehavior
{
public EmptyBodyBehavior EmptyBodyBehavior { get; }
}
}

View File

@ -222,7 +222,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
ProcessRouteParameters(context);
// Set IsRequired=true
ProcessIsRequired(context);
ProcessIsRequired(context, _mvcOptions);
// Set DefaultValue
ProcessParameterDefaultValue(context);
@ -273,13 +273,20 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
}
}
internal static void ProcessIsRequired(ApiParameterContext context)
internal static void ProcessIsRequired(ApiParameterContext context, MvcOptions mvcOptions)
{
foreach (var parameter in context.Results)
{
if (parameter.Source == BindingSource.Body)
{
parameter.IsRequired = true;
if (parameter.BindingInfo == null || parameter.BindingInfo.EmptyBodyBehavior == EmptyBodyBehavior.Default)
{
parameter.IsRequired = !mvcOptions.AllowEmptyInputInBodyModelBinding;
}
else
{
parameter.IsRequired = !(parameter.BindingInfo.EmptyBodyBehavior == EmptyBodyBehavior.Allow);
}
}
if (parameter.ModelMetadata != null && parameter.ModelMetadata.IsBindingRequired)
@ -466,6 +473,8 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
public string PropertyName { get; set; }
public BindingInfo BindingInfo { get; set; }
public static ApiParameterDescriptionContext GetContext(
ModelMetadata metadata,
BindingInfo bindingInfo,
@ -478,6 +487,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
BinderModelName = bindingInfo?.BinderModelName,
BindingSource = bindingInfo?.BindingSource,
PropertyName = propertyName ?? metadata.Name,
BindingInfo = bindingInfo,
};
}
}
@ -607,6 +617,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
Source = source,
Type = bindingContext.ModelMetadata.ModelType,
ParameterDescriptor = Parameter,
BindingInfo = bindingContext.BindingInfo
};
}

View File

@ -1725,12 +1725,47 @@ namespace Microsoft.AspNetCore.Mvc.Description
var context = GetApiParameterContext(description);
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
// Assert
Assert.True(description.IsRequired);
}
[Fact]
public void ProcessIsRequired_SetsFalse_IfAllowEmptyInputInBodyModelBinding_IsSetInMvcOptions()
{
// Arrange
var description = new ApiParameterDescription { Source = BindingSource.Body, };
var context = GetApiParameterContext(description);
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions { AllowEmptyInputInBodyModelBinding = true });
// Assert
Assert.False(description.IsRequired);
}
[Fact]
public void ProcessIsRequired_SetsFalse_IfEmptyBodyBehaviorIsAllowedInBindingInfo()
{
// Arrange
var description = new ApiParameterDescription
{
Source = BindingSource.Body,
BindingInfo = new BindingInfo
{
EmptyBodyBehavior = EmptyBodyBehavior.Allow,
}
};
var context = GetApiParameterContext(description);
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
// Assert
Assert.False(description.IsRequired);
}
[Fact]
public void ProcessIsRequired_SetsTrue_ForParameterDescriptorsWithBindRequired()
{
@ -1747,7 +1782,7 @@ namespace Microsoft.AspNetCore.Mvc.Description
description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name));
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
// Assert
Assert.True(description.IsRequired);
@ -1765,7 +1800,7 @@ namespace Microsoft.AspNetCore.Mvc.Description
var context = GetApiParameterContext(description);
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
// Assert
Assert.True(description.IsRequired);
@ -1779,7 +1814,7 @@ namespace Microsoft.AspNetCore.Mvc.Description
var context = GetApiParameterContext(description);
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
// Assert
Assert.False(description.IsRequired);
@ -1798,7 +1833,7 @@ namespace Microsoft.AspNetCore.Mvc.Description
description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name));
// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
// Assert
Assert.False(description.IsRequired);

View File

@ -745,6 +745,7 @@ namespace Microsoft.AspNetCore.Mvc
{
public FromBodyAttribute() { }
public Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource BindingSource { get { throw null; } }
public Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior EmptyBodyBehavior { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
}
[System.AttributeUsageAttribute(System.AttributeTargets.Parameter | System.AttributeTargets.Property, AllowMultiple=false, Inherited=true)]
public partial class FromFormAttribute : System.Attribute, Microsoft.AspNetCore.Mvc.ModelBinding.IBindingSourceMetadata, Microsoft.AspNetCore.Mvc.ModelBinding.IModelNameProvider

View File

@ -10,9 +10,19 @@ namespace Microsoft.AspNetCore.Mvc
/// Specifies that a parameter or property should be bound using the request body.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class FromBodyAttribute : Attribute, IBindingSourceMetadata
public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEmptyBodyBehavior
{
/// <inheritdoc />
public BindingSource BindingSource => BindingSource.Body;
/// <summary>
/// Gets or sets a value which decides whether body model binding should treat empty
/// input as valid.
/// </summary>
/// <remarks>
/// The default behavior is to use framework defaults as configured by <see cref="MvcOptions.AllowEmptyInputInBodyModelBinding"/>.
/// Specifying <see cref="EmptyBodyBehavior.Allow"/> or <see cref="EmptyBodyBehavior.Disallow" /> will override the framework defaults.
/// </remarks>
public EmptyBodyBehavior EmptyBodyBehavior { get; set; }
}
}

View File

@ -91,6 +91,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
_options = options;
}
internal bool AllowEmptyBody { get; set; }
/// <inheritdoc />
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
@ -116,15 +118,13 @@ 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,
allowEmptyInputInModelBinding);
AllowEmptyBody);
var formatter = (IInputFormatter)null;
for (var i = 0; i < _formatters.Count; i++)

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
{
@ -26,7 +27,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
/// <param name="formatters">The list of <see cref="IInputFormatter"/>.</param>
/// <param name="readerFactory">The <see cref="IHttpRequestStreamReaderFactory"/>.</param>
public BodyModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory)
: this(formatters, readerFactory, loggerFactory: null)
: this(formatters, readerFactory, loggerFactory: NullLoggerFactory.Instance)
{
}
@ -89,10 +90,25 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
typeof(IInputFormatter).FullName));
}
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory, _options);
var treatEmptyInputAsDefaultValue = CalculateAllowEmptyBody(context.BindingInfo.EmptyBodyBehavior, _options);
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory, _options)
{
AllowEmptyBody = treatEmptyInputAsDefaultValue,
};
}
return null;
}
internal static bool CalculateAllowEmptyBody(EmptyBodyBehavior emptyBodyBehavior, MvcOptions options)
{
if (emptyBodyBehavior == EmptyBodyBehavior.Default)
{
return options?.AllowEmptyInputInBodyModelBinding ?? false;
}
return emptyBodyBehavior == EmptyBodyBehavior.Allow;
}
}
}

View File

@ -86,6 +86,53 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
provider.GetBinder(context);
}
[Fact]
public void CalculateAllowEmptyBody_EmptyBodyBehaviorIsDefaultValue_UsesMvcOptions()
{
// Arrange
var options = new MvcOptions { AllowEmptyInputInBodyModelBinding = true };
// Act
var allowEmpty = BodyModelBinderProvider.CalculateAllowEmptyBody(EmptyBodyBehavior.Default, options);
// Assert
Assert.True(allowEmpty);
}
[Fact]
public void CalculateAllowEmptyBody_EmptyBodyBehaviorIsDefaultValue_DefaultsToFalseWhenOptionsIsUnavailable()
{
// Act
var allowEmpty = BodyModelBinderProvider.CalculateAllowEmptyBody(EmptyBodyBehavior.Default, options: null);
// Assert
Assert.False(allowEmpty);
}
[Fact]
public void CalculateAllowEmptyBody_EmptyBodyBehaviorIsAllow()
{
// Act
var allowEmpty = BodyModelBinderProvider.CalculateAllowEmptyBody(EmptyBodyBehavior.Allow, options: new MvcOptions());
// Assert
Assert.True(allowEmpty);
}
[Fact]
public void CalculateAllowEmptyBody_EmptyBodyBehaviorIsDisallowed()
{
// Arrange
// MvcOptions.AllowEmptyInputInBodyModelBinding should be ignored if EmptyBodyBehavior disallows it
var options = new MvcOptions { AllowEmptyInputInBodyModelBinding = true };
// Act
var allowEmpty = BodyModelBinderProvider.CalculateAllowEmptyBody(EmptyBodyBehavior.Disallow, options);
// Assert
Assert.False(allowEmpty);
}
private static BodyModelBinderProvider CreateProvider(params IInputFormatter[] formatters)
{
var sink = new TestSink();

View File

@ -657,8 +657,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
private static BodyModelBinder CreateBinder(IList<IInputFormatter> formatters, bool treatEmptyInputAsDefaultValueOption = false)
{
var options = new MvcOptions { AllowEmptyInputInBodyModelBinding = treatEmptyInputAsDefaultValueOption };
return CreateBinder(formatters, options);
var options = new MvcOptions();
var binder = CreateBinder(formatters, options);
binder.AllowEmptyBody = treatEmptyInputAsDefaultValueOption;
return binder;
}
private static BodyModelBinder CreateBinder(IList<IInputFormatter> formatters, MvcOptions mvcOptions)

View File

@ -3,8 +3,10 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using FormatterWebSite.Controllers;
using FormatterWebSite.Models;
using Microsoft.AspNetCore.Testing;
using Newtonsoft.Json;
@ -22,9 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public HttpClient Client { get; }
[ConditionalFact]
// Mono issue - https://github.com/aspnet/External/issues/18
[FrameworkSkipCondition(RuntimeFrameworks.Mono)]
[Fact]
public async Task CheckIfXmlInputFormatterIsBeingCalled()
{
// Arrange
@ -168,5 +168,33 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("The DerivedProperty field is required.", value.First);
});
}
[Fact]
public async Task BodyIsRequiredByDefault()
{
// Act
var response = await Client.PostAsJsonAsync<object>($"Home/{nameof(HomeController.DefaultBody)}", value: null);
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.Collection(
problemDetails.Errors,
kvp =>
{
Assert.Empty(kvp.Key);
Assert.Equal("A non-empty request body is required.", Assert.Single(kvp.Value));
});
}
[Fact]
public async Task OptionalFromBodyWorks()
{
// Act
var response = await Client.PostAsJsonAsync<object>($"Home/{nameof(HomeController.OptionalBody)}", value: null);
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
}
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace FormatterWebSite.Controllers
{
@ -34,5 +35,13 @@ namespace FormatterWebSite.Controllers
SampleIntInDerived = 50
};
}
[HttpPost]
public IActionResult DefaultBody([FromBody] DummyClass dummy)
=> ModelState.IsValid ? Ok() : ValidationProblem();
[HttpPost]
public IActionResult OptionalBody([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] DummyClass dummy)
=> ModelState.IsValid ? Ok() : ValidationProblem();
}
}