Skip to content
This repository was archived by the owner on Dec 14, 2018. It is now read-only.

Commit 7f21449

Browse files
authored
Introduce a filter to send bad request results with details when ModelState is invalid (#6849)
* Introduce a filter to send bad request results with details when ModelState is invalid Fixes #6789
1 parent 6780f07 commit 7f21449

File tree

20 files changed

+756
-7
lines changed

20 files changed

+756
-7
lines changed

src/Microsoft.AspNetCore.Mvc.Abstractions/Filters/FilterDescriptor.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Diagnostics;
56

67
namespace Microsoft.AspNetCore.Mvc.Filters
78
{
@@ -21,6 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters
2122
/// For <see cref="IExceptionFilter"/> implementations, the filter runs only after an exception has occurred,
2223
/// and so the observed order of execution will be opposite that of other filters.
2324
/// </remarks>
25+
[DebuggerDisplay("Filter = {Filter.ToString(),nq}, Order = {Order}")]
2426
public class FilterDescriptor
2527
{
2628
/// <summary>
@@ -43,9 +45,8 @@ public FilterDescriptor(IFilterMetadata filter, int filterScope)
4345
Filter = filter;
4446
Scope = filterScope;
4547

46-
var orderedFilter = Filter as IOrderedFilter;
4748

48-
if (orderedFilter != null)
49+
if (Filter is IOrderedFilter orderedFilter)
4950
{
5051
Order = orderedFilter.Order;
5152
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
using System;
5+
using Microsoft.AspNetCore.Mvc.ModelBinding;
6+
7+
namespace Microsoft.AspNetCore.Mvc
8+
{
9+
/// <summary>
10+
/// Options used to configure behavior for types annotated with <see cref="ApiControllerAttribute"/>.
11+
/// </summary>
12+
public class ApiBehaviorOptions
13+
{
14+
private Func<ActionContext, IActionResult> _invalidModelStateResponseFactory;
15+
16+
/// <summary>
17+
/// Delegate invoked on actions annotated with <see cref="ApiControllerAttribute"/> to convert invalid
18+
/// <see cref="ModelStateDictionary"/> into an <see cref="IActionResult"/>
19+
/// <para>
20+
/// By default, the delegate produces a <see cref="BadRequestObjectResult"/> using <see cref="ProblemDetails"/>
21+
/// as the problem format.
22+
/// </para>
23+
/// </summary>
24+
public Func<ActionContext, IActionResult> InvalidModelStateResponseFactory
25+
{
26+
get => _invalidModelStateResponseFactory;
27+
set => _invalidModelStateResponseFactory = value ?? throw new ArgumentNullException(nameof(value));
28+
}
29+
30+
/// <summary>
31+
/// Disables the filter that returns an <see cref="BadRequestObjectResult"/> when
32+
/// <see cref="ActionContext.ModelState"/> is invalid.
33+
/// <seealso cref="InvalidModelStateResponseFactory"/>.
34+
/// </summary>
35+
public bool EnableModelStateInvalidFilter { get; set; } = true;
36+
}
37+
}

src/Microsoft.AspNetCore.Mvc.Core/ApiControllerAttribute.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ namespace Microsoft.AspNetCore.Mvc
1111
/// this attribute can be used to target conventions, filters and other behaviors based on the purpose
1212
/// of the controller.
1313
/// </summary>
14-
[AttributeUsage(AttributeTargets.Class)]
15-
public class ApiControllerAttribute : ControllerAttribute , IApiBehaviorMetadata
14+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
15+
public class ApiControllerAttribute : ControllerAttribute, IApiBehaviorMetadata
1616
{
1717
}
1818
}

src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ internal static void AddMvcCoreServices(IServiceCollection services)
147147
//
148148
services.TryAddEnumerable(
149149
ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetup>());
150+
services.TryAddEnumerable(
151+
ServiceDescriptor.Transient<IConfigureOptions<ApiBehaviorOptions>, ApiBehaviorOptionsSetup>());
150152
services.TryAddEnumerable(
151153
ServiceDescriptor.Transient<IConfigureOptions<RouteOptions>, MvcCoreRouteOptionsSetup>());
152154

@@ -157,8 +159,11 @@ internal static void AddMvcCoreServices(IServiceCollection services)
157159

158160
services.TryAddEnumerable(
159161
ServiceDescriptor.Transient<IApplicationModelProvider, DefaultApplicationModelProvider>());
162+
services.TryAddEnumerable(
163+
ServiceDescriptor.Transient<IApplicationModelProvider, ApiControllerApplicationModelProvider>());
160164
services.TryAddEnumerable(
161165
ServiceDescriptor.Transient<IActionDescriptorProvider, ControllerActionDescriptorProvider>());
166+
162167
services.TryAddSingleton<IActionDescriptorCollectionProvider, ActionDescriptorCollectionProvider>();
163168

164169
//
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
using System;
5+
using Microsoft.AspNetCore.Mvc.Filters;
6+
using Microsoft.AspNetCore.Mvc.Internal;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace Microsoft.AspNetCore.Mvc.Infrastructure
10+
{
11+
/// <summary>
12+
/// A <see cref="IActionFilter"/> that responds to invalid <see cref="ActionContext.ModelState"/>. This filter is
13+
/// added to all types and actions annotated with <see cref="ApiControllerAttribute"/>.
14+
/// See <see cref="ApiBehaviorOptions"/> for ways to configure this filter.
15+
/// </summary>
16+
public class ModelStateInvalidFilter : IActionFilter, IOrderedFilter
17+
{
18+
private readonly ApiBehaviorOptions _apiBehaviorOptions;
19+
private readonly ILogger _logger;
20+
21+
public ModelStateInvalidFilter(ApiBehaviorOptions apiBehaviorOptions, ILogger logger)
22+
{
23+
_apiBehaviorOptions = apiBehaviorOptions ?? throw new ArgumentNullException(nameof(apiBehaviorOptions));
24+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
25+
}
26+
27+
/// <summary>
28+
/// Gets the order value for determining the order of execution of filters. Filters execute in
29+
/// ascending numeric value of the <see cref="Order"/> property.
30+
/// </summary>
31+
/// <remarks>
32+
/// <para>
33+
/// Filters are executed in a sequence determined by an ascending sort of the <see cref="Order"/> property.
34+
/// </para>
35+
/// <para>
36+
/// The default Order for this attribute is -2000 so that it runs early in the pipeline.
37+
/// </para>
38+
/// <para>
39+
/// Look at <see cref="IOrderedFilter.Order"/> for more detailed info.
40+
/// </para>
41+
/// </remarks>
42+
public int Order => -2000;
43+
44+
/// <inheritdoc />
45+
public bool IsReusable => true;
46+
47+
public void OnActionExecuted(ActionExecutedContext context)
48+
{
49+
}
50+
51+
public void OnActionExecuting(ActionExecutingContext context)
52+
{
53+
if (context.Result == null && !context.ModelState.IsValid)
54+
{
55+
_logger.ModelStateInvalidFilterExecuting();
56+
context.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context);
57+
}
58+
}
59+
}
60+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
using System;
5+
using Microsoft.AspNetCore.Mvc.Infrastructure;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace Microsoft.AspNetCore.Mvc.Internal
9+
{
10+
public class ApiBehaviorOptionsSetup : IConfigureOptions<ApiBehaviorOptions>
11+
{
12+
private readonly IErrorDescriptionFactory _errorDescriptionFactory;
13+
14+
public ApiBehaviorOptionsSetup(IErrorDescriptionFactory errorDescriptionFactory)
15+
{
16+
_errorDescriptionFactory = errorDescriptionFactory;
17+
}
18+
19+
public void Configure(ApiBehaviorOptions options)
20+
{
21+
if (options == null)
22+
{
23+
throw new ArgumentNullException(nameof(options));
24+
}
25+
26+
options.InvalidModelStateResponseFactory = GetInvalidModelStateResponse;
27+
28+
IActionResult GetInvalidModelStateResponse(ActionContext context)
29+
{
30+
var errorDetails = _errorDescriptionFactory.CreateErrorDescription(
31+
context.ActionDescriptor,
32+
new ValidationProblemDetails(context.ModelState));
33+
34+
return new BadRequestObjectResult(errorDetails)
35+
{
36+
ContentTypes =
37+
{
38+
"application/problem+json",
39+
"application/problem+xml",
40+
},
41+
};
42+
}
43+
}
44+
}
45+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
using System;
5+
using System.Diagnostics;
6+
using System.Linq;
7+
using Microsoft.AspNetCore.Mvc.ApplicationModels;
8+
using Microsoft.AspNetCore.Mvc.Core;
9+
using Microsoft.AspNetCore.Mvc.Infrastructure;
10+
using Microsoft.Extensions.Logging;
11+
using Microsoft.Extensions.Options;
12+
13+
namespace Microsoft.AspNetCore.Mvc.Internal
14+
{
15+
public class ApiControllerApplicationModelProvider : IApplicationModelProvider
16+
{
17+
private readonly ApiBehaviorOptions _apiBehaviorOptions;
18+
private readonly ModelStateInvalidFilter _modelStateInvalidFilter;
19+
20+
public ApiControllerApplicationModelProvider(IOptions<ApiBehaviorOptions> apiBehaviorOptions, ILoggerFactory loggerFactory)
21+
{
22+
_apiBehaviorOptions = apiBehaviorOptions.Value;
23+
if (_apiBehaviorOptions.EnableModelStateInvalidFilter && _apiBehaviorOptions.InvalidModelStateResponseFactory == null)
24+
{
25+
throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull(
26+
typeof(ApiBehaviorOptions),
27+
nameof(ApiBehaviorOptions.InvalidModelStateResponseFactory)));
28+
}
29+
30+
_modelStateInvalidFilter = new ModelStateInvalidFilter(
31+
apiBehaviorOptions.Value,
32+
loggerFactory.CreateLogger<ModelStateInvalidFilter>());
33+
}
34+
35+
/// <remarks>
36+
/// Order is set to execute after the <see cref="DefaultApplicationModelProvider"/>.
37+
/// </remarks>
38+
public int Order => -1000 + 10;
39+
40+
public void OnProvidersExecuted(ApplicationModelProviderContext context)
41+
{
42+
}
43+
44+
public void OnProvidersExecuting(ApplicationModelProviderContext context)
45+
{
46+
foreach (var controllerModel in context.Result.Controllers)
47+
{
48+
if (controllerModel.Attributes.OfType<IApiBehaviorMetadata>().Any())
49+
{
50+
if (_apiBehaviorOptions.EnableModelStateInvalidFilter)
51+
{
52+
Debug.Assert(_apiBehaviorOptions.InvalidModelStateResponseFactory != null);
53+
controllerModel.Filters.Add(_modelStateInvalidFilter);
54+
}
55+
56+
continue;
57+
}
58+
59+
foreach (var actionModel in controllerModel.Actions)
60+
{
61+
if (actionModel.Attributes.OfType<IApiBehaviorMetadata>().Any())
62+
{
63+
if (_apiBehaviorOptions.EnableModelStateInvalidFilter)
64+
{
65+
Debug.Assert(_apiBehaviorOptions.InvalidModelStateResponseFactory != null);
66+
actionModel.Filters.Add(_modelStateInvalidFilter);
67+
}
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}

src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreLoggerExtensions.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ internal static class MvcCoreLoggerExtensions
7979

8080
private static readonly Action<ILogger, Exception> _cannotApplyRequestFormLimits;
8181
private static readonly Action<ILogger, Exception> _appliedRequestFormLimits;
82-
82+
83+
private static readonly Action<ILogger, Exception> _modelStateInvalidFilterExecuting;
8384

8485
static MvcCoreLoggerExtensions()
8586
{
@@ -282,6 +283,12 @@ static MvcCoreLoggerExtensions()
282283
LogLevel.Debug,
283284
2,
284285
"Applied the configured form options on the current request.");
286+
287+
_modelStateInvalidFilterExecuting = LoggerMessage.Define(
288+
LogLevel.Debug,
289+
1,
290+
"The request has model state errors, returning an error response.");
291+
285292
}
286293

287294
public static IDisposable ActionScope(this ILogger logger, ActionDescriptor action)
@@ -592,6 +599,8 @@ public static void AppliedRequestFormLimits(this ILogger logger)
592599
_appliedRequestFormLimits(logger, null);
593600
}
594601

602+
public static void ModelStateInvalidFilterExecuting(this ILogger logger) => _modelStateInvalidFilterExecuting(logger, null);
603+
595604
private class ActionLogScope : IReadOnlyList<KeyValuePair<string, object>>
596605
{
597606
private readonly ActionDescriptor _action;

test/Microsoft.AspNetCore.Mvc.Core.Test/DependencyInjection/MvcCoreServiceCollectionExtensionsTest.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,13 @@ private Dictionary<Type, Type[]> MutliRegistrationServiceTypes
248248
typeof(MvcCoreRouteOptionsSetup),
249249
}
250250
},
251+
{
252+
typeof(IConfigureOptions<ApiBehaviorOptions>),
253+
new Type[]
254+
{
255+
typeof(ApiBehaviorOptionsSetup),
256+
}
257+
},
251258
{
252259
typeof(IActionConstraintProvider),
253260
new Type[]
@@ -288,6 +295,7 @@ private Dictionary<Type, Type[]> MutliRegistrationServiceTypes
288295
new Type[]
289296
{
290297
typeof(DefaultApplicationModelProvider),
298+
typeof(ApiControllerApplicationModelProvider),
291299
}
292300
},
293301
};

0 commit comments

Comments
 (0)