Add helper methods on ControllerBase to return ProblemDetails (#12298)

* Add helper methods on ControllerBase to return ProblemDetails

* Introduce ControllerBase.Problem and ValidationProblem overload
  that accepts optional parameters
* Consistently use ClientErrorData when generating ProblemDetails
* Clean-up InvalidModelStateResponseFactory initialization.

Fixes https://github.com/aspnet/AspNetCore/issues/8537
This commit is contained in:
Pranav K 2019-08-05 11:28:28 -07:00 committed by GitHub
parent 2ff6a5c0f8
commit 709b390157
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 716 additions and 166 deletions

View File

@ -271,6 +271,7 @@ namespace Microsoft.AspNetCore.Mvc
public Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderFactory ModelBinderFactory { get { throw null; } set { } }
public Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary ModelState { get { throw null; } }
public Microsoft.AspNetCore.Mvc.ModelBinding.Validation.IObjectModelValidator ObjectValidator { get { throw null; } set { } }
public Microsoft.AspNetCore.Mvc.Infrastructure.ProblemDetailsFactory ProblemDetailsFactory { get { throw null; } set { } }
public Microsoft.AspNetCore.Http.HttpRequest Request { get { throw null; } }
public Microsoft.AspNetCore.Http.HttpResponse Response { get { throw null; } }
public Microsoft.AspNetCore.Routing.RouteData RouteData { get { throw null; } }
@ -445,6 +446,8 @@ namespace Microsoft.AspNetCore.Mvc
[Microsoft.AspNetCore.Mvc.NonActionAttribute]
public virtual Microsoft.AspNetCore.Mvc.PhysicalFileResult PhysicalFile(string physicalPath, string contentType, string fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue entityTag, bool enableRangeProcessing) { throw null; }
[Microsoft.AspNetCore.Mvc.NonActionAttribute]
public virtual Microsoft.AspNetCore.Mvc.ObjectResult Problem(string detail = null, string instance = null, int? statusCode = default(int?), string title = null, string type = null) { throw null; }
[Microsoft.AspNetCore.Mvc.NonActionAttribute]
public virtual Microsoft.AspNetCore.Mvc.RedirectResult Redirect(string url) { throw null; }
[Microsoft.AspNetCore.Mvc.NonActionAttribute]
public virtual Microsoft.AspNetCore.Mvc.RedirectResult RedirectPermanent(string url) { throw null; }
@ -586,6 +589,8 @@ namespace Microsoft.AspNetCore.Mvc
public virtual Microsoft.AspNetCore.Mvc.ActionResult ValidationProblem([Microsoft.AspNetCore.Mvc.Infrastructure.ActionResultObjectValueAttribute]Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelStateDictionary) { throw null; }
[Microsoft.AspNetCore.Mvc.NonActionAttribute]
public virtual Microsoft.AspNetCore.Mvc.ActionResult ValidationProblem([Microsoft.AspNetCore.Mvc.Infrastructure.ActionResultObjectValueAttribute]Microsoft.AspNetCore.Mvc.ValidationProblemDetails descriptor) { throw null; }
[Microsoft.AspNetCore.Mvc.NonActionAttribute]
public virtual Microsoft.AspNetCore.Mvc.ActionResult ValidationProblem(string detail = null, string instance = null, int? statusCode = default(int?), string title = null, string type = null, [Microsoft.AspNetCore.Mvc.Infrastructure.ActionResultObjectValueAttribute]Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelStateDictionary = null) { throw null; }
}
public partial class ControllerContext : Microsoft.AspNetCore.Mvc.ActionContext
{
@ -2439,6 +2444,12 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
public long Length { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
}
public abstract partial class ProblemDetailsFactory
{
protected ProblemDetailsFactory() { }
public abstract Microsoft.AspNetCore.Mvc.ProblemDetails CreateProblemDetails(Microsoft.AspNetCore.Http.HttpContext httpContext, int? statusCode = default(int?), string title = null, string type = null, string detail = null, string instance = null);
public abstract Microsoft.AspNetCore.Mvc.ValidationProblemDetails CreateValidationProblemDetails(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelStateDictionary, int? statusCode = default(int?), string title = null, string type = null, string detail = null, string instance = null);
}
public partial class RedirectResultExecutor : Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultExecutor<Microsoft.AspNetCore.Mvc.RedirectResult>
{
public RedirectResultExecutor(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory urlHelperFactory) { }

View File

@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Microsoft.AspNetCore.Mvc
@ -31,6 +32,7 @@ namespace Microsoft.AspNetCore.Mvc
private IModelBinderFactory _modelBinderFactory;
private IObjectModelValidator _objectValidator;
private IUrlHelper _url;
private ProblemDetailsFactory _problemDetailsFactory;
/// <summary>
/// Gets the <see cref="Http.HttpContext"/> for the executing action.
@ -189,6 +191,28 @@ namespace Microsoft.AspNetCore.Mvc
}
}
public ProblemDetailsFactory ProblemDetailsFactory
{
get
{
if (_problemDetailsFactory == null)
{
_problemDetailsFactory = HttpContext?.RequestServices?.GetRequiredService<ProblemDetailsFactory>();
}
return _problemDetailsFactory;
}
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_problemDetailsFactory = value;
}
}
/// <summary>
/// Gets the <see cref="ClaimsPrincipal"/> for user associated with the executing action.
/// </summary>
@ -1821,6 +1845,34 @@ namespace Microsoft.AspNetCore.Mvc
public virtual ConflictObjectResult Conflict([ActionResultObjectValue] ModelStateDictionary modelState)
=> new ConflictObjectResult(modelState);
/// <summary>
/// Creates an <see cref="ObjectResult"/> that produces a <see cref="ProblemDetails"/> response.
/// </summary>
/// <param name="statusCode">The value for <see cref="ProblemDetails.Status" />..</param>
/// <param name="detail">The value for <see cref="ProblemDetails.Detail" />.</param>
/// <param name="instance">The value for <see cref="ProblemDetails.Instance" />.</param>
/// <param name="title">The value for <see cref="ProblemDetails.Title" />.</param>
/// <param name="type">The value for <see cref="ProblemDetails.Type" />.</param>
/// <returns>The created <see cref="ObjectResult"/> for the response.</returns>
[NonAction]
public virtual ObjectResult Problem(
string detail = null,
string instance = null,
int? statusCode = null,
string title = null,
string type = null)
{
var problemDetails = ProblemDetailsFactory.CreateProblemDetails(
HttpContext,
statusCode: statusCode ?? 500,
title: title,
type: type,
detail: detail,
instance: instance);
return new ObjectResult(problemDetails);
}
/// <summary>
/// Creates an <see cref="BadRequestObjectResult"/> that produces a <see cref="StatusCodes.Status400BadRequest"/> response.
/// </summary>
@ -1837,31 +1889,64 @@ namespace Microsoft.AspNetCore.Mvc
}
/// <summary>
/// Creates an <see cref="BadRequestObjectResult"/> that produces a <see cref="StatusCodes.Status400BadRequest"/> response.
/// Creates an <see cref="ActionResult"/> that produces a <see cref="StatusCodes.Status400BadRequest"/> response
/// with validation errors from <paramref name="modelStateDictionary"/>.
/// </summary>
/// <param name="modelStateDictionary">The <see cref="ModelStateDictionary"/>.</param>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction]
public virtual ActionResult ValidationProblem([ActionResultObjectValue] ModelStateDictionary modelStateDictionary)
{
if (modelStateDictionary == null)
{
throw new ArgumentNullException(nameof(modelStateDictionary));
}
=> ValidationProblem(detail: null, modelStateDictionary: modelStateDictionary);
var validationProblem = new ValidationProblemDetails(modelStateDictionary);
return new BadRequestObjectResult(validationProblem);
}
/// <summary>
/// Creates an <see cref="BadRequestObjectResult"/> that produces a <see cref="StatusCodes.Status400BadRequest"/> response
/// Creates an <see cref="ActionResult"/> that produces a <see cref="StatusCodes.Status400BadRequest"/> response
/// with validation errors from <see cref="ModelState"/>.
/// </summary>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
/// <returns>The created <see cref="ActionResult"/> for the response.</returns>
[NonAction]
public virtual ActionResult ValidationProblem()
=> ValidationProblem(ModelState);
/// <summary>
/// Creates an <see cref="ActionResult"/> that produces a <see cref="StatusCodes.Status400BadRequest"/> response
/// with a <see cref="ValidationProblemDetails"/> value.
/// </summary>
/// <param name="detail">The value for <see cref="ProblemDetails.Detail" />.</param>
/// <param name="instance">The value for <see cref="ProblemDetails.Instance" />.</param>
/// <param name="statusCode">The status code.</param>
/// <param name="title">The value for <see cref="ProblemDetails.Title" />.</param>
/// <param name="type">The value for <see cref="ProblemDetails.Type" />.</param>
/// <param name="modelStateDictionary">The <see cref="ModelStateDictionary"/>.
/// When <see langword="null"/> uses <see cref="ModelState"/>.</param>
/// <returns>The created <see cref="ActionResult"/> for the response.</returns>
[NonAction]
public virtual ActionResult ValidationProblem(
string detail = null,
string instance = null,
int? statusCode = null,
string title = null,
string type = null,
[ActionResultObjectValue] ModelStateDictionary modelStateDictionary = null)
{
var validationProblem = new ValidationProblemDetails(ModelState);
return new BadRequestObjectResult(validationProblem);
modelStateDictionary ??= ModelState;
var validationProblem = ProblemDetailsFactory.CreateValidationProblemDetails(
HttpContext,
modelStateDictionary,
statusCode: statusCode,
title: title,
type: type,
detail: detail,
instance: instance);
if (validationProblem.Status == 400)
{
// For compatibility with 2.x, continue producing BadRequestObjectResult instances if the status code is 400.
return new BadRequestObjectResult(validationProblem);
}
return new ObjectResult(validationProblem);
}
/// <summary>

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.AspNetCore.Mvc.Infrastructure;
@ -10,13 +9,9 @@ using Microsoft.Extensions.Options;
namespace Microsoft.Extensions.DependencyInjection
{
internal class ApiBehaviorOptionsSetup :
IConfigureOptions<ApiBehaviorOptions>,
IPostConfigureOptions<ApiBehaviorOptions>
internal class ApiBehaviorOptionsSetup : IConfigureOptions<ApiBehaviorOptions>
{
internal static readonly Func<ActionContext, IActionResult> DefaultFactory = DefaultInvalidModelStateResponse;
internal static readonly Func<ActionContext, IActionResult> ProblemDetailsFactory =
ProblemDetailsInvalidModelStateResponse;
private ProblemDetailsFactory _problemDetailsFactory;
public void Configure(ApiBehaviorOptions options)
{
@ -25,20 +20,34 @@ namespace Microsoft.Extensions.DependencyInjection
throw new ArgumentNullException(nameof(options));
}
options.InvalidModelStateResponseFactory = DefaultFactory;
options.InvalidModelStateResponseFactory = context =>
{
// ProblemDetailsFactory depends on the ApiBehaviorOptions instance. We intentionally avoid constructor injecting
// it in this options setup to to avoid a DI cycle.
_problemDetailsFactory ??= context.HttpContext.RequestServices.GetRequiredService<ProblemDetailsFactory>();
return ProblemDetailsInvalidModelStateResponse(_problemDetailsFactory, context);
};
ConfigureClientErrorMapping(options);
}
public void PostConfigure(string name, ApiBehaviorOptions options)
internal static IActionResult ProblemDetailsInvalidModelStateResponse(ProblemDetailsFactory problemDetailsFactory, ActionContext context)
{
// We want to use problem details factory only if
// (a) it has not been opted out of (SuppressMapClientErrors = true)
// (b) a different factory was configured
if (!options.SuppressMapClientErrors &&
object.ReferenceEquals(options.InvalidModelStateResponseFactory, DefaultFactory))
var problemDetails = problemDetailsFactory.CreateValidationProblemDetails(context.HttpContext, context.ModelState);
ObjectResult result;
if (problemDetails.Status == 400)
{
options.InvalidModelStateResponseFactory = ProblemDetailsFactory;
// For compatibility with 2.x, continue producing BadRequestObjectResult instances if the status code is 400.
result = new BadRequestObjectResult(problemDetails);
}
else
{
result = new ObjectResult(problemDetails);
}
result.ContentTypes.Add("application/problem+json");
result.ContentTypes.Add("application/problem+xml");
return result;
}
// Internal for unit testing
@ -91,33 +100,12 @@ namespace Microsoft.Extensions.DependencyInjection
Link = "https://tools.ietf.org/html/rfc4918#section-11.2",
Title = Resources.ApiConventions_Title_422,
};
}
private static IActionResult DefaultInvalidModelStateResponse(ActionContext context)
{
var result = new BadRequestObjectResult(context.ModelState);
result.ContentTypes.Add("application/json");
result.ContentTypes.Add("application/xml");
return result;
}
internal static IActionResult ProblemDetailsInvalidModelStateResponse(ActionContext context)
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
options.ClientErrorMapping[500] = new ClientErrorData
{
Status = StatusCodes.Status400BadRequest,
Link = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
Title = Resources.ApiConventions_Title_500,
};
ProblemDetailsClientErrorFactory.SetTraceId(context, problemDetails);
var result = new BadRequestObjectResult(problemDetails);
result.ContentTypes.Add("application/problem+json");
result.ContentTypes.Add("application/problem+xml");
return result;
}
}
}

