diff --git a/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs b/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs index 9b3bffac2b29..fe674265788d 100644 --- a/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs @@ -118,5 +118,35 @@ public static TBuilder WithMetadata(this TBuilder builder, params obje return builder; } + + /// + /// Sets the for all endpoints produced + /// on the target given the . + /// The on the endpoint is used for link generation and + /// is treated as the operation ID in the given endpoint's OpenAPI specification. + /// + /// The . + /// The endpoint name. + /// The . + public static TBuilder WithName(this TBuilder builder, string endpointName) where TBuilder : IEndpointConventionBuilder + { + builder.WithMetadata(new EndpointNameAttribute(endpointName)); + return builder; + } + + /// + /// Sets the for all endpoints produced + /// on the target given the . + /// The on the endpoint is used to set the endpoint's + /// GroupName in the OpenAPI specification. + /// + /// The . + /// The endpoint group name. + /// The . + public static TBuilder WithGroupName(this TBuilder builder, string endpointGroupName) where TBuilder : IEndpointConventionBuilder + { + builder.WithMetadata(new EndpointGroupNameAttribute(endpointGroupName)); + return builder; + } } } diff --git a/src/Http/Routing/src/EndpointGroupNameAttribute.cs b/src/Http/Routing/src/EndpointGroupNameAttribute.cs new file mode 100644 index 000000000000..68511b6ca930 --- /dev/null +++ b/src/Http/Routing/src/EndpointGroupNameAttribute.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.Http; + + namespace Microsoft.AspNetCore.Routing + { + /// + /// Specifies the endpoint group name in . + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate | AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class EndpointGroupNameAttribute : Attribute, IEndpointGroupNameMetadata + { + /// + /// Initializes an instance of the . + /// + /// The endpoint group name. + public EndpointGroupNameAttribute(string endpointGroupName) + { + if (endpointGroupName == null) + { + throw new ArgumentNullException(nameof(endpointGroupName)); + } + + EndpointGroupName = endpointGroupName; + } + + /// + public string EndpointGroupName { get; } + } + } \ No newline at end of file diff --git a/src/Http/Routing/src/EndpointNameAttribute.cs b/src/Http/Routing/src/EndpointNameAttribute.cs new file mode 100644 index 000000000000..9692dc8321b9 --- /dev/null +++ b/src/Http/Routing/src/EndpointNameAttribute.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.Http; + + namespace Microsoft.AspNetCore.Routing + { + /// + /// Specifies the endpoint name in . + /// + /// + /// Endpoint names must be unique within an application, and can be used to unambiguously + /// identify a desired endpoint for URI generation using + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate, Inherited = false, AllowMultiple = false)] + public sealed class EndpointNameAttribute : Attribute, IEndpointNameMetadata + { + /// + /// Initializes an instance of the EndpointNameAttribute. + /// + /// The endpoint name. + public EndpointNameAttribute(string endpointName) + { + if (endpointName == null) + { + throw new ArgumentNullException(nameof(endpointName)); + } + + EndpointName = endpointName; + } + + /// + public string EndpointName { get; } + } + } \ No newline at end of file diff --git a/src/Http/Routing/src/ExcludeFromDescriptionAttribute.cs b/src/Http/Routing/src/ExcludeFromDescriptionAttribute.cs new file mode 100644 index 000000000000..6aeb8426e5bf --- /dev/null +++ b/src/Http/Routing/src/ExcludeFromDescriptionAttribute.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Indicates that this should not be included in the generated API metadata. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Delegate, AllowMultiple = false, Inherited = true)] + public sealed class ExcludeFromDescriptionAttribute : Attribute, IExcludeFromDescriptionMetadata + { + /// + public bool ExcludeFromDescription => true; + } +} diff --git a/src/Http/Routing/src/IEndpointGroupNameMetadata.cs b/src/Http/Routing/src/IEndpointGroupNameMetadata.cs new file mode 100644 index 000000000000..08d7fefc63d3 --- /dev/null +++ b/src/Http/Routing/src/IEndpointGroupNameMetadata.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Defines a contract used to specify an endpoint group name in . + /// + public interface IEndpointGroupNameMetadata + { + /// + /// Gets the endpoint group name. + /// + string EndpointGroupName { get; } + } +} diff --git a/src/Http/Routing/src/IExcludeFromDescriptionMetadata.cs b/src/Http/Routing/src/IExcludeFromDescriptionMetadata.cs new file mode 100644 index 000000000000..4e3c1eb997da --- /dev/null +++ b/src/Http/Routing/src/IExcludeFromDescriptionMetadata.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Indicates whether or not that API explorer data should be emitted for this endpoint. + /// + public interface IExcludeFromDescriptionMetadata + { + /// + /// Gets a value indicating whether OpenAPI + /// data should be excluded for this endpoint. If , + /// API metadata is not emitted. + /// + bool ExcludeFromDescription { get; } + } +} diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 99913da6a3da..9d83b7228220 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -23,3 +23,18 @@ static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions. static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapMethods(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Collections.Generic.IEnumerable! httpMethods, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPost(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPut(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! +Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata +Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata.EndpointGroupName.get -> string! +Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute +Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupNameAttribute(string! endpointGroupName) -> void +Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupName.get -> string! +Microsoft.AspNetCore.Routing.EndpointNameAttribute +Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointNameAttribute(string! endpointName) -> void +Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointName.get -> string! +static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithName(this TBuilder builder, string! endpointName) -> TBuilder +static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithGroupName(this TBuilder builder, string! endpointGroupName) -> TBuilder +Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata +Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata.ExcludeFromDescription.get -> bool +Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute +Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescriptionAttribute() -> void +Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescription.get -> bool diff --git a/src/Http/Routing/test/UnitTests/Builder/RoutingEndpointConventionBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/RoutingEndpointConventionBuilderExtensionsTest.cs index f66cec45c520..9141829aeae4 100644 --- a/src/Http/Routing/test/UnitTests/Builder/RoutingEndpointConventionBuilderExtensionsTest.cs +++ b/src/Http/Routing/test/UnitTests/Builder/RoutingEndpointConventionBuilderExtensionsTest.cs @@ -115,6 +115,38 @@ public void WithMetadata_ChainedCall_ReturnedBuilderIsDerivedType() Assert.True(chainedBuilder.TestProperty); } + [Fact] + public void WithName_SetsEndpointName() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.WithName("SomeEndpointName"); + + // Assert + var endpoint = builder.Build(); + + var endpointName = endpoint.Metadata.GetMetadata(); + Assert.Equal("SomeEndpointName", endpointName.EndpointName); + } + + [Fact] + public void WithGroupName_SetsEndpointGroupName() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.WithGroupName("SomeEndpointGroupName"); + + // Assert + var endpoint = builder.Build(); + + var endpointGroupName = endpoint.Metadata.GetMetadata(); + Assert.Equal("SomeEndpointGroupName", endpointGroupName.EndpointGroupName); + } + private TestEndpointConventionBuilder CreateBuilder() { var conventionBuilder = new DefaultEndpointConventionBuilder(new RouteEndpointBuilder( diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index b7cad53ad3e7..bac4c5b7fed9 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -79,8 +79,14 @@ private ICollection GetApiResponseTypes( Type defaultErrorType) { var contentTypes = new MediaTypeCollection(); + var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); - var responseTypes = ReadResponseMetadata(responseMetadataAttributes, type, defaultErrorType, contentTypes); + var responseTypes = ReadResponseMetadata( + responseMetadataAttributes, + type, + defaultErrorType, + contentTypes, + responseTypeMetadataProviders); // Set the default status only when no status has already been set explicitly if (responseTypes.Count == 0 && type != null) @@ -102,7 +108,10 @@ private ICollection GetApiResponseTypes( contentTypes.Add((string)null!); } - CalculateResponseFormats(responseTypes, contentTypes); + foreach (var apiResponse in responseTypes) + { + CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider); + } return responseTypes; } @@ -112,7 +121,9 @@ internal static List ReadResponseMetadata( IReadOnlyList responseMetadataAttributes, Type? type, Type defaultErrorType, - MediaTypeCollection contentTypes) + MediaTypeCollection contentTypes, + IEnumerable? responseTypeMetadataProviders = null, + IModelMetadataProvider? modelMetadataProvider = null) { var results = new Dictionary(); @@ -123,7 +134,18 @@ internal static List ReadResponseMetadata( { foreach (var metadataAttribute in responseMetadataAttributes) { - metadataAttribute.SetContentTypes(contentTypes); + // All ProducesXAttributes, except for ProducesResponseTypeAttribute do + // not allow multiple instances on the same method/class/etc. For those + // scenarios, the `SetContentTypes` method on the attribute continuously + // clears out more general content types in favor of more specific ones + // since we iterate through the attributes in order. For example, if a + // Produces exists on both a controller and an action within the controller, + // we favor the definition in the action. This is a semantic that does not + // apply to ProducesResponseType, which allows multiple instances on an target. + if (metadataAttribute is not ProducesResponseTypeAttribute) + { + metadataAttribute.SetContentTypes(contentTypes); + } var statusCode = metadataAttribute.StatusCode; @@ -157,6 +179,18 @@ internal static List ReadResponseMetadata( } } + // We special case the handling of ProcuesResponseTypeAttributes since + // multiple ProducesResponseTypeAttributes are permitted on a single + // action/controller/etc. In that scenario, instead of picking the most-specific + // set of content types (like we do with the Produces attribute above) we process + // the content types for each attribute independently. + if (metadataAttribute is ProducesResponseTypeAttribute) + { + var attributeContentTypes = new MediaTypeCollection(); + metadataAttribute.SetContentTypes(attributeContentTypes); + CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider); + } + if (apiResponseType.Type != null) { results[apiResponseType.StatusCode] = apiResponseType; @@ -167,9 +201,15 @@ internal static List ReadResponseMetadata( return results.Values.ToList(); } - private void CalculateResponseFormats(ICollection responseTypes, MediaTypeCollection declaredContentTypes) + private static void CalculateResponseFormatForType(ApiResponseType apiResponse, MediaTypeCollection declaredContentTypes, IEnumerable? responseTypeMetadataProviders, IModelMetadataProvider? modelMetadataProvider) { - var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType(); + // If response formats have already been calculate for this type, + // then exit early. This avoids populating the ApiResponseFormat for + // types that have already been handled, specifically ProducesResponseTypes. + if (apiResponse.ApiResponseFormats.Count > 0) + { + return; + } // Given the content-types that were declared for this action, determine the formatters that support the content-type for the given // response type. @@ -179,21 +219,20 @@ private void CalculateResponseFormats(ICollection responseTypes // 3. When no formatter supports the specified content-type, use the user specified value as is. This is useful in actions where the user // dictates the content-type. // e.g. [Produces("application/pdf")] Action() => FileStream("somefile.pdf", "application/pdf"); - - foreach (var apiResponse in responseTypes) + var responseType = apiResponse.Type; + if (responseType == null || responseType == typeof(void)) { - var responseType = apiResponse.Type; - if (responseType == null || responseType == typeof(void)) - { - continue; - } + return; + } - apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType); + apiResponse.ModelMetadata = modelMetadataProvider?.GetMetadataForType(responseType); - foreach (var contentType in declaredContentTypes) - { - var isSupportedContentType = false; + foreach (var contentType in declaredContentTypes) + { + var isSupportedContentType = false; + if (responseTypeMetadataProviders != null) + { foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders) { var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes( @@ -216,15 +255,17 @@ private void CalculateResponseFormats(ICollection responseTypes }); } } + } + + - if (!isSupportedContentType && contentType != null) + if (!isSupportedContentType && contentType != null) + { + // No output formatter was found that supports this content type. Add the user specified content type as-is to the result. + apiResponse.ApiResponseFormats.Add(new ApiResponseFormat { - // No output formatter was found that supports this content type. Add the user specified content type as-is to the result. - apiResponse.ApiResponseFormats.Add(new ApiResponseFormat - { - MediaType = contentType, - }); - } + MediaType = contentType, + }); } } } diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 0eccb64e7b35..f61212044b0c 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -52,7 +52,8 @@ public void OnProvidersExecuting(ApiDescriptionProviderContext context) { if (endpoint is RouteEndpoint routeEndpoint && routeEndpoint.Metadata.GetMetadata() is { } methodInfo && - routeEndpoint.Metadata.GetMetadata() is { } httpMethodMetadata) + routeEndpoint.Metadata.GetMetadata() is { } httpMethodMetadata && + routeEndpoint.Metadata.GetMetadata() is null or { ExcludeFromDescription: false }) { // REVIEW: Should we add an ApiDescription for endpoints without IHttpMethodMetadata? Swagger doesn't handle // a null HttpMethod even though it's nullable on ApiDescription, so we'd need to define "default" HTTP methods. @@ -89,6 +90,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string var apiDescription = new ApiDescription { HttpMethod = httpMethod, + GroupName = routeEndpoint.Metadata.GetMetadata()?.EndpointGroupName, RelativePath = routeEndpoint.RoutePattern.RawText?.TrimStart('/'), ActionDescriptor = new ActionDescriptor { @@ -267,7 +269,9 @@ private static void AddSupportedResponseTypes( { AddResponseContentTypes(apiResponseType.ApiResponseFormats, contentTypes); } - else if (CreateDefaultApiResponseFormat(apiResponseType.Type) is { } defaultResponseFormat) + // Only set the default response type if it hasn't already been set via a + // ProducesResponseTypeAttribute. + else if (apiResponseType.ApiResponseFormats.Count == 0 && CreateDefaultApiResponseFormat(apiResponseType.Type) is { } defaultResponseFormat) { apiResponseType.ApiResponseFormats.Add(defaultResponseFormat); } diff --git a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs index 8a8f0e2091a4..fe0225df8e97 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/ApiResponseTypeProviderTest.cs @@ -709,6 +709,51 @@ public void GetApiResponseTypes_UsesContentTypeWithoutWildCard_WhenNoFormatterSu }); } + [Fact] + public void GetApiResponseTypes_HandlesActionWithMultipleContentTypesAndProduces() + { + // Arrange + var actionDescriptor = GetControllerActionDescriptor(typeof(TestController), nameof(TestController.GetUser)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesAttribute("text/xml") { Type = typeof(BaseModel) }, FilterScope.Action)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(ValidationProblemDetails), 400, "application/validationproblem+json"), FilterScope.Action)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 404, "application/problem+json"), FilterScope.Action)); + actionDescriptor.FilterDescriptors.Add(new FilterDescriptor(new ProducesResponseTypeAttribute(409), FilterScope.Action)); + + var provider = new ApiResponseTypeProvider(new EmptyModelMetadataProvider(), new ActionResultTypeMapper(), new MvcOptions()); + + // Act + var result = provider.GetApiResponseTypes(actionDescriptor); + + // Assert + Assert.Collection( + result.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(BaseModel), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "text/xml" }, GetSortedMediaTypes(responseType)); + + }, + responseType => + { + Assert.Equal(typeof(ValidationProblemDetails), responseType.Type); + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(new[] { "application/validationproblem+json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(new[] { "application/problem+json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(void), responseType.Type); + Assert.Equal(409, responseType.StatusCode); + Assert.Empty(GetSortedMediaTypes(responseType)); + }); + } + private static ApiResponseTypeProvider GetProvider() { var mvcOptions = new MvcOptions @@ -719,6 +764,13 @@ private static ApiResponseTypeProvider GetProvider() return provider; } + private static IEnumerable GetSortedMediaTypes(ApiResponseType apiResponseType) + { + return apiResponseType.ApiResponseFormats + .OrderBy(format => format.MediaType) + .Select(format => format.MediaType); + } + private static ControllerActionDescriptor GetControllerActionDescriptor(Type type, string name) { var method = type.GetMethod(name); diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index afd698344d74..e390d4c85ef4 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -8,6 +8,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -358,6 +359,177 @@ public void AddsMetadataFromRouteEndpoint() Assert.True(apiExplorerSettings.IgnoreApi); } + [Fact] + public void RespectsProducesProblemExtensionMethod() + { + // Arrange + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null)); + builder.MapGet("/api/todos", () => "").ProducesProblem(StatusCodes.Status400BadRequest); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + var responseTypes = Assert.Single(apiDescription.SupportedResponseTypes); + Assert.Equal(typeof(ProblemDetails), responseTypes.Type); + } + + [Fact] + public void RespectsProducesWithGroupNameExtensionMethod() + { + // Arrange + var endpointGroupName = "SomeEndpointGroupName"; + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null)); + builder.MapGet("/api/todos", () => "").Produces().WithGroupName(endpointGroupName); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + var responseTypes = Assert.Single(apiDescription.SupportedResponseTypes); + Assert.Equal(typeof(InferredJsonClass), responseTypes.Type); + Assert.Equal(endpointGroupName, apiDescription.GroupName); + } + + [Fact] + public void RespectsExcludeFromDescription() + { + // Arrange + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null)); + builder.MapGet("/api/todos", () => "").Produces().ExcludeFromDescription(); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + Assert.Empty(context.Results); + } + + [Fact] + public void HandlesProducesWithProducesProblem() + { + // Arrange + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null)); + builder.MapGet("/api/todos", () => "") + .Produces(StatusCodes.Status200OK) + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status409Conflict); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + Assert.Collection( + context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + + }, + responseType => + { + Assert.Equal(typeof(HttpValidationProblemDetails), responseType.Type); + Assert.Equal(400, responseType.StatusCode); + Assert.Equal(new[] { "application/validationproblem+json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.Equal(404, responseType.StatusCode); + Assert.Equal(new[] { "application/problem+json" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(typeof(ProblemDetails), responseType.Type); + Assert.Equal(409, responseType.StatusCode); + Assert.Equal(new[] { "application/problem+json" }, GetSortedMediaTypes(responseType)); + }); + } + + [Fact] + public void HandleMultipleProduces() + { + // Arrange + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(null)); + builder.MapGet("/api/todos", () => "") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status201Created); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = new EndpointMetadataApiDescriptionProvider(endpointDataSource, hostEnvironment, new ServiceProviderIsService()); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + Assert.Collection( + context.Results.SelectMany(r => r.SupportedResponseTypes).OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + + }, + responseType => + { + Assert.Equal(typeof(InferredJsonClass), responseType.Type); + Assert.Equal(201, responseType.StatusCode); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + }); + } + + private static IEnumerable GetSortedMediaTypes(ApiResponseType apiResponseType) + { + return apiResponseType.ApiResponseFormats + .OrderBy(format => format.MediaType) + .Select(format => format.MediaType); + } + private IList GetApiDescriptions( Delegate action, string pattern = null, @@ -423,5 +595,22 @@ private class HostEnvironment : IHostEnvironment public string ContentRootPath { get; set; } public IFileProvider ContentRootFileProvider { get; set; } } + + private class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + public TestEndpointRouteBuilder(IApplicationBuilder applicationBuilder) + { + ApplicationBuilder = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder)); + DataSources = new List(); + } + + public IApplicationBuilder ApplicationBuilder { get; } + + public IApplicationBuilder CreateApplicationBuilder() => ApplicationBuilder.New(); + + public ICollection DataSources { get; } + + public IServiceProvider ServiceProvider => ApplicationBuilder.ApplicationServices; + } } } diff --git a/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs b/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs new file mode 100644 index 000000000000..78c9b6a826db --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Builder/OpenApiEndpointConventionBuilderExtensions.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Http +{ + /// + /// Extension methods for adding response type metadata to endpoints. + /// + public static class OpenApiEndpointConventionBuilderExtensions + { + private static readonly ExcludeFromDescriptionAttribute _excludeFromDescriptionMetadataAttribute = new(); + + /// + /// Adds the to for all builders + /// produced by . + /// + /// The . + /// A that can be used to further customize the endpoint. + public static MinimalActionEndpointConventionBuilder ExcludeFromDescription(this MinimalActionEndpointConventionBuilder builder) + { + builder.WithMetadata(_excludeFromDescriptionMetadataAttribute); + + return builder; + } + + /// + /// Adds the to for all builders + /// produced by . + /// + /// The type of the response. + /// The . + /// The response status code. Defaults to StatusCodes.Status200OK. + /// The response content type. Defaults to "application/json". + /// Additional response content types the endpoint produces for the supplied status code. + /// A that can be used to further customize the endpoint. +#pragma warning disable RS0026 + public static MinimalActionEndpointConventionBuilder Produces(this MinimalActionEndpointConventionBuilder builder, +#pragma warning restore RS0026 + int statusCode = StatusCodes.Status200OK, + string? contentType = null, + params string[] additionalContentTypes) + { + return Produces(builder, statusCode, typeof(TResponse), contentType, additionalContentTypes); + } + + /// + /// Adds the to for all builders + /// produced by . + /// + /// The . + /// The response status code. + /// The type of the response. Defaults to null. + /// The response content type. Defaults to "application/json" if responseType is not null, otherwise defaults to null. + /// Additional response content types the endpoint produces for the supplied status code. + /// A that can be used to further customize the endpoint. +#pragma warning disable RS0026 + public static MinimalActionEndpointConventionBuilder Produces(this MinimalActionEndpointConventionBuilder builder, +#pragma warning restore RS0026 + int statusCode, + Type? responseType = null, + string? contentType = null, + params string[] additionalContentTypes) + { + if (responseType is Type && string.IsNullOrEmpty(contentType)) + { + contentType = "application/json"; + } + + if (contentType is null) + { + builder.WithMetadata(new ProducesResponseTypeAttribute(responseType ?? typeof(void), statusCode)); + return builder; + } + + builder.WithMetadata(new ProducesResponseTypeAttribute(responseType ?? typeof(void), statusCode, contentType, additionalContentTypes)); + + return builder; + } + + /// + /// Adds the with a type + /// to for all builders produced by . + /// + /// The . + /// The response status code. + /// The response content type. Defaults to "application/problem+json". + /// A that can be used to further customize the endpoint. + public static MinimalActionEndpointConventionBuilder ProducesProblem(this MinimalActionEndpointConventionBuilder builder, + int statusCode, + string? contentType = null) + { + if (string.IsNullOrEmpty(contentType)) + { + contentType = "application/problem+json"; + } + + return Produces(builder, statusCode, contentType); + } + + /// + /// Adds the with a type + /// to for all builders produced by . + /// + /// The . + /// The response status code. Defaults to StatusCodes.Status400BadRequest. + /// The response content type. Defaults to "application/validationproblem+json". + /// A that can be used to further customize the endpoint. + public static MinimalActionEndpointConventionBuilder ProducesValidationProblem(this MinimalActionEndpointConventionBuilder builder, + int statusCode = StatusCodes.Status400BadRequest, + string? contentType = null) + { + if (string.IsNullOrEmpty(contentType)) + { + contentType = "application/validationproblem+json"; + } + + return Produces(builder, statusCode, contentType); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs index c954dfba5eb1..ed19ccd5ebe0 100644 --- a/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs @@ -4,6 +4,8 @@ using System; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; +using Resources = Microsoft.AspNetCore.Mvc.Core.Resources; namespace Microsoft.AspNetCore.Mvc { @@ -13,6 +15,8 @@ namespace Microsoft.AspNetCore.Mvc [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] public class ProducesResponseTypeAttribute : Attribute, IApiResponseMetadataProvider { + private readonly MediaTypeCollection? _contentTypes; + /// /// Initializes an instance of . /// @@ -35,6 +39,33 @@ public ProducesResponseTypeAttribute(Type type, int statusCode) IsResponseTypeSetByDefault = false; } + /// + /// Initializes an instance of . + /// + /// The of object that is going to be written in the response. + /// The HTTP response status code. + /// The content type associated with the response. + /// Additional content types supported by the response. + public ProducesResponseTypeAttribute(Type type, int statusCode, string contentType, params string[] additionalContentTypes) + { + if (contentType == null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + Type = type ?? throw new ArgumentNullException(nameof(type)); + StatusCode = statusCode; + IsResponseTypeSetByDefault = false; + + MediaTypeHeaderValue.Parse(contentType); + for (var i = 0; i < additionalContentTypes.Length; i++) + { + MediaTypeHeaderValue.Parse(additionalContentTypes[i]); + } + + _contentTypes = GetContentTypes(contentType, additionalContentTypes); + } + /// /// Gets or sets the type of the value returned by an action. /// @@ -57,10 +88,40 @@ public ProducesResponseTypeAttribute(Type type, int statusCode) /// internal bool IsResponseTypeSetByDefault { get; } + // Internal for testing + internal MediaTypeCollection? ContentTypes => _contentTypes; + /// void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes) { - // Users are supposed to use the 'Produces' attribute to set the content types that an action can support. + if (_contentTypes is not null) + { + contentTypes.Clear(); + foreach (var contentType in _contentTypes) + { + contentTypes.Add(contentType); + } + } + } + + private static MediaTypeCollection GetContentTypes(string contentType, string[] additionalContentTypes) + { + var completeContentTypes = new List(additionalContentTypes.Length + 1); + completeContentTypes.Add(contentType); + completeContentTypes.AddRange(additionalContentTypes); + MediaTypeCollection contentTypes = new(); + foreach (var type in completeContentTypes) + { + var mediaType = new MediaType(type); + if (mediaType.HasWildcard) + { + throw new InvalidOperationException(Resources.FormatGetContentTypes_WildcardsNotSupported(type)); + } + + contentTypes.Add(type); + } + + return contentTypes; } } } diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 7b9282c00c38..02d3d3791fdd 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -3219,4 +3219,10 @@ virtual Microsoft.AspNetCore.Mvc.ProducesAttribute.OnResultExecuted(Microsoft.As virtual Microsoft.AspNetCore.Mvc.ProducesAttribute.OnResultExecuting(Microsoft.AspNetCore.Mvc.Filters.ResultExecutingContext! context) -> void virtual Microsoft.AspNetCore.Mvc.RequireHttpsAttribute.HandleNonHttpsRequest(Microsoft.AspNetCore.Mvc.Filters.AuthorizationFilterContext! filterContext) -> void virtual Microsoft.AspNetCore.Mvc.RequireHttpsAttribute.OnAuthorization(Microsoft.AspNetCore.Mvc.Filters.AuthorizationFilterContext! filterContext) -> void - +Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute.ProducesResponseTypeAttribute(System.Type! type, int statusCode, string! contentType, params string![]! additionalContentTypes) -> void +Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.ExcludeFromDescription(this Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Produces(this Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! builder, int statusCode = 200, string? contentType = null, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.Produces(this Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! builder, int statusCode, System.Type? responseType = null, string? contentType = null, params string![]! additionalContentTypes) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.ProducesProblem(this Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! builder, int statusCode, string? contentType = null) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! +static Microsoft.AspNetCore.Http.OpenApiEndpointConventionBuilderExtensions.ProducesValidationProblem(this Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! builder, int statusCode = 400, string? contentType = null) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder! diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx index f27f845f7162..ab50909c99cf 100644 --- a/src/Mvc/Mvc.Core/src/Resources.resx +++ b/src/Mvc/Mvc.Core/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -510,4 +510,7 @@ {0} cannot update a record type model. If a '{1}' must be updated, include it in an object type. + + Could not parse '{0}'. Content types with wildcards are not supported. + \ No newline at end of file diff --git a/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs b/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs new file mode 100644 index 000000000000..bb2baa722733 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc +{ + public class ProducesResponseTypeAttributeTests + { + [Fact] + public void ProducesResponseTypeAttribute_SetsContentType() + { + // Arrange + var mediaType1 = new StringSegment("application/json"); + var mediaType2 = new StringSegment("text/json;charset=utf-8"); + var producesContentAttribute = new ProducesResponseTypeAttribute(typeof(void), StatusCodes.Status200OK, "application/json", "text/json;charset=utf-8"); + + // Assert + Assert.Equal(2, producesContentAttribute.ContentTypes.Count); + MediaTypeAssert.Equal(mediaType1, producesContentAttribute.ContentTypes[0]); + MediaTypeAssert.Equal(mediaType2, producesContentAttribute.ContentTypes[1]); + } + + [Theory] + [InlineData("application/*", "application/*")] + [InlineData("application/xml, application/*, application/json", "application/*")] + [InlineData("application/*, application/json", "application/*")] + + [InlineData("*/*", "*/*")] + [InlineData("application/xml, */*, application/json", "*/*")] + [InlineData("*/*, application/json", "*/*")] + [InlineData("application/*+json", "application/*+json")] + [InlineData("application/json;v=1;*", "application/json;v=1;*")] + public void ProducesResponseTypeAttribute_InvalidContentType_Throws(string content, string invalidContentType) + { + // Act + var contentTypes = content.Split(',').Select(contentType => contentType.Trim()).ToArray(); + + // Assert + var ex = Assert.Throws( + () => new ProducesResponseTypeAttribute(typeof(void), StatusCodes.Status200OK, contentTypes[0], contentTypes.Skip(1).ToArray())); + + Assert.Equal( + $"Could not parse '{invalidContentType}'. Content types with wildcards are not supported.", + ex.Message); + } + + [Fact] + public void ProducesResponseTypeAttribute_WithTypeOnly_SetsTypeProperty() + { + // Arrange + var producesResponseTypeAttribute = new ProducesResponseTypeAttribute(typeof(Person), StatusCodes.Status200OK); + + // Act and Assert + Assert.NotNull(producesResponseTypeAttribute.Type); + Assert.Same(typeof(Person), producesResponseTypeAttribute.Type); + } + + [Fact] + public void ProducesResponseTypeAttribute_WithTypeOnly_DoesNotSetContentTypes() + { + // Arrange + var producesResponseTypeAttribute = new ProducesResponseTypeAttribute(typeof(Person), StatusCodes.Status200OK); + + // Act and Assert + Assert.Null(producesResponseTypeAttribute.ContentTypes); + } + + private class Person + { + public int Id { get; set; } + + public string Name { get; set; } + } + } +} diff --git a/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs b/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs index f9432bcd55b4..d6305c36acfe 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs @@ -766,6 +766,41 @@ public async Task ApiExplorer_ResponseType_OverrideOnAction() }); } + [Fact] + public async Task ApiExplorer_ResponseTypeWithContentType_OverrideOnAction() + { + // This test scenario validates that a ProducesResponseType attribute will overide + // content-type given by a Produces attribute with a lower-specificity. + // Arrange + var type = "ApiExplorerWebSite.Customer"; + var errorType = "ApiExplorerWebSite.ErrorInfo"; + + // Act + var response = await Client.GetAsync( + "http://localhost/ApiExplorerResponseTypeOverrideOnAction/Action2"); + + var body = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(body); + + // Assert + var description = Assert.Single(result); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(responseType => responseType.StatusCode), + responseType => + { + Assert.Equal(type, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Equal(new[] { "text/plain" }, GetSortedMediaTypes(responseType)); + }, + responseType => + { + Assert.Equal(errorType, responseType.ResponseType); + Assert.Equal(500, responseType.StatusCode); + Assert.Equal(new[] { "application/json" }, GetSortedMediaTypes(responseType)); + }); + } + [ConditionalFact] // Mono issue - https://github.com/aspnet/External/issues/18 [FrameworkSkipCondition(RuntimeFrameworks.Mono)] diff --git a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs index c9a09accea2f..7afcdd4d9487 100644 --- a/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs +++ b/src/Mvc/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeOverrideOnActionController.cs @@ -22,6 +22,13 @@ public object GetAction() { return null; } + + [HttpGet("Action2")] + [ProducesResponseType(typeof(Customer), 200, "text/plain")] + public object GetActionWithContentTypeOverride() + { + return null; + } } public class ErrorInfo