Add support for default response (#8028)

* Add support for default response

Fixes #6828
This commit is contained in:
Pranav K 2018-07-06 16:44:07 -07:00 committed by GitHub
parent 335500ab0e
commit d2bb674b0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 253 additions and 49 deletions

View File

@ -29,7 +29,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
_mvcOptions = mvcOptions;
}
public IList<ApiResponseType> GetApiResponseTypes(ControllerActionDescriptor action)
public ICollection<ApiResponseType> GetApiResponseTypes(ControllerActionDescriptor action)
{
// We only provide response info if we can figure out a type that is a user-data type.
// Void /Task object/IActionResult will result in no data.
@ -38,7 +38,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
var runtimeReturnType = GetRuntimeReturnType(declaredReturnType);
var responseMetadataAttributes = GetResponseMetadataAttributes(action);
if (responseMetadataAttributes.Count == 0 &&
if (responseMetadataAttributes.Count == 0 &&
action.Properties.TryGetValue(typeof(ApiConventionResult), out var result))
{
// Action does not have any conventions. Use conventions on it if present.
@ -67,14 +67,11 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
.ToArray();
}
private IList<ApiResponseType> GetApiResponseTypes(
private ICollection<ApiResponseType> GetApiResponseTypes(
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
Type type)
{
var results = new List<ApiResponseType>();
// Build list of all possible return types (and status codes) for an action.
var objectTypes = new Dictionary<int, Type>();
var results = new Dictionary<int, ApiResponseType>();
// Get the content type that the action explicitly set to support.
// Walk through all 'filter' attributes in order, and allow each one to see or override
@ -86,7 +83,17 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
metadataAttribute.SetContentTypes(contentTypes);
if (metadataAttribute.Type == typeof(void) &&
ApiResponseType apiResponseType;
if (metadataAttribute is IApiDefaultResponseMetadataProvider)
{
apiResponseType = new ApiResponseType
{
IsDefaultResponse = true,
Type = metadataAttribute.Type,
};
}
else if (metadataAttribute.Type == typeof(void) &&
type != null &&
(metadataAttribute.StatusCode == StatusCodes.Status200OK || metadataAttribute.StatusCode == StatusCodes.Status201Created))
{
@ -94,20 +101,38 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
// In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a
// [ProducesResponseType(201)] instead of [ProducesResponseType(201, typeof(Person)] when typeof(Person) can be inferred
// from the return type.
objectTypes[metadataAttribute.StatusCode] = type;
apiResponseType = new ApiResponseType
{
StatusCode = metadataAttribute.StatusCode,
Type = type,
};
}
else if (metadataAttribute.Type != null)
{
objectTypes[metadataAttribute.StatusCode] = metadataAttribute.Type;
apiResponseType = new ApiResponseType
{
StatusCode = metadataAttribute.StatusCode,
Type = metadataAttribute.Type,
};
}
else
{
continue;
}
results[apiResponseType.StatusCode] = apiResponseType;
}
}
// Set the default status only when no status has already been set explicitly
if (objectTypes.Count == 0 && type != null)
if (results.Count == 0 && type != null)
{
objectTypes[StatusCodes.Status200OK] = type;
results[StatusCodes.Status200OK] = new ApiResponseType
{
StatusCode = StatusCodes.Status200OK,
Type = type,
};
}
if (contentTypes.Count == 0)
@ -117,25 +142,15 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType<IApiResponseTypeMetadataProvider>();
foreach (var objectType in objectTypes)
foreach (var apiResponse in results.Values)
{
if (objectType.Value == null || objectType.Value == typeof(void))
var responseType = apiResponse.Type;
if (responseType == null || responseType == typeof(void))
{
results.Add(new ApiResponseType()
{
StatusCode = objectType.Key,
Type = objectType.Value
});
continue;
}
var apiResponseType = new ApiResponseType()
{
Type = objectType.Value,
StatusCode = objectType.Key,
ModelMetadata = _modelMetadataProvider.GetMetadataForType(objectType.Value)
};
apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType);
foreach (var contentType in contentTypes)
{
@ -143,7 +158,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes(
contentType,
objectType.Value);
responseType);
if (formatterSupportedContentTypes == null)
{
@ -152,7 +167,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
foreach (var formatterSupportedContentType in formatterSupportedContentTypes)
{
apiResponseType.ApiResponseFormats.Add(new ApiResponseFormat()
apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
{
Formatter = (IOutputFormatter)responseTypeMetadataProvider,
MediaType = formatterSupportedContentType,
@ -160,11 +175,9 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
}
}
}
results.Add(apiResponseType);
}
return results;
return results.Values;
}
private Type GetDeclaredReturnType(ControllerActionDescriptor action)

View File

@ -74,7 +74,7 @@ namespace Microsoft.AspNetCore.Mvc
var errorMessage = Resources.FormatApiConvention_UnsupportedAttributesOnConvention(
methodDisplayName,
Environment.NewLine + string.Join(Environment.NewLine, unsupportedAttributes) + Environment.NewLine,
$"{nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}");
$"{nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}");
throw new ArgumentException(errorMessage, nameof(conventionType));
}
@ -83,6 +83,7 @@ namespace Microsoft.AspNetCore.Mvc
private static bool IsAllowedAttribute(object attribute)
{
return attribute is ProducesResponseTypeAttribute ||
attribute is ProducesDefaultResponseTypeAttribute ||
attribute is ApiConventionNameMatchAttribute;
}
}

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.ApiExplorer
{
/// <summary>
/// Provides a return type for all HTTP status codes that are not covered by other <see cref="IApiResponseMetadataProvider"/> instances.
/// </summary>
public interface IApiDefaultResponseMetadataProvider : IApiResponseMetadataProvider
{
}
}