View File

@ -149,8 +149,6 @@ namespace Microsoft.Extensions.DependencyInjection
ServiceDescriptor.Transient<IPostConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetup>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IConfigureOptions<ApiBehaviorOptions>, ApiBehaviorOptionsSetup>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IPostConfigureOptions<ApiBehaviorOptions>, ApiBehaviorOptionsSetup>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IConfigureOptions<RouteOptions>, MvcCoreRouteOptionsSetup>());
@ -260,6 +258,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddSingleton<IActionResultExecutor<ContentResult>, ContentResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<JsonResult>, SystemTextJsonResultExecutor>();
services.TryAddSingleton<IClientErrorFactory, ProblemDetailsClientErrorFactory>();
services.TryAddSingleton<ProblemDetailsFactory, DefaultProblemDetailsFactory>();
//
// Route Handlers

View File

@ -0,0 +1,97 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
internal sealed class DefaultProblemDetailsFactory : ProblemDetailsFactory
{
private readonly ApiBehaviorOptions _options;
public DefaultProblemDetailsFactory(IOptions<ApiBehaviorOptions> options)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public override ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string title = null,
string type = null,
string detail = null,
string instance = null)
{
statusCode ??= 500;
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Type = type,
Detail = detail,
Instance = instance,
};
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
return problemDetails;
}
public override ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string title = null,
string type = null,
string detail = null,
string instance = null)
{
if (modelStateDictionary == null)
{
throw new ArgumentNullException(nameof(modelStateDictionary));
}
statusCode ??= 400;
var problemDetails = new ValidationProblemDetails(modelStateDictionary)
{
Status = statusCode,
Type = type,
Detail = detail,
Instance = instance,
};
if (title != null)
{
// For validation problem details, don't overwrite the default title with null.
problemDetails.Title = title;
}
ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value);
return problemDetails;
}
private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode)
{
problemDetails.Status ??= statusCode;
if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData))
{
problemDetails.Title ??= clientErrorData.Title;
problemDetails.Type ??= clientErrorData.Link;
}
var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier;
if (traceId != null)
{
problemDetails.Extensions["traceId"] = traceId;
}
}
}
}

