Skip to content

Support OpenAPI summaries and descriptions on minimal endpoints #40088

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/IDescriptionMetadata.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/ISummaryMetadata.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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!
29 changes: 29 additions & 0 deletions src/Http/Http.Extensions/src/EndpointDescriptionAttribute.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions src/Http/Http.Extensions/src/EndpointSummaryAttribute.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
#nullable enable
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.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!
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -30,7 +30,7 @@ public static RouteHandlerBuilder ExcludeFromDescription(this RouteHandlerBuilde
}

/// <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>
Expand All @@ -50,7 +50,7 @@ public static RouteHandlerBuilder Produces<TResponse>(this RouteHandlerBuilder b
}

/// <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>
Expand Down Expand Up @@ -85,7 +85,7 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder,

/// <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>
Expand All @@ -105,7 +105,7 @@ public static RouteHandlerBuilder ProducesProblem(this RouteHandlerBuilder build

/// <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>
Expand All @@ -124,7 +124,7 @@ public static RouteHandlerBuilder ProducesValidationProblem(this RouteHandlerBui
}

/// <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>
Expand All @@ -142,7 +142,7 @@ public static RouteHandlerBuilder WithTags(this RouteHandlerBuilder builder, par
}

/// <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>
Expand All @@ -159,7 +159,7 @@ public static RouteHandlerBuilder Accepts<TRequest>(this RouteHandlerBuilder bui
}

/// <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>
Expand All @@ -177,7 +177,7 @@ public static RouteHandlerBuilder Accepts<TRequest>(this RouteHandlerBuilder bui
}

/// <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>
Expand All @@ -193,7 +193,7 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder,
}

/// <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>
Expand All @@ -209,6 +209,32 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder,
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];
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Routing/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Microsoft.AspNetCore.Routing.RouteOptions!>! routeOptions, System.IServiceProvider! serviceProvider) -> void
Microsoft.AspNetCore.Routing.DefaultInlineConstraintResolver.DefaultInlineConstraintResolver(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Routing.RouteOptions!>! 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!
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1145,6 +1146,68 @@ public void HandlesEndpointWithRouteConstraints()
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
Expand Down