View File

@ -10,6 +10,7 @@ namespace Microsoft.AspNetCore.Mvc
{
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesDefaultResponseType]
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
public static void Get(
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)]
@ -18,6 +19,7 @@ namespace Microsoft.AspNetCore.Mvc
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesDefaultResponseType]
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
public static void Post(
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)]
@ -27,6 +29,7 @@ namespace Microsoft.AspNetCore.Mvc
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesDefaultResponseType]
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
public static void Put(
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)]
@ -40,6 +43,7 @@ namespace Microsoft.AspNetCore.Mvc
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesDefaultResponseType]
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
public static void Delete(
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)]

View File

@ -0,0 +1,49 @@
// 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.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace Microsoft.AspNetCore.Mvc
{
/// <summary>
/// A filter that specifies the <see cref="System.Type"/> for all HTTP status codes that are not covered by <see cref="ProducesResponseTypeAttribute"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class ProducesDefaultResponseTypeAttribute : Attribute, IApiDefaultResponseMetadataProvider
{
/// <summary>
/// Initializes an instance of <see cref="ProducesResponseTypeAttribute"/>.
/// </summary>
public ProducesDefaultResponseTypeAttribute()
: this(typeof(void))
{
}
/// <summary>
/// Initializes an instance of <see cref="ProducesResponseTypeAttribute"/>.
/// </summary>
/// <param name="type">The <see cref="Type"/> of object that is going to be written in the response.</param>
public ProducesDefaultResponseTypeAttribute(Type type)
{
Type = type ?? throw new ArgumentNullException(nameof(type));
}
/// <summary>
/// Gets or sets the type of the value returned by an action.
/// </summary>
public Type Type { get; }
/// <summary>
/// Gets or sets the HTTP status code of the response.
/// </summary>
public int StatusCode { get; }
/// <inheritdoc />
void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes)
{
// Users are supposed to use the 'Produces' attribute to set the content types that an action can support.
}
}
}

View File

@ -72,6 +72,60 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
public Task<ActionResult<BaseModel>> Get(int id) => null;
}
[Fact]
public void GetApiResponseTypes_CombinesFilters()
{
// Arrange
var filterDescriptors = new[]
{
new FilterDescriptor(new ProducesResponseTypeAttribute(400), FilterScope.Global),
new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(object), 201), FilterScope.Controller),
new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 400), FilterScope.Controller),
new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(BaseModel), 201), FilterScope.Action),
new FilterDescriptor(new ProducesResponseTypeAttribute(404), FilterScope.Action),
};
var actionDescriptor = new ControllerActionDescriptor
{
FilterDescriptors = filterDescriptors,
MethodInfo = typeof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController).GetMethod(nameof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController.Get)),
};
var provider = GetProvider();
// Act
var result = provider.GetApiResponseTypes(actionDescriptor);
// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(201, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
},
responseType =>
{
Assert.Equal(400, responseType.StatusCode);
Assert.Equal(typeof(ProblemDetails), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
},
responseType =>
{
Assert.Equal(404, responseType.StatusCode);
Assert.Equal(typeof(void), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Empty(responseType.ApiResponseFormats);
});
}
[Fact]
public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem()
{
@ -159,6 +213,54 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
public Task<ActionResult<BaseModel>> PostModel(int id, BaseModel model) => null;
}
[Fact]
public void GetApiResponseTypes_ReturnsDefaultProblemResponse()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(
typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController),
nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase));
actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[]
{
new ProducesResponseTypeAttribute(201),
new ProducesResponseTypeAttribute(404),
new ProducesDefaultResponseTypeAttribute(typeof(SerializableError)),
});
var provider = GetProvider();
// Act
var result = provider.GetApiResponseTypes(actionDescriptor);
// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.True(responseType.IsDefaultResponse);
Assert.Equal(typeof(SerializableError), responseType.Type);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
},
responseType =>
{
Assert.Equal(201, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
},
responseType =>
{
Assert.Equal(404, responseType.StatusCode);
Assert.Equal(typeof(void), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Empty(responseType.ApiResponseFormats);
});
}
private static ApiResponseTypeProvider GetProvider()
{
var mvcOptions = new MvcOptions

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;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Testing;
@ -15,11 +16,10 @@ namespace Microsoft.AspNetCore.Mvc
public void Constructor_ThrowsIfConventionMethodIsAnnotatedWithProducesAttribute()
{
// Arrange
var expected = $"Method {typeof(ConventionWithProducesAttribute).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" +
Environment.NewLine +
typeof(ProducesAttribute).FullName +
Environment.NewLine +
$"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}";
var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get);
var attribute = typeof(ProducesAttribute);
var expected = GetErrorMessage(methodName, attribute);
// Act & Assert
ExceptionAssert.ThrowsArgument(
@ -38,11 +38,9 @@ namespace Microsoft.AspNetCore.Mvc
public void Constructor_ThrowsIfConventionMethodHasRouteAttribute()
{
// Arrange
var expected = $"Method {typeof(ConventionWithRouteAttribute).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" +
Environment.NewLine +
typeof(HttpGetAttribute).FullName +
Environment.NewLine +
$"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}";
var methodName = typeof(ConventionWithRouteAttribute).FullName + '.' + nameof(ConventionWithRouteAttribute.Get);
var attribute = typeof(HttpGetAttribute);
var expected = GetErrorMessage(methodName, attribute);
// Act & Assert
ExceptionAssert.ThrowsArgument(
@ -61,11 +59,9 @@ namespace Microsoft.AspNetCore.Mvc
public void Constructor_ThrowsIfMultipleUnsupportedAttributesArePresentOnConvention()
{
// Arrange
var expected = $"Method {typeof(ConventionWitUnsupportedAttributes).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" +
Environment.NewLine +
string.Join(Environment.NewLine, typeof(ProducesAttribute).FullName, typeof(ServiceFilterAttribute).FullName, typeof(AuthorizeAttribute).FullName) +
Environment.NewLine +
$"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}";
var methodName = typeof(ConventionWitUnsupportedAttributes).FullName + '.' + nameof(ConventionWitUnsupportedAttributes.Get);
var attributes = new[] { typeof(ProducesAttribute), typeof(ServiceFilterAttribute), typeof(AuthorizeAttribute) };
var expected = GetErrorMessage(methodName, attributes);
// Act & Assert
ExceptionAssert.ThrowsArgument(
@ -82,5 +78,14 @@ namespace Microsoft.AspNetCore.Mvc
[Authorize]
public static void Get() { }
}
private static string GetErrorMessage(string methodName, params Type[] attributes)
{
return $"Method {methodName} is decorated with the following attributes that are not allowed on an API convention method:" +
Environment.NewLine +
string.Join(Environment.NewLine, attributes.Select(a => a.FullName)) +
Environment.NewLine +
$"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}";
}
}
}

View File

@ -111,6 +111,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
Assert.True(result);
Assert.Collection(
conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode),
r => Assert.IsAssignableFrom<IApiDefaultResponseMetadataProvider>(r),
r => Assert.Equal(200, r.StatusCode),
r => Assert.Equal(404, r.StatusCode));
}
@ -130,6 +131,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
Assert.True(result);
Assert.Collection(
conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode),
r => Assert.IsAssignableFrom<IApiDefaultResponseMetadataProvider>(r),
r => Assert.Equal(201, r.StatusCode),
r => Assert.Equal(400, r.StatusCode));
}
@ -152,6 +154,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
Assert.True(result);
Assert.Collection(
conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode),
r => Assert.IsAssignableFrom<IApiDefaultResponseMetadataProvider>(r),
r => Assert.Equal(204, r.StatusCode),
r => Assert.Equal(400, r.StatusCode),
r => Assert.Equal(404, r.StatusCode));
@ -175,6 +178,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
Assert.True(result);
Assert.Collection(
conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode),
r => Assert.IsAssignableFrom<IApiDefaultResponseMetadataProvider>(r),
r => Assert.Equal(200, r.StatusCode),
r => Assert.Equal(400, r.StatusCode),
r => Assert.Equal(404, r.StatusCode));

View File

@ -711,7 +711,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// Assert
var description = Assert.Single(result);
Assert.Equal(2, description.SupportedResponseTypes.Count);
Assert.Collection(
description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode),
@ -749,7 +748,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// Assert
var description = Assert.Single(result);
Assert.Equal(2, description.SupportedResponseTypes.Count);
Assert.Collection(
description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode),
@ -1171,6 +1169,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.True(responseType.IsDefaultResponse);
},
responseType =>
{
Assert.Equal(typeof(Product).FullName, responseType.ResponseType);
Assert.Equal(200, responseType.StatusCode);
@ -1255,6 +1257,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.True(responseType.IsDefaultResponse);
},
responseType =>
{
Assert.Equal(typeof(void).FullName, responseType.ResponseType);
Assert.Equal(201, responseType.StatusCode);
@ -1283,6 +1289,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.True(responseType.IsDefaultResponse);
},
responseType =>
{
Assert.Equal(typeof(void).FullName, responseType.ResponseType);
Assert.Equal(204, responseType.StatusCode);
@ -1316,6 +1326,10 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.True(responseType.IsDefaultResponse);
},
responseType =>
{
Assert.Equal(typeof(void).FullName, responseType.ResponseType);
Assert.Equal(200, responseType.StatusCode);