Browse Source

Support OpenAPI summaries and descriptions on minimal endpoints (#40088) (#40718)

* Support OpenAPI summaries and descriptions on minimal endpoints
* Prefix new types with 'Endpoint'
* Bring back docstring fixes
* Remove AttributeTargets.Delegate from attributes
Safia Abdalla 4 years ago
parent
commit
06b701dfa0

+ 15 - 0
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;
+
+/// <summary>
+/// Defines a contract used to specify a description in <see cref="Endpoint.Metadata"/>.
+/// </summary>
+public interface IEndpointDescriptionMetadata
+{
+    /// <summary>
+    /// Gets the description associated with the endpoint.
+    /// </summary>
+    string Description { get; }
+}

+ 15 - 0
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;
+
+/// <summary>
+/// Defines a contract used to specify a summary in <see cref="Endpoint.Metadata"/>.
+/// </summary>
+public interface IEndpointSummaryMetadata
+{
+    /// <summary>
+    /// Gets the summary associated with the endpoint.
+    /// </summary>
+    string Summary { get; }
+}

+ 4 - 0
src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

@@ -14,3 +14,7 @@ Microsoft.AspNetCore.Http.RouteHandlerFilterContext
 Microsoft.AspNetCore.Http.RouteHandlerFilterContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext!
 Microsoft.AspNetCore.Http.RouteHandlerFilterContext.Parameters.get -> System.Collections.Generic.IList<object?>!
 Microsoft.AspNetCore.Http.IRouteHandlerFilter
+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!

+ 29 - 0
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;
+
+/// <summary>
+/// Specifies a description for the endpoint in <see cref="Endpoint.Metadata"/>.
+/// </summary>
+/// <remarks>
+/// 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.
+/// </remarks>
+[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
+public sealed class EndpointDescriptionAttribute : Attribute, IEndpointDescriptionMetadata
+{
+    /// <summary>
+    /// Initializes an instance of the <see cref="EndpointDescriptionAttribute"/>.
+    /// </summary>
+    /// <param name="description">The description associated with the endpoint or parameter.</param>
+    public EndpointDescriptionAttribute(string description)
+    {
+        Description = description;
+    }
+
+    /// <inheritdoc />
+    public string Description { get; }
+}

+ 25 - 0
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;
+
+/// <summary>
+/// Specifies a summary in <see cref="Endpoint.Metadata"/>.
+/// </summary>
+[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
+public sealed class EndpointSummaryAttribute : Attribute, IEndpointSummaryMetadata
+{
+    /// <summary>
+    /// Initializes an instance of the <see cref="EndpointSummaryAttribute"/>.
+    /// </summary>
+    /// <param name="summary">The summary associated with the endpoint or parameter.</param>
+    public EndpointSummaryAttribute(string summary)
+    {
+        Summary = summary;
+    }
+
+    /// <inheritdoc />
+    public string Summary { get; }
+}

+ 6 - 0
src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt

@@ -3,3 +3,9 @@ Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions
 static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Http.Json.JsonOptions!>! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
 Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilters.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Http.IRouteHandlerFilter!>?
 Microsoft.AspNetCore.Http.RequestDelegateFactoryOptions.RouteHandlerFilters.init -> void
+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!

+ 36 - 10
src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs

@@ -17,7 +17,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     private static readonly ExcludeFromDescriptionAttribute _excludeFromDescriptionMetadataAttribute = new();
 
     /// <summary>
-    /// Adds the <see cref="IExcludeFromDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds the <see cref="IExcludeFromDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -30,7 +30,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <typeparam name="TResponse">The type of the response.</typeparam>
@@ -50,7 +50,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -85,7 +85,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
 
     /// <summary>
     /// Adds an <see cref="IProducesResponseTypeMetadata"/> with a <see cref="ProblemDetails"/> type
-    /// to <see cref="EndpointBuilder.Metadata"/> for all builders produced by <paramref name="builder"/>.
+    /// to <see cref="EndpointBuilder.Metadata"/> for all endpoints produced by <paramref name="builder"/>.
     /// </summary>
     /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
     /// <param name="statusCode">The response status code.</param>
@@ -105,7 +105,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
 
     /// <summary>
     /// Adds an <see cref="IProducesResponseTypeMetadata"/> with a <see cref="HttpValidationProblemDetails"/> type
-    /// to <see cref="EndpointBuilder.Metadata"/> for all builders produced by <paramref name="builder"/>.
+    /// to <see cref="EndpointBuilder.Metadata"/> for all endpoints produced by <paramref name="builder"/>.
     /// </summary>
     /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
     /// <param name="statusCode">The response status code. Defaults to <see cref="StatusCodes.Status400BadRequest"/>.</param>
@@ -124,7 +124,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds the <see cref="ITagsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds the <see cref="ITagsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <remarks>
@@ -142,7 +142,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <typeparam name="TRequest">The type of the request body.</typeparam>
@@ -159,7 +159,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <typeparam name="TRequest">The type of the request body.</typeparam>
@@ -177,7 +177,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -193,7 +193,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
     }
 
     /// <summary>
-    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
+    /// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
     /// produced by <paramref name="builder"/>.
     /// </summary>
     /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -209,6 +209,32 @@ public static class OpenApiRouteHandlerBuilderExtensions
         return builder;
     }
 
+    /// <summary>
+    /// Adds <see cref="IEndpointDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
+    /// produced by <paramref name="builder"/>.
+    /// </summary>
+    /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
+    /// <param name="description">A string representing a detailed description of the endpoint.</param>
+    /// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
+    public static RouteHandlerBuilder WithDescription(this RouteHandlerBuilder builder, string description)
+    {
+        builder.WithMetadata(new EndpointDescriptionAttribute(description));
+        return builder;
+    }
+
+    /// <summary>
+    /// Adds <see cref="IEndpointSummaryMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
+    /// produced by <paramref name="builder"/>.
+    /// </summary>
+    /// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
+    /// <param name="summary">A string representing a brief description of the endpoint.</param>
+    /// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
+    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];

