diff --git a/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs index 92bc264271ba..4f832cfea935 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IProducesResponseTypeMetadata.cs @@ -18,6 +18,11 @@ public interface IProducesResponseTypeMetadata /// int StatusCode { get; } + /// + /// Gets the description of the response. + /// + string? Description { get; } + /// /// Gets the content types supported by the metadata. /// diff --git a/src/Http/Http.Abstractions/src/Metadata/ProducesResponseTypeMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/ProducesResponseTypeMetadata.cs index edb1ab091dc0..2186b76be610 100644 --- a/src/Http/Http.Abstractions/src/Metadata/ProducesResponseTypeMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/ProducesResponseTypeMetadata.cs @@ -68,6 +68,11 @@ private ProducesResponseTypeMetadata(int statusCode, Type? type, IEnumerable public int StatusCode { get; private set; } + /// + /// Gets or sets the description of the response. + /// + public string? Description { get; set; } + /// /// Gets or sets the content types associated with the response. /// diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 0d2aab25f97d..ccdd75e4934b 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -11,3 +11,6 @@ Microsoft.AspNetCore.Http.Metadata.IParameterBindingMetadata.HasTryParse.get -> Microsoft.AspNetCore.Http.Metadata.IParameterBindingMetadata.IsOptional.get -> bool Microsoft.AspNetCore.Http.Metadata.IParameterBindingMetadata.Name.get -> string! Microsoft.AspNetCore.Http.Metadata.IParameterBindingMetadata.ParameterInfo.get -> System.Reflection.ParameterInfo! +Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.get -> string? +Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.set -> void +Microsoft.AspNetCore.Http.Metadata.IProducesResponseTypeMetadata.Description.get -> string? diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index 159982900eda..25f2513c7d4e 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -274,7 +274,7 @@ private static RequestDelegateFactoryContext CreateFactoryContext(RequestDelegat var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices ?? EmptyServiceProvider.Instance; var endpointBuilder = options?.EndpointBuilder ?? new RdfEndpointBuilder(serviceProvider); var jsonSerializerOptions = serviceProvider.GetService>()?.Value.SerializerOptions ?? JsonOptions.DefaultSerializerOptions; - var formDataMapperOptions = new FormDataMapperOptions();; + var formDataMapperOptions = new FormDataMapperOptions(); var factoryContext = new RequestDelegateFactoryContext { diff --git a/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs b/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs index 71fc8ecf46d9..2bfe14ff7b45 100644 --- a/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs +++ b/src/Mvc/Mvc.Abstractions/src/ApiExplorer/ApiResponseType.cs @@ -33,6 +33,11 @@ public class ApiResponseType /// public Type? Type { get; set; } + /// + /// Gets or sets the description of the response. + /// + public string? Description { get; set; } + /// /// Gets or sets the HTTP response status code. /// diff --git a/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..ada28c50d99a 100644 --- a/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.get -> string? +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.set -> void diff --git a/src/Mvc/Mvc.Api.Analyzers/test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs b/src/Mvc/Mvc.Api.Analyzers/test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs index 6bf00e9c555d..d355f834ceeb 100644 --- a/src/Mvc/Mvc.Api.Analyzers/test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs +++ b/src/Mvc/Mvc.Api.Analyzers/test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; @@ -60,6 +60,8 @@ public class CustomResponseTypeAttribute : Attribute, IApiResponseMetadataProvid public int StatusCode { get; set; } + public string Description { get; set; } + public void SetContentTypes(MediaTypeCollection contentTypes) { } diff --git a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs index 4877cedefe18..d7e6296af6e9 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs @@ -166,11 +166,14 @@ internal static Dictionary ReadResponseMetadata( var statusCode = metadataAttribute.StatusCode; + var description = metadataAttribute.Description; + var apiResponseType = new ApiResponseType { Type = metadataAttribute.Type, StatusCode = statusCode, IsDefaultResponse = metadataAttribute is IApiDefaultResponseMetadataProvider, + Description = description }; if (apiResponseType.Type == typeof(void)) diff --git a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..3c89ceb1b24c 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.ApiExplorer/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.get -> string? (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) +Microsoft.AspNetCore.Mvc.ApiExplorer.ApiResponseType.Description.set -> void (forwarded, contained in Microsoft.AspNetCore.Mvc.Abstractions) diff --git a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs index aa05ff5f8c01..41e13c229863 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/DefaultApiDescriptionProviderTest.cs @@ -1107,7 +1107,8 @@ public void GetApiDescription_PopulatesResponseInformation_WhenSetByFilter(strin var action = CreateActionDescriptor(methodName); var filter = new ContentTypeAttribute("text/*") { - Type = typeof(Order) + Type = typeof(Order), + Description = "Example" }; action.FilterDescriptors = new List @@ -1124,6 +1125,7 @@ public void GetApiDescription_PopulatesResponseInformation_WhenSetByFilter(strin Assert.NotNull(responseTypes.ModelMetadata); Assert.Equal(200, responseTypes.StatusCode); Assert.Equal(typeof(Order), responseTypes.Type); + Assert.Equal("Example", responseTypes.Description); foreach (var responseFormat in responseTypes.ApiResponseFormats) { @@ -2874,6 +2876,8 @@ public ContentTypeAttribute(string mediaType) public Type Type { get; set; } + public string Description { get; set; } + public void SetContentTypes(MediaTypeCollection contentTypes) { contentTypes.Clear(); diff --git a/src/Mvc/Mvc.Core/src/ApiExplorer/IApiResponseMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ApiExplorer/IApiResponseMetadataProvider.cs index 37e1d7daffab..5b65a587f830 100644 --- a/src/Mvc/Mvc.Core/src/ApiExplorer/IApiResponseMetadataProvider.cs +++ b/src/Mvc/Mvc.Core/src/ApiExplorer/IApiResponseMetadataProvider.cs @@ -17,6 +17,11 @@ public interface IApiResponseMetadataProvider : IFilterMetadata /// Type? Type { get; } + /// + /// Gets the description of the response. + /// + string? Description { get; } + /// /// Gets the HTTP status code of the response. /// diff --git a/src/Mvc/Mvc.Core/src/ProducesAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesAttribute.cs index f7bbbc380dfc..bcef087a2e6a 100644 --- a/src/Mvc/Mvc.Core/src/ProducesAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ProducesAttribute.cs @@ -52,6 +52,9 @@ public ProducesAttribute(string contentType, params string[] additionalContentTy /// public Type? Type { get; set; } + /// + public string? Description { get; set; } + /// /// Gets or sets the supported response content types. Used to set . /// diff --git a/src/Mvc/Mvc.Core/src/ProducesDefaultResponseTypeAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesDefaultResponseTypeAttribute.cs index 95468183d7c1..1ba001648769 100644 --- a/src/Mvc/Mvc.Core/src/ProducesDefaultResponseTypeAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ProducesDefaultResponseTypeAttribute.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Mvc.ApiExplorer; @@ -39,6 +39,11 @@ public ProducesDefaultResponseTypeAttribute(Type type) /// public int StatusCode { get; } + /// + /// Gets or sets the description of the response. + /// + public string? Description { get; set; } + /// void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes) { diff --git a/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs b/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs index 6399a66d464d..f9c55192040e 100644 --- a/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ProducesResponseTypeAttribute.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Mvc.ApiExplorer; @@ -87,6 +87,11 @@ public ProducesResponseTypeAttribute(Type type, int statusCode, string contentTy // Internal for testing internal MediaTypeCollection? ContentTypes => _contentTypes; + /// + /// Gets or sets the description of the response. + /// + public string? Description { get; set; } + /// void IApiResponseMetadataProvider.SetContentTypes(MediaTypeCollection contentTypes) { diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index b4560b45280f..9ad015d5f12f 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -1,6 +1,13 @@ #nullable enable *REMOVED*virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Mvc.ObjectResult! *REMOVED*virtual Microsoft.AspNetCore.Mvc.ControllerBase.ValidationProblem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary? modelStateDictionary = null) -> Microsoft.AspNetCore.Mvc.ActionResult! +Microsoft.AspNetCore.Mvc.ApiExplorer.IApiResponseMetadataProvider.Description.get -> string? +Microsoft.AspNetCore.Mvc.ProducesAttribute.Description.get -> string? +Microsoft.AspNetCore.Mvc.ProducesAttribute.Description.set -> void +Microsoft.AspNetCore.Mvc.ProducesDefaultResponseTypeAttribute.Description.get -> string? +Microsoft.AspNetCore.Mvc.ProducesDefaultResponseTypeAttribute.Description.set -> void +Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute.Description.get -> string? +Microsoft.AspNetCore.Mvc.ProducesResponseTypeAttribute.Description.set -> void virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail, string? instance, int? statusCode, string? title, string? type) -> Microsoft.AspNetCore.Mvc.ObjectResult! virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null, System.Collections.Generic.IDictionary? extensions = null) -> Microsoft.AspNetCore.Mvc.ObjectResult! virtual Microsoft.AspNetCore.Mvc.ControllerBase.ValidationProblem(string? detail, string? instance, int? statusCode, string? title, string? type, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary? modelStateDictionary) -> Microsoft.AspNetCore.Mvc.ActionResult! diff --git a/src/Mvc/Mvc.Core/test/ProducesAttributeTests.cs b/src/Mvc/Mvc.Core/test/ProducesAttributeTests.cs index d29fa66a9f37..15cbd07e0bc1 100644 --- a/src/Mvc/Mvc.Core/test/ProducesAttributeTests.cs +++ b/src/Mvc/Mvc.Core/test/ProducesAttributeTests.cs @@ -151,6 +151,29 @@ public void ProducesAttribute_WithTypeOnly_DoesNotSetContentTypes() Assert.Empty(producesAttribute.ContentTypes); } + [Fact] + public void ProducesAttribute_SetsDescription() + { + // Arrange + var producesAttribute = new ProducesAttribute(typeof(Person)) + { + Description = "Example" + }; + + // Act and Assert + Assert.Equal("Example", producesAttribute.Description); + } + + [Fact] + public void ProducesAttribute_WithTypeOnly_DoesNotSetDescription() + { + // Arrange + var producesAttribute = new ProducesAttribute(typeof(Person)); + + // Act and Assert + Assert.Null(producesAttribute.Description); + } + private static ResultExecutedContext CreateResultExecutedContext(ResultExecutingContext context) { return new ResultExecutedContext(context, context.Filters, context.Result, context.Controller); diff --git a/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs b/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs index 204925a95df2..3cb9e69dd87c 100644 --- a/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs +++ b/src/Mvc/Mvc.Core/test/ProducesResponseTypeAttributeTests.cs @@ -67,6 +67,29 @@ public void ProducesResponseTypeAttribute_WithTypeOnly_DoesNotSetContentTypes() Assert.Null(producesResponseTypeAttribute.ContentTypes); } + [Fact] + public void ProducesResponseTypeAttribute_SetsDescription() + { + // Arrange + var producesResponseTypeAttribute = new ProducesResponseTypeAttribute(typeof(Person), StatusCodes.Status200OK) + { + Description = "Example" + }; + + // Act and Assert + Assert.Equal("Example", producesResponseTypeAttribute.Description); + } + + [Fact] + public void ProducesResponseTypeAttribute_WithTypeOnly_DoesNotSetDescription() + { + // Arrange + var producesResponseTypeAttribute = new ProducesResponseTypeAttribute(typeof(Person), StatusCodes.Status200OK); + + // Act and Assert + Assert.Null(producesResponseTypeAttribute.Description); + } + private class Person { public int Id { get; set; } diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index a9a7ecf6a1a6..c16407212f14 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -356,10 +356,9 @@ private async Task GetResponseAsync( IOpenApiSchemaTransformer[] schemaTransformers, CancellationToken cancellationToken) { - var description = ReasonPhrases.GetReasonPhrase(statusCode); var response = new OpenApiResponse { - Description = description, + Description = apiResponseType.Description ?? ReasonPhrases.GetReasonPhrase(statusCode), Content = new Dictionary() }; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs index 9c1d5b3c61ab..2f4e1f2e8e88 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Responses.cs @@ -304,4 +304,69 @@ await VerifyOpenApiDocument(builder, document => }); }); } + + [Fact] + public async Task GetOpenApiResponse_UsesDescriptionSetByUser() + { + // Arrange + var builder = CreateBuilder(); + + const string expectedCreatedDescription = "A new todo item was created"; + const string expectedBadRequestDescription = "Validation failed for the request"; + + // Act + builder.MapGet("/api/todos", + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = expectedCreatedDescription)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Description = expectedBadRequestDescription)] + () => + { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + Assert.Collection(operation.Responses.OrderBy(r => r.Key), + response => + { + Assert.Equal("201", response.Key); + Assert.Equal(expectedCreatedDescription, response.Value.Description); + }, + response => + { + Assert.Equal("400", response.Key); + Assert.Equal(expectedBadRequestDescription, response.Value.Description); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_UsesStatusCodeReasonPhraseWhenExplicitDescriptionIsMissing() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created, Description = null)] // Explicitly set to NULL + [ProducesResponseType(StatusCodes.Status400BadRequest)] // Omitted, meaning it should be NULL + () => + { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + Assert.Collection(operation.Responses.OrderBy(r => r.Key), + response => + { + Assert.Equal("201", response.Key); + Assert.Equal("Created", response.Value.Description); + }, + response => + { + Assert.Equal("400", response.Key); + Assert.Equal("Bad Request", response.Value.Description); + }); + }); + } }