diff --git a/src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs new file mode 100644 index 000000000000..067f48f70411 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// Defines a contract used to specify a description in . +/// +public interface IEndpointDescriptionMetadata +{ + /// + /// Gets the description associated with the endpoint. + /// + string Description { get; } +} diff --git a/src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs new file mode 100644 index 000000000000..fa1083d52183 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.Metadata; + +/// +/// Defines a contract used to specify a summary in . +/// +public interface IEndpointSummaryMetadata +{ + /// + /// Gets the summary associated with the endpoint. + /// + string Summary { get; } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 92c400c0bac6..7e611715814c 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -4,3 +4,7 @@ Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string? abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string? Microsoft.AspNetCore.Http.Metadata.ISkipStatusCodePagesMetadata +Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata +Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata.Description.get -> string! +Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata +Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata.Summary.get -> string! diff --git a/src/Http/Http.Extensions/src/EndpointDescriptionAttribute.cs b/src/Http/Http.Extensions/src/EndpointDescriptionAttribute.cs new file mode 100644 index 000000000000..c8e7b51a2614 --- /dev/null +++ b/src/Http/Http.Extensions/src/EndpointDescriptionAttribute.cs @@ -0,0 +1,29 @@ +// 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.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Specifies a description for the endpoint in . +/// +/// +/// The OpenAPI specification supports a description attribute on operations and parameters that +/// can be used to annotate endpoints with detailed, multiline descriptors of their behavior. +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] +public sealed class EndpointDescriptionAttribute : Attribute, IEndpointDescriptionMetadata +{ + /// + /// Initializes an instance of the . + /// + /// The description associated with the endpoint or parameter. + public EndpointDescriptionAttribute(string description) + { + Description = description; + } + + /// + public string Description { get; } +} diff --git a/src/Http/Http.Extensions/src/EndpointSummaryAttribute.cs b/src/Http/Http.Extensions/src/EndpointSummaryAttribute.cs new file mode 100644 index 000000000000..d5435365dc4e --- /dev/null +++ b/src/Http/Http.Extensions/src/EndpointSummaryAttribute.cs @@ -0,0 +1,25 @@ +// 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.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Specifies a summary in . +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] +public sealed class EndpointSummaryAttribute : Attribute, IEndpointSummaryMetadata +{ + /// + /// Initializes an instance of the . + /// + /// The summary associated with the endpoint or parameter. + public EndpointSummaryAttribute(string summary) + { + Summary = summary; + } + + /// + public string Summary { get; } +} diff --git a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt index 1030d0f0793e..28a2fa1553be 100644 --- a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt @@ -1,3 +1,9 @@ #nullable enable Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.AspNetCore.Http.EndpointDescriptionAttribute +Microsoft.AspNetCore.Http.EndpointDescriptionAttribute.EndpointDescriptionAttribute(string! description) -> void +Microsoft.AspNetCore.Http.EndpointDescriptionAttribute.Description.get -> string! +Microsoft.AspNetCore.Http.EndpointSummaryAttribute +Microsoft.AspNetCore.Http.EndpointSummaryAttribute.EndpointSummaryAttribute(string! summary) -> void +Microsoft.AspNetCore.Http.EndpointSummaryAttribute.Summary.get -> string! diff --git a/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs b/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs index 31696a3da7be..2456ed622c2d 100644 --- a/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs @@ -17,7 +17,7 @@ public static class OpenApiRouteHandlerBuilderExtensions private static readonly ExcludeFromDescriptionAttribute _excludeFromDescriptionMetadataAttribute = new(); /// - /// Adds the to for all builders + /// Adds the to for all endpoints /// produced by . /// /// The . @@ -30,7 +30,7 @@ public static RouteHandlerBuilder ExcludeFromDescription(this RouteHandlerBuilde } /// - /// Adds an to for all builders + /// Adds an to for all endpoints /// produced by . /// /// The type of the response. @@ -50,7 +50,7 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder b } /// - /// Adds an to for all builders + /// Adds an to for all endpoints /// produced by . /// /// The . @@ -85,7 +85,7 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, /// /// Adds an with a type - /// to for all builders produced by . + /// to for all endpoints produced by . /// /// The . /// The response status code. @@ -105,7 +105,7 @@ public static RouteHandlerBuilder ProducesProblem(this RouteHandlerBuilder build /// /// Adds an with a type - /// to for all builders produced by . + /// to for all endpoints produced by . /// /// The . /// The response status code. Defaults to . @@ -124,7 +124,7 @@ public static RouteHandlerBuilder ProducesValidationProblem(this RouteHandlerBui } /// - /// Adds the to for all builders + /// Adds the to for all endpoints /// produced by . /// /// @@ -142,7 +142,7 @@ public static RouteHandlerBuilder WithTags(this RouteHandlerBuilder builder, par } /// - /// Adds to for all builders + /// Adds to for all endpoints /// produced by . /// /// The type of the request body. @@ -159,7 +159,7 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder bui } /// - /// Adds to for all builders + /// Adds to for all endpoints /// produced by . /// /// The type of the request body. @@ -177,7 +177,7 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder bui } /// - /// Adds to for all builders + /// Adds to for all endpoints /// produced by . /// /// The . @@ -193,7 +193,7 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, } /// - /// Adds to for all builders + /// Adds to for all endpoints /// produced by . /// /// The . @@ -209,6 +209,32 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, return builder; } + /// + /// Adds to for all endpoints + /// produced by . + /// + /// The . + /// A string representing a detailed description of the endpoint. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithDescription(this RouteHandlerBuilder builder, string description) + { + builder.WithMetadata(new EndpointDescriptionAttribute(description)); + return builder; + } + + /// + /// Adds to for all endpoints + /// produced by . + /// + /// The . + /// A string representing a brief description of the endpoint. + /// A that can be used to further customize the endpoint. + public static RouteHandlerBuilder WithSummary(this RouteHandlerBuilder builder, string summary) + { + builder.WithMetadata(new EndpointSummaryAttribute(summary)); + return builder; + } + private static string[] GetAllContentTypes(string contentType, string[] additionalContentTypes) { var allContentTypes = new string[additionalContentTypes.Length + 1]; diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 341f2446a1cf..17d11a018a8e 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -6,3 +6,5 @@ static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapPatch(this override Microsoft.AspNetCore.Routing.RouteValuesAddress.ToString() -> string? *REMOVED*~Microsoft.AspNetCore.Routing.DefaultInlineConstraintResolver.DefaultInlineConstraintResolver(Microsoft.Extensions.Options.IOptions! routeOptions, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Routing.DefaultInlineConstraintResolver.DefaultInlineConstraintResolver(Microsoft.Extensions.Options.IOptions! routeOptions, System.IServiceProvider! serviceProvider) -> void +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithDescription(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! description) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithSummary(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! summary) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index f933be54ce6d..5a77ef5f9b55 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -1145,6 +1146,68 @@ public void HandlesEndpointWithRouteConstraints() constraint => Assert.IsType(constraint)); } + [Fact] + public void HandlesEndpointWithDescriptionAndSummary_WithExtensionMethods() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos/{id}", (int id) => "").WithDescription("A description").WithSummary("A summary"); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + Assert.NotEmpty(apiDescription.ActionDescriptor.EndpointMetadata); + + var descriptionMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(descriptionMetadata); + Assert.Equal("A description", descriptionMetadata.Description); + + var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(summaryMetadata); + Assert.Equal("A summary", summaryMetadata.Summary); + } + + [Fact] + public void HandlesEndpointWithDescriptionAndSummary_WithAttributes() + { + var builder = CreateBuilder(); + builder.MapGet("/api/todos/{id}", [EndpointSummary("A summary")][EndpointDescription("A description")] (int id) => ""); + + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest) + }; + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var apiDescription = Assert.Single(context.Results); + Assert.NotEmpty(apiDescription.ActionDescriptor.EndpointMetadata); + + var descriptionMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(descriptionMetadata); + Assert.Equal("A description", descriptionMetadata.Description); + + var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType().SingleOrDefault(); + Assert.NotNull(summaryMetadata); + Assert.Equal("A summary", summaryMetadata.Summary); + } + private static IEnumerable GetSortedMediaTypes(ApiResponseType apiResponseType) { return apiResponseType.ApiResponseFormats