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.
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Abstractions;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
@ -9,6 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// <summary>
/// Represents an API exposed by this application.
/// </summary>
[DebuggerDisplay("{ActionDescriptor.DisplayName,nq}")]
public class ApiDescription
{
/// <summary>

View File

@ -26,6 +26,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>();
services.TryAddEnumerable(
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>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction]
public virtual BadRequestObjectResult ValidationProblem(ValidationProblemDescription descriptor)
public virtual ActionResult ValidationProblem(ValidationProblemDetails descriptor)
{
if (descriptor == null)
{
@ -1518,14 +1518,14 @@ namespace Microsoft.AspNetCore.Mvc
/// </summary>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction]
public virtual BadRequestObjectResult ValidationProblem(ModelStateDictionary modelStateDictionary)
public virtual ActionResult ValidationProblem(ModelStateDictionary modelStateDictionary)
{
if (modelStateDictionary == null)
{
throw new ArgumentNullException(nameof(modelStateDictionary));
}
var validationProblem = new ValidationProblemDescription(modelStateDictionary);
var validationProblem = new ValidationProblemDetails(modelStateDictionary);
return new BadRequestObjectResult(validationProblem);
}
@ -1535,9 +1535,9 @@ namespace Microsoft.AspNetCore.Mvc
/// </summary>
/// <returns>The created <see cref="BadRequestObjectResult"/> for the response.</returns>
[NonAction]
public virtual BadRequestObjectResult ValidationProblem()
public virtual ActionResult ValidationProblem()
{
var validationProblem = new ValidationProblemDescription(ModelState);
var validationProblem = new ValidationProblemDetails(ModelState);
return new BadRequestObjectResult(validationProblem);
}

View File

@ -204,6 +204,9 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddTransient<RequestSizeLimitFilter>();
services.TryAddTransient<DisableRequestSizeLimitFilter>();
// Error description
services.TryAddSingleton<IErrorDescriptionFactory, DefaultErrorDescriptorFactory>();
//
// 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>
/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807.
/// </summary>
public class ProblemDescription
public class ProblemDetails
{
/// <summary>
/// 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

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->

View File

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

View File

@ -6,13 +6,13 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc
{
public class ValidationProblemDescriptionTest
public class ValidationProblemDetailsTest
{
[Fact]
public void Constructor_SetsTitle()
{
// Arrange & Act
var problemDescription = new ValidationProblemDescription();
var problemDescription = new ValidationProblemDetails();
// Assert
Assert.Equal("One or more validation errors occured.", problemDescription.Title);
@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc
modelStateDictionary.AddModelError("key3", "error3");
// Act
var problemDescription = new ValidationProblemDescription(modelStateDictionary);
var problemDescription = new ValidationProblemDetails(modelStateDictionary);
// Assert
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);
}
[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)
{
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.Internal;
using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.AspNetCore.Mvc.RazorPages.Internal;
using Microsoft.AspNetCore.Mvc.TagHelpers;
@ -418,6 +417,7 @@ namespace Microsoft.AspNetCore.Mvc
new Type[]
{
typeof(DefaultApiDescriptionProvider),
typeof(ProblemDetailsApiDescriptionProvider),
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)
{
}
}
}