Skip to content

Add support for optional FromBody parameters (#22634) #23246

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ public ApiDescriptionProviderContext(System.Collections.Generic.IReadOnlyList<Mi
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 { } }
Expand Down Expand Up @@ -462,6 +463,7 @@ public BindingInfo(Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo other) { }
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; }
Expand Down Expand Up @@ -500,6 +502,12 @@ public partial class CompositeBindingSource : Microsoft.AspNetCore.Mvc.ModelBind
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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public class ApiParameterDescription
/// </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>
Expand Down
16 changes: 16 additions & 0 deletions src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public BindingInfo(BindingInfo other)
BinderType = other.BinderType;
PropertyFilterProvider = other.PropertyFilterProvider;
RequestPredicate = other.RequestPredicate;
EmptyBodyBehavior = other.EmptyBodyBehavior;
}

/// <summary>
Expand Down Expand Up @@ -87,6 +88,11 @@ public Type BinderType
/// </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>
Expand Down Expand Up @@ -160,6 +166,13 @@ public static BindingInfo GetBindingInfo(IEnumerable<object> attributes)
}
}

foreach (var configureEmptyBodyBehavior in attributes.OfType<IConfigureEmptyBodyBehavior>())
{
isBindingInfoPresent = true;
bindingInfo.EmptyBodyBehavior = configureEmptyBodyBehavior.EmptyBodyBehavior;
break;
}

return isBindingInfoPresent ? bindingInfo : null;
}

Expand Down Expand Up @@ -235,6 +248,9 @@ public bool TryApplyBindingInfo(ModelMetadata modelMetadata)
PropertyFilterProvider = modelMetadata.PropertyFilterProvider;
}

// There isn't a ModelMetadata feature to configure AllowEmptyInputInBodyModelBinding,
// so nothing to infer from it.

return isBindingInfoPresent;
}

Expand Down
27 changes: 27 additions & 0 deletions src/Mvc/Mvc.Abstractions/src/ModelBinding/EmptyBodyBehavior.cs
Original file line number Diff line number Diff line change
@@ -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,
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
}
17 changes: 14 additions & 3 deletions src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ private IList<ApiParameterDescription> GetParameters(ApiParameterContext context
ProcessRouteParameters(context);

// Set IsRequired=true
ProcessIsRequired(context);
ProcessIsRequired(context, _mvcOptions);

// Set DefaultValue
ProcessParameterDefaultValue(context);
Expand Down Expand Up @@ -273,13 +273,20 @@ private void ProcessRouteParameters(ApiParameterContext context)
}
}

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)
Expand Down Expand Up @@ -466,6 +473,8 @@ private class ApiParameterDescriptionContext

public string PropertyName { get; set; }

public BindingInfo BindingInfo { get; set; }

public static ApiParameterDescriptionContext GetContext(
ModelMetadata metadata,
BindingInfo bindingInfo,
Expand All @@ -478,6 +487,7 @@ public static ApiParameterDescriptionContext GetContext(
BinderModelName = bindingInfo?.BinderModelName,
BindingSource = bindingInfo?.BindingSource,
PropertyName = propertyName ?? metadata.Name,
BindingInfo = bindingInfo,
};
}
}
Expand Down Expand Up @@ -607,6 +617,7 @@ private ApiParameterDescription CreateResult(
Source = source,
Type = bindingContext.ModelMetadata.ModelType,
ParameterDescriptor = Parameter,
BindingInfo = bindingContext.BindingInfo
};
}

Expand Down
45 changes: 40 additions & 5 deletions src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1725,12 +1725,47 @@ public void ProcessIsRequired_SetsTrue_ForFromBodyParameters()
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()
{
Expand All @@ -1747,7 +1782,7 @@ public void ProcessIsRequired_SetsTrue_ForParameterDescriptorsWithBindRequired()
description.ModelMetadata = modelMetadataProvider.GetMetadataForProperty(typeof(Person), nameof(Person.Name));

// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());

// Assert
Assert.True(description.IsRequired);
Expand All @@ -1765,7 +1800,7 @@ public void ProcessIsRequired_SetsTrue_ForRequiredRouteParameterDescriptors()
var context = GetApiParameterContext(description);

// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());

// Assert
Assert.True(description.IsRequired);
Expand All @@ -1779,7 +1814,7 @@ public void ProcessIsRequired_DoesNotSetToTrue_ByDefault()
var context = GetApiParameterContext(description);

// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());

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

// Act
DefaultApiDescriptionProvider.ProcessIsRequired(context);
DefaultApiDescriptionProvider.ProcessIsRequired(context, new MvcOptions());

// Assert
Assert.False(description.IsRequired);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ public partial class FromBodyAttribute : System.Attribute, Microsoft.AspNetCore.
{
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
Expand Down
12 changes: 11 additions & 1 deletion src/Mvc/Mvc.Core/src/FromBodyAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
6 changes: 3 additions & 3 deletions src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public BodyModelBinder(
_options = options;
}

internal bool AllowEmptyBody { get; set; }

/// <inheritdoc />
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
Expand All @@ -116,15 +118,13 @@ public async Task BindModelAsync(ModelBindingContext bindingContext)

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++)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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
{
Expand All @@ -26,7 +27,7 @@ public class BodyModelBinderProvider : IModelBinderProvider
/// <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)
{
}

Expand Down Expand Up @@ -89,10 +90,25 @@ public IModelBinder GetBinder(ModelBinderProviderContext context)
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;
}
}
}
Loading