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

@ -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)
{
}
}
}