View File

@ -2,37 +2,21 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
internal class ProblemDetailsClientErrorFactory : IClientErrorFactory
{
private static readonly string TraceIdentifierKey = "traceId";
private readonly ApiBehaviorOptions _options;
private readonly ProblemDetailsFactory _problemDetailsFactory;
public ProblemDetailsClientErrorFactory(IOptions<ApiBehaviorOptions> options)
public ProblemDetailsClientErrorFactory(ProblemDetailsFactory problemDetailsFactory)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_problemDetailsFactory = problemDetailsFactory ?? throw new ArgumentNullException(nameof(problemDetailsFactory));
}
public IActionResult GetClientError(ActionContext actionContext, IClientErrorActionResult clientError)
{
var problemDetails = new ProblemDetails
{
Status = clientError.StatusCode,
Type = "about:blank",
};
if (clientError.StatusCode is int statusCode &&
_options.ClientErrorMapping.TryGetValue(statusCode, out var errorData))
{
problemDetails.Title = errorData.Title;
problemDetails.Type = errorData.Link;
SetTraceId(actionContext, problemDetails);
}
var problemDetails = _problemDetailsFactory.CreateProblemDetails(actionContext.HttpContext, clientError.StatusCode);
return new ObjectResult(problemDetails)
{
@ -44,11 +28,5 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
},
};
}
internal static void SetTraceId(ActionContext actionContext, ProblemDetails problemDetails)
{
var traceId = Activity.Current?.Id ?? actionContext.HttpContext.TraceIdentifier;
problemDetails.Extensions[TraceIdentifierKey] = traceId;
}
}
}

