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
This commit is contained in:
parent
6780f07b3c
commit
7f214492b8
|
|
@ -2,6 +2,7 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Filters
|
||||
{
|
||||
|
|
@ -21,6 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters
|
|||
/// For <see cref="IExceptionFilter"/> implementations, the filter runs only after an exception has occurred,
|
||||
/// and so the observed order of execution will be opposite that of other filters.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("Filter = {Filter.ToString(),nq}, Order = {Order}")]
|
||||
public class FilterDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -43,9 +45,8 @@ namespace Microsoft.AspNetCore.Mvc.Filters
|
|||
Filter = filter;
|
||||
Scope = filterScope;
|
||||
|
||||
var orderedFilter = Filter as IOrderedFilter;
|
||||
|
||||
if (orderedFilter != null)
|
||||
if (Filter is IOrderedFilter orderedFilter)
|
||||
{
|
||||
Order = orderedFilter.Order;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc
|
||||
{
|
||||
/// <summary>
|
||||
/// Options used to configure behavior for types annotated with <see cref="ApiControllerAttribute"/>.
|
||||
/// </summary>
|
||||
public class ApiBehaviorOptions
|
||||
{
|
||||
private Func<ActionContext, IActionResult> _invalidModelStateResponseFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Delegate invoked on actions annotated with <see cref="ApiControllerAttribute"/> to convert invalid
|
||||
/// <see cref="ModelStateDictionary"/> into an <see cref="IActionResult"/>
|
||||
/// <para>
|
||||
/// By default, the delegate produces a <see cref="BadRequestObjectResult"/> using <see cref="ProblemDetails"/>
|
||||
/// as the problem format.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Func<ActionContext, IActionResult> InvalidModelStateResponseFactory
|
||||
{
|
||||
get => _invalidModelStateResponseFactory;
|
||||
set => _invalidModelStateResponseFactory = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables the filter that returns an <see cref="BadRequestObjectResult"/> when
|
||||
/// <see cref="ActionContext.ModelState"/> is invalid.
|
||||
/// <seealso cref="InvalidModelStateResponseFactory"/>.
|
||||
/// </summary>
|
||||
public bool EnableModelStateInvalidFilter { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
/// this attribute can be used to target conventions, filters and other behaviors based on the purpose
|
||||
/// of the controller.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public class ApiControllerAttribute : ControllerAttribute , IApiBehaviorMetadata
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
|
||||
public class ApiControllerAttribute : ControllerAttribute, IApiBehaviorMetadata
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||
//
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetup>());
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Transient<IConfigureOptions<ApiBehaviorOptions>, ApiBehaviorOptionsSetup>());
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Transient<IConfigureOptions<RouteOptions>, MvcCoreRouteOptionsSetup>());
|
||||
|
||||
|
|
@ -157,8 +159,11 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Transient<IApplicationModelProvider, DefaultApplicationModelProvider>());
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Transient<IApplicationModelProvider, ApiControllerApplicationModelProvider>());
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Transient<IActionDescriptorProvider, ControllerActionDescriptorProvider>());
|
||||
|
||||
services.TryAddSingleton<IActionDescriptorCollectionProvider, ActionDescriptorCollectionProvider>();
|
||||
|
||||
//
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.Internal;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="IActionFilter"/> that responds to invalid <see cref="ActionContext.ModelState"/>. This filter is
|
||||
/// added to all types and actions annotated with <see cref="ApiControllerAttribute"/>.
|
||||
/// See <see cref="ApiBehaviorOptions"/> for ways to configure this filter.
|
||||
/// </summary>
|
||||
public class ModelStateInvalidFilter : IActionFilter, IOrderedFilter
|
||||
{
|
||||
private readonly ApiBehaviorOptions _apiBehaviorOptions;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ModelStateInvalidFilter(ApiBehaviorOptions apiBehaviorOptions, ILogger logger)
|
||||
{
|
||||
_apiBehaviorOptions = apiBehaviorOptions ?? throw new ArgumentNullException(nameof(apiBehaviorOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the order value for determining the order of execution of filters. Filters execute in
|
||||
/// ascending numeric value of the <see cref="Order"/> property.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Filters are executed in a sequence determined by an ascending sort of the <see cref="Order"/> property.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default Order for this attribute is -2000 so that it runs early in the pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Look at <see cref="IOrderedFilter.Order"/> for more detailed info.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int Order => -2000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsReusable => true;
|
||||
|
||||
public void OnActionExecuted(ActionExecutedContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
if (context.Result == null && !context.ModelState.IsValid)
|
||||
{
|
||||
_logger.ModelStateInvalidFilterExecuting();
|
||||
context.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Internal
|
||||
{
|
||||
public class ApiBehaviorOptionsSetup : IConfigureOptions<ApiBehaviorOptions>
|
||||
{
|
||||
private readonly IErrorDescriptionFactory _errorDescriptionFactory;
|
||||
|
||||
public ApiBehaviorOptionsSetup(IErrorDescriptionFactory errorDescriptionFactory)
|
||||
{
|
||||
_errorDescriptionFactory = errorDescriptionFactory;
|
||||
}
|
||||
|
||||
public void Configure(ApiBehaviorOptions options)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.InvalidModelStateResponseFactory = GetInvalidModelStateResponse;
|
||||
|
||||
IActionResult GetInvalidModelStateResponse(ActionContext context)
|
||||
{
|
||||
var errorDetails = _errorDescriptionFactory.CreateErrorDescription(
|
||||
context.ActionDescriptor,
|
||||
new ValidationProblemDetails(context.ModelState));
|
||||
|
||||
return new BadRequestObjectResult(errorDetails)
|
||||
{
|
||||
ContentTypes =
|
||||
{
|
||||
"application/problem+json",
|
||||
"application/problem+xml",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Mvc.Core;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Internal
|
||||
{
|
||||
public class ApiControllerApplicationModelProvider : IApplicationModelProvider
|
||||
{
|
||||
private readonly ApiBehaviorOptions _apiBehaviorOptions;
|
||||
private readonly ModelStateInvalidFilter _modelStateInvalidFilter;
|
||||
|
||||
public ApiControllerApplicationModelProvider(IOptions<ApiBehaviorOptions> apiBehaviorOptions, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_apiBehaviorOptions = apiBehaviorOptions.Value;
|
||||
if (_apiBehaviorOptions.EnableModelStateInvalidFilter && _apiBehaviorOptions.InvalidModelStateResponseFactory == null)
|
||||
{
|
||||
throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull(
|
||||
typeof(ApiBehaviorOptions),
|
||||
nameof(ApiBehaviorOptions.InvalidModelStateResponseFactory)));
|
||||
}
|
||||
|
||||
_modelStateInvalidFilter = new ModelStateInvalidFilter(
|
||||
apiBehaviorOptions.Value,
|
||||
loggerFactory.CreateLogger<ModelStateInvalidFilter>());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Order is set to execute after the <see cref="DefaultApplicationModelProvider"/>.
|
||||
/// </remarks>
|
||||
public int Order => -1000 + 10;
|
||||
|
||||
public void OnProvidersExecuted(ApplicationModelProviderContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnProvidersExecuting(ApplicationModelProviderContext context)
|
||||
{
|
||||
foreach (var controllerModel in context.Result.Controllers)
|
||||
{
|
||||
if (controllerModel.Attributes.OfType<IApiBehaviorMetadata>().Any())
|
||||
{
|
||||
if (_apiBehaviorOptions.EnableModelStateInvalidFilter)
|
||||
{
|
||||
Debug.Assert(_apiBehaviorOptions.InvalidModelStateResponseFactory != null);
|
||||
controllerModel.Filters.Add(_modelStateInvalidFilter);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var actionModel in controllerModel.Actions)
|
||||
{
|
||||
if (actionModel.Attributes.OfType<IApiBehaviorMetadata>().Any())
|
||||
{
|
||||
if (_apiBehaviorOptions.EnableModelStateInvalidFilter)
|
||||
{
|
||||
Debug.Assert(_apiBehaviorOptions.InvalidModelStateResponseFactory != null);
|
||||
actionModel.Filters.Add(_modelStateInvalidFilter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal
|
|||
|
||||
private static readonly Action<ILogger, Exception> _cannotApplyRequestFormLimits;
|
||||
private static readonly Action<ILogger, Exception> _appliedRequestFormLimits;
|
||||
|
||||
|
||||
private static readonly Action<ILogger, Exception> _modelStateInvalidFilterExecuting;
|
||||
|
||||
static MvcCoreLoggerExtensions()
|
||||
{
|
||||
|
|
@ -282,6 +283,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal
|
|||
LogLevel.Debug,
|
||||
2,
|
||||
"Applied the configured form options on the current request.");
|
||||
|
||||
_modelStateInvalidFilterExecuting = LoggerMessage.Define(
|
||||
LogLevel.Debug,
|
||||
1,
|
||||
"The request has model state errors, returning an error response.");
|
||||
|
||||
}
|
||||
|
||||
public static IDisposable ActionScope(this ILogger logger, ActionDescriptor action)
|
||||
|
|
@ -592,6 +599,8 @@ namespace Microsoft.AspNetCore.Mvc.Internal
|
|||
_appliedRequestFormLimits(logger, null);
|
||||
}
|
||||
|
||||
public static void ModelStateInvalidFilterExecuting(this ILogger logger) => _modelStateInvalidFilterExecuting(logger, null);
|
||||
|
||||
private class ActionLogScope : IReadOnlyList<KeyValuePair<string, object>>
|
||||
{
|
||||
private readonly ActionDescriptor _action;
|
||||
|
|
|
|||
|
|
@ -248,6 +248,13 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
typeof(MvcCoreRouteOptionsSetup),
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(IConfigureOptions<ApiBehaviorOptions>),
|
||||
new Type[]
|
||||
{
|
||||
typeof(ApiBehaviorOptionsSetup),
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(IActionConstraintProvider),
|
||||
new Type[]
|
||||
|
|
@ -288,6 +295,7 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
new Type[]
|
||||
{
|
||||
typeof(DefaultApplicationModelProvider),
|
||||
typeof(ApiControllerApplicationModelProvider),
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Infrastructure
|
||||
{
|
||||
public class ModelStateInvalidFilterTest
|
||||
{
|
||||
[Fact]
|
||||
public void OnActionExecuting_NoOpsIfResultIsAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ApiBehaviorOptions
|
||||
{
|
||||
InvalidModelStateResponseFactory = _ => new BadRequestResult(),
|
||||
};
|
||||
var filter = new ModelStateInvalidFilter(options, NullLogger.Instance);
|
||||
var context = GetActionExecutingContext();
|
||||
var expected = new OkResult();
|
||||
context.Result = expected;
|
||||
|
||||
// Act
|
||||
filter.OnActionExecuting(context);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expected, context.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnActionExecuting_NoOpsIfModelStateIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ApiBehaviorOptions
|
||||
{
|
||||
InvalidModelStateResponseFactory = _ => new BadRequestResult(),
|
||||
};
|
||||
var filter = new ModelStateInvalidFilter(options, NullLogger.Instance);
|
||||
var context = GetActionExecutingContext();
|
||||
|
||||
// Act
|
||||
filter.OnActionExecuting(context);
|
||||
|
||||
// Assert
|
||||
Assert.Null(context.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnActionExecuting_InvokesResponseFactoryIfModelStateIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var expected = new BadRequestResult();
|
||||
var options = new ApiBehaviorOptions
|
||||
{
|
||||
InvalidModelStateResponseFactory = _ => expected,
|
||||
};
|
||||
var filter = new ModelStateInvalidFilter(options, NullLogger.Instance);
|
||||
var context = GetActionExecutingContext();
|
||||
context.ModelState.AddModelError("some-key", "some-error");
|
||||
|
||||
// Act
|
||||
filter.OnActionExecuting(context);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expected, context.Result);
|
||||
}
|
||||
|
||||
private static ActionExecutingContext GetActionExecutingContext()
|
||||
{
|
||||
return new ActionExecutingContext(
|
||||
new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()),
|
||||
Array.Empty<IFilterMetadata>(),
|
||||
new Dictionary<string, object>(),
|
||||
new object());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.Internal
|
||||
{
|
||||
public class ApiControllerApplicationModelProviderTest
|
||||
{
|
||||
[Fact]
|
||||
public void OnProvidersExecuting_AddsModelStateInvalidFilter_IfTypeIsAnnotatedWithAttribute()
|
||||
{
|
||||
// Arrange
|
||||
var context = GetContext(typeof(TestApiController));
|
||||
var options = new TestOptionsManager<ApiBehaviorOptions>(new ApiBehaviorOptions
|
||||
{
|
||||
InvalidModelStateResponseFactory = _ => null,
|
||||
});
|
||||
|
||||
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Act
|
||||
provider.OnProvidersExecuting(context);
|
||||
|
||||
// Assert
|
||||
var controllerModel = Assert.Single(context.Result.Controllers);
|
||||
Assert.IsType<ModelStateInvalidFilter>(controllerModel.Filters.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnProvidersExecuting_DoesNotAddModelStateInvalidFilterToController_IfFeatureIsDisabledViaOptions()
|
||||
{
|
||||
// Arrange
|
||||
var context = GetContext(typeof(TestApiController));
|
||||
var options = new TestOptionsManager<ApiBehaviorOptions>(new ApiBehaviorOptions
|
||||
{
|
||||
EnableModelStateInvalidFilter = false,
|
||||
});
|
||||
|
||||
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Act
|
||||
provider.OnProvidersExecuting(context);
|
||||
|
||||
// Assert
|
||||
var controllerModel = Assert.Single(context.Result.Controllers);
|
||||
Assert.DoesNotContain(typeof(ModelStateInvalidFilter), controllerModel.Filters.Select(f => f.GetType()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnProvidersExecuting_AddsModelStateInvalidFilter_IfActionIsAnnotatedWithAttribute()
|
||||
{
|
||||
// Arrange
|
||||
var context = GetContext(typeof(SimpleController));
|
||||
var options = new TestOptionsManager<ApiBehaviorOptions>(new ApiBehaviorOptions
|
||||
{
|
||||
InvalidModelStateResponseFactory = _ => null,
|
||||
});
|
||||
|
||||
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Act
|
||||
provider.OnProvidersExecuting(context);
|
||||
|
||||
// Assert
|
||||
Assert.Collection(
|
||||
Assert.Single(context.Result.Controllers).Actions.OrderBy(a => a.ActionName),
|
||||
action =>
|
||||
{
|
||||
Assert.Contains(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType()));
|
||||
},
|
||||
action =>
|
||||
{
|
||||
Assert.DoesNotContain(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType()));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnProvidersExecuting_SkipsAddingFilterToActionIfFeatureIsDisabledUsingOptions()
|
||||
{
|
||||
// Arrange
|
||||
var context = GetContext(typeof(SimpleController));
|
||||
var options = new TestOptionsManager<ApiBehaviorOptions>(new ApiBehaviorOptions
|
||||
{
|
||||
EnableModelStateInvalidFilter = false,
|
||||
});
|
||||
|
||||
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Act
|
||||
provider.OnProvidersExecuting(context);
|
||||
|
||||
// Assert
|
||||
Assert.Collection(
|
||||
Assert.Single(context.Result.Controllers).Actions.OrderBy(a => a.ActionName),
|
||||
action =>
|
||||
{
|
||||
Assert.DoesNotContain(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType()));
|
||||
},
|
||||
action =>
|
||||
{
|
||||
Assert.DoesNotContain(typeof(ModelStateInvalidFilter), action.Filters.Select(f => f.GetType()));
|
||||
});
|
||||
}
|
||||
|
||||
private static ApplicationModelProviderContext GetContext(Type type)
|
||||
{
|
||||
var context = new ApplicationModelProviderContext(new[] { type.GetTypeInfo() });
|
||||
new DefaultApplicationModelProvider(new TestOptionsManager<MvcOptions>()).OnProvidersExecuting(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
private class TestApiController : Controller
|
||||
{
|
||||
public IActionResult TestAction() => null;
|
||||
}
|
||||
|
||||
|
||||
private class SimpleController : Controller
|
||||
{
|
||||
public IActionResult ActionWithoutFilter() => null;
|
||||
|
||||
[TestApiBehavior]
|
||||
public IActionResult ActionWithFilter() => null;
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
private class TestApiBehavior : Attribute, IApiBehaviorMetadata
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// 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.
|
||||
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using BasicWebSite;
|
||||
using BasicWebSite.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
|
||||
{
|
||||
public class ApiControllerAttributeTests : IClassFixture<MvcTestFixture<BasicWebSite.Startup>>
|
||||
{
|
||||
public ApiControllerAttributeTests(MvcTestFixture<BasicWebSite.Startup> fixture)
|
||||
{
|
||||
Client = fixture.Client;
|
||||
}
|
||||
|
||||
public HttpClient Client { get; }
|
||||
|
||||
[Fact]
|
||||
public async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var contactModel = new Contact
|
||||
{
|
||||
Name = "Abc",
|
||||
City = "Redmond",
|
||||
State = "WA",
|
||||
Zip = "Invalid",
|
||||
};
|
||||
var expected = new ValidationProblemDetails
|
||||
{
|
||||
Errors =
|
||||
{
|
||||
["Zip"] = new[] { @"The field Zip must match the regular expression '\d{5}'." },
|
||||
["Name"] = new[] { "The field Name must be a string with a minimum length of 5 and a maximum length of 30." },
|
||||
},
|
||||
};
|
||||
var contactString = JsonConvert.SerializeObject(contactModel);
|
||||
|
||||
// Act
|
||||
var response = await Client.PostAsJsonAsync("/contact", contactModel);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("application/problem+json", response.Content.Headers.ContentType.MediaType);
|
||||
var actual = JsonConvert.DeserializeObject<ValidationProblemDetails>(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(expected.Errors.Count, actual.Errors.Count);
|
||||
foreach (var error in expected.Errors)
|
||||
{
|
||||
Assert.Equal(error.Value, actual.Errors[error.Key]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActionsReturnBadRequest_UsesProblemDescriptionProviderAndApiConventionsToConfigureErrorResponse()
|
||||
{
|
||||
// Arrange
|
||||
var contactModel = new Contact
|
||||
{
|
||||
Name = "Abc",
|
||||
City = "Redmond",
|
||||
State = "WA",
|
||||
Zip = "Invalid",
|
||||
};
|
||||
var expected = new[]
|
||||
{
|
||||
new VndError
|
||||
{
|
||||
Path = "Name",
|
||||
Message = "The field Name must be a string with a minimum length of 5 and a maximum length of 30.",
|
||||
},
|
||||
new VndError
|
||||
{
|
||||
Path = "Zip",
|
||||
Message = @"The field Zip must match the regular expression '\d{5}'.",
|
||||
},
|
||||
};
|
||||
var contactString = JsonConvert.SerializeObject(contactModel);
|
||||
|
||||
// Act
|
||||
var response = await Client.PostAsJsonAsync("/contact/PostWithVnd", contactModel);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("application/vnd.error+json", response.Content.Headers.ContentType.MediaType);
|
||||
var actual = JsonConvert.DeserializeObject<VndError[]>(await response.Content.ReadAsStringAsync());
|
||||
actual = actual.OrderBy(e => e.Path).ToArray();
|
||||
Assert.Equal(expected.Length, actual.Length);
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
Assert.Equal(expected[i].Path, expected[i].Path);
|
||||
Assert.Equal(expected[i].Message, expected[i].Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -348,6 +348,13 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
typeof(MvcCoreRouteOptionsSetup),
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(IConfigureOptions<ApiBehaviorOptions>),
|
||||
new Type[]
|
||||
{
|
||||
typeof(ApiBehaviorOptionsSetup),
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(IConfigureOptions<MvcViewOptions>),
|
||||
new Type[]
|
||||
|
|
@ -410,6 +417,7 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
typeof(CorsApplicationModelProvider),
|
||||
typeof(AuthorizationApplicationModelProvider),
|
||||
typeof(TempDataApplicationModelProvider),
|
||||
typeof(ApiControllerApplicationModelProvider),
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
// 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.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BasicWebSite.Models;
|
||||
|
||||
namespace BasicWebSite
|
||||
{
|
||||
public class ContactsRepository
|
||||
{
|
||||
private readonly List<Contact> _contacts = new List<Contact>();
|
||||
|
||||
public Contact GetContact(int id)
|
||||
{
|
||||
return _contacts.FirstOrDefault(f => f.ContactId == id);
|
||||
}
|
||||
|
||||
public void Add(Contact contact)
|
||||
{
|
||||
contact.ContactId = _contacts.Count + 1;
|
||||
_contacts.Add(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BasicWebSite.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BasicWebSite
|
||||
{
|
||||
[ApiController]
|
||||
[Route("/contact")]
|
||||
public class ContactApiController : Controller
|
||||
{
|
||||
private readonly ContactsRepository _repository;
|
||||
|
||||
public ContactApiController(ContactsRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<Contact> Get(int id)
|
||||
{
|
||||
var contact = _repository.GetContact(id);
|
||||
if (contact == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public ActionResult<Contact> Post([FromBody] Contact contact)
|
||||
{
|
||||
_repository.Add(contact);
|
||||
return CreatedAtAction(nameof(Get), new { id = contact.ContactId }, contact);
|
||||
}
|
||||
|
||||
[VndError]
|
||||
[HttpPost("PostWithVnd")]
|
||||
public ActionResult<Contact> PostWithVnd([FromBody] Contact contact)
|
||||
{
|
||||
_repository.Add(contact);
|
||||
return CreatedAtAction(nameof(Get), new { id = contact.ContactId }, contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
// 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.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BasicWebSite.Models
|
||||
{
|
||||
public class Contact
|
||||
{
|
||||
public int ContactId { get; set; }
|
||||
|
||||
[StringLength(30, MinimumLength = 5)]
|
||||
public string Name { get; set; }
|
||||
|
||||
public GenderType Gender { get; set; }
|
||||
|
|
@ -17,6 +20,7 @@ namespace BasicWebSite.Models
|
|||
|
||||
public string State { get; set; }
|
||||
|
||||
[RegularExpression(@"\d{5}")]
|
||||
public string Zip { get; set; }
|
||||
|
||||
public string Email { get; set; }
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BasicWebSite
|
||||
|
|
@ -16,13 +19,30 @@ namespace BasicWebSite
|
|||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddMvc(
|
||||
options => { options.Conventions.Add(new ApplicationDescription("This is a basic website.")); })
|
||||
.AddMvc(options => options.Conventions.Add(new ApplicationDescription("This is a basic website.")))
|
||||
.AddXmlDataContractSerializerFormatters();
|
||||
|
||||
services.Configure<ApiBehaviorOptions>(options =>
|
||||
{
|
||||
var previous = options.InvalidModelStateResponseFactory;
|
||||
options.InvalidModelStateResponseFactory = context =>
|
||||
{
|
||||
var result = (BadRequestObjectResult) previous(context);
|
||||
if (context.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is VndErrorAttribute))
|
||||
{
|
||||
result.ContentTypes.Clear();
|
||||
result.ContentTypes.Add("application/vnd.error+json");
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
});
|
||||
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IActionDescriptorProvider, ActionDescriptorCreationCounter>();
|
||||
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||
services.AddSingleton<ContactsRepository>();
|
||||
services.AddSingleton<IErrorDescriptorProvider, VndErrorDescriptionProvider>();
|
||||
services.AddScoped<RequestIdService>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BasicWebSite
|
||||
{
|
||||
public class VndError
|
||||
{
|
||||
public string LogRef { get; set; }
|
||||
|
||||
public string Path { get; set; }
|
||||
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace BasicWebSite
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class VndErrorAttribute : Attribute, IFilterMetadata
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// 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.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BasicWebSite
|
||||
{
|
||||
public class VndErrorDescriptionProvider : IErrorDescriptorProvider
|
||||
{
|
||||
public int Order => 0;
|
||||
|
||||
public void OnProvidersExecuting(ErrorDescriptionContext context)
|
||||
{
|
||||
if (context.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is VndErrorAttribute) &&
|
||||
context.Result is ValidationProblemDetails problemDetails)
|
||||
{
|
||||
var vndErrors = new List<VndError>();
|
||||
foreach (var item in problemDetails.Errors)
|
||||
{
|
||||
foreach (var message in item.Value)
|
||||
{
|
||||
vndErrors.Add(new VndError
|
||||
{
|
||||
LogRef = problemDetails.Title,
|
||||
Path = item.Key,
|
||||
Message = message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
context.Result = vndErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue