diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiDescription.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiDescription.cs index dddafbd826..07265342d6 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. /// diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs index a02a5595fd..cbc77adbed 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseFormat.cs @@ -1,7 +1,9 @@ // 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.Formatters; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Microsoft.AspNetCore.Mvc.ApiExplorer { @@ -19,5 +21,27 @@ public class ApiResponseFormat /// The media type of the response. /// public string MediaType { get; set; } + + /// + /// Gets or sets for the or null. + /// + /// + /// Will be null if is null or void. + /// + 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; } + + /// + /// 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 dcab0a3d8c..a541880547 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs @@ -114,35 +114,22 @@ private ApiDescription CreateApiDescription( // 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 responseFormats = GetResponseFormats(action, responseMetadataAttributes, runtimeReturnType); + foreach (var responseFormat in responseFormats) { - // 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.SupportedResponseFormats.Add(responseFormat); } // 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); } @@ -371,6 +358,23 @@ private IReadOnlyList GetResponseFormats( { var results = new List(); + // Build list of all possible return types (and status codes) for an action. + var responseTypes = new Dictionary(); + + // If the action returns a type, then automatically consider it as a possible api response format with + // a status code 200 OK. + // NOTE: In some cases this might sound incorrect, for example in the case where the action is returning + // 'void' or 'Task'. We want to keep it this way to enable users to override the type via the response + // metadata attributes. So an exmple is that action returns void but there is a Produces attribute which + // sets the response type to be 'Produces' + if (type != null) + { + // This return type can be overriden by any response metadata + // attributes later if the user wishes to. + responseTypes[200] = 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 +383,11 @@ private IReadOnlyList GetResponseFormats( foreach (var metadataAttribute in responseMetadataAttributes) { metadataAttribute.SetContentTypes(contentTypes); + + if (metadataAttribute.Type != null) + { + responseTypes[metadataAttribute.StatusCode] = metadataAttribute.Type; + } } } @@ -387,25 +396,46 @@ private IReadOnlyList GetResponseFormats( contentTypes.Add((string)null); } - foreach (var contentType in contentTypes) + var responseFormatMetadataProviders = _outputFormatters.OfType(); + + foreach (var responseType in responseTypes) { - foreach (var formatter in _outputFormatters) + if (responseType.Value == typeof(void)) { - var responseFormatMetadataProvider = formatter as IApiResponseFormatMetadataProvider; - if (responseFormatMetadataProvider != null) + results.Add(new ApiResponseFormat() { - var supportedTypes = responseFormatMetadataProvider.GetSupportedContentTypes(contentType, type); + StatusCode = responseType.Key, + ResponseType = responseType.Value + }); - if (supportedTypes != null) + continue; + } + + var responseTypeModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType.Value); + + foreach (var contentType in contentTypes) + { + foreach (var responseFormatMetadataProvider in responseFormatMetadataProviders) + { + var formatterSupportedContentTypes = responseFormatMetadataProvider.GetSupportedContentTypes( + contentType, + responseType.Value); + + if (formatterSupportedContentTypes == null) { - foreach (var supportedType in supportedTypes) + continue; + } + + foreach (var formatterSupportedContentType in formatterSupportedContentTypes) + { + results.Add(new ApiResponseFormat() { - results.Add(new ApiResponseFormat() - { - Formatter = formatter, - MediaType = supportedType, - }); - } + Formatter = (IOutputFormatter)responseFormatMetadataProvider, + MediaType = formatterSupportedContentType, + ResponseType = responseType.Value, + StatusCode = responseType.Key, + ResponseModelMetadata = responseTypeModelMetadata + }); } } } @@ -445,28 +475,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. // @@ -731,5 +741,18 @@ public int GetHashCode(PropertyKey obj) } } } + + private class ResponseType + { + public ResponseType(Type type, int statusCode) + { + Type = type; + StatusCode = statusCode; + } + + public Type Type { get; } + + public int StatusCode { get; } + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs index c7a073e30c..08bcfce58c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs @@ -7,7 +7,8 @@ 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 { @@ -16,6 +17,11 @@ public interface IApiResponseMetadataProvider /// Type Type { get; } + /// + /// 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/ProducesAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs index efc2100597..4a2e90ce90 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 System.Net; 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,14 @@ public ProducesAttribute(string contentType, params string[] additionalContentTy public MediaTypeCollection ContentTypes { get; set; } + public int StatusCode + { + get + { + return (int)HttpStatusCode.OK; + } + } + 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..168d56dc6b --- /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 the type of the value and status code returned by the action + /// which can be used to select a formatter while executing . + /// + [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; + } + + /// + /// The type of the value returned by an action. + /// + public Type Type { get; set; } + + /// + /// Http status code response. + /// + public int StatusCode { get; set; } + + public void SetContentTypes(MediaTypeCollection contentTypes) + { + // Users are supposed to use the 'Produces' attribute to set the content types that an aciton can support. + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs index eeaeabe56d..54a1d237f7 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs @@ -20,7 +20,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; @@ -217,7 +216,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")] @@ -373,8 +372,12 @@ public void GetApiDescription_PopulatesResponseType_WithProduct() // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(Product), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); + Assert.Equal(4, description.SupportedResponseFormats.Count); + foreach (var responseFormat in description.SupportedResponseFormats) + { + Assert.Equal(typeof(Product), responseFormat.ResponseType); + Assert.NotNull(responseFormat.ResponseModelMetadata); + } } [Fact] @@ -388,8 +391,13 @@ public void GetApiDescription_PopulatesResponseType_WithTaskOfProduct() // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(Product), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); + Assert.Equal(4, description.SupportedResponseFormats.Count); + + foreach (var responseFormat in description.SupportedResponseFormats) + { + Assert.Equal(typeof(Product), responseFormat.ResponseType); + Assert.NotNull(responseFormat.ResponseModelMetadata); + } } [Theory] @@ -409,11 +417,185 @@ public void GetApiDescription_DoesNotPopulatesResponseInformation_WhenUnknown(st // Assert var description = Assert.Single(descriptions); - Assert.Null(description.ResponseType); - Assert.Null(description.ResponseModelMetadata); Assert.Empty(description.SupportedResponseFormats); } + 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; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(6, description.SupportedResponseFormats.Count); + + var formats = description.SupportedResponseFormats.Where(frmt => frmt.ResponseType == typeof(Customer)); + Assert.Equal(2, formats.Count()); + var format = Assert.Single(formats.Where(frmt => frmt.MediaType == "text/json")); + Assert.Equal(200, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "application/json")); + Assert.Equal(200, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + + formats = description.SupportedResponseFormats.Where(frmt => frmt.ResponseType == typeof(BadData)); + Assert.Equal(2, formats.Count()); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "text/json")); + Assert.Equal(400, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "application/json")); + Assert.Equal(400, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + + formats = description.SupportedResponseFormats.Where(frmt => frmt.ResponseType == typeof(ErrorDetails)); + Assert.Equal(2, formats.Count()); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "text/json")); + Assert.Equal(500, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "application/json")); + Assert.Equal(500, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + } + + 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(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; + + // Act + var descriptions = GetApiDescriptions(action); + + // Assert + var description = Assert.Single(descriptions); + Assert.Equal(5, description.SupportedResponseFormats.Count); + + var formats = description.SupportedResponseFormats.Where(frmt => frmt.ResponseType == typeof(void)); + var format = Assert.Single(formats); + Assert.Equal(200, format.StatusCode); + Assert.Null(format.ResponseModelMetadata); + Assert.Null(format.MediaType); + Assert.Null(format.Formatter); + Assert.Equal(typeof(void), format.ResponseType); + + formats = description.SupportedResponseFormats.Where(frmt => frmt.ResponseType == typeof(BadData)); + Assert.Equal(2, formats.Count()); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "text/json")); + Assert.Equal(400, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "application/json")); + Assert.Equal(400, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + + formats = description.SupportedResponseFormats.Where(frmt => frmt.ResponseType == typeof(ErrorDetails)); + Assert.Equal(2, formats.Count()); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "text/json")); + Assert.Equal(500, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + format = Assert.Single(formats.Where(frmt => frmt.MediaType == "application/json")); + Assert.Equal(500, format.StatusCode); + Assert.NotNull(format.ResponseModelMetadata); + } + [Theory] [InlineData(nameof(ReturnsVoid))] [InlineData(nameof(ReturnsTask))] @@ -427,9 +609,9 @@ public void GetApiDescription_DoesNotPopulatesResponseInformation_WhenVoid(strin // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(void), description.ResponseType); - Assert.Null(description.ResponseModelMetadata); - Assert.Empty(description.SupportedResponseFormats); + var responseFormat = Assert.Single(description.SupportedResponseFormats); + Assert.Equal(typeof(void), responseFormat.ResponseType); + Assert.Null(responseFormat.ResponseModelMetadata); } [Theory] @@ -458,8 +640,14 @@ public void GetApiDescription_PopulatesResponseInformation_WhenSetByFilter(strin // Assert var description = Assert.Single(descriptions); - Assert.Equal(typeof(Order), description.ResponseType); - Assert.NotNull(description.ResponseModelMetadata); + Assert.Equal(2, description.SupportedResponseFormats.Count); + foreach (var responseFormat in description.SupportedResponseFormats) + { + Assert.Equal(typeof(Order), responseFormat.ResponseType); + Assert.NotNull(responseFormat.ResponseModelMetadata); + Assert.Equal(200, responseFormat.StatusCode); + Assert.StartsWith("text/", responseFormat.MediaType); + } } [Fact] @@ -527,13 +715,11 @@ 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 responseFormat = Assert.Single(description.SupportedResponseFormats); + Assert.Equal(typeof(Order), responseFormat.ResponseType); + Assert.NotNull(responseFormat.ResponseModelMetadata); + Assert.Equal("text/json", responseFormat.MediaType); + Assert.Same(formatters[0], responseFormat.Formatter); } [Fact] @@ -1151,7 +1337,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(); @@ -1356,6 +1542,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; } @@ -1519,10 +1738,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..92c6e0b6c3 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,11 @@ public async Task ApiExplorer_ResponseType_VoidWithoutAttribute(string action) // Assert var description = Assert.Single(result); - Assert.Equal(typeof(void).FullName, description.ResponseType); + var responseFormat = Assert.Single(description.SupportedResponseFormats); + Assert.Equal(typeof(void).FullName, responseFormat.ResponseType); + Assert.Null(responseFormat.FormatterType); + Assert.Null(responseFormat.MediaType); + //Assert.Null(responseFormat.StatusCode); } [Theory] @@ -427,7 +432,7 @@ public async Task ApiExplorer_ResponseType_UnknownWithoutAttribute(string action // Assert var description = Assert.Single(result); - Assert.Null(description.ResponseType); + Assert.Empty(description.SupportedResponseFormats); } [Theory] @@ -446,14 +451,69 @@ public async Task ApiExplorer_ResponseType_KnownWithoutAttribute(string action, // Assert var description = Assert.Single(result); - Assert.Equal(type, description.ResponseType); + Assert.Equal(4, description.SupportedResponseFormats.Count); + Assert.Equal(4, description.SupportedResponseFormats + .Where(respData => respData.ResponseType == type && respData.StatusCode == 200) + .Count()); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "text/json"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "application/json"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "text/xml"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "application/xml"); + } + + [Fact] + public async Task ApiExplorer_ResponseType_KnownWithoutAttribute_ReturnVoid() + { + // Arrange + var type = "ApiExplorerWebSite.Customer"; + + // 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); + Assert.Equal(4, description.SupportedResponseFormats.Count); + Assert.Equal(4, description.SupportedResponseFormats + .Where(respData => respData.ResponseType == type && respData.StatusCode == 200) + .Count()); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "text/json"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "application/json"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "text/xml"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "application/xml"); + } + + [Fact] + public async Task ApiExplorer_ResponseType_DifferentOnAttributeThanReturnType() + { + // Arrange + var type = "ApiExplorerWebSite.Customer"; + + // 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); + Assert.Equal(4, description.SupportedResponseFormats.Count); + Assert.Equal(4, description.SupportedResponseFormats + .Where(respData => respData.ResponseType == type && respData.StatusCode == 200) + .Count()); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "text/json"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "application/json"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "text/xml"); + Assert.Single(description.SupportedResponseFormats, frmt => frmt.MediaType == "application/xml"); } [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 +526,74 @@ public async Task ApiExplorer_ResponseType_KnownWithAttribute(string action, str // Assert var description = Assert.Single(result); - Assert.Equal(type, description.ResponseType); + var responseFormat = Assert.Single(description.SupportedResponseFormats); + Assert.Equal(type, responseFormat.ResponseType); + Assert.Equal("application/json", responseFormat.MediaType); + Assert.Equal(200, responseFormat.StatusCode); } - [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/" + action); + "http://localhost/ApiExplorerResponseTypeOverrideOnAction/Controller"); 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.SupportedResponseFormats.Count); + var responseFormat = Assert.Single(description.SupportedResponseFormats.Where( + respData => respData.ResponseType == type)); + Assert.Equal(type, responseFormat.ResponseType); + Assert.Equal(200, responseFormat.StatusCode); + Assert.Equal("application/json", responseFormat.MediaType); + + responseFormat = Assert.Single(description.SupportedResponseFormats.Where( + respData => respData.ResponseType == errorType)); + Assert.Equal(errorType, responseFormat.ResponseType); + Assert.Equal(500, responseFormat.StatusCode); + Assert.Equal("application/json", responseFormat.MediaType); + } + + [Fact] + public async Task ApiExplorer_ResponseType_OverrideOnAction() + { + // Arrange + var type = "ApiExplorerWebSite.Customer"; + var errorType = "ApiExplorerWebSite.ErrorInfoOverride"; // type overriding the one specified on the controller + + // Act + var response = await Client.GetAsync( + "http://localhost/ApiExplorerResponseTypeOverrideOnAction/Action"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + Assert.Equal(8, description.SupportedResponseFormats.Count); + var formats = description.SupportedResponseFormats + .Where(respData => respData.ResponseType == type && respData.StatusCode == 200); + Assert.Equal(4, formats.Count()); + Assert.Single(formats, frmt => frmt.MediaType == "text/json"); + Assert.Single(formats, frmt => frmt.MediaType == "application/json"); + Assert.Single(formats, frmt => frmt.MediaType == "text/xml"); + Assert.Single(formats, frmt => frmt.MediaType == "application/xml"); + + formats = description.SupportedResponseFormats + .Where(respData => respData.ResponseType == errorType && respData.StatusCode == 500); + Assert.Equal(4, formats.Count()); + Assert.Single(formats, frmt => frmt.MediaType == "text/json"); + Assert.Single(formats, frmt => frmt.MediaType == "application/json"); + Assert.Single(formats, frmt => frmt.MediaType == "text/xml"); + Assert.Single(formats, frmt => frmt.MediaType == "application/xml"); } [ConditionalFact] @@ -729,8 +839,6 @@ private class ApiExplorerData public string RelativePath { get; set; } - public string ResponseType { get; set; } - public List SupportedResponseFormats { get; } = new List(); } @@ -762,6 +870,10 @@ private class ApiExplorerResponseData public string MediaType { get; set; } public string FormatterType { get; set; } + + public string ResponseType { get; set; } + + public int StatusCode { get; set; } } } } \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs index db16c34f60..8ae1f037ba 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) @@ -82,8 +81,10 @@ private ApiExplorerData CreateSerializableData(ApiDescription description) { var responseData = new ApiExplorerResponseData() { - FormatterType = response.Formatter.GetType().FullName, - MediaType = response.MediaType.ToString(), + FormatterType = response.Formatter?.GetType().FullName, + MediaType = response.MediaType, + StatusCode = response.StatusCode, + ResponseType = response.ResponseType?.FullName }; data.SupportedResponseFormats.Add(responseData); @@ -103,8 +104,6 @@ private class ApiExplorerData public string RelativePath { get; set; } - public string ResponseType { get; set; } - public List SupportedResponseFormats { get; } = new List(); } @@ -136,6 +135,10 @@ private class ApiExplorerResponseData public string MediaType { get; set; } public string FormatterType { get; set; } + + public string ResponseType { get; set; } + + public int StatusCode { get; set; } } } } \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs index 8c6a946710..0b7d4bbce0 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,12 @@ 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 ErrorInfoOverride { } } \ No newline at end of file diff --git a/test/WebSites/ApiExplorerWebSite/Models/ErrorInfo.cs b/test/WebSites/ApiExplorerWebSite/Models/ErrorInfo.cs new file mode 100644 index 0000000000..d8f9c2b536 --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Models/ErrorInfo.cs @@ -0,0 +1,10 @@ +// 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 ApiExplorerWebSite +{ + public class ErrorInfo + { + public string Message { get; set; } + } +}