diff --git a/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs b/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs index 2456ed622c2d..2615e70e1ac9 100644 --- a/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs +++ b/src/Http/Routing/src/Builder/OpenApiRouteHandlerBuilderExtensions.cs @@ -16,6 +16,15 @@ public static class OpenApiRouteHandlerBuilderExtensions { private static readonly ExcludeFromDescriptionAttribute _excludeFromDescriptionMetadataAttribute = new(); + /// + /// Adds the to for all endpoints + /// produced by . + /// + /// The . + /// A that can be used to further customize the endpoint. + public static TBuilder ExcludeFromDescription(this TBuilder builder) where TBuilder : IEndpointConventionBuilder + => builder.WithMetadata(_excludeFromDescriptionMetadataAttribute); + /// /// Adds the to for all endpoints /// produced by . @@ -23,11 +32,7 @@ public static class OpenApiRouteHandlerBuilderExtensions /// The . /// A that can be used to further customize the endpoint. public static RouteHandlerBuilder ExcludeFromDescription(this RouteHandlerBuilder builder) - { - builder.WithMetadata(_excludeFromDescriptionMetadataAttribute); - - return builder; - } + => ExcludeFromDescription(builder); /// /// Adds an to for all endpoints @@ -40,9 +45,10 @@ public static RouteHandlerBuilder ExcludeFromDescription(this RouteHandlerBuilde /// Additional response content types the endpoint produces for the supplied status code. /// A that can be used to further customize the endpoint. #pragma warning disable RS0026 - public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, + public static RouteHandlerBuilder Produces( #pragma warning restore RS0026 - int statusCode = StatusCodes.Status200OK, + this RouteHandlerBuilder builder, + int statusCode = StatusCodes.Status200OK, string? contentType = null, params string[] additionalContentTypes) { @@ -60,9 +66,10 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder b /// Additional response content types the endpoint produces for the supplied status code. /// A that can be used to further customize the endpoint. #pragma warning disable RS0026 - public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, + public static RouteHandlerBuilder Produces( #pragma warning restore RS0026 - int statusCode, + this RouteHandlerBuilder builder, + int statusCode, Type? responseType = null, string? contentType = null, params string[] additionalContentTypes) @@ -74,13 +81,10 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, if (contentType is null) { - builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), statusCode)); - return builder; + return builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), statusCode)); } - builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), statusCode, contentType, additionalContentTypes)); - - return builder; + return builder.WithMetadata(new ProducesResponseTypeMetadata(responseType ?? typeof(void), statusCode, contentType, additionalContentTypes)); } /// @@ -91,16 +95,14 @@ public static RouteHandlerBuilder Produces(this RouteHandlerBuilder builder, /// The response status code. /// The response content type. Defaults to "application/problem+json". /// A that can be used to further customize the endpoint. - public static RouteHandlerBuilder ProducesProblem(this RouteHandlerBuilder builder, - int statusCode, - string? contentType = null) + public static RouteHandlerBuilder ProducesProblem(this RouteHandlerBuilder builder, int statusCode, string? contentType = null) { if (string.IsNullOrEmpty(contentType)) { contentType = "application/problem+json"; } - return Produces(builder, statusCode, contentType); + return Produces(builder, statusCode, typeof(ProblemDetails), contentType); } /// @@ -111,7 +113,8 @@ public static RouteHandlerBuilder ProducesProblem(this RouteHandlerBuilder build /// The response status code. Defaults to . /// The response content type. Defaults to "application/problem+json". /// A that can be used to further customize the endpoint. - public static RouteHandlerBuilder ProducesValidationProblem(this RouteHandlerBuilder builder, + public static RouteHandlerBuilder ProducesValidationProblem( + this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string? contentType = null) { @@ -120,9 +123,24 @@ public static RouteHandlerBuilder ProducesValidationProblem(this RouteHandlerBui contentType = "application/problem+json"; } - return Produces(builder, statusCode, contentType); + return Produces(builder, statusCode, typeof(HttpValidationProblemDetails), contentType); } + /// + /// Adds the to for all endpoints + /// produced by . + /// + /// + /// The OpenAPI specification supports a tags classification to categorize operations + /// into related groups. These tags are typically included in the generated specification + /// and are typically used to group operations by tags in the UI. + /// + /// The . + /// A collection of tags to be associated with the endpoint. + /// A that can be used to further customize the endpoint. + public static TBuilder WithTags(this TBuilder builder, params string[] tags) where TBuilder : IEndpointConventionBuilder + => builder.WithMetadata(new TagsAttribute(tags)); + /// /// Adds the to for all endpoints /// produced by . @@ -136,10 +154,7 @@ public static RouteHandlerBuilder ProducesValidationProblem(this RouteHandlerBui /// A collection of tags to be associated with the endpoint. /// A that can be used to further customize the endpoint. public static RouteHandlerBuilder WithTags(this RouteHandlerBuilder builder, params string[] tags) - { - builder.WithMetadata(new TagsAttribute(tags)); - return builder; - } + => WithTags(builder, tags); /// /// Adds to for all endpoints @@ -150,12 +165,12 @@ public static RouteHandlerBuilder WithTags(this RouteHandlerBuilder builder, par /// The request content type that the endpoint accepts. /// The list of additional request content types that the endpoint accepts. /// A that can be used to further customize the endpoint. - public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, - string contentType, params string[] additionalContentTypes) where TRequest : notnull + public static RouteHandlerBuilder Accepts( + this RouteHandlerBuilder builder, + string contentType, + params string[] additionalContentTypes) where TRequest : notnull { - Accepts(builder, typeof(TRequest), contentType, additionalContentTypes); - - return builder; + return Accepts(builder, typeof(TRequest), contentType, additionalContentTypes); } /// @@ -168,12 +183,13 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder bui /// The request content type that the endpoint accepts. /// The list of additional request content types that the endpoint accepts. /// A that can be used to further customize the endpoint. - public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, - bool isOptional, string contentType, params string[] additionalContentTypes) where TRequest : notnull + public static RouteHandlerBuilder Accepts( + this RouteHandlerBuilder builder, + bool isOptional, + string contentType, + params string[] additionalContentTypes) where TRequest : notnull { - Accepts(builder, typeof(TRequest), isOptional, contentType, additionalContentTypes); - - return builder; + return Accepts(builder, typeof(TRequest), isOptional, contentType, additionalContentTypes); } /// @@ -185,11 +201,13 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder bui /// The request content type that the endpoint accepts. /// The list of additional request content types that the endpoint accepts. /// A that can be used to further customize the endpoint. - public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, - Type requestType, string contentType, params string[] additionalContentTypes) + public static RouteHandlerBuilder Accepts( + this RouteHandlerBuilder builder, + Type requestType, + string contentType, + params string[] additionalContentTypes) { - builder.WithMetadata(new AcceptsMetadata(requestType, false, GetAllContentTypes(contentType, additionalContentTypes))); - return builder; + return Accepts(builder, requestType, isOptional: false, contentType, additionalContentTypes); } /// @@ -202,38 +220,36 @@ public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, /// The request content type that the endpoint accepts. /// The list of additional request content types that the endpoint accepts. /// A that can be used to further customize the endpoint. - public static RouteHandlerBuilder Accepts(this RouteHandlerBuilder builder, - Type requestType, bool isOptional, string contentType, params string[] additionalContentTypes) + public static RouteHandlerBuilder Accepts( + this RouteHandlerBuilder builder, + Type requestType, + bool isOptional, + string contentType, + params string[] additionalContentTypes) { - builder.WithMetadata(new AcceptsMetadata(requestType, isOptional, GetAllContentTypes(contentType, additionalContentTypes))); - return builder; + var contentTypes = GetAllContentTypes(contentType, additionalContentTypes); + return builder.WithMetadata(new AcceptsMetadata(requestType, isOptional, contentTypes)); } /// /// Adds to for all endpoints /// produced by . /// - /// The . + /// 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; - } + public static TBuilder WithDescription(this TBuilder builder, string description) where TBuilder : IEndpointConventionBuilder + => builder.WithMetadata(new EndpointDescriptionAttribute(description)); /// /// Adds to for all endpoints /// produced by . /// - /// The . + /// 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; - } + public static TBuilder WithSummary(this TBuilder builder, string summary) where TBuilder : IEndpointConventionBuilder + => builder.WithMetadata(new EndpointSummaryAttribute(summary)); private static string[] GetAllContentTypes(string contentType, string[] additionalContentTypes) { diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 632d25fbd8af..6b1ab9652506 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -11,12 +11,14 @@ 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.ExcludeFromDescription(this TBuilder builder) -> TBuilder +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithDescription(this TBuilder builder, string! description) -> TBuilder +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithSummary(this TBuilder builder, string! summary) -> TBuilder +static Microsoft.AspNetCore.Http.OpenApiRouteHandlerBuilderExtensions.WithTags(this TBuilder builder, params string![]! tags) -> TBuilder 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! filterFactory) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.RouteHandlerFilterExtensions.AddFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder! builder, System.Func>! routeHandlerFilter) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder! static Microsoft.AspNetCore.Http.RouteHandlerFilterExtensions.AddFilter(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! static Microsoft.AspNetCore.Routing.LinkGeneratorEndpointNameAddressExtensions.GetPathByName(this Microsoft.AspNetCore.Routing.LinkGenerator! generator, Microsoft.AspNetCore.Http.HttpContext! httpContext, string! endpointName, Microsoft.AspNetCore.Routing.RouteValueDictionary? values = null, Microsoft.AspNetCore.Http.PathString? pathBase = null, Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions? options = null) -> string? static Microsoft.AspNetCore.Routing.LinkGeneratorEndpointNameAddressExtensions.GetPathByName(this Microsoft.AspNetCore.Routing.LinkGenerator! generator, string! endpointName, Microsoft.AspNetCore.Routing.RouteValueDictionary? values = null, Microsoft.AspNetCore.Http.PathString pathBase = default(Microsoft.AspNetCore.Http.PathString), Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions? options = null) -> string? static Microsoft.AspNetCore.Routing.LinkGeneratorEndpointNameAddressExtensions.GetUriByName(this Microsoft.AspNetCore.Routing.LinkGenerator! generator, Microsoft.AspNetCore.Http.HttpContext! httpContext, string! endpointName, Microsoft.AspNetCore.Routing.RouteValueDictionary? values = null, string? scheme = null, Microsoft.AspNetCore.Http.HostString? host = null, Microsoft.AspNetCore.Http.PathString? pathBase = null, Microsoft.AspNetCore.Http.FragmentString fragment = default(Microsoft.AspNetCore.Http.FragmentString), Microsoft.AspNetCore.Routing.LinkOptions? options = null) -> string? diff --git a/src/Http/Routing/test/UnitTests/Builder/OpenApiRouteHandlerBuilderExtensionsTest.cs b/src/Http/Routing/test/UnitTests/Builder/OpenApiRouteHandlerBuilderExtensionsTest.cs new file mode 100644 index 000000000000..54a87d2aa5aa --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Builder/OpenApiRouteHandlerBuilderExtensionsTest.cs @@ -0,0 +1,158 @@ +// 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.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Routing.Builder; + +public class OpenApiRouteHandlerBuilderExtensionsTest +{ + [Fact] + public void ExcludeFromDescription_AddsExcludeFromDescriptionAttribute() + { + static void GenericExclude(IEndpointConventionBuilder builder) => builder.ExcludeFromDescription(); + static void SpecificExclude(RouteHandlerBuilder builder) => builder.ExcludeFromDescription(); + + static void AssertMetadata(EndpointBuilder builder) + => Assert.IsType(Assert.Single(builder.Metadata)); + + RunWithBothBuilders(GenericExclude, SpecificExclude, AssertMetadata); + } + + [Fact] + public void WithTags_AddsTagsAttribute() + { + static void GenericWithTags(IEndpointConventionBuilder builder) => builder.WithTags("a", "b", "c"); + static void SpecificWithTags(RouteHandlerBuilder builder) => builder.WithTags("a", "b", "c"); + + static void AssertMetadata(EndpointBuilder builder) + { + var tags = Assert.IsType(Assert.Single(builder.Metadata)); + Assert.Collection(tags.Tags, + tag => Assert.Equal("a", tag), + tag => Assert.Equal("b", tag), + tag => Assert.Equal("c", tag)); + } + + RunWithBothBuilders(GenericWithTags, SpecificWithTags, AssertMetadata); + } + + [Fact] + public void Produces_AddsProducesResponseTypeMetadataWithJsonContentType() + { + var testBuilder = new TestEndointConventionBuilder(); + var builder = new RouteHandlerBuilder(new[] { testBuilder }); + + builder.Produces(); + + var metadata = Assert.IsType(Assert.Single(testBuilder.Metadata)); + Assert.Equal(typeof(TestEndointConventionBuilder), metadata.Type); + Assert.Equal(StatusCodes.Status200OK, metadata.StatusCode); + Assert.Equal("application/json", Assert.Single(metadata.ContentTypes)); + } + + [Fact] + public void Produces_AddsProducesResponseTypeMetadataWithVoidType() + { + var testBuilder = new TestEndointConventionBuilder(); + var builder = new RouteHandlerBuilder(new[] { testBuilder }); + + builder.Produces(StatusCodes.Status404NotFound); + + var metadata = Assert.IsType(Assert.Single(testBuilder.Metadata)); + Assert.Equal(typeof(void), metadata.Type); + Assert.Equal(StatusCodes.Status404NotFound, metadata.StatusCode); + Assert.Empty(metadata.ContentTypes); + } + + [Fact] + public void ProdcesProblem_AddsProducesResponseTypeMetadataWithProblemDetailsType() + { + var testBuilder = new TestEndointConventionBuilder(); + var builder = new RouteHandlerBuilder(new[] { testBuilder }); + + builder.ProducesProblem(StatusCodes.Status400BadRequest); + + var metadata = Assert.IsType(Assert.Single(testBuilder.Metadata)); + Assert.Equal(typeof(ProblemDetails), metadata.Type); + Assert.Equal(StatusCodes.Status400BadRequest, metadata.StatusCode); + Assert.Equal("application/problem+json", Assert.Single(metadata.ContentTypes)); + } + + [Fact] + public void ProdcesValidiationProblem_AddsProducesResponseTypeMetadataWithHttpValidationProblemDetailsType() + { + var testBuilder = new TestEndointConventionBuilder(); + var builder = new RouteHandlerBuilder(new[] { testBuilder }); + + builder.ProducesValidationProblem(); + + var metadata = Assert.IsType(Assert.Single(testBuilder.Metadata)); + Assert.Equal(typeof(HttpValidationProblemDetails), metadata.Type); + Assert.Equal(StatusCodes.Status400BadRequest, metadata.StatusCode); + Assert.Equal("application/problem+json", Assert.Single(metadata.ContentTypes)); + } + + [Fact] + public void Accepts_AddsAcceptsMetadataWithSpecifiedType() + { + var testBuilder = new TestEndointConventionBuilder(); + var builder = new RouteHandlerBuilder(new[] { testBuilder }); + + builder.Accepts("text/plain"); + + var metadata = Assert.IsType(Assert.Single(testBuilder.Metadata)); + + Assert.Equal(typeof(TestEndointConventionBuilder), metadata.RequestType); + Assert.Equal("text/plain", Assert.Single(metadata.ContentTypes)); + Assert.False(metadata.IsOptional); + } + + [Fact] + public void WithDescription_AddsEndpointDescriptionAttribute() + { + var builder = new TestEndointConventionBuilder(); + builder.WithDescription("test description"); + + var metadata = Assert.IsType(Assert.Single(builder.Metadata)); + Assert.Equal("test description", metadata.Description); + } + + [Fact] + public void WithSummary_AddsEndpointSummaryAttribute() + { + var builder = new TestEndointConventionBuilder(); + builder.WithSummary("test summary"); + + var metadata = Assert.IsType(Assert.Single(builder.Metadata)); + Assert.Equal("test summary", metadata.Summary); + } + + private void RunWithBothBuilders( + Action genericSetup, + Action specificSetup, + Action assert) + { + var testBuilder = new TestEndointConventionBuilder(); + genericSetup(testBuilder); + assert(testBuilder); + + var routeTestBuilder = new TestEndointConventionBuilder(); + var routeHandlerBuilder = new RouteHandlerBuilder(new[] { routeTestBuilder }); + specificSetup(routeHandlerBuilder); + assert(routeTestBuilder); + } + + private sealed class TestEndointConventionBuilder : EndpointBuilder, IEndpointConventionBuilder + { + public void Add(Action convention) + { + convention(this); + } + + public override Endpoint Build() => throw new NotImplementedException(); + } +} diff --git a/src/Http/samples/MinimalSample/Program.cs b/src/Http/samples/MinimalSample/Program.cs index 4eb5ecd34d41..4694e666df9a 100644 --- a/src/Http/samples/MinimalSample/Program.cs +++ b/src/Http/samples/MinimalSample/Program.cs @@ -18,7 +18,7 @@ var nestedGroup = app.MapGroup("/group/{groupName}") .MapGroup("/nested/{nestedName}") - .WithMetadata(new TagsAttribute("nested")); + .WithTags("nested"); nestedGroup .MapGet("/", (string groupName, string nestedName) =>