Skip to content

Commit baf7e6c

Browse files
authored
Add support for optional FromBody parameters (#22634) (#23246)
* Add support for optional FromBody parameters (#22634) * Add support for optional FromBody parameters Fixes #6878 * Fixup nullable * Changes per API review * Fixup doc comment (#23229)
1 parent 62a390e commit baf7e6c

File tree

15 files changed

+245
-19
lines changed

15 files changed

+245
-19
lines changed

src/Mvc/Mvc.Abstractions/ref/Microsoft.AspNetCore.Mvc.Abstractions.netcoreapp.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public ApiDescriptionProviderContext(System.Collections.Generic.IReadOnlyList<Mi
160160
public partial class ApiParameterDescription
161161
{
162162
public ApiParameterDescription() { }
163+
public Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo BindingInfo { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
163164
public object DefaultValue { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
164165
public bool IsRequired { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
165166
public Microsoft.AspNetCore.Mvc.ModelBinding.ModelMetadata ModelMetadata { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
@@ -462,6 +463,7 @@ public BindingInfo(Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo other) { }
462463
public string BinderModelName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
463464
public System.Type BinderType { get { throw null; } set { } }
464465
public Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource BindingSource { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
466+
public Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior EmptyBodyBehavior { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
465467
public Microsoft.AspNetCore.Mvc.ModelBinding.IPropertyFilterProvider PropertyFilterProvider { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
466468
public System.Func<Microsoft.AspNetCore.Mvc.ActionContext, bool> RequestPredicate { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
467469
public static Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo GetBindingInfo(System.Collections.Generic.IEnumerable<object> attributes) { throw null; }
@@ -500,6 +502,12 @@ public partial class CompositeBindingSource : Microsoft.AspNetCore.Mvc.ModelBind
500502
public override bool CanAcceptDataFrom(Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource bindingSource) { throw null; }
501503
public static Microsoft.AspNetCore.Mvc.ModelBinding.CompositeBindingSource Create(System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource> bindingSources, string displayName) { throw null; }
502504
}
505+
public enum EmptyBodyBehavior
506+
{
507+
Default = 0,
508+
Allow = 1,
509+
Disallow = 2,
510+
}
503511
[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
504512
public readonly partial struct EnumGroupAndName
505513
{

src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiParameterDescription.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ public class ApiParameterDescription
3232
/// </summary>
3333
public BindingSource Source { get; set; }
3434

35+
/// <summary>
36+
/// Gets or sets the <see cref="BindingInfo"/>.
37+
/// </summary>
38+
public BindingInfo BindingInfo { get; set; }
39+
3540
/// <summary>
3641
/// Gets or sets the parameter type.
3742
/// </summary>

src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public BindingInfo(BindingInfo other)
3838
BinderType = other.BinderType;
3939
PropertyFilterProvider = other.PropertyFilterProvider;
4040
RequestPredicate = other.RequestPredicate;
41+
EmptyBodyBehavior = other.EmptyBodyBehavior;
4142
}
4243

4344
/// <summary>
@@ -87,6 +88,11 @@ public Type BinderType
8788
/// </summary>
8889
public Func<ActionContext, bool> RequestPredicate { get; set; }
8990

91+
/// <summary>
92+
/// Gets or sets the value which decides if empty bodies are treated as valid inputs.
93+
/// </summary>
94+
public EmptyBodyBehavior EmptyBodyBehavior { get; set; }
95+
9096
/// <summary>
9197
/// Constructs a new instance of <see cref="BindingInfo"/> from the given <paramref name="attributes"/>.
9298
/// <para>
@@ -160,6 +166,13 @@ public static BindingInfo GetBindingInfo(IEnumerable<object> attributes)
160166
}
161167
}
162168

169+
foreach (var configureEmptyBodyBehavior in attributes.OfType<IConfigureEmptyBodyBehavior>())
170+
{
171+
isBindingInfoPresent = true;
172+
bindingInfo.EmptyBodyBehavior = configureEmptyBodyBehavior.EmptyBodyBehavior;
173+
break;
174+
}
175+
163176
return isBindingInfoPresent ? bindingInfo : null;
164177
}
165178

@@ -235,6 +248,9 @@ public bool TryApplyBindingInfo(ModelMetadata modelMetadata)
235248
PropertyFilterProvider = modelMetadata.PropertyFilterProvider;
236249
}
237250

251+
// There isn't a ModelMetadata feature to configure AllowEmptyInputInBodyModelBinding,
252+
// so nothing to infer from it.
253+
238254
return isBindingInfoPresent;
239255
}
240256

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Mvc.ModelBinding
5+
{
6+
/// <summary>
7+
/// Determines the behavior for processing empty bodies during input formatting.
8+
/// </summary>
9+
public enum EmptyBodyBehavior
10+
{
11+
/// <summary>
12+
/// Uses the framework default behavior for processing empty bodies.
13+
/// This is typically configured using <c>MvcOptions.AllowEmptyInputInBodyModelBinding</c>.
14+
/// </summary>
15+
Default,
16+
17+
/// <summary>
18+
/// Empty bodies are treated as valid inputs.
19+
/// </summary>
20+
Allow,
21+
22+
/// <summary>
23+
/// Empty bodies are treated as invalid inputs.
24+
/// </summary>
25+
Disallow,
26+
}
27+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Mvc.ModelBinding
5+
{
6+
internal interface IConfigureEmptyBodyBehavior
7+
{
8+
public EmptyBodyBehavior EmptyBodyBehavior { get; }
9+
}
10+
}

src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ private IList<ApiParameterDescription> GetParameters(ApiParameterContext context
222222
ProcessRouteParameters(context);
223223

224224
// Set IsRequired=true
225-
ProcessIsRequired(context);
225+
ProcessIsRequired(context, _mvcOptions);
226226

227227
// Set DefaultValue
228228
ProcessParameterDefaultValue(context);
@@ -273,13 +273,20 @@ private void ProcessRouteParameters(ApiParameterContext context)
273273
}
274274
}
275275

276-
internal static void ProcessIsRequired(ApiParameterContext context)
276+
internal static void ProcessIsRequired(ApiParameterContext context, MvcOptions mvcOptions)
277277
{
278278
foreach (var parameter in context.Results)
279279
{
280280
if (parameter.Source == BindingSource.Body)
281281
{
282-
parameter.IsRequired = true;
282+
if (parameter.BindingInfo == null || parameter.BindingInfo.EmptyBodyBehavior == EmptyBodyBehavior.Default)
283+
{
284+
parameter.IsRequired = !mvcOptions.AllowEmptyInputInBodyModelBinding;
285+
}
286+
else
287+
{
288+
parameter.IsRequired = !(parameter.BindingInfo.EmptyBodyBehavior == EmptyBodyBehavior.Allow);
289+
}
283290
}
284291

285292
if (parameter.ModelMetadata != null && parameter.ModelMetadata.IsBindingRequired)
@@ -466,6 +473,8 @@ private class ApiParameterDescriptionContext
466473

467474
public string PropertyName { get; set; }
468475

476+
public BindingInfo BindingInfo { get; set; }
477+
469478
public static ApiParameterDescriptionContext GetContext(
470479
ModelMetadata metadata,
471480
BindingInfo bindingInfo,
@@ -478,6 +487,7 @@ public static ApiParameterDescriptionContext GetContext(
478487
BinderModelName = bindingInfo?.BinderModelName,
479488
BindingSource = bindingInfo?.BindingSource,
480489
PropertyName = propertyName ?? metadata.Name,
490+
BindingInfo = bindingInfo,
481491
};
482492
}
483493
}
@@ -607,6 +617,7 @@ private ApiParameterDescription CreateResult(
607617
Source = source,
608618
Type = bindingContext.ModelMetadata.ModelType,
609619
ParameterDescriptor = Parameter,
620+
BindingInfo = bindingContext.BindingInfo
610621
};
611622
}
612623

src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,12 +1725,47 @@ public void ProcessIsRequired_SetsTrue_ForFromBodyParameters()
17251725
var context = GetApiParameterContext(description);
17261726

17271727
// Act
1728-
DefaultApiDescriptionProvider.ProcessIsRequired(context);
1728+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
17291729

17301730
// Assert
17311731
Assert.True(description.IsRequired);
17321732
}
17331733

1734+
[Fact]
1735+
public void ProcessIsRequired_SetsFalse_IfAllowEmptyInputInBodyModelBinding_IsSetInMvcOptions()
1736+
{
1737+
// Arrange
1738+
var description = new ApiParameterDescription { Source = BindingSource.Body, };
1739+
var context = GetApiParameterContext(description);
1740+
1741+
// Act
1742+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions { AllowEmptyInputInBodyModelBinding = true });
1743+
1744+
// Assert
1745+
Assert.False(description.IsRequired);
1746+
}
1747+
1748+
[Fact]
1749+
public void ProcessIsRequired_SetsFalse_IfEmptyBodyBehaviorIsAllowedInBindingInfo()
1750+
{
1751+
// Arrange
1752+
var description = new ApiParameterDescription
1753+
{
1754+
Source = BindingSource.Body,
1755+
BindingInfo = new BindingInfo
1756+
{
1757+
EmptyBodyBehavior = EmptyBodyBehavior.Allow,
1758+
}
1759+
};
1760+
var context = GetApiParameterContext(description);
1761+
1762+
// Act
1763+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
1764+
1765+
// Assert
1766+
Assert.False(description.IsRequired);
1767+
}
1768+
17341769
[Fact]
17351770
public void ProcessIsRequired_SetsTrue_ForParameterDescriptorsWithBindRequired()
17361771
{
@@ -1747,7 +1782,7 @@ public void ProcessIsRequired_SetsTrue_ForParameterDescriptorsWithBindRequired()
17471782
description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name));
17481783

17491784
// Act
1750-
DefaultApiDescriptionProvider.ProcessIsRequired(context);
1785+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
17511786

17521787
// Assert
17531788
Assert.True(description.IsRequired);
@@ -1765,7 +1800,7 @@ public void ProcessIsRequired_SetsTrue_ForRequiredRouteParameterDescriptors()
17651800
var context = GetApiParameterContext(description);
17661801

17671802
// Act
1768-
DefaultApiDescriptionProvider.ProcessIsRequired(context);
1803+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
17691804

17701805
// Assert
17711806
Assert.True(description.IsRequired);
@@ -1779,7 +1814,7 @@ public void ProcessIsRequired_DoesNotSetToTrue_ByDefault()
17791814
var context = GetApiParameterContext(description);
17801815

17811816
// Act
1782-
DefaultApiDescriptionProvider.ProcessIsRequired(context);
1817+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
17831818

17841819
// Assert
17851820
Assert.False(description.IsRequired);
@@ -1798,7 +1833,7 @@ public void ProcessIsRequired_DoesNotSetToTrue_ForParameterDescriptorsWithValida
17981833
description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name));
17991834

18001835
// Act
1801-
DefaultApiDescriptionProvider.ProcessIsRequired(context);
1836+
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());
18021837

18031838
// Assert
18041839
Assert.False(description.IsRequired);

src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@ public partial class FromBodyAttribute : System.Attribute, Microsoft.AspNetCore.
745745
{
746746
public FromBodyAttribute() { }
747747
public Microsoft.AspNetCore.Mvc.ModelBinding.BindingSource BindingSource { get { throw null; } }
748+
public Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior EmptyBodyBehavior { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
748749
}
749750
[System.AttributeUsageAttribute(System.AttributeTargets.Parameter | System.AttributeTargets.Property, AllowMultiple=false, Inherited=true)]
750751
public partial class FromFormAttribute : System.Attribute, Microsoft.AspNetCore.Mvc.ModelBinding.IBindingSourceMetadata, Microsoft.AspNetCore.Mvc.ModelBinding.IModelNameProvider

src/Mvc/Mvc.Core/src/FromBodyAttribute.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ namespace Microsoft.AspNetCore.Mvc
1010
/// Specifies that a parameter or property should be bound using the request body.
1111
/// </summary>
1212
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
13-
public class FromBodyAttribute : Attribute, IBindingSourceMetadata
13+
public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEmptyBodyBehavior
1414
{
1515
/// <inheritdoc />
1616
public BindingSource BindingSource => BindingSource.Body;
17+
18+
/// <summary>
19+
/// Gets or sets a value which decides whether body model binding should treat empty
20+
/// input as valid.
21+
/// </summary>
22+
/// <remarks>
23+
/// The default behavior is to use framework defaults as configured by <see cref="MvcOptions.AllowEmptyInputInBodyModelBinding"/>.
24+
/// Specifying <see cref="EmptyBodyBehavior.Allow"/> or <see cref="EmptyBodyBehavior.Disallow" /> will override the framework defaults.
25+
/// </remarks>
26+
public EmptyBodyBehavior EmptyBodyBehavior { get; set; }
1727
}
1828
}

src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ public BodyModelBinder(
9191
_options = options;
9292
}
9393

94+
internal bool AllowEmptyBody { get; set; }
95+
9496
/// <inheritdoc />
9597
public async Task BindModelAsync(ModelBindingContext bindingContext)
9698
{
@@ -116,15 +118,13 @@ public async Task BindModelAsync(ModelBindingContext bindingContext)
116118

117119
var httpContext = bindingContext.HttpContext;
118120

119-
var allowEmptyInputInModelBinding = _options?.AllowEmptyInputInBodyModelBinding == true;
120-
121121
var formatterContext = new InputFormatterContext(
122122
httpContext,
123123
modelBindingKey,
124124
bindingContext.ModelState,
125125
bindingContext.ModelMetadata,
126126
_readerFactory,
127-
allowEmptyInputInModelBinding);
127+
AllowEmptyBody);
128128

129129
var formatter = (IInputFormatter)null;
130130
for (var i = 0; i < _formatters.Count; i++)

src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinderProvider.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Microsoft.AspNetCore.Mvc.Formatters;
88
using Microsoft.AspNetCore.Mvc.Infrastructure;
99
using Microsoft.Extensions.Logging;
10+
using Microsoft.Extensions.Logging.Abstractions;
1011

1112
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
1213
{
@@ -26,7 +27,7 @@ public class BodyModelBinderProvider : IModelBinderProvider
2627
/// <param name="formatters">The list of <see cref="IInputFormatter"/>.</param>
2728
/// <param name="readerFactory">The <see cref="IHttpRequestStreamReaderFactory"/>.</param>
2829
public BodyModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory)
29-
: this(formatters, readerFactory, loggerFactory: null)
30+
: this(formatters, readerFactory, loggerFactory: NullLoggerFactory.Instance)
3031
{
3132
}
3233

@@ -89,10 +90,25 @@ public IModelBinder GetBinder(ModelBinderProviderContext context)
8990
typeof(IInputFormatter).FullName));
9091
}
9192

92-
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory, _options);
93+
var treatEmptyInputAsDefaultValue = CalculateAllowEmptyBody(context.BindingInfo.EmptyBodyBehavior, _options);
94+
95+
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory, _options)
96+
{
97+
AllowEmptyBody = treatEmptyInputAsDefaultValue,
98+
};
9399
}
94100

95101
return null;
96102
}
103+
104+
internal static bool CalculateAllowEmptyBody(EmptyBodyBehavior emptyBodyBehavior, MvcOptions options)
105+
{
106+
if (emptyBodyBehavior == EmptyBodyBehavior.Default)
107+
{
108+
return options?.AllowEmptyInputInBodyModelBinding ?? false;
109+
}
110+
111+
return emptyBodyBehavior == EmptyBodyBehavior.Allow;
112+
}
97113
}
98114
}

0 commit comments

Comments
 (0)