+ 2 - 0
src/Http/Routing/src/PublicAPI.Unshipped.txt

@@ -10,3 +10,5 @@ Microsoft.AspNetCore.Routing.DefaultInlineConstraintResolver.DefaultInlineConstr
 static Microsoft.AspNetCore.Http.RouteHandlerFilterExtensions.AddFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, Microsoft.AspNetCore.Http.IRouteHandlerFilter! filter) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder!
 static Microsoft.AspNetCore.Http.RouteHandlerFilterExtensions.AddFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, System.Func<Microsoft.AspNetCore.Http.RouteHandlerFilterContext!, System.Func<Microsoft.AspNetCore.Http.RouteHandlerFilterContext!, System.Threading.Tasks.ValueTask<object?>>!, System.Threading.Tasks.ValueTask<object?>>! routeHandlerFilter) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder!
 static Microsoft.AspNetCore.Http.RouteHandlerFilterExtensions.AddFilter<TFilterType>(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder!
+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!

+ 63 - 0
src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs

@@ -6,6 +6,7 @@ using System.Reflection;
 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 class EndpointMetadataApiDescriptionProviderTest
             constraint => Assert.IsType<MaxLengthRouteConstraint>(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<ActionDescriptor>());
+
+        var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().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<IEndpointDescriptionMetadata>().SingleOrDefault();
+        Assert.NotNull(descriptionMetadata);
+        Assert.Equal("A description", descriptionMetadata.Description);
+
+        var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType<IEndpointSummaryMetadata>().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<ActionDescriptor>());
+
+        var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().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<IEndpointDescriptionMetadata>().SingleOrDefault();
+        Assert.NotNull(descriptionMetadata);
+        Assert.Equal("A description", descriptionMetadata.Description);
+
+        var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType<IEndpointSummaryMetadata>().SingleOrDefault();
+        Assert.NotNull(summaryMetadata);
+        Assert.Equal("A summary", summaryMetadata.Summary);
+    }
+
     private static IEnumerable<string> GetSortedMediaTypes(ApiResponseType apiResponseType)
     {
         return apiResponseType.ApiResponseFormats