From 1f9048ddeb1ed0b043e5c86502c78b0e44fe2c31 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 --- .../MvcSandbox/Controllers/HomeController.cs | 26 +++ .../ApiDescription.cs | 17 -- .../ApiResponseFormat.cs | 21 +++ .../DefaultApiDescriptionProvider.cs | 155 +++++++++++++----- .../IApiResponseMetadataProvider.cs | 2 + .../ProducesAttribute.cs | 9 + .../ProducesResponseTypeAttribute.cs | 49 ++++++ .../DefaultApiDescriptionProviderTest.cs | 49 +++--- .../ApiExplorerDataFilter.cs | 13 +- 9 files changed, 263 insertions(+), 78 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/ProducesResponseTypeAttribute.cs diff --git a/samples/MvcSandbox/Controllers/HomeController.cs b/samples/MvcSandbox/Controllers/HomeController.cs index cf675012e4..f3cbdcc0c5 100644 --- a/samples/MvcSandbox/Controllers/HomeController.cs +++ b/samples/MvcSandbox/Controllers/HomeController.cs @@ -2,14 +2,40 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; namespace MvcSandbox.Controllers { public class HomeController : Controller { + private readonly IApiDescriptionGroupCollectionProvider _apiDescriptionProvider; + + public HomeController(IApiDescriptionGroupCollectionProvider apiDescriptionProvider) + { + _apiDescriptionProvider = apiDescriptionProvider; + } + + [ApiExplorerSettings(IgnoreApi = true)] public IActionResult Index() { + var apiDescriptions = _apiDescriptionProvider.ApiDescriptionGroups; return View(); } + + [Produces("application/json", "text/xml", Type = typeof(Customer))] + public IActionResult Foo() + { + return Ok("hello"); + } + } + + public class Customer + { + public int Id { get; set; } + } + + public class Error + { + public string Message { get; set; } } } 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..1240952a3d 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,24 @@ 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. + /// + 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; } + + 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..7444008fbb 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/DefaultApiDescriptionProvider.cs @@ -116,33 +116,39 @@ private ApiDescription CreateApiDescription( // what the logical data type is using filters. var runtimeReturnType = GetRuntimeReturnType(declaredReturnType, responseMetadataAttributes); - // 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)) - { - // 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; + //// 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)) + //{ + // // 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); + // apiDescription.ResponseModelMetadata = _modelMetadataProvider.GetMetadataForType(runtimeReturnType); - var formats = GetResponseFormats(action, responseMetadataAttributes, runtimeReturnType); - foreach (var format in formats) - { - apiDescription.SupportedResponseFormats.Add(format); - } + // var formats = GetResponseFormats(action, responseMetadataAttributes, runtimeReturnType); + // foreach (var format in formats) + // { + // apiDescription.SupportedResponseFormats.Add(format); + // } + //} + + var responseFormats = GetResponseFormats(action, responseMetadataAttributes, runtimeReturnType); + foreach (var format in responseFormats) + { + apiDescription.SupportedResponseFormats.Add(format); } // 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); } @@ -367,47 +373,109 @@ private IReadOnlyList GetRequestFormats( private IReadOnlyList GetResponseFormats( ControllerActionDescriptor action, IApiResponseMetadataProvider[] responseMetadataAttributes, - Type type) + Type returnType) { var results = new List(); // 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(); + var actionSupportedContentTypes = new MediaTypeCollection(); if (responseMetadataAttributes != null) { foreach (var metadataAttribute in responseMetadataAttributes) { - metadataAttribute.SetContentTypes(contentTypes); + metadataAttribute.SetContentTypes(actionSupportedContentTypes); } } - if (contentTypes.Count == 0) + if (actionSupportedContentTypes.Count == 0) { - contentTypes.Add((string)null); + actionSupportedContentTypes.Add((string)null); } - foreach (var contentType in contentTypes) + var responseTypes = new List(); + + // Go through rest of metadata attributes to add their response types too + if (responseMetadataAttributes != null) { - foreach (var formatter in _outputFormatters) + foreach (var respMetadata in responseMetadataAttributes) { - var responseFormatMetadataProvider = formatter as IApiResponseFormatMetadataProvider; - if (responseFormatMetadataProvider != null) + if (respMetadata.Type != null) { - var supportedTypes = responseFormatMetadataProvider.GetSupportedContentTypes(contentType, type); + responseTypes.Add(new ResponseType(respMetadata.Type, respMetadata.StatusCode)); + } + } + } - if (supportedTypes != null) + // If the action returns a model type, then automatically consider it as a possible + // api response format. + if (responseTypes.Count == 0) + { + if (returnType != null) + { + responseTypes.Add(new ResponseType(returnType, 200)); + } + } + + //if (responseMetadataAttributes == null || responseMetadataAttributes.Length == 0) + //{ + // if (returnType != null) + // { + // responseTypes.Add(new ResponseType(returnType, 200)); + // } + //} + //else + //{ + // foreach (var respMetadata in responseMetadataAttributes) + // { + // if (respMetadata.Type != null) + // { + // responseTypes.Add(new ResponseType(respMetadata.Type, respMetadata.StatusCode)); + // } + // } + //} + + var responseFormatMetadataProviders = _outputFormatters.OfType(); + + foreach (var responseType in responseTypes) + { + if (responseType.Type == typeof(void)) + { + results.Add(new ApiResponseFormat() + { + ResponseType = responseType.Type + }); + + continue; + } + + foreach (var actionSupportedContentType in actionSupportedContentTypes) + { + foreach (var responseFormatMetadataProvider in responseFormatMetadataProviders) + { + + var supportedTypes = responseFormatMetadataProvider.GetSupportedContentTypes( + actionSupportedContentType, + responseType.Type); + + if (supportedTypes == null) { - foreach (var supportedType in supportedTypes) + continue; + } + + foreach (var supportedType in supportedTypes) + { + results.Add(new ApiResponseFormat() { - results.Add(new ApiResponseFormat() - { - Formatter = formatter, - MediaType = supportedType, - }); - } + Formatter = (IOutputFormatter)responseFormatMetadataProvider, + MediaType = supportedType, + ResponseType = responseType.Type, + StatusCode = responseType.StatusCode, + ResponseModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType.Type) + }); } } + } } @@ -731,5 +799,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..f885c387f3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/IApiResponseMetadataProvider.cs @@ -16,6 +16,8 @@ public interface IApiResponseMetadataProvider /// Type Type { get; } + 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..0a878bf304 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ProducesAttribute.cs @@ -4,6 +4,7 @@ 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; @@ -64,6 +65,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..8bcef80b24 --- /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 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.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// Specifies the allowed content types and the type of the value 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 + { + /// + /// Initializes an instance of . + /// + /// The of object that is going to be written in the response. + /// + public ProducesResponseTypeAttribute(Type type, int statusCode) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + Type = type; + StatusCode = statusCode; + } + + public Type Type { get; set; } + + public int StatusCode { get; set; } + + public void SetContentTypes(MediaTypeCollection contentTypes) + { + // do not do anything here + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs index eeaeabe56d..021bd1cc75 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/DefaultApiDescriptionProviderTest.cs @@ -217,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")] @@ -373,8 +373,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 +392,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,8 +418,6 @@ public void GetApiDescription_DoesNotPopulatesResponseInformation_WhenUnknown(st // Assert var description = Assert.Single(descriptions); - Assert.Null(description.ResponseType); - Assert.Null(description.ResponseModelMetadata); Assert.Empty(description.SupportedResponseFormats); } @@ -427,9 +434,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 +465,12 @@ 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); + } } [Fact] @@ -527,13 +538,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] @@ -1523,6 +1532,8 @@ public ContentTypeAttribute(string mediaType) 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/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs b/test/WebSites/ApiExplorerWebSite/ApiExplorerDataFilter.cs index db16c34f60..7265dbc52c 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) @@ -84,6 +83,8 @@ private ApiExplorerData CreateSerializableData(ApiDescription description) { FormatterType = response.Formatter.GetType().FullName, MediaType = response.MediaType.ToString(), + StatusCode = response.StatusCode, + ResponseType = response.ResponseType?.FullName }; data.SupportedResponseFormats.Add(responseData); @@ -103,9 +104,7 @@ private class ApiExplorerData public string RelativePath { get; set; } - public string ResponseType { get; set; } - - public List SupportedResponseFormats { get; } = new List(); +public List SupportedResponseFormats { get; } = new List(); } // Used to serialize data between client and server @@ -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