View File

@ -0,0 +1,52 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
/// <summary>
/// Factory to produce <see cref="ProblemDetails" /> and <see cref="ValidationProblemDetails" />.
/// </summary>
public abstract class ProblemDetailsFactory
{
/// <summary>
/// Creates a <see cref="ProblemDetails" /> instance that configures defaults based on values specified in <see cref="ApiBehaviorOptions" />.
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext" />.</param>
/// <param name="statusCode">The value for <see cref="ProblemDetails.Status"/>.</param>
/// <param name="title">The value for <see cref="ProblemDetails.Title" />.</param>
/// <param name="type">The value for <see cref="ProblemDetails.Type" />.</param>
/// <param name="detail">The value for <see cref="ProblemDetails.Detail" />.</param>
/// <param name="instance">The value for <see cref="ProblemDetails.Instance" />.</param>
/// <returns>The <see cref="ProblemDetails"/> instance.</returns>
public abstract ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string title = null,
string type = null,
string detail = null,
string instance = null);
/// <summary>
/// Creates a <see cref="ValidationProblemDetails" /> instance that configures defaults based on values specified in <see cref="ApiBehaviorOptions" />.
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext" />.</param>
/// <param name="modelStateDictionary">The <see cref="ModelStateDictionary" />.</param>
/// <param name="statusCode">The value for <see cref="ProblemDetails.Status"/>.</param>
/// <param name="title">The value for <see cref="ProblemDetails.Title" />.</param>
/// <param name="type">The value for <see cref="ProblemDetails.Type" />.</param>
/// <param name="detail">The value for <see cref="ProblemDetails.Detail" />.</param>
/// <param name="instance">The value for <see cref="ProblemDetails.Instance" />.</param>
/// <returns>The <see cref="ValidationProblemDetails"/> instance.</returns>
public abstract ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string title = null,
string type = null,
string detail = null,
string instance = null);
}
}

