Skip to content

Commit bd10e95

Browse files
authored
Support OpenAPI summaries and descriptions on minimal endpoints (#40088)
* Support OpenAPI summaries and descriptions on minimal endpoints * Prefix new types with 'Endpoint' * Bring back docstring fixes * Remove AttributeTargets.Delegate from attributes
1 parent 196656f commit bd10e95

9 files changed

+195
-10
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http.Metadata;
5+
6+
/// <summary>
7+
/// Defines a contract used to specify a description in <see cref="Endpoint.Metadata"/>.
8+
/// </summary>
9+
public interface IEndpointDescriptionMetadata
10+
{
11+
/// <summary>
12+
/// Gets the description associated with the endpoint.
13+
/// </summary>
14+
string Description { get; }
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http.Metadata;
5+
6+
/// <summary>
7+
/// Defines a contract used to specify a summary in <see cref="Endpoint.Metadata"/>.
8+
/// </summary>
9+
public interface IEndpointSummaryMetadata
10+
{
11+
/// <summary>
12+
/// Gets the summary associated with the endpoint.
13+
/// </summary>
14+
string Summary { get; }
15+
}

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata
44
Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string?
55
abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string?
66
Microsoft.AspNetCore.Http.Metadata.ISkipStatusCodePagesMetadata
7+
Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata
8+
Microsoft.AspNetCore.Http.Metadata.IEndpointDescriptionMetadata.Description.get -> string!
9+
Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata
10+
Microsoft.AspNetCore.Http.Metadata.IEndpointSummaryMetadata.Summary.get -> string!
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Http.Metadata;
5+
6+
namespace Microsoft.AspNetCore.Http;
7+
8+
/// <summary>
9+
/// Specifies a description for the endpoint in <see cref="Endpoint.Metadata"/>.
10+
/// </summary>
11+
/// <remarks>
12+
/// The OpenAPI specification supports a description attribute on operations and parameters that
13+
/// can be used to annotate endpoints with detailed, multiline descriptors of their behavior.
14+
/// </remarks>
15+
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
16+
public sealed class EndpointDescriptionAttribute : Attribute, IEndpointDescriptionMetadata
17+
{
18+
/// <summary>
19+
/// Initializes an instance of the <see cref="EndpointDescriptionAttribute"/>.
20+
/// </summary>
21+
/// <param name="description">The description associated with the endpoint or parameter.</param>
22+
public EndpointDescriptionAttribute(string description)
23+
{
24+
Description = description;
25+
}
26+
27+
/// <inheritdoc />
28+
public string Description { get; }
29+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Http.Metadata;
5+
6+
namespace Microsoft.AspNetCore.Http;
7+
8+
/// <summary>
9+
/// Specifies a summary in <see cref="Endpoint.Metadata"/>.
10+
/// </summary>
11+
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
12+
public sealed class EndpointSummaryAttribute : Attribute, IEndpointSummaryMetadata
13+
{
14+
/// <summary>
15+
/// Initializes an instance of the <see cref="EndpointSummaryAttribute"/>.
16+
/// </summary>
17+
/// <param name="summary">The summary associated with the endpoint or parameter.</param>
18+
public EndpointSummaryAttribute(string summary)
19+
{
20+
Summary = summary;
21+
}
22+
23+
/// <inheritdoc />
24+
public string Summary { get; }
25+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
#nullable enable
22
Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions
33
static Microsoft.Extensions.DependencyInjection.RouteHandlerJsonServiceExtensions.ConfigureRouteHandlerJsonOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Http.Json.JsonOptions!>! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
4+
Microsoft.AspNetCore.Http.EndpointDescriptionAttribute
5+
Microsoft.AspNetCore.Http.EndpointDescriptionAttribute.EndpointDescriptionAttribute(string! description) -> void
6+
Microsoft.AspNetCore.Http.EndpointDescriptionAttribute.Description.get -> string!
7+
Microsoft.AspNetCore.Http.EndpointSummaryAttribute
8+
Microsoft.AspNetCore.Http.EndpointSummaryAttribute.EndpointSummaryAttribute(string! summary) -> void
9+
Microsoft.AspNetCore.Http.EndpointSummaryAttribute.Summary.get -> string!

src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public static class OpenApiRouteHandlerBuilderExtensions
1717
private static readonly ExcludeFromDescriptionAttribute _excludeFromDescriptionMetadataAttribute = new();
1818

1919
/// <summary>
20-
/// Adds the <see cref="IExcludeFromDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
20+
/// Adds the <see cref="IExcludeFromDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
2121
/// produced by <paramref name="builder"/>.
2222
/// </summary>
2323
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -30,7 +30,7 @@ public static RouteHandlerBuilder ExcludeFromDescription(this RouteHandlerBuilde
3030
}
3131

3232
/// <summary>
33-
/// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
33+
/// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
3434
/// produced by <paramref name="builder"/>.
3535
/// </summary>
3636
/// <typeparam name="TResponse">The type of the response.</typeparam>
@@ -50,7 +50,7 @@ public static RouteHandlerBuilder Produces<TResponse>(this RouteHandlerBuilder b
5050
}
5151

5252
/// <summary>
53-
/// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
53+
/// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
5454
/// produced by <paramref name="builder"/>.
5555
/// </summary>
5656
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -85,7 +85,7 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder,
8585

8686
/// <summary>
8787
/// Adds an <see cref="IProducesResponseTypeMetadata"/> with a <see cref="ProblemDetails"/> type
88-
/// to <see cref="EndpointBuilder.Metadata"/> for all builders produced by <paramref name="builder"/>.
88+
/// to <see cref="EndpointBuilder.Metadata"/> for all endpoints produced by <paramref name="builder"/>.
8989
/// </summary>
9090
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
9191
/// <param name="statusCode">The response status code.</param>
@@ -105,7 +105,7 @@ public static RouteHandlerBuilder ProducesProblem(this RouteHandlerBuilder build
105105

106106
/// <summary>
107107
/// Adds an <see cref="IProducesResponseTypeMetadata"/> with a <see cref="HttpValidationProblemDetails"/> type
108-
/// to <see cref="EndpointBuilder.Metadata"/> for all builders produced by <paramref name="builder"/>.
108+
/// to <see cref="EndpointBuilder.Metadata"/> for all endpoints produced by <paramref name="builder"/>.
109109
/// </summary>
110110
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
111111
/// <param name="statusCode">The response status code. Defaults to <see cref="StatusCodes.Status400BadRequest"/>.</param>
@@ -124,7 +124,7 @@ public static RouteHandlerBuilder ProducesValidationProblem(this RouteHandlerBui
124124
}
125125

126126
/// <summary>
127-
/// Adds the <see cref="ITagsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
127+
/// Adds the <see cref="ITagsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
128128
/// produced by <paramref name="builder"/>.
129129
/// </summary>
130130
/// <remarks>
@@ -142,7 +142,7 @@ public static RouteHandlerBuilder WithTags(this RouteHandlerBuilder builder, par
142142
}
143143

144144
/// <summary>
145-
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
145+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
146146
/// produced by <paramref name="builder"/>.
147147
/// </summary>
148148
/// <typeparam name="TRequest">The type of the request body.</typeparam>
@@ -159,7 +159,7 @@ public static RouteHandlerBuilder Accepts<TRequest>(this RouteHandlerBuilder bui
159159
}
160160

161161
/// <summary>
162-
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
162+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
163163
/// produced by <paramref name="builder"/>.
164164
/// </summary>
165165
/// <typeparam name="TRequest">The type of the request body.</typeparam>
@@ -177,7 +177,7 @@ public static RouteHandlerBuilder Accepts<TRequest>(this RouteHandlerBuilder bui
177177
}
178178

179179
/// <summary>
180-
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
180+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
181181
/// produced by <paramref name="builder"/>.
182182
/// </summary>
183183
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -193,7 +193,7 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder,
193193
}
194194

195195
/// <summary>
196-
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all builders
196+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
197197
/// produced by <paramref name="builder"/>.
198198
/// </summary>
199199
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
@@ -209,6 +209,32 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder,
209209
return builder;
210210
}
211211

212+
/// <summary>
213+
/// Adds <see cref="IEndpointDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
214+
/// produced by <paramref name="builder"/>.
215+
/// </summary>
216+
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
217+
/// <param name="description">A string representing a detailed description of the endpoint.</param>
218+
/// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
219+
public static RouteHandlerBuilder WithDescription(this RouteHandlerBuilder builder, string description)
220+
{
221+
builder.WithMetadata(new EndpointDescriptionAttribute(description));
222+
return builder;
223+
}
224+
225+
/// <summary>
226+
/// Adds <see cref="IEndpointSummaryMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
227+
/// produced by <paramref name="builder"/>.
228+
/// </summary>
229+
/// <param name="builder">The <see cref="RouteHandlerBuilder"/>.</param>
230+
/// <param name="summary">A string representing a brief description of the endpoint.</param>
231+
/// <returns>A <see cref="RouteHandlerBuilder"/> that can be used to further customize the endpoint.</returns>
232+
public static RouteHandlerBuilder WithSummary(this RouteHandlerBuilder builder, string summary)
233+
{
234+
builder.WithMetadata(new EndpointSummaryAttribute(summary));
235+
return builder;
236+
}
237+
212238
private static string[] GetAllContentTypes(string contentType, string[] additionalContentTypes)
213239
{
214240
var allContentTypes = new string[additionalContentTypes.Length + 1];

src/Http/Routing/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ static Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions.MapPatch(this
66
override Microsoft.AspNetCore.Routing.RouteValuesAddress.ToString() -> string?
77
*REMOVED*~Microsoft.AspNetCore.Routing.DefaultInlineConstraintResolver.DefaultInlineConstraintResolver(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Routing.RouteOptions!>! routeOptions, System.IServiceProvider! serviceProvider) -> void
88
Microsoft.AspNetCore.Routing.DefaultInlineConstraintResolver.DefaultInlineConstraintResolver(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Routing.RouteOptions!>! routeOptions, System.IServiceProvider! serviceProvider) -> void
9+
static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithDescription(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! description) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder!
10+
static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithSummary(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, string! summary) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder!

src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Security.Claims;
77
using Microsoft.AspNetCore.Builder;
88
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.Http.Metadata;
910
using Microsoft.AspNetCore.Mvc.Abstractions;
1011
using Microsoft.AspNetCore.Mvc.Infrastructure;
1112
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -1145,6 +1146,68 @@ public void HandlesEndpointWithRouteConstraints()
11451146
constraint => Assert.IsType<MaxLengthRouteConstraint>(constraint));
11461147
}
11471148

1149+
[Fact]
1150+
public void HandlesEndpointWithDescriptionAndSummary_WithExtensionMethods()
1151+
{
1152+
var builder = CreateBuilder();
1153+
builder.MapGet("/api/todos/{id}", (int id) => "").WithDescription("A description").WithSummary("A summary");
1154+
1155+
var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>());
1156+
1157+
var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().Single();
1158+
var hostEnvironment = new HostEnvironment
1159+
{
1160+
ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest)
1161+
};
1162+
var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource);
1163+
1164+
// Act
1165+
provider.OnProvidersExecuting(context);
1166+
1167+
// Assert
1168+
var apiDescription = Assert.Single(context.Results);
1169+
Assert.NotEmpty(apiDescription.ActionDescriptor.EndpointMetadata);
1170+
1171+
var descriptionMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType<IEndpointDescriptionMetadata>().SingleOrDefault();
1172+
Assert.NotNull(descriptionMetadata);
1173+
Assert.Equal("A description", descriptionMetadata.Description);
1174+
1175+
var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType<IEndpointSummaryMetadata>().SingleOrDefault();
1176+
Assert.NotNull(summaryMetadata);
1177+
Assert.Equal("A summary", summaryMetadata.Summary);
1178+
}
1179+
1180+
[Fact]
1181+
public void HandlesEndpointWithDescriptionAndSummary_WithAttributes()
1182+
{
1183+
var builder = CreateBuilder();
1184+
builder.MapGet("/api/todos/{id}", [EndpointSummary("A summary")][EndpointDescription("A description")] (int id) => "");
1185+
1186+
var context = new ApiDescriptionProviderContext(Array.Empty<ActionDescriptor>());
1187+
1188+
var endpointDataSource = builder.DataSources.OfType<EndpointDataSource>().Single();
1189+
var hostEnvironment = new HostEnvironment
1190+
{
1191+
ApplicationName = nameof(EndpointMetadataApiDescriptionProviderTest)
1192+
};
1193+
var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource);
1194+
1195+
// Act
1196+
provider.OnProvidersExecuting(context);
1197+
1198+
// Assert
1199+
var apiDescription = Assert.Single(context.Results);
1200+
Assert.NotEmpty(apiDescription.ActionDescriptor.EndpointMetadata);
1201+
1202+
var descriptionMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType<IEndpointDescriptionMetadata>().SingleOrDefault();
1203+
Assert.NotNull(descriptionMetadata);
1204+
Assert.Equal("A description", descriptionMetadata.Description);
1205+
1206+
var summaryMetadata = apiDescription.ActionDescriptor.EndpointMetadata.OfType<IEndpointSummaryMetadata>().SingleOrDefault();
1207+
Assert.NotNull(summaryMetadata);
1208+
Assert.Equal("A summary", summaryMetadata.Summary);
1209+
}
1210+
11481211
private static IEnumerable<string> GetSortedMediaTypes(ApiResponseType apiResponseType)
11491212
{
11501213
return apiResponseType.ApiResponseFormats

0 commit comments

Comments
 (0)