From 3670144259e8d79c4c0c32c88741f437f1e5c065 Mon Sep 17 00:00:00 2001 From: Kiran Challa Date: Mon, 7 Mar 2016 09:43:41 -0800 Subject: [PATCH] Added new attribute ProducesResponseTypeAttribute to enable ApiExplorer to expose response type and StatusCode. [Fixes #4101] StatusCode Metadata --- .../ApiDescription.cs | 19 +- .../ApiResponseFormat.cs | 10 +- .../ApiResponseType.cs | 43 +++ .../DefaultApiDescriptionProvider.cs | 123 ++++---- .../IApiResponseMetadataProvider.cs | 10 +- ...cs => IApiResponseTypeMetadataProvider.cs} | 2 +- .../Formatters/OutputFormatter.cs | 2 +- .../ProducesAttribute.cs | 4 +- .../ProducesResponseTypeAttribute.cs | 49 +++ .../DefaultApiDescriptionProviderTest.cs | 288 ++++++++++++++++-- .../ApiExplorerTest.cs | 192 ++++++++++-- .../ApiExplorerDataFilter.cs | 38 ++- ...rResponseTypeOverrideOnActionController.cs | 9 + ...rResponseTypeWithoutAttributeController.cs | 2 + 14 files changed, 627 insertions(+), 164 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseType.cs rename src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/{IApiResponseFormatMetadataProvider.cs => IApiResponseTypeMetadataProvider.cs} (96%) create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/ProducesResponseTypeAttribute.cs diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiDescription.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiDescription.cs index dddafbd826..d1b4e0c9de 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiDescription.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiDescription.cs @@ -43,23 +43,6 @@ public class ApiDescription /// public string RelativePath { get; set; } - /// - /// Gets or sets for the or null. - /// - /// - /// Will be null if is null. - /// - public ModelMetadata ResponseModelMetadata { get; set; } - - /// - /// Gets or sets the CLR data type of the response or null. - /// - /// - /// Will be null if the action returns no response, or if the response type is unclear. Use - /// ProducesAttribute on an action method to specify a response type. - /// - public Type ResponseType { get; set; } - /// /// Gets the list of possible formats for a response. /// @@ -76,6 +59,6 @@ public class ApiDescription /// Will be empty if the action returns no response, or if the response type is unclear. Use /// ProducesAttribute on an action method to specify a response type. /// - public IList SupportedResponseFormats { get; } = new List(); + public IList SupportedResponseTypes { get; } = new List(); } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs index a02a5595fd..bf04db9da7 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Formatters; @@ -6,18 +6,18 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { /// - /// Represents a possible format for the body of a response. + /// Possible format for an . /// public class ApiResponseFormat { /// - /// The formatter used to output this response. + /// Gets or sets the formatter used to output this response. /// public IOutputFormatter Formatter { get; set; } /// - /// The media type of the response. + /// Gets or sets the media type of the response. /// public string MediaType { get; set; } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseType.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseType.cs new file mode 100644 index 0000000000..37d34b9af6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseType.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Possible type of the response body which is formatted by . + /// + public class ApiResponseType + { + /// + /// Gets or sets the response formats supported by this type. + /// + public IList ApiResponseFormats { get; set; } = new List(); + + /// + /// Gets or sets for the or null. + /// + /// + /// Will be null if is null or void. + /// + public ModelMetadata ModelMetadata { get; set; } + + /// + /// Gets or sets the CLR data type of the response or null. + /// + /// + /// Will be null if the action returns no response, or if the response type is unclear. Use + /// or on an action method + /// to specify a response type. + /// + public Type Type { get; set; } + + /// + /// Gets or sets the HTTP response status code. + /// + public int StatusCode { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs index 1186588832..d22bdb81a6 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs @@ -8,6 +8,7 @@ using System.Reflection; #endif using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Formatters; @@ -112,37 +113,20 @@ private ApiDescription CreateApiDescription( // Void /Task object/IActionResult will result in no data. var declaredReturnType = GetDeclaredReturnType(action); - // Now 'simulate' an action execution. This attempts to figure out to the best of our knowledge - // what the logical data type is using filters. - var runtimeReturnType = GetRuntimeReturnType(declaredReturnType, responseMetadataAttributes); + var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); - // We might not be able to figure out a good runtime return type. If that's the case we don't - // provide any information about outputs. The workaround is to attribute the action. - if (runtimeReturnType == typeof(void)) + var apiResponseTypes = GetApiResponseTypes(action, responseMetadataAttributes, runtimeReturnType); + foreach (var apiResponseType in apiResponseTypes) { - // As a special case, if the return type is void - we want to surface that information - // specifically, but nothing else. This can be overridden with a filter/attribute. - apiDescription.ResponseType = runtimeReturnType; - } - else if (runtimeReturnType != null) - { - apiDescription.ResponseType = runtimeReturnType; - - apiDescription.ResponseModelMetadata = _modelMetadataProvider.GetMetadataForType(runtimeReturnType); - - var formats = GetResponseFormats(action, responseMetadataAttributes, runtimeReturnType); - foreach (var format in formats) - { - apiDescription.SupportedResponseFormats.Add(format); - } + apiDescription.SupportedResponseTypes.Add(apiResponseType); } // It would be possible here to configure an action with multiple body parameters, in which case you // could end up with duplicate data. foreach (var parameter in apiDescription.ParameterDescriptions.Where(p => p.Source == BindingSource.Body)) { - var formats = GetRequestFormats(action, requestMetadataAttributes, parameter.Type); - foreach (var format in formats) + var requestFormats = GetRequestFormats(action, requestMetadataAttributes, parameter.Type); + foreach (var format in requestFormats) { apiDescription.SupportedRequestFormats.Add(format); } @@ -364,13 +348,24 @@ private IReadOnlyList GetRequestFormats( return results; } - private IReadOnlyList GetResponseFormats( + private IReadOnlyList GetApiResponseTypes( ControllerActionDescriptor action, IApiResponseMetadataProvider[] responseMetadataAttributes, Type type) { - var results = new List(); + var results = new List(); + // Build list of all possible return types (and status codes) for an action. + var objectTypes = new Dictionary(); + + if (type != null && type != typeof(void)) + { + // This return type can be overriden by any response metadata + // attributes later if the user wishes to. + objectTypes[StatusCodes.Status200OK] = type; + } + + // 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 // the results of the previous ones. This is similar to the execution path for content-negotiation. var contentTypes = new MediaTypeCollection(); @@ -379,6 +374,11 @@ private IReadOnlyList GetResponseFormats( foreach (var metadataAttribute in responseMetadataAttributes) { metadataAttribute.SetContentTypes(contentTypes); + + if (metadataAttribute.Type != null) + { + objectTypes[metadataAttribute.StatusCode] = metadataAttribute.Type; + } } } @@ -387,28 +387,53 @@ private IReadOnlyList GetResponseFormats( contentTypes.Add((string)null); } - foreach (var contentType in contentTypes) + var responseTypeMetadataProviders = _outputFormatters.OfType(); + + foreach (var objectType in objectTypes) { - foreach (var formatter in _outputFormatters) + if (objectType.Value == typeof(void)) { - var responseFormatMetadataProvider = formatter as IApiResponseFormatMetadataProvider; - if (responseFormatMetadataProvider != null) + results.Add(new ApiResponseType() { - var supportedTypes = responseFormatMetadataProvider.GetSupportedContentTypes(contentType, type); + StatusCode = objectType.Key, + Type = objectType.Value + }); - if (supportedTypes != null) + continue; + } + + var apiResponseType = new ApiResponseType() + { + Type = objectType.Value, + StatusCode = objectType.Key, + ModelMetadata = _modelMetadataProvider.GetMetadataForType(objectType.Value) + }; + + foreach (var contentType in contentTypes) + { + foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders) + { + var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes( + contentType, + objectType.Value); + + if (formatterSupportedContentTypes == null) { - foreach (var supportedType in supportedTypes) + continue; + } + + foreach (var formatterSupportedContentType in formatterSupportedContentTypes) + { + apiResponseType.ApiResponseFormats.Add(new ApiResponseFormat() { - results.Add(new ApiResponseFormat() - { - Formatter = formatter, - MediaType = supportedType, - }); - } + Formatter = (IOutputFormatter)responseTypeMetadataProvider, + MediaType = formatterSupportedContentType, + }); } } } + + results.Add(apiResponseType); } return results; @@ -445,28 +470,8 @@ private static Type GetTaskInnerTypeOrNull(Type type) return genericType?.GenericTypeArguments[0]; } - private Type GetRuntimeReturnType(Type declaredReturnType, IApiResponseMetadataProvider[] metadataAttributes) + private Type GetRuntimeReturnType(Type declaredReturnType) { - // Walk through all of the filter attributes and allow them to set the type. This will execute them - // in filter-order allowing the desired behavior for overriding. - if (metadataAttributes != null) - { - Type typeSetByAttribute = null; - foreach (var metadataAttribute in metadataAttributes) - { - if (metadataAttribute.Type != null) - { - typeSetByAttribute = metadataAttribute.Type; - } - } - - // If one of the filters set a type, then trust it. - if (typeSetByAttribute != null) - { - return typeSetByAttribute; - } - } - // If we get here, then a filter didn't give us an answer, so we need to figure out if we // want to use the declared return type. // diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs index c7a073e30c..5ae22c29d2 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs @@ -7,15 +7,21 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { /// - /// Provides a return type and a set of possible content types returned by a successful execution of the action. + /// Provides a return type, status code and a set of possible content types returned by a + /// successful execution of the action. /// public interface IApiResponseMetadataProvider { /// - /// Optimistic return type of the action. + /// Gets the optimistic return type of the action. /// Type Type { get; } + /// + /// Gets the HTTP status code of the response. + /// + int StatusCode { get; } + /// /// Configures a collection of allowed content types which can be produced by the action. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseFormatMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseTypeMetadataProvider.cs similarity index 96% rename from src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseFormatMetadataProvider.cs rename to src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseTypeMetadataProvider.cs index fa8254dd99..8b15eb0989 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseFormatMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseTypeMetadataProvider.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer /// An should implement this interface to expose metadata information /// to an IApiDescriptionProvider. /// - public interface IApiResponseFormatMetadataProvider + public interface IApiResponseTypeMetadataProvider { /// /// Gets a filtered list of content types which are supported by the diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs index 9ba3d68b3e..28b0007641 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Formatters/OutputFormatter.cs @@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters /// /// Writes an object to the output stream. /// - public abstract class OutputFormatter : IOutputFormatter, IApiResponseFormatMetadataProvider + public abstract class OutputFormatter : IOutputFormatter, IApiResponseTypeMetadataProvider { /// /// Gets the mutable collection of media type elements supported by diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs index efc2100597..08c156d6e4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Formatters.Internal; -using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc @@ -64,6 +64,8 @@ public ProducesAttribute(string contentType, params string[] additionalContentTy public MediaTypeCollection ContentTypes { get; set; } + public int StatusCode => StatusCodes.Status200OK; + public override void OnResultExecuting(ResultExecutingContext context) { if (context == null) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ProducesResponseTypeAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProducesResponseTypeAttribute.cs new file mode 100644 index 0000000000..da2a7ca33a --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProducesResponseTypeAttribute.cs @@ -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.Filters; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// Specifies the type of the value and status code returned by the action. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class ProducesResponseTypeAttribute : Attribute, IApiResponseMetadataProvider, IFilterMetadata + { + /// + /// Initializes an instance of . + /// + /// The of object that is going to be written in the response. + /// HTTP response status code + public ProducesResponseTypeAttribute(Type type, int statusCode) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + Type = type; + StatusCode = statusCode; + } + + /// + /// Gets or sets the type of the value returned by an action. + /// + public Type Type { get; set; } + + /// + /// Gets or sets the HTTP status code of the response. + /// + public int StatusCode { get; set; } + + /// + void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes) + { + // Users are supposed to use the 'Produces' attribute to set the content types that an action can support. + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs index 581f141b36..5c5e3b9ac2 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs @@ -21,7 +21,6 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Constraints; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Moq; using Xunit; @@ -218,7 +217,7 @@ public void GetApiDescription_PopulatesParametersThatAppearOnRouteTemplate_AndHa } // Only a parameter which comes from a route or model binding or unknown should - // include route info. + // include route info. [Theory] [InlineData("api/products/{id}", nameof(FromBody), "Body")] [InlineData("api/products/{id}", nameof(FromHeader), "Header")] @@ -374,8 +373,9 @@ public void GetApiDescription_PopulatesResponseType_WithProduct() // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(Product), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(Product), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); } [Fact] @@ -389,8 +389,9 @@ public void GetApiDescription_PopulatesResponseType_WithTaskOfProduct() // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(Product), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(Product), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); } [Theory] @@ -410,9 +411,182 @@ public void GetApiDescription_DoesNotPopulatesResponseInformation_WhenUnknown(st // Assert var description = Assert.Single(descriptions); - Assert.Null(description.ResponseType); - Assert.Null(description.ResponseModelMetadata); - Assert.Empty(description.SupportedResponseFormats); + Assert.Empty(description.SupportedResponseTypes); + } + + public static TheoryData ReturnsActionResultWithProducesAndProducesContentTypeData + { + get + { + var filterDescriptors = new List() + { + new FilterDescriptor( + new ProducesAttribute("text/json", "application/json") { Type = typeof(Customer) }, + FilterScope.Action), + new FilterDescriptor( + new ProducesResponseTypeAttribute(typeof(BadData), 400), + FilterScope.Action), + new FilterDescriptor( + new ProducesResponseTypeAttribute(typeof(ErrorDetails), 500), + FilterScope.Action) + }; + + return new TheoryData> + { + { + typeof(DefaultApiDescriptionProviderTest), + nameof(DefaultApiDescriptionProviderTest.ReturnsTaskOfActionResult), + filterDescriptors + }, + { + typeof(DefaultApiDescriptionProviderTest), + nameof(DefaultApiDescriptionProviderTest.ReturnsActionResult), + filterDescriptors + }, + { + typeof(DerivedProducesController), + nameof(DerivedProducesController.ReturnsActionResult), + filterDescriptors + }, + }; + } + } + + [Theory] + [MemberData(nameof(ReturnsActionResultWithProducesAndProducesContentTypeData))] + public void GetApiDescription_ReturnsActionResultWithProduces_And_ProducesContentType( + Type controllerType, + string methodName, + List filterDescriptors) + { + // Arrange + var action = CreateActionDescriptor(methodName, controllerType); + action.FilterDescriptors = filterDescriptors; + var expectedMediaTypes = new[] { "application/json", "text/json" }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(3, description.SupportedResponseTypes.Count); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), + responseType => + { + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(typeof(Customer), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(typeof(BadData), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(500, responseType.StatusCode); + Assert.Equal(typeof(ErrorDetails), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); + } + + public static TheoryData> ReturnsVoidOrTaskWithProducesContentTypeData + { + get + { + var filterDescriptors = new List() + { + // Since action is returning Void or Task, it does not make sense to provide a value for the + // 'Type' property to ProducesAttribute. But the same action could return other types of data + // based on runtime conditions. + new FilterDescriptor( + new ProducesAttribute("text/json", "application/json"), + FilterScope.Action), + new FilterDescriptor( + new ProducesResponseTypeAttribute(typeof(void), 204), + FilterScope.Action), + new FilterDescriptor( + new ProducesResponseTypeAttribute(typeof(BadData), 400), + FilterScope.Action), + new FilterDescriptor( + new ProducesResponseTypeAttribute(typeof(ErrorDetails), 500), + FilterScope.Action) + }; + + return new TheoryData> + { + { + typeof(DefaultApiDescriptionProviderTest), + nameof(DefaultApiDescriptionProviderTest.ReturnsVoid), + filterDescriptors + }, + { + typeof(DefaultApiDescriptionProviderTest), + nameof(DefaultApiDescriptionProviderTest.ReturnsTask), + filterDescriptors + }, + { + typeof(DerivedProducesController), + nameof(DerivedProducesController.ReturnsVoid), + filterDescriptors + }, + { + typeof(DerivedProducesController), + nameof(DerivedProducesController.ReturnsTask), + filterDescriptors + }, + }; + } + } + + [Theory] + [MemberData(nameof(ReturnsVoidOrTaskWithProducesContentTypeData))] + public void GetApiDescription_ReturnsVoidWithProducesContentType( + Type controllerType, + string methodName, + List filterDescriptors) + { + // Arrange + var action = CreateActionDescriptor(methodName, controllerType); + action.FilterDescriptors = filterDescriptors; + var expectedMediaTypes = new[] { "application/json", "text/json" }; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(3, description.SupportedResponseTypes.Count); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), + responseType => + { + Assert.Equal(typeof(void), responseType.Type); + Assert.Equal(204, responseType.StatusCode); + Assert.Null(responseType.ModelMetadata); + Assert.Empty(responseType.ApiResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(BadData), responseType.Type); + Assert.Equal(400, responseType.StatusCode); + Assert.NotNull(responseType.ModelMetadata); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ErrorDetails), responseType.Type); + Assert.Equal(500, responseType.StatusCode); + Assert.NotNull(responseType.ModelMetadata); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); } [Theory] @@ -422,15 +596,19 @@ public void GetApiDescription_DoesNotPopulatesResponseInformation_WhenVoid(strin { // Arrange var action = CreateActionDescriptor(methodName); + var filter = new ProducesResponseTypeAttribute(typeof(void), statusCode: 204); + action.FilterDescriptors = new List(); + action.FilterDescriptors.Add(new FilterDescriptor(filter, FilterScope.Action)); // Act var descriptions = GetApiDescriptions(action); // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(void), description.ResponseType); - Assert.Null(description.ResponseModelMetadata); - Assert.Empty(description.SupportedResponseFormats); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(void), responseType.Type); + Assert.Equal(204, responseType.StatusCode); + Assert.Null(responseType.ModelMetadata); } [Theory] @@ -459,8 +637,15 @@ public void GetApiDescription_PopulatesResponseInformation_WhenSetByFilter(strin // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(Order), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); + var responseTypes = Assert.Single(description.SupportedResponseTypes); + Assert.NotNull(responseTypes.ModelMetadata); + Assert.Equal(200, responseTypes.StatusCode); + Assert.Equal(typeof(Order), responseTypes.Type); + + foreach (var responseFormat in responseTypes.ApiResponseFormats) + { + Assert.StartsWith("text/", responseFormat.MediaType); + } } [Fact] @@ -468,18 +653,15 @@ public void GetApiDescription_IncludesResponseFormats() { // Arrange var action = CreateActionDescriptor(nameof(ReturnsProduct)); + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; // Act var descriptions = GetApiDescriptions(action); // Assert var description = Assert.Single(descriptions); - Assert.Collection( - description.SupportedResponseFormats.OrderBy(f => f.MediaType.ToString()), - f => Assert.Equal("application/json", f.MediaType.ToString()), - f => Assert.Equal("application/xml", f.MediaType.ToString()), - f => Assert.Equal("text/json", f.MediaType.ToString()), - f => Assert.Equal("text/xml", f.MediaType.ToString())); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); } [Fact] @@ -487,7 +669,7 @@ public void GetApiDescription_IncludesResponseFormats_FilteredByAttribute() { // Arrange var action = CreateActionDescriptor(nameof(ReturnsProduct)); - + var expectedMediaTypes = new[] { "text/json", "text/xml" }; action.FilterDescriptors = new List(); action.FilterDescriptors.Add(new FilterDescriptor(new ContentTypeAttribute("text/*"), FilterScope.Action)); @@ -496,10 +678,8 @@ public void GetApiDescription_IncludesResponseFormats_FilteredByAttribute() // Assert var description = Assert.Single(descriptions); - Assert.Collection( - description.SupportedResponseFormats.OrderBy(f => f.MediaType.ToString()), - f => Assert.Equal("text/json", f.MediaType.ToString()), - f => Assert.Equal("text/xml", f.MediaType.ToString())); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); } [Fact] @@ -528,13 +708,12 @@ public void GetApiDescription_IncludesResponseFormats_FilteredByType() // Assert var description = Assert.Single(descriptions); - Assert.Equal(1, description.SupportedResponseFormats.Count); - Assert.Equal(typeof(Order), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); - - var formats = description.SupportedResponseFormats; - Assert.Single(formats, f => f.MediaType.ToString() == "text/json"); - Assert.Same(formatters[0], formats[0].Formatter); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(Order), responseType.Type); + Assert.NotNull(responseType.ModelMetadata); + var apiResponseFormat = Assert.Single( + responseType.ApiResponseFormats.Where(responseFormat => responseFormat.MediaType == "text/json")); + Assert.Same(formatters[0], apiResponseFormat.Formatter); } [Fact] @@ -1152,7 +1331,7 @@ private ControllerActionDescriptor CreateActionDescriptor(string methodName = nu { action.MethodInfo = controllerType.GetMethod( methodName ?? "ReturnsObject", - BindingFlags.Instance | BindingFlags.Public); + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); action.ControllerTypeInfo = controllerType.GetTypeInfo(); action.BoundProperties = new List(); @@ -1192,6 +1371,13 @@ private ControllerActionDescriptor CreateActionDescriptor(string methodName = nu return action; } + private IEnumerable GetSortedMediaTypes(ApiResponseType apiResponseType) + { + return apiResponseType.ApiResponseFormats + .OrderBy(responseType => responseType.MediaType) + .Select(responseType => responseType.MediaType); + } + private object ReturnsObject() { return null; @@ -1357,6 +1543,39 @@ public void FromQueryName([FromQuery] string name) } } + public class Customer + { + } + + public class BadData + { + } + + public class ErrorDetails + { + } + + public class BaseProducesController : Controller + { + public IActionResult ReturnsActionResult() + { + return null; + } + + public Task ReturnsTask() + { + return null; + } + + public void ReturnsVoid() + { + } + } + + public class DerivedProducesController : BaseProducesController + { + } + private class Product { public int ProductId { get; set; } @@ -1520,10 +1739,13 @@ private class ContentTypeAttribute : public ContentTypeAttribute(string mediaType) { ContentTypes.Add(mediaType); + StatusCode = 200; } public MediaTypeCollection ContentTypes { get; } = new MediaTypeCollection(); + public int StatusCode { get; set; } + public Type Type { get; set; } public void SetContentTypes(MediaTypeCollection contentTypes) diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index e121481499..dbc224ae35 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -1,6 +1,7 @@ // 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.Linq; using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; @@ -406,7 +407,10 @@ public async Task ApiExplorer_ResponseType_VoidWithoutAttribute(string action) // Assert var description = Assert.Single(result); - Assert.Equal(typeof(void).FullName, description.ResponseType); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(204, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); } [Theory] @@ -427,7 +431,7 @@ public async Task ApiExplorer_ResponseType_UnknownWithoutAttribute(string action // Assert var description = Assert.Single(result); - Assert.Null(description.ResponseType); + Assert.Empty(description.SupportedResponseTypes); } [Theory] @@ -443,17 +447,63 @@ public async Task ApiExplorer_ResponseType_KnownWithoutAttribute(string action, var body = await response.Content.ReadAsStringAsync(); var result = JsonConvert.DeserializeObject>(body); + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; // Assert var description = Assert.Single(result); - Assert.Equal(type, description.ResponseType); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + } + + [Fact] + public async Task ApiExplorer_ResponseType_KnownWithoutAttribute_ReturnVoid() + { + // Arrange + var type = "ApiExplorerWebSite.Customer"; + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.GetAsync( + "http://localhost/ApiExplorerResponseTypeWithAttribute/GetVoid"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + } + + [Fact] + public async Task ApiExplorer_ResponseType_DifferentOnAttributeThanReturnType() + { + // Arrange + var type = "ApiExplorerWebSite.Customer"; + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.GetAsync( + "http://localhost/ApiExplorerResponseTypeWithAttribute/GetProduct"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); } [Theory] - [InlineData("GetVoid", "ApiExplorerWebSite.Customer")] [InlineData("GetObject", "ApiExplorerWebSite.Product")] [InlineData("GetIActionResult", "System.String")] - [InlineData("GetProduct", "ApiExplorerWebSite.Customer")] [InlineData("GetTask", "System.Int32")] public async Task ApiExplorer_ResponseType_KnownWithAttribute(string action, string type) { @@ -466,24 +516,83 @@ public async Task ApiExplorer_ResponseType_KnownWithAttribute(string action, str // Assert var description = Assert.Single(result); - Assert.Equal(type, description.ResponseType); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + var responseFormat = Assert.Single(responseType.ResponseFormats); + Assert.Equal("application/json", responseFormat.MediaType); } - [Theory] - [InlineData("Controller", "ApiExplorerWebSite.Product")] - [InlineData("Action", "ApiExplorerWebSite.Customer")] - public async Task ApiExplorer_ResponseType_OverrideOnAction(string action, string type) + [Fact] + public async Task ApiExplorer_ResponseType_InheritingFromController() { - // Arrange & Act + // Arrange + var type = "ApiExplorerWebSite.Product"; + var errorType = "ApiExplorerWebSite.ErrorInfo"; + + // Act + var response = await Client.GetAsync( + "http://localhost/ApiExplorerResponseTypeOverrideOnAction/Controller"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(2, description.SupportedResponseTypes.Count); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), + responseType => + { + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + var responseFormat = Assert.Single(responseType.ResponseFormats); + Assert.Equal("application/json", responseFormat.MediaType); + }, + responseType => + { + Assert.Equal(errorType, responseType.ResponseType); + Assert.Equal(500, responseType.StatusCode); + var responseFormat = Assert.Single(responseType.ResponseFormats); + Assert.Equal("application/json", responseFormat.MediaType); + }); + } + + [Fact] + public async Task ApiExplorer_ResponseType_OverrideOnAction() + { + // Arrange + var type = "ApiExplorerWebSite.Customer"; + // type overriding the one specified on the controller + var errorType = "ApiExplorerWebSite.ErrorInfoOverride"; + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act var response = await Client.GetAsync( - "http://localhost/ApiExplorerResponseTypeOverrideOnAction/" + action); + "http://localhost/ApiExplorerResponseTypeOverrideOnAction/Action"); var body = await response.Content.ReadAsStringAsync(); var result = JsonConvert.DeserializeObject>(body); // Assert var description = Assert.Single(result); - Assert.Equal(type, description.ResponseType); + Assert.Equal(2, description.SupportedResponseTypes.Count); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), + responseType => + { + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(errorType, responseType.ResponseType); + Assert.Equal(500, responseType.StatusCode); + Assert.Equal(expectedMediaTypes, GetSortedMediaTypes(responseType)); + }); } [ConditionalFact] @@ -500,17 +609,17 @@ public async Task ApiExplorer_ResponseContentType_Unset() // Assert var description = Assert.Single(result); - var formats = description.SupportedResponseFormats; - Assert.Equal(4, formats.Count); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(4, responseType.ResponseFormats.Count); - var textXml = Assert.Single(formats, f => f.MediaType == "text/xml"); + var textXml = Assert.Single(responseType.ResponseFormats, f => f.MediaType == "text/xml"); Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, textXml.FormatterType); - var applicationXml = Assert.Single(formats, f => f.MediaType == "application/xml"); + var applicationXml = Assert.Single(responseType.ResponseFormats, f => f.MediaType == "application/xml"); Assert.Equal(typeof(XmlDataContractSerializerOutputFormatter).FullName, applicationXml.FormatterType); - var textJson = Assert.Single(formats, f => f.MediaType == "text/json"); + var textJson = Assert.Single(responseType.ResponseFormats, f => f.MediaType == "text/json"); Assert.Equal(typeof(JsonOutputFormatter).FullName, textJson.FormatterType); - var applicationJson = Assert.Single(formats, f => f.MediaType == "application/json"); + var applicationJson = Assert.Single(responseType.ResponseFormats, f => f.MediaType == "application/json"); Assert.Equal(typeof(JsonOutputFormatter).FullName, applicationJson.FormatterType); } @@ -526,13 +635,15 @@ public async Task ApiExplorer_ResponseContentType_Specific() // Assert var description = Assert.Single(result); - var formats = description.SupportedResponseFormats; - Assert.Equal(2, formats.Count); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Equal(2, responseType.ResponseFormats.Count); - var applicationJson = Assert.Single(formats, f => f.MediaType == "application/json"); + var applicationJson = Assert.Single( + responseType.ResponseFormats, + format => format.MediaType == "application/json"); Assert.Equal(typeof(JsonOutputFormatter).FullName, applicationJson.FormatterType); - var textJson = Assert.Single(formats, f => f.MediaType == "text/json"); + var textJson = Assert.Single(responseType.ResponseFormats, f => f.MediaType == "text/json"); Assert.Equal(typeof(JsonOutputFormatter).FullName, textJson.FormatterType); } @@ -547,9 +658,8 @@ public async Task ApiExplorer_ResponseContentType_NoMatch() // Assert var description = Assert.Single(result); - - var formats = description.SupportedResponseFormats; - Assert.Empty(formats); + var responseType = Assert.Single(description.SupportedResponseTypes); + Assert.Empty(responseType.ResponseFormats); } [ConditionalTheory] @@ -572,9 +682,10 @@ public async Task ApiExplorer_ResponseContentType_OverrideOnAction( // Assert var description = Assert.Single(result); - var format = Assert.Single(description.SupportedResponseFormats); - Assert.Equal(contentType, format.MediaType); - Assert.Equal(formatterType, format.FormatterType); + var responseType = Assert.Single(description.SupportedResponseTypes); + var responseFormat = Assert.Single(responseType.ResponseFormats); + Assert.Equal(contentType, responseFormat.MediaType); + Assert.Equal(formatterType, responseFormat.FormatterType); } [Fact] @@ -718,6 +829,13 @@ public async Task ApiExplorer_Parameters_SimpleTypes_ComplexModel() Assert.Equal(typeof(string).FullName, feedback.Type); } + private IEnumerable GetSortedMediaTypes(ApiExplorerResponseType apiResponseType) + { + return apiResponseType.ResponseFormats + .OrderBy(format => format.MediaType) + .Select(format => format.MediaType); + } + // Used to serialize data between client and server private class ApiExplorerData { @@ -729,9 +847,7 @@ private class ApiExplorerData public string RelativePath { get; set; } - public string ResponseType { get; set; } - - public List SupportedResponseFormats { get; } = new List(); + public List SupportedResponseTypes { get; } = new List(); } // Used to serialize data between client and server @@ -757,7 +873,17 @@ private class ApiExplorerParameterRouteInfo } // Used to serialize data between client and server - private class ApiExplorerResponseData + private class ApiExplorerResponseType + { + public IList ResponseFormats { get; } + = new List(); + + public string ResponseType { get; set; } + + public int StatusCode { get; set; } + } + + private class ApiExplorerResponseFormat { public string MediaType { get; set; } diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs index db16c34f60..d91179e35c 100644 --- a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs +++ b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs @@ -52,8 +52,7 @@ private ApiExplorerData CreateSerializableData(ApiDescription description) { GroupName = description.GroupName, HttpMethod = description.HttpMethod, - RelativePath = description.RelativePath, - ResponseType = description.ResponseType?.FullName, + RelativePath = description.RelativePath }; foreach (var parameter in description.ParameterDescriptions) @@ -78,15 +77,24 @@ private ApiExplorerData CreateSerializableData(ApiDescription description) data.ParameterDescriptions.Add(parameterData); } - foreach (var response in description.SupportedResponseFormats) + foreach (var response in description.SupportedResponseTypes) { - var responseData = new ApiExplorerResponseData() + var responseType = new ApiExplorerResponseType() { - FormatterType = response.Formatter.GetType().FullName, - MediaType = response.MediaType.ToString(), + StatusCode = response.StatusCode, + ResponseType = response.Type?.FullName }; - data.SupportedResponseFormats.Add(responseData); + foreach(var responseFormat in response.ApiResponseFormats) + { + responseType.ResponseFormats.Add(new ApiExplorerResponseFormat() + { + FormatterType = responseFormat.Formatter?.GetType().FullName, + MediaType = responseFormat.MediaType + }); + } + + data.SupportedResponseTypes.Add(responseType); } return data; @@ -103,9 +111,7 @@ private class ApiExplorerData public string RelativePath { get; set; } - public string ResponseType { get; set; } - - public List SupportedResponseFormats { get; } = new List(); + public List SupportedResponseTypes { get; } = new List(); } // Used to serialize data between client and server @@ -131,7 +137,17 @@ private class ApiExplorerParameterRouteInfo } // Used to serialize data between client and server - private class ApiExplorerResponseData + private class ApiExplorerResponseType + { + public IList ResponseFormats { get; } + = new List(); + + public string ResponseType { get; set; } + + public int StatusCode { get; set; } + } + + private class ApiExplorerResponseFormat { public string MediaType { get; set; } diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs index 8c6a946710..10f9a95249 100644 --- a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs @@ -6,6 +6,7 @@ namespace ApiExplorerWebSite { [Produces("application/json", Type = typeof(Product))] + [ProducesResponseType(typeof(ErrorInfo), 500)] [Route("ApiExplorerResponseTypeOverrideOnAction")] public class ApiExplorerResponseTypeOverrideOnActionController : Controller { @@ -16,9 +17,17 @@ public void GetController() [HttpGet("Action")] [Produces(typeof(Customer))] + [ProducesResponseType(typeof(ErrorInfoOverride), 500)] // overriding the type specified on the server public object GetAction() { return null; } } + + public class ErrorInfo + { + public string Message { get; set; } + } + + public class ErrorInfoOverride { } } \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs index dd11097d18..024ff2a822 100644 --- a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithoutAttributeController.cs @@ -10,6 +10,7 @@ namespace ApiExplorerWebSite public class ApiExplorerResponseTypeWithoutAttributeController : Controller { [HttpGet] + [ProducesResponseType(typeof(void), 204)] public void GetVoid() { } @@ -45,6 +46,7 @@ public int GetInt() } [HttpGet] + [ProducesResponseType(typeof(void), 204)] public Task GetTask() { return Task.FromResult(true);