View File

@ -510,4 +510,7 @@
<data name="UnexpectedJsonEnd" xml:space="preserve">
<value>Unexcepted end when reading JSON.</value>
</data>
<data name="ApiConventions_Title_500" xml:space="preserve">
<value>An error occured while processing your request.</value>
</data>
</root>

View File

@ -9,8 +9,11 @@ using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Routing;
@ -2187,7 +2190,6 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test
{
// Arrange
var contentController = new ContentController();
var expected = MediaTypeHeaderValue.Parse("text/plain; charset=utf-8");
// Act
var contentResult = (ContentResult)contentController.Content_WithNoEncoding();
@ -2290,6 +2292,163 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test
Assert.Equal(statusCode, result.StatusCode);
}
[Fact]
public void ValidationProblemDetails_Works()
{
// Arrange
var context = new ControllerContext(new ActionContext(
new DefaultHttpContext { TraceIdentifier = "some-trace" },
new RouteData(),
new ControllerActionDescriptor()));
context.ModelState.AddModelError("key1", "error1");
var options = GetApiBehaviorOptions();
var controller = new TestableController
{
ProblemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(options)),
ControllerContext = context,
};
// Act
var actionResult = controller.ValidationProblem();
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(actionResult);
var problemDetails = Assert.IsType<ValidationProblemDetails>(badRequestResult.Value);
Assert.Equal(400, problemDetails.Status);
Assert.Equal("One or more validation errors occurred.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type);
Assert.Equal("some-trace", problemDetails.Extensions["traceId"]);
Assert.Equal(new[] { "error1" }, problemDetails.Errors["key1"]);
}
[Fact]
public void ValidationProblemDetails_UsesSpecifiedTitle()
{
// Arrange
var detail = "My detail";
var title = "Custom title";
var type = "http://custom-link";
var options = GetApiBehaviorOptions();
var controller = new TestableController
{
ProblemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(options)),
};
// Act
var actionResult = controller.ValidationProblem(detail: detail, title: title, type: type);
// Assert
var badRequestResult = Assert.IsType<BadRequestObjectResult>(actionResult);
var problemDetails = Assert.IsType<ValidationProblemDetails>(badRequestResult.Value);
Assert.Equal(title, problemDetails.Title);
Assert.Equal(type, problemDetails.Type);
Assert.Equal(detail, problemDetails.Detail);
}
[Fact]
public void ProblemDetails_Works()
{
// Arrange
var context = new ControllerContext(new ActionContext(
new DefaultHttpContext { TraceIdentifier = "some-trace" },
new RouteData(),
new ControllerActionDescriptor()));
var options = GetApiBehaviorOptions();
var controller = new TestableController
{
ProblemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(options)),
ControllerContext = context,
};
// Act
var actionResult = controller.Problem();
// Assert
var badRequestResult = Assert.IsType<ObjectResult>(actionResult);
var problemDetails = Assert.IsType<ProblemDetails>(badRequestResult.Value);
Assert.Equal(500, problemDetails.Status);
Assert.Equal("An error occured while processing your request.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.6.1", problemDetails.Type);
Assert.Equal("some-trace", problemDetails.Extensions["traceId"]);
}
[Fact]
public void ProblemDetails_UsesPassedInValues()
{
// Arrange
var title = "The website is down.";
var detail = "Try again in a few minutes.";
var options = GetApiBehaviorOptions();
var controller = new TestableController
{
ProblemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(options)),
};
// Act
var actionResult = controller.Problem(detail, title: title);
// Assert
var badRequestResult = Assert.IsType<ObjectResult>(actionResult);
var problemDetails = Assert.IsType<ProblemDetails>(badRequestResult.Value);
Assert.Equal(500, problemDetails.Status);
Assert.Equal(title, problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.6.1", problemDetails.Type);
Assert.Equal(detail, problemDetails.Detail);
}
[Fact]
public void ProblemDetails_UsesPassedInStatusCode()
{
// Arrange
var options = GetApiBehaviorOptions();
var controller = new TestableController
{
ProblemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(options)),
};
// Act
var actionResult = controller.Problem(statusCode: 422);
// Assert
var badRequestResult = Assert.IsType<ObjectResult>(actionResult);
var problemDetails = Assert.IsType<ProblemDetails>(badRequestResult.Value);
Assert.Equal(422, problemDetails.Status);
Assert.Equal("Unprocessable entity.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc4918#section-11.2", problemDetails.Type);
}
private static ApiBehaviorOptions GetApiBehaviorOptions()
{
return new ApiBehaviorOptions
{
ClientErrorMapping =
{
[400] = new ClientErrorData
{
Title = "One or more validation errors occurred.",
Link = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
},
[422] = new ClientErrorData
{
Title = "Unprocessable entity.",
Link = "https://tools.ietf.org/html/rfc4918#section-11.2"
},
[500] = new ClientErrorData
{
Title = "An error occured while processing your request.",
Link = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
}
}
};
}
public static IEnumerable<object[]> RedirectTestData
{
get

View File

@ -6,31 +6,18 @@ using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Xunit;
namespace Microsoft.Extensions.DependencyInjection
{
public class ApiBehaviorOptionsSetupTest
{
[Fact]
public void Configure_AssignsInvalidModelStateResponseFactory()
{
// Arrange
var optionsSetup = new ApiBehaviorOptionsSetup();
var options = new ApiBehaviorOptions();
// Act
optionsSetup.Configure(options);
// Assert
Assert.Same(ApiBehaviorOptionsSetup.DefaultFactory, options.InvalidModelStateResponseFactory);
}
[Fact]
public void Configure_AddsClientErrorMappings()
{
// Arrange
var expected = new[] { 400, 401, 403, 404, 406, 409, 415, 422, };
var expected = new[] { 400, 401, 403, 404, 406, 409, 415, 422, 500, };
var optionsSetup = new ApiBehaviorOptionsSetup();
var options = new ApiBehaviorOptions();
@ -41,50 +28,15 @@ namespace Microsoft.Extensions.DependencyInjection
Assert.Equal(expected, options.ClientErrorMapping.Keys);
}
[Fact]
public void PostConfigure_SetProblemDetailsModelStateResponseFactory()
{
// Arrange
var optionsSetup = new ApiBehaviorOptionsSetup();
var options = new ApiBehaviorOptions();
// Act
optionsSetup.Configure(options);
optionsSetup.PostConfigure(string.Empty, options);
// Assert
Assert.Same(ApiBehaviorOptionsSetup.ProblemDetailsFactory, options.InvalidModelStateResponseFactory);
}
[Fact]
public void PostConfigure_DoesNotSetProblemDetailsFactory_IfValueWasModified()
{
// Arrange
var optionsSetup = new ApiBehaviorOptionsSetup();
var options = new ApiBehaviorOptions();
Func<ActionContext, IActionResult> expected = _ => null;
// Act
optionsSetup.Configure(options);
// This is equivalent to user code updating the value via ConfigureOptions
options.InvalidModelStateResponseFactory = expected;
optionsSetup.PostConfigure(string.Empty, options);
// Assert
Assert.Same(expected, options.InvalidModelStateResponseFactory);
}
[Fact]
public void ProblemDetailsInvalidModelStateResponse_ReturnsBadRequestWithProblemDetails()
{
// Arrange
var actionContext = new ActionContext
{
HttpContext = new DefaultHttpContext { TraceIdentifier = "42" },
};
var actionContext = GetActionContext();
var factory = GetProblemDetailsFactory();
// Act
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(actionContext);
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(factory, actionContext);
// Assert
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
@ -92,6 +44,30 @@ namespace Microsoft.Extensions.DependencyInjection
var problemDetails = Assert.IsType<ValidationProblemDetails>(badRequest.Value);
Assert.Equal(400, problemDetails.Status);
Assert.Equal("One or more validation errors occurred.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type);
}
[Fact]
public void ProblemDetailsInvalidModelStateResponse_UsesUserConfiguredLink()
{
// Arrange
var link = "http://mylink";
var actionContext = GetActionContext();
var factory = GetProblemDetailsFactory(options => options.ClientErrorMapping[400].Link = link);
// Act
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(factory, actionContext);
// Assert
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, badRequest.ContentTypes.OrderBy(c => c));
var problemDetails = Assert.IsType<ValidationProblemDetails>(badRequest.Value);
Assert.Equal(400, problemDetails.Status);
Assert.Equal("One or more validation errors occurred.", problemDetails.Title);
Assert.Equal(link, problemDetails.Type);
}
[Fact]
@ -100,13 +76,11 @@ namespace Microsoft.Extensions.DependencyInjection
// Arrange
using (new ActivityReplacer())
{
var actionContext = new ActionContext
{
HttpContext = new DefaultHttpContext { TraceIdentifier = "42" },
};
var actionContext = GetActionContext();
var factory = GetProblemDetailsFactory();
// Act
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(actionContext);
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(factory, actionContext);
// Assert
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
@ -119,18 +93,38 @@ namespace Microsoft.Extensions.DependencyInjection
public void ProblemDetailsInvalidModelStateResponse_SetsTraceIdFromRequest_IfActivityIsNull()
{
// Arrange
var actionContext = new ActionContext
{
HttpContext = new DefaultHttpContext { TraceIdentifier = "42" },
};
var actionContext = GetActionContext();
var factory = GetProblemDetailsFactory();
// Act
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(actionContext);
var result = ApiBehaviorOptionsSetup.ProblemDetailsInvalidModelStateResponse(factory, actionContext);
// Assert
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
var problemDetails = Assert.IsType<ValidationProblemDetails>(badRequest.Value);
Assert.Equal("42", problemDetails.Extensions["traceId"]);
}
private static ProblemDetailsFactory GetProblemDetailsFactory(Action<ApiBehaviorOptions> configure = null)
{
var options = new ApiBehaviorOptions();
var setup = new ApiBehaviorOptionsSetup();
setup.Configure(options);
if (configure != null)
{
configure(options);
}
return new DefaultProblemDetailsFactory(Options.Options.Create(options));
}
private static ActionContext GetActionContext()
{
return new ActionContext
{
HttpContext = new DefaultHttpContext { TraceIdentifier = "42" },
};
}
}
}

View File

@ -264,13 +264,6 @@ namespace Microsoft.AspNetCore.Mvc
typeof(ApiBehaviorOptionsSetup),
}
},
{
typeof(IPostConfigureOptions<ApiBehaviorOptions>),
new Type[]
{
typeof(ApiBehaviorOptionsSetup),
}
},
{
typeof(IActionConstraintProvider),
new Type[]

View File

@ -1,8 +1,8 @@
// 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.IO;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;

View File

@ -0,0 +1,188 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
public class ProblemDetailsFactoryTest
{
private readonly ProblemDetailsFactory Factory = GetProblemDetails();
[Fact]
public void CreateProblemDetails_DefaultValues()
{
// Act
var problemDetails = Factory.CreateProblemDetails(GetHttpContext());
// Assert
Assert.Equal(500, problemDetails.Status);
Assert.Equal("An error occured while processing your request.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.6.1", problemDetails.Type);
Assert.Null(problemDetails.Instance);
Assert.Null(problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal("some-trace", kvp.Value);
});
}
[Fact]
public void CreateProblemDetails_WithStatusCode()
{
// Act
var problemDetails = Factory.CreateProblemDetails(GetHttpContext(), statusCode: 406);
// Assert
Assert.Equal(406, problemDetails.Status);
Assert.Equal("Not Acceptable", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.6", problemDetails.Type);
Assert.Null(problemDetails.Instance);
Assert.Null(problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal("some-trace", kvp.Value);
});
}
[Fact]
public void CreateProblemDetails_WithDetailAndTitle()
{
// Act
var title = "Some title";
var detail = "some detail";
var problemDetails = Factory.CreateProblemDetails(GetHttpContext(), statusCode: 406, title: title, detail: detail);
// Assert
Assert.Equal(406, problemDetails.Status);
Assert.Equal(title, problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.6", problemDetails.Type);
Assert.Null(problemDetails.Instance);
Assert.Equal(detail, problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal("some-trace", kvp.Value);
});
}
[Fact]
public void CreateValidationProblemDetails_DefaultValues()
{
// Act
var modelState = new ModelStateDictionary();
modelState.AddModelError("some-key", "some-value");
var problemDetails = Factory.CreateValidationProblemDetails(GetHttpContext(), modelState);
// Assert
Assert.Equal(400, problemDetails.Status);
Assert.Equal("One or more validation errors occurred.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type);
Assert.Null(problemDetails.Instance);
Assert.Null(problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal("some-trace", kvp.Value);
});
Assert.Collection(
problemDetails.Errors,
kvp =>
{
Assert.Equal("some-key", kvp.Key);
Assert.Equal(new[] { "some-value" }, kvp.Value);
});
}
[Fact]
public void CreateValidationProblemDetails_WithStatusCode()
{
// Act
var modelState = new ModelStateDictionary();
modelState.AddModelError("some-key", "some-value");
var problemDetails = Factory.CreateValidationProblemDetails(GetHttpContext(), modelState, 422);
// Assert
Assert.Equal(422, problemDetails.Status);
Assert.Equal("One or more validation errors occurred.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc4918#section-11.2", problemDetails.Type);
Assert.Null(problemDetails.Instance);
Assert.Null(problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal("some-trace", kvp.Value);
});
Assert.Collection(
problemDetails.Errors,
kvp =>
{
Assert.Equal("some-key", kvp.Key);
Assert.Equal(new[] { "some-value" }, kvp.Value);
});
}
[Fact]
public void CreateValidationProblemDetails_WithTitleAndInstance()
{
// Act
var title = "Some title";
var instance = "some instance";
var modelState = new ModelStateDictionary();
modelState.AddModelError("some-key", "some-value");
var problemDetails = Factory.CreateValidationProblemDetails(GetHttpContext(), modelState, title: title, instance: instance);
// Assert
Assert.Equal(400, problemDetails.Status);
Assert.Equal(title, problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type);
Assert.Equal(instance, problemDetails.Instance);
Assert.Null(problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal("some-trace", kvp.Value);
});
Assert.Collection(
problemDetails.Errors,
kvp =>
{
Assert.Equal("some-key", kvp.Key);
Assert.Equal(new[] { "some-value" }, kvp.Value);
});
}
private static DefaultHttpContext GetHttpContext()
{
return new DefaultHttpContext
{
TraceIdentifier = "some-trace",
};
}
private static ProblemDetailsFactory GetProblemDetails()
{
var options = new ApiBehaviorOptions();
new ApiBehaviorOptionsSetup().Configure(options);
return new DefaultProblemDetailsFactory(Options.Create(options));
}
}
}

View File

@ -15,13 +15,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
// Arrange
var clientError = new UnsupportedMediaTypeResult();
var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions
var problemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(new ApiBehaviorOptions
{
ClientErrorMapping =
{
[405] = new ClientErrorData { Link = "Some link", Title = "Summary" },
},
}));
var factory = new ProblemDetailsClientErrorFactory(problemDetailsFactory);
// Act
var result = factory.GetClientError(GetActionContext(), clientError);
@ -31,7 +32,6 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
Assert.Equal(new[] { "application/problem+json", "application/problem+xml" }, objectResult.ContentTypes);
var problemDetails = Assert.IsType<ProblemDetails>(objectResult.Value);
Assert.Equal(415, problemDetails.Status);
Assert.Equal("about:blank", problemDetails.Type);
Assert.Null(problemDetails.Title);
Assert.Null(problemDetails.Detail);
Assert.Null(problemDetails.Instance);
@ -42,13 +42,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
// Arrange
var clientError = new UnsupportedMediaTypeResult();
var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions
var problemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(new ApiBehaviorOptions
{
ClientErrorMapping =
{
[415] = new ClientErrorData { Link = "Some link", Title = "Summary" },
},
}));
var factory = new ProblemDetailsClientErrorFactory(problemDetailsFactory);
// Act
var result = factory.GetClientError(GetActionContext(), clientError);
@ -71,13 +72,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
using (new ActivityReplacer())
{
var clientError = new UnsupportedMediaTypeResult();
var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions
var problemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(new ApiBehaviorOptions
{
ClientErrorMapping =
{
[415] = new ClientErrorData { Link = "Some link", Title = "Summary" },
[405] = new ClientErrorData { Link = "Some link", Title = "Summary" },
},
}));
var factory = new ProblemDetailsClientErrorFactory(problemDetailsFactory);
// Act
var result = factory.GetClientError(GetActionContext(), clientError);
@ -96,13 +98,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
// Arrange
var clientError = new UnsupportedMediaTypeResult();
var factory = new ProblemDetailsClientErrorFactory(Options.Create(new ApiBehaviorOptions
var problemDetailsFactory = new DefaultProblemDetailsFactory(Options.Create(new ApiBehaviorOptions
{
ClientErrorMapping =
{
[415] = new ClientErrorData { Link = "Some link", Title = "Summary" },
[405] = new ClientErrorData { Link = "Some link", Title = "Summary" },
},
}));
var factory = new ProblemDetailsClientErrorFactory(problemDetailsFactory);
// Act
var result = factory.GetClientError(GetActionContext(), clientError);

View File

@ -56,6 +56,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
Converters = { new ValidationProblemDetailsConverter() }
});
Assert.Equal("One or more validation errors occurred.", problemDetails.Title);
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problemDetails.Type);
Assert.Collection(
problemDetails.Errors.OrderBy(kvp => kvp.Key),
kvp =>

View File

@ -24,15 +24,11 @@ namespace BasicWebSite
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");
}
var result = new BadRequestObjectResult(context.ModelState);
result.ContentTypes.Clear();
result.ContentTypes.Add("application/vnd.error+json");
return result;
};