Allow ProducesAttribute to apply along with conventions

Fixes #8389
This commit is contained in:
Pranav K 2018-09-07 15:58:21 -07:00
parent a73d073eea
commit cb88e906b2
18 changed files with 185 additions and 54 deletions

View File

@ -38,12 +38,12 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); var runtimeReturnType = GetRuntimeReturnType(declaredReturnType);
var responseMetadataAttributes = GetResponseMetadataAttributes(action); var responseMetadataAttributes = GetResponseMetadataAttributes(action);
if (responseMetadataAttributes.Count == 0 && if (!HasSignificantMetadataProvider(responseMetadataAttributes) &&
action.Properties.TryGetValue(typeof(ApiConventionResult), out var result)) action.Properties.TryGetValue(typeof(ApiConventionResult), out var result))
{ {
// Action does not have any conventions. Use conventions on it if present. // Action does not have any conventions. Use conventions on it if present.
var apiConventionResult = (ApiConventionResult)result; var apiConventionResult = (ApiConventionResult)result;
responseMetadataAttributes = apiConventionResult.ResponseMetadataProviders; responseMetadataAttributes.AddRange(apiConventionResult.ResponseMetadataProviders);
} }
var defaultErrorType = typeof(void); var defaultErrorType = typeof(void);
@ -56,11 +56,11 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
return apiResponseTypes; return apiResponseTypes;
} }
private IReadOnlyList<IApiResponseMetadataProvider> GetResponseMetadataAttributes(ControllerActionDescriptor action) private static List<IApiResponseMetadataProvider> GetResponseMetadataAttributes(ControllerActionDescriptor action)
{ {
if (action.FilterDescriptors == null) if (action.FilterDescriptors == null)
{ {
return Array.Empty<IApiResponseMetadataProvider>(); return new List<IApiResponseMetadataProvider>();
} }
// This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory // This technique for enumerating filters will intentionally ignore any filter that is an IFilterFactory
@ -70,7 +70,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
return action.FilterDescriptors return action.FilterDescriptors
.Select(fd => fd.Filter) .Select(fd => fd.Filter)
.OfType<IApiResponseMetadataProvider>() .OfType<IApiResponseMetadataProvider>()
.ToArray(); .ToList();
} }
private ICollection<ApiResponseType> GetApiResponseTypes( private ICollection<ApiResponseType> GetApiResponseTypes(
@ -188,7 +188,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
} }
// Unwrap the type if it's a Task<T>. The Task (non-generic) case was already handled. // Unwrap the type if it's a Task<T>. The Task (non-generic) case was already handled.
Type unwrappedType = declaredReturnType; var unwrappedType = declaredReturnType;
if (declaredReturnType.IsGenericType && if (declaredReturnType.IsGenericType &&
declaredReturnType.GetGenericTypeDefinition() == typeof(Task<>)) declaredReturnType.GetGenericTypeDefinition() == typeof(Task<>))
{ {
@ -228,5 +228,24 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{ {
return statusCode >= 400 && statusCode < 500; return statusCode >= 400 && statusCode < 500;
} }
private static bool HasSignificantMetadataProvider(IReadOnlyList<IApiResponseMetadataProvider> providers)
{
for (var i = 0; i < providers.Count; i++)
{
var provider = providers[i];
if (provider is ProducesAttribute producesAttribute && producesAttribute.Type is null)
{
// ProducesAttribute that does not specify type is considered not significant.
continue;
}
// Any other IApiResponseMetadataProvider is considered significant
return true;
}
return false;
}
} }
} }

View File

@ -54,12 +54,6 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
private static void DiscoverApiConvention(ActionModel action) private static void DiscoverApiConvention(ActionModel action)
{ {
if (action.Filters.OfType<IApiResponseMetadataProvider>().Any())
{
// If an action already has providers, don't discover any from conventions.
return;
}
var controller = action.Controller; var controller = action.Controller;
var apiConventionAttributes = controller.Attributes.OfType<ApiConventionTypeAttribute>().ToArray(); var apiConventionAttributes = controller.Attributes.OfType<ApiConventionTypeAttribute>().ToArray();
if (apiConventionAttributes.Length == 0) if (apiConventionAttributes.Length == 0)

View File

@ -550,6 +550,119 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
}); });
} }
[Fact]
public void GetApiResponseTypes_CombinesProducesAttributeAndConventions()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.PutModel));
actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("application/json"), FilterScope.Controller));
actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[]
{
new ProducesResponseTypeAttribute(200),
new ProducesResponseTypeAttribute(400),
new ProducesDefaultResponseTypeAttribute(),
});
actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(ProblemDetails));
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(ProblemDetails), responseType.Type);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
},
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(DerivedModel), 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));
});
}
[Fact]
public void GetApiResponseTypes_DoesNotCombineProducesAttributeThatSpecifiesType()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.PutModel));
actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("application/json") { Type = typeof(string) }, FilterScope.Controller));
actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[]
{
new ProducesResponseTypeAttribute(200),
new ProducesResponseTypeAttribute(400),
new ProducesDefaultResponseTypeAttribute(),
});
actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(ProblemDetails));
var provider = GetProvider();
// Act
var result = provider.GetApiResponseTypes(actionDescriptor);
// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(string), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
});
}
[Fact]
public void GetApiResponseTypes_DoesNotCombineProducesResponseTypeAttributeThatSpecifiesStatusCode()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.PutModel));
actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[]
{
new ProducesResponseTypeAttribute(200),
});
actionDescriptor.Properties[typeof(ProducesErrorResponseTypeAttribute)] = new ProducesErrorResponseTypeAttribute(typeof(ProblemDetails));
var provider = GetProvider();
// Act
var result = provider.GetApiResponseTypes(actionDescriptor);
// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(200, responseType.StatusCode);
Assert.Equal(typeof(DerivedModel), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
});
}
private static ApiResponseTypeProvider GetProvider() private static ApiResponseTypeProvider GetProvider()
{ {
var mvcOptions = new MvcOptions var mvcOptions = new MvcOptions

View File

@ -15,38 +15,6 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels
{ {
public class ApiConventionApplicationModelConventionTest public class ApiConventionApplicationModelConventionTest
{ {
[Fact]
public void Apply_DoesNotAddConventionItem_IfActionHasProducesResponseTypeAttribute()
{
// Arrange
var actionModel = GetActionModel(nameof(TestController.Delete));
actionModel.Filters.Add(new ProducesResponseTypeAttribute(200));
var convention = GetConvention();
// Act
convention.Apply(actionModel);
// Assert
Assert.DoesNotContain(typeof(ApiConventionResult), actionModel.Properties.Keys);
}
[Fact]
public void Apply_DoesNotAddConventionItem_IfActionHasProducesAttribute()
{
// Arrange
var actionModel = GetActionModel(nameof(TestController.Delete));
actionModel.Filters.Add(new ProducesAttribute(typeof(object)));
var convention = GetConvention();
// Act
convention.Apply(actionModel);
// Assert
Assert.DoesNotContain(typeof(ApiConventionResult), actionModel.Properties.Keys);
}
[Fact] [Fact]
public void Apply_DoesNotAddConventionItem_IfNoConventionMatches() public void Apply_DoesNotAddConventionItem_IfNoConventionMatches()
{ {

View File

@ -2,14 +2,12 @@
// 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; using System;
using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Xunit; using Xunit;
namespace Microsoft.AspNetCore.Mvc.Test.ApplicationModel namespace Microsoft.AspNetCore.Mvc.Test.ApplicationModels
{ {
public class RouteTokenTransformerConventionTest public class RouteTokenTransformerConventionTest
{ {

View File

@ -1277,6 +1277,41 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
}); });
} }
[Fact]
public async Task ApiConvention_ForPostActionWithProducesAttribute()
{
// Arrange
var expectedMediaTypes = new[] { "application/json", "text/json", };
// Act
var response = await Client.PostAsync(
$"ApiExplorerResponseTypeWithApiConventionController/PostWithProduces",
new StringContent(string.Empty));
var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(responseBody);
// Assert
var description = Assert.Single(result);
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);
Assert.Empty(responseType.ResponseFormats);
},
responseType =>
{
Assert.Equal(typeof(ProblemDetails).FullName, responseType.ResponseType);
Assert.Equal(400, responseType.StatusCode);
Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType));
});
}
[Fact] [Fact]
public async Task ApiConvention_ForPutActionThatMatchesConvention() public async Task ApiConvention_ForPutActionThatMatchesConvention()
{ {

View File

@ -27,6 +27,10 @@ namespace ApiExplorerWebSite
[ProducesResponseType(403)] [ProducesResponseType(403)]
public IActionResult PostWithConventions() => null; public IActionResult PostWithConventions() => null;
[HttpPost]
[Produces("application/json", "text/json")]
public IActionResult PostWithProduces(Product p) => null;
[HttpPost] [HttpPost]
public Task<IActionResult> PostTaskOfProduct(Product p) => null; public Task<IActionResult> PostTaskOfProduct(Product p) => null;