Added new attribute ProducesResponseTypeAttribute to enable ApiExplorer to expose response type and StatusCode.

[Fixes #4101] StatusCode Metadata
This commit is contained in:
Kiran Challa 2016-03-07 09:43:41 -08:00
parent fb81a5e11e
commit 6e9a6a2db1
14 changed files with 630 additions and 167 deletions

View File

@ -43,23 +43,6 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// </summary>
public string RelativePath { get; set; }
/// <summary>
/// Gets or sets <see cref="ModelMetadata"/> for the <see cref="ResponseType"/> or null.
/// </summary>
/// <remarks>
/// Will be null if <see cref="ResponseType"/> is null.
/// </remarks>
public ModelMetadata ResponseModelMetadata { get; set; }
/// <summary>
/// Gets or sets the CLR data type of the response or null.
/// </summary>
/// <remarks>
/// Will be null if the action returns no response, or if the response type is unclear. Use
/// <c>ProducesAttribute</c> on an action method to specify a response type.
/// </remarks>
public Type ResponseType { get; set; }
/// <summary>
/// Gets the list of possible formats for a response.
/// </summary>
@ -76,6 +59,6 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// Will be empty if the action returns no response, or if the response type is unclear. Use
/// <c>ProducesAttribute</c> on an action method to specify a response type.
/// </remarks>
public IList<ApiResponseFormat> SupportedResponseFormats { get; } = new List<ApiResponseFormat>();
public IList<ApiResponseType> SupportedResponseTypes { get; } = new List<ApiResponseType>();
}
}

View File

@ -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 @@ using Microsoft.AspNetCore.Mvc.Formatters;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
/// <summary>
/// Represents a possible format for the body of a response.
/// Possible format for an <see cref="ApiResponseType"/>.
/// </summary>
public class ApiResponseFormat
{
/// <summary>
/// The formatter used to output this response.
/// Gets or sets the formatter used to output this response.
/// </summary>
public IOutputFormatter Formatter { get; set; }
/// <summary>
/// The media type of the response.
/// Gets or sets the media type of the response.
/// </summary>
public string MediaType { get; set; }
}
}
}

View File

@ -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
{
/// <summary>
/// Possible type of the response body which is formatted by <see cref="ApiResponseFormats"/>.
/// </summary>
public class ApiResponseType
{
/// <summary>
/// Gets or sets the response formats supported by this type.
/// </summary>
public IList<ApiResponseFormat> ApiResponseFormats { get; set; } = new List<ApiResponseFormat>();
/// <summary>
/// Gets or sets <see cref="ModelBinding.ModelMetadata"/> for the <see cref="Type"/> or null.
/// </summary>
/// <remarks>
/// Will be null if <see cref="Type"/> is null or void.
/// </remarks>
public ModelMetadata ModelMetadata { get; set; }
/// <summary>
/// Gets or sets the CLR data type of the response or null.
/// </summary>
/// <remarks>
/// Will be null if the action returns no response, or if the response type is unclear. Use
/// <see cref="ProducesAttribute"/> or <see cref="ProducesResponseTypeAttribute"/> on an action method
/// to specify a response type.
/// </remarks>
public Type Type { get; set; }
/// <summary>
/// Gets or sets the HTTP response status code.
/// </summary>
public int StatusCode { get; set; }
}
}

View File

@ -8,6 +8,7 @@ using System.Linq;
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 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
// 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 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
return results;
}
private IReadOnlyList<ApiResponseFormat> GetResponseFormats(
private IReadOnlyList<ApiResponseType> GetApiResponseTypes(
ControllerActionDescriptor action,
IApiResponseMetadataProvider[] responseMetadataAttributes,
Type type)
{
var results = new List<ApiResponseFormat>();
var results = new List<ApiResponseType>();
// Build list of all possible return types (and status codes) for an action.
var objectTypes = new Dictionary<int, Type>();
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 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
foreach (var metadataAttribute in responseMetadataAttributes)
{
metadataAttribute.SetContentTypes(contentTypes);
if (metadataAttribute.Type != null)
{
objectTypes[metadataAttribute.StatusCode] = metadataAttribute.Type;
}
}
}
@ -387,28 +387,53 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
contentTypes.Add((string)null);
}
foreach (var contentType in contentTypes)
{
foreach (var formatter in _outputFormatters)
{
var responseFormatMetadataProvider = formatter as IApiResponseFormatMetadataProvider;
if (responseFormatMetadataProvider != null)
{
var supportedTypes = responseFormatMetadataProvider.GetSupportedContentTypes(contentType, type);
var responseTypeMetadataProviders = _outputFormatters.OfType<IApiResponseTypeMetadataProvider>();
if (supportedTypes != null)
foreach (var objectType in objectTypes)
{
if (objectType.Value == typeof(void))
{
results.Add(new ApiResponseType()
{
StatusCode = objectType.Key,
Type = objectType.Value
});
continue;
}
var apiResponseType = new ApiResponseType()
{
Type = objectType.Value,
StatusCode = objectType.Key,
ModelMetadata = _modelMetadataProvider.GetMetadataForType(objectType.Value)
};
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 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
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.
//

View File

@ -7,15 +7,21 @@ using Microsoft.AspNetCore.Mvc.Formatters;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
/// <summary>
/// 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.
/// </summary>
public interface IApiResponseMetadataProvider
{
/// <summary>
/// Optimistic return type of the action.
/// Gets the optimistic return type of the action.
/// </summary>
Type Type { get; }
/// <summary>
/// Gets the HTTP status code of the response.
/// </summary>
int StatusCode { get; }
/// <summary>
/// Configures a collection of allowed content types which can be produced by the action.
/// </summary>

View File

@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// An <see cref="Formatters.IOutputFormatter"/> should implement this interface to expose metadata information
/// to an <c>IApiDescriptionProvider</c>.
/// </remarks>
public interface IApiResponseFormatMetadataProvider
public interface IApiResponseTypeMetadataProvider
{
/// <summary>
/// Gets a filtered list of content types which are supported by the <see cref="Formatters.IOutputFormatter"/>

View File

@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
/// <summary>
/// Writes an object to the output stream.
/// </summary>
public abstract class OutputFormatter : IOutputFormatter, IApiResponseFormatMetadataProvider
public abstract class OutputFormatter : IOutputFormatter, IApiResponseTypeMetadataProvider
{
/// <summary>
/// Gets the mutable collection of media type elements supported by

View File

@ -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 @@ namespace Microsoft.AspNetCore.Mvc
public MediaTypeCollection ContentTypes { get; set; }
public int StatusCode => StatusCodes.Status200OK;
public override void OnResultExecuting(ResultExecutingContext context)
{
if (context == null)

View File

@ -0,0 +1,49 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace Microsoft.AspNetCore.Mvc
{
/// <summary>
/// Specifies the type of the value and status code returned by the action.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class ProducesResponseTypeAttribute : Attribute, IApiResponseMetadataProvider, IFilterMetadata
{
/// <summary>
/// Initializes an instance of <see cref="ProducesResponseTypeAttribute"/>.
/// </summary>
/// <param name="type">The <see cref="Type"/> of object that is going to be written in the response.</param>
/// <param name="statusCode">HTTP response status code</param>
public ProducesResponseTypeAttribute(Type type, int statusCode)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
Type = type;
StatusCode = statusCode;
}
/// <summary>
/// Gets or sets the type of the value returned by an action.
/// </summary>
public Type Type { get; set; }
/// <summary>
/// Gets or sets the HTTP status code of the response.
/// </summary>
public int StatusCode { get; set; }
/// <inheritdoc />
void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes)
{
// Users are supposed to use the 'Produces' attribute to set the content types that an action can support.
}
}
}

View File

@ -21,7 +21,6 @@ using Microsoft.AspNetCore.Mvc.Routing;
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 @@ namespace Microsoft.AspNetCore.Mvc.Description
}
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
// 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<FilterDescriptor>()
{
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<Type, string, List<FilterDescriptor>>
{
{
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<FilterDescriptor> 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<Type, string, List<FilterDescriptor>> ReturnsVoidOrTaskWithProducesContentTypeData
{
get
{
var filterDescriptors = new List<FilterDescriptor>()
{
// 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<Type, string, List<FilterDescriptor>>
{
{
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<FilterDescriptor> 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
{
// Arrange
var action = CreateActionDescriptor(methodName);
var filter = new ProducesResponseTypeAttribute(typeof(void), statusCode: 204);
action.FilterDescriptors = new List<FilterDescriptor>();
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 @@ namespace Microsoft.AspNetCore.Mvc.Description
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
{
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
{
// Arrange
var action = CreateActionDescriptor(nameof(ReturnsProduct));
var expectedMediaTypes = new[] { "text/json", "text/xml" };
action.FilterDescriptors = new List<FilterDescriptor>();
action.FilterDescriptors.Add(new FilterDescriptor(new ContentTypeAttribute("text/*"), FilterScope.Action));
@ -496,10 +678,8 @@ namespace Microsoft.AspNetCore.Mvc.Description
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
// 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 @@ namespace Microsoft.AspNetCore.Mvc.Description
{
action.MethodInfo = controllerType.GetMethod(
methodName ?? "ReturnsObject",
BindingFlags.Instance | BindingFlags.Public);
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
action.ControllerTypeInfo = controllerType.GetTypeInfo();
action.BoundProperties = new List<ParameterDescriptor>();
@ -1192,6 +1371,13 @@ namespace Microsoft.AspNetCore.Mvc.Description
return action;
}
private IEnumerable<string> GetSortedMediaTypes(ApiResponseType apiResponseType)
{
return apiResponseType.ApiResponseFormats
.OrderBy(responseType => responseType.MediaType)
.Select(responseType => responseType.MediaType);
}
private object ReturnsObject()
{
return null;
@ -1357,6 +1543,39 @@ namespace Microsoft.AspNetCore.Mvc.Description
}
}
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 @@ namespace Microsoft.AspNetCore.Mvc.Description
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)

View File

@ -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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// Assert
var description = Assert.Single(result);
Assert.Null(description.ResponseType);
Assert.Empty(description.SupportedResponseTypes);
}
[Theory]
@ -443,17 +447,63 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(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<List<ApiExplorerData>>(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<List<ApiExplorerData>>(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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// 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/" + action);
"http://localhost/ApiExplorerResponseTypeOverrideOnAction/Controller");
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(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);
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");
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<ApiExplorerData>>(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);
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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
// 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal(typeof(string).FullName, feedback.Type);
}
private IEnumerable<string> 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 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public string RelativePath { get; set; }
public string ResponseType { get; set; }
public List<ApiExplorerResponseData> SupportedResponseFormats { get; } = new List<ApiExplorerResponseData>();
public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>();
}
// Used to serialize data between client and server
@ -757,7 +873,17 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
}
// Used to serialize data between client and server
private class ApiExplorerResponseData
private class ApiExplorerResponseType
{
public IList<ApiExplorerResponseFormat> ResponseFormats { get; }
= new List<ApiExplorerResponseFormat>();
public string ResponseType { get; set; }
public int StatusCode { get; set; }
}
private class ApiExplorerResponseFormat
{
public string MediaType { get; set; }

View File

@ -52,8 +52,7 @@ namespace ApiExplorerWebSite
{
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 @@ namespace ApiExplorerWebSite
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 @@ namespace ApiExplorerWebSite
public string RelativePath { get; set; }
public string ResponseType { get; set; }
public List<ApiExplorerResponseData> SupportedResponseFormats { get; } = new List<ApiExplorerResponseData>();
public List<ApiExplorerResponseType> SupportedResponseTypes { get; } = new List<ApiExplorerResponseType>();
}
// Used to serialize data between client and server
@ -131,7 +137,17 @@ namespace ApiExplorerWebSite
}
// Used to serialize data between client and server
private class ApiExplorerResponseData
private class ApiExplorerResponseType
{
public IList<ApiExplorerResponseFormat> ResponseFormats { get; }
= new List<ApiExplorerResponseFormat>();
public string ResponseType { get; set; }
public int StatusCode { get; set; }
}
private class ApiExplorerResponseFormat
{
public string MediaType { get; set; }

View File

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ApiExplorerWebSite
{
[Produces("application/json", Type = typeof(Product))]
[ProducesResponseType(typeof(ErrorInfo), 500)]
[Route("ApiExplorerResponseTypeOverrideOnAction")]
public class ApiExplorerResponseTypeOverrideOnActionController : Controller
{
@ -16,9 +17,17 @@ namespace ApiExplorerWebSite
[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 { }
}

View File

@ -10,6 +10,7 @@ namespace ApiExplorerWebSite
public class ApiExplorerResponseTypeWithoutAttributeController : Controller
{
[HttpGet]
[ProducesResponseType(typeof(void), 204)]
public void GetVoid()
{
}
@ -45,6 +46,7 @@ namespace ApiExplorerWebSite
}
[HttpGet]
[ProducesResponseType(typeof(void), 204)]
public Task GetTask()
{
return Task.FromResult(true);