Introduce opinionated API defaults.

* Introduce ProblemDescriptionAttribute to enhance some 4xx messages and produce better API description.
* Introduce IErrorDescriptionProvider to modify the shape of error response.

Fixes #6785, Fixes #6786
This commit is contained in:
Pranav K 2017-09-07 11:24:57 -07:00
parent 037c1ec47d
commit 776c2604f8
17 changed files with 348 additions and 43 deletions

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Abstractions;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer namespace Microsoft.AspNetCore.Mvc.ApiExplorer
@ -9,6 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// <summary> /// <summary>
/// Represents an API exposed by this application. /// Represents an API exposed by this application.
/// </summary> /// </summary>
[DebuggerDisplay("{ActionDescriptor.DisplayName,nq}")]
public class ApiDescription public class ApiDescription
{ {
/// <summary> /// <summary>

View File

@ -26,6 +26,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>(); services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>();
services.TryAddEnumerable( services.TryAddEnumerable(
ServiceDescriptor.Transient<IApiDescriptionProvider, DefaultApiDescriptionProvider>()); ServiceDescriptor.Transient<IApiDescriptionProvider, DefaultApiDescriptionProvider>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IApiDescriptionProvider, ProblemDetailsApiDescriptionProvider>());
} }
} }
} }

View File

@ -0,0 +1,77 @@
// 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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
public class ProblemDetailsApiDescriptionProvider : IApiDescriptionProvider
{
private readonly IModelMetadataProvider _modelMetadaProvider;
public ProblemDetailsApiDescriptionProvider(IModelMetadataProvider modelMetadataProvider)
{
_modelMetadaProvider = modelMetadataProvider;
}
/// <remarks>
/// The order is set to execute after the <see cref="DefaultApiDescriptionProvider"/>.
/// </remarks>
public int Order => -1000 + 10;
public void OnProvidersExecuted(ApiDescriptionProviderContext context)
{
}
public void OnProvidersExecuting(ApiDescriptionProviderContext context)
{
foreach (var apiDescription in context.Results)
{
if (!apiDescription.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is ProblemDetailsAttribute))
{
continue;
}
var parameters = apiDescription.ActionDescriptor.Parameters.Concat(apiDescription.ActionDescriptor.BoundProperties);
if (parameters.Any())
{
apiDescription.SupportedResponseTypes.Add(CreateProblemResponse(StatusCodes.Status400BadRequest));
if (parameters.Any(p => p.Name.EndsWith("id", StringComparison.OrdinalIgnoreCase)))
{
apiDescription.SupportedResponseTypes.Add(CreateProblemResponse(StatusCodes.Status404NotFound));
}
}
// We don't have a good way to signal a "default" response type. We'll use 0 to indicate this until we come up
// with something better.
apiDescription.SupportedResponseTypes.Add(CreateProblemResponse(statusCode: 0));
}
}
private ApiResponseType CreateProblemResponse(int statusCode)
{
return new ApiResponseType
{
ApiResponseFormats = new List<ApiResponseFormat>
{
new ApiResponseFormat
{
MediaType = "application/problem+json",
},
new ApiResponseFormat
{
MediaType = "application/problem+xml",
},
},
ModelMetadata = _modelMetadaProvider.GetMetadataForType(typeof(ProblemDetails)),
StatusCode = statusCode,
Type = typeof(ProblemDetails),
};
}
}
}

View File

@ -1503,7 +1503,7 @@ namespace Microsoft.AspNetCore.Mvc
/// </summary> /// </summary>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns> /// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction] [NonAction]
public virtual BadRequestObjectResult ValidationProblem(ValidationProblemDescription descriptor) public virtual ActionResult ValidationProblem(ValidationProblemDetails descriptor)
{ {
if (descriptor == null) if (descriptor == null)
{ {
@ -1518,14 +1518,14 @@ namespace Microsoft.AspNetCore.Mvc
/// </summary> /// </summary>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns> /// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction] [NonAction]
public virtual BadRequestObjectResult ValidationProblem(ModelStateDictionary modelStateDictionary) public virtual ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary)
{ {
if (modelStateDictionary == null) if (modelStateDictionary == null)
{ {
throw new ArgumentNullException(nameof(modelStateDictionary)); throw new ArgumentNullException(nameof(modelStateDictionary));
} }
var validationProblem = new ValidationProblemDescription(modelStateDictionary); var validationProblem = new ValidationProblemDetails(modelStateDictionary);
return new BadRequestObjectResult(validationProblem); return new BadRequestObjectResult(validationProblem);
} }
@ -1535,9 +1535,9 @@ namespace Microsoft.AspNetCore.Mvc
/// </summary> /// </summary>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns> /// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction] [NonAction]
public virtual BadRequestObjectResult ValidationProblem() public virtual ActionResult ValidationProblem()
{ {
var validationProblem = new ValidationProblemDescription(ModelState); var validationProblem = new ValidationProblemDetails(ModelState);
return new BadRequestObjectResult(validationProblem); return new BadRequestObjectResult(validationProblem);
} }

View File

@ -204,6 +204,9 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddTransient<RequestSizeLimitFilter>(); services.TryAddTransient<RequestSizeLimitFilter>();
services.TryAddTransient<DisableRequestSizeLimitFilter>(); services.TryAddTransient<DisableRequestSizeLimitFilter>();
// Error description
services.TryAddSingleton<IErrorDescriptionFactory, DefaultErrorDescriptorFactory>();
// //
// ModelBinding, Validation // ModelBinding, Validation
// //

View File

@ -0,0 +1,19 @@
// 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.Mvc.Abstractions;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
public class ErrorDescriptionContext
{
public ErrorDescriptionContext(ActionDescriptor actionDescriptor)
{
ActionDescriptor = actionDescriptor;
}
public ActionDescriptor ActionDescriptor { get; }
public object Result { get; set; }
}
}

View File

@ -0,0 +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 Microsoft.AspNetCore.Mvc.Abstractions;
namespace Microsoft.AspNetCore.Mvc.Infrastructure
{
/// <summary>
/// Defines a contract for creating or modifying an error response.
/// </summary>
public interface IErrorDescriptionFactory
{
object CreateErrorDescription(ActionDescriptor actionDescriptor, object result);
}
}

View File

@ -0,0 +1,12 @@
// 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.Infrastructure
{
public interface IErrorDescriptorProvider
{
int Order { get; }
void OnProvidersExecuting(ErrorDescriptionContext context);
}
}

View File

@ -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 Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Microsoft.AspNetCore.Mvc.Internal
{
public class DefaultErrorDescriptorFactory : IErrorDescriptionFactory
{
private readonly IErrorDescriptorProvider[] _providers;
public DefaultErrorDescriptorFactory(IEnumerable<IErrorDescriptorProvider> providers)
{
if (providers == null)
{
throw new ArgumentNullException(nameof(providers));
}
_providers = providers.OrderBy(p => p.Order).ToArray();
}
public object CreateErrorDescription(ActionDescriptor actionDescriptor, object result)
{
if (actionDescriptor == null)
{
throw new ArgumentNullException(nameof(actionDescriptor));
}
if (result == null)
{
throw new ArgumentNullException(nameof(result));
}
var context = new ErrorDescriptionContext(actionDescriptor)
{
Result = result,
};
for (var i = 0; i < _providers.Length; i++)
{
_providers[i].OnProvidersExecuting(context);
}
return context.Result ?? result;
}
}
}

View File

@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Mvc
/// <summary> /// <summary>
/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. /// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807.
/// </summary> /// </summary>
public class ProblemDescription public class ProblemDetails
{ {
/// <summary> /// <summary>
/// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when

View File

@ -0,0 +1,16 @@
// 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 Microsoft.AspNetCore.Mvc
{
/// <summary>
/// Adds an <see cref="IFilterMetadata"/> that indicates to the framework that the current action conforms to well-known API behavior.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class ProblemDetailsAttribute : Attribute, IFilterMetadata
{
}
}

View File

@ -9,19 +9,19 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Microsoft.AspNetCore.Mvc namespace Microsoft.AspNetCore.Mvc
{ {
/// <summary> /// <summary>
/// A <see cref="ProblemDescription"/> for validation errors. /// A <see cref="ProblemDetails"/> for validation errors.
/// </summary> /// </summary>
public class ValidationProblemDescription : ProblemDescription public class ValidationProblemDetails : ProblemDetails
{ {
/// <summary> /// <summary>
/// Intializes a new instance of <see cref="ValidationProblemDescription"/>. /// Intializes a new instance of <see cref="ValidationProblemDetails"/>.
/// </summary> /// </summary>
public ValidationProblemDescription() public ValidationProblemDetails()
{ {
Title = Resources.ValidationProblemDescription_Title; Title = Resources.ValidationProblemDescription_Title;
} }
public ValidationProblemDescription(ModelStateDictionary modelState) public ValidationProblemDetails(ModelStateDictionary modelState)
: this() : this()
{ {
if (modelState == null) if (modelState == null)
@ -62,7 +62,7 @@ namespace Microsoft.AspNetCore.Mvc
} }
/// <summary> /// <summary>
/// Gets or sets the validation errors associated with this instance of <see cref="ValidationProblemDescription"/>. /// Gets or sets the validation errors associated with this instance of <see cref="ValidationProblemDetails"/>.
/// </summary> /// </summary>
public IDictionary<string, string[]> Errors { get; } = new Dictionary<string, string[]>(StringComparer.Ordinal); public IDictionary<string, string[]> Errors { get; } = new Dictionary<string, string[]>(StringComparer.Ordinal);
} }

View File

@ -6,13 +6,13 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc namespace Microsoft.AspNetCore.Mvc
{ {
public class ValidationProblemDescriptionTest public class ValidationProblemDetailsTest
{ {
[Fact] [Fact]
public void Constructor_SetsTitle() public void Constructor_SetsTitle()
{ {
// Arrange & Act // Arrange & Act
var problemDescription = new ValidationProblemDescription(); var problemDescription = new ValidationProblemDetails();
// Assert // Assert
Assert.Equal("One or more validation errors occured.", problemDescription.Title); Assert.Equal("One or more validation errors occured.", problemDescription.Title);
@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc
modelStateDictionary.AddModelError("key3", "error3"); modelStateDictionary.AddModelError("key3", "error3");
// Act // Act
var problemDescription = new ValidationProblemDescription(modelStateDictionary); var problemDescription = new ValidationProblemDetails(modelStateDictionary);
// Assert // Assert
Assert.Equal("One or more validation errors occured.", problemDescription.Title); Assert.Equal("One or more validation errors occured.", problemDescription.Title);

View File

@ -1065,6 +1065,88 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("ApiExplorerInboundOutbound/SuppressedForLinkGeneration", description.RelativePath); Assert.Equal("ApiExplorerInboundOutbound/SuppressedForLinkGeneration", description.RelativePath);
} }
[Fact]
public async Task ProblemDetails_AddsProblemAsDefaultErrorResult()
{
// Act
var body = await Client.GetStringAsync("ApiExplorerProblemDetails/ActionWithoutParameters");
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(body);
// Assert
var description = Assert.Single(result);
Assert.Collection(
description.SupportedResponseTypes,
response =>
{
Assert.Equal(0, response.StatusCode);
AssertProblemDetails(response);
});
}
[Fact]
public async Task ProblemDetails_AddsProblemAsErrorResultForBadResult_WhenActionHasParameters()
{
// Act
var body = await Client.GetStringAsync("ApiExplorerProblemDetails/ActionWithSomeParameters");
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(body);
// Assert
var description = Assert.Single(result);
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
response =>
{
Assert.Equal(0, response.StatusCode);
AssertProblemDetails(response);
},
response => Assert.Equal(200, response.StatusCode),
response =>
{
Assert.Equal(400, response.StatusCode);
AssertProblemDetails(response);
});
}
[Theory]
[InlineData("ApiExplorerProblemDetails/ActionWithIdParameter")]
[InlineData("ApiExplorerProblemDetails/ActionWithIdSuffixParameter")]
public async Task ProblemDetails_AddsProblemAsErrorResultForNotFoundResult_WhenActionHasAnIdParameters(string url)
{
// Act
var body = await Client.GetStringAsync(url);
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(body);
// Assert
var description = Assert.Single(result);
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
response =>
{
Assert.Equal(0, response.StatusCode);
AssertProblemDetails(response);
},
response => Assert.Equal(200, response.StatusCode),
response =>
{
Assert.Equal(400, response.StatusCode);
AssertProblemDetails(response);
},
response =>
{
Assert.Equal(404, response.StatusCode);
AssertProblemDetails(response);
});
}
private void AssertProblemDetails(ApiExplorerResponseType response)
{
Assert.Equal("Microsoft.AspNetCore.Mvc.ProblemDetails", response.ResponseType);
Assert.Collection(
GetSortedMediaTypes(response),
mediaType => Assert.Equal("application/problem+json", mediaType),
mediaType => Assert.Equal("application/problem+xml", mediaType));
}
private IEnumerable<string> GetSortedMediaTypes(ApiExplorerResponseType apiResponseType) private IEnumerable<string> GetSortedMediaTypes(ApiExplorerResponseType apiResponseType)
{ {
return apiResponseType.ResponseFormats return apiResponseType.ResponseFormats

View File

@ -23,7 +23,6 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Razor.Compilation; using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Microsoft.AspNetCore.Mvc.Razor.Internal; using Microsoft.AspNetCore.Mvc.Razor.Internal;
using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.AspNetCore.Mvc.RazorPages.Internal; using Microsoft.AspNetCore.Mvc.RazorPages.Internal;
using Microsoft.AspNetCore.Mvc.TagHelpers; using Microsoft.AspNetCore.Mvc.TagHelpers;
@ -418,6 +417,7 @@ namespace Microsoft.AspNetCore.Mvc
new Type[] new Type[]
{ {
typeof(DefaultApiDescriptionProvider), typeof(DefaultApiDescriptionProvider),
typeof(ProblemDetailsApiDescriptionProvider),
typeof(JsonPatchOperationsArrayProvider), typeof(JsonPatchOperationsArrayProvider),
} }
}, },

View File

@ -0,0 +1,26 @@
// 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.Mvc;
namespace ApiExplorerWebSite
{
[Route("ApiExplorerProblemDetails/[action]")]
[ProblemDetails]
public class ApiExplorerProblemDetailsController : Controller
{
public IActionResult ActionWithoutParameters() => Ok();
public void ActionWithSomeParameters(object input)
{
}
public void ActionWithIdParameter(int id, string name)
{
}
public void ActionWithIdSuffixParameter(int personId, string personName)
{
}
}
}