diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index 55e8d3ed765b..6758d349fbaf 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -16,11 +16,22 @@ internal static class ApiDescriptionExtensions /// Maps the HTTP method of the ApiDescription to the HttpMethod. /// /// The ApiDescription to resolve an HttpMethod from. - /// The associated with the given , if known. - public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription) => - apiDescription.HttpMethod?.ToUpperInvariant() switch + /// + /// The associated with the given , including custom methods + /// when the provided HTTP method token is valid. Returns when the HTTP method is missing or invalid. + /// + public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription) + { + var httpMethod = apiDescription.HttpMethod; + if (string.IsNullOrWhiteSpace(httpMethod)) + { + return null; + } + + var normalizedHttpMethod = httpMethod.Trim(); + + return normalizedHttpMethod.ToUpperInvariant() switch { - // Only add methods documented in the OpenAPI spec: https://spec.openapis.org/oas/v3.2.0.html#path-item-object "GET" => HttpMethod.Get, "POST" => HttpMethod.Post, "PUT" => HttpMethod.Put, @@ -30,9 +41,22 @@ internal static class ApiDescriptionExtensions "OPTIONS" => HttpMethod.Options, "TRACE" => HttpMethod.Trace, "QUERY" => HttpMethod.Query, - _ => null, + _ => TryCreateHttpMethod(normalizedHttpMethod), }; + static HttpMethod? TryCreateHttpMethod(string httpMethod) + { + try + { + return new HttpMethod(httpMethod); + } + catch (FormatException) + { + return null; + } + } + } + /// /// Maps the relative path included in the ApiDescription to the path /// that should be included in the OpenApiDocument. This typically diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index 47e1d67e53bc..6756b0ec5335 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Net.Http; - namespace Microsoft.AspNetCore.OpenApi; internal static class OpenApiConstants @@ -19,21 +17,6 @@ internal static class OpenApiConstants internal const string RefPrefix = "#"; internal const string NullableProperty = "x-is-nullable-property"; internal const string DefaultOpenApiResponseKey = "default"; - // Since there's a finite set of HTTP methods that can be included in a given - // OpenApiPaths, we can pre-allocate an array of these methods and use a direct - // lookup on the OpenApiPaths dictionary to avoid allocating an enumerator - // over the KeyValuePairs in OpenApiPaths. - internal static readonly HttpMethod[] HttpMethods = [ - HttpMethod.Get, - HttpMethod.Post, - HttpMethod.Put, - HttpMethod.Delete, - HttpMethod.Options, - HttpMethod.Head, - HttpMethod.Patch, - HttpMethod.Trace, - HttpMethod.Query - ]; // Represents primitive types that should never be represented as // a schema reference and always inlined. internal static readonly List PrimitiveTypes = diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index c45b76e82b67..2ac9af88a658 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -163,14 +163,13 @@ internal async Task ForEachOperationAsync( { foreach (var pathItem in document.Paths.Values) { - for (var i = 0; i < OpenApiConstants.HttpMethods.Length; i++) + if (pathItem.Operations is null) { - var httpMethod = OpenApiConstants.HttpMethods[i]; - if (pathItem.Operations is null || !pathItem.Operations.TryGetValue(httpMethod, out var operation)) - { - continue; - } + continue; + } + foreach (var operation in pathItem.Operations.Values) + { if (operation.Metadata is { } annotations && annotations.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionId) && descriptionId is string descriptionIdString && @@ -294,7 +293,7 @@ private async Task> GetOperationsAsync( if (description.GetHttpMethod() is not { } method) { - // Skip unsupported HTTP methods + // Skip descriptions with a missing or invalid HTTP method. continue; } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs index 91952fa9c278..93a16e579da6 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs @@ -58,7 +58,7 @@ public void MapRelativePathToItemPath_WithRoutePattern_HandlesRoutesThatStartWit public static class HttpMethodTestData { - public static IEnumerable TestCases => new List + public static IEnumerable KnownMethods => new List { new object[] { "GET", HttpMethod.Get }, new object[] { "POST", HttpMethod.Post }, @@ -69,40 +69,84 @@ public static class HttpMethodTestData new object[] { "OPTIONS", HttpMethod.Options }, new object[] { "TRACE", HttpMethod.Trace }, new object[] { "QUERY", HttpMethod.Query }, - new object[] { "gEt", HttpMethod.Get }, // Test case-insensitivity + new object[] { "gEt", HttpMethod.Get }, + new object[] { "pOsT", HttpMethod.Post }, + new object[] { "QuErY", HttpMethod.Query }, + new object[] { " GET ", HttpMethod.Get }, + }; + + public static IEnumerable UnsupportedMethods => new List + { + new object[] { "foo", "foo" }, + new object[] { "Foo", "Foo" }, + new object[] { "FOO", "FOO" }, + new object[] { "customMethod", "customMethod" }, + new object[] { " FOO ", "FOO" }, + new object[] { " FooBar ", "FooBar" }, + }; + + public static IEnumerable InvalidMethods => new List + { + new object[] { "FOO BAR" }, }; } [Theory] - [MemberData(nameof(HttpMethodTestData.TestCases), MemberType = typeof(HttpMethodTestData))] - public void GetHttpMethod_ReturnsHttpMethodForApiDescription(string httpMethod, HttpMethod expectedHttpMethod) + [MemberData(nameof(HttpMethodTestData.KnownMethods), MemberType = typeof(HttpMethodTestData))] + public void GetHttpMethod_ReturnsKnownHttpMethodForApiDescription(string httpMethod, HttpMethod expectedHttpMethod) { - // Arrange var apiDescription = new ApiDescription { HttpMethod = httpMethod }; - // Act var result = apiDescription.GetHttpMethod(); - // Assert Assert.Equal(expectedHttpMethod, result); + Assert.Equal(expectedHttpMethod.Method, result?.Method); } - [Fact] - public void GetHttpMethod_ReturnsNullForUnsupportedMethod() + [Theory] + [MemberData(nameof(HttpMethodTestData.UnsupportedMethods), MemberType = typeof(HttpMethodTestData))] + public void GetHttpMethod_PreservesUnsupportedMethodCasingAfterTrimming(string httpMethod, string expectedHttpMethod) { - // Arrange - Test that unsupported HTTP methods return null var apiDescription = new ApiDescription { - HttpMethod = "UNSUPPORTED" + HttpMethod = httpMethod + }; + + var result = apiDescription.GetHttpMethod(); + + Assert.Equal(expectedHttpMethod, result?.Method); + } + + [Theory] + [MemberData(nameof(HttpMethodTestData.InvalidMethods), MemberType = typeof(HttpMethodTestData))] + public void GetHttpMethod_ReturnsNullForInvalidHttpMethodToken(string httpMethod) + { + var apiDescription = new ApiDescription + { + HttpMethod = httpMethod + }; + + var result = apiDescription.GetHttpMethod(); + + Assert.Null(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetHttpMethod_ReturnsNullWhenApiDescriptionHasNoHttpMethod(string httpMethod) + { + var apiDescription = new ApiDescription + { + HttpMethod = httpMethod }; - // Act var result = apiDescription.GetHttpMethod(); - // Assert Assert.Null(result); } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 767a26b50d05..6e101884883f 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -156,6 +156,37 @@ } } }, + "/unsupported": { + "x-oai-additionalOperations": { + "FOO": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } + } + }, "/query-with-body": { "x-oai-additionalOperations": { "QUERY": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index b2d056690a11..c91d1790090c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -156,6 +156,37 @@ } } }, + "/unsupported": { + "x-oai-additionalOperations": { + "FOO": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } + } + }, "/query-with-body": { "x-oai-additionalOperations": { "QUERY": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 569657ee3707..cbd1509f948a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_2/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -154,6 +154,37 @@ } } }, + "/unsupported": { + "additionalOperations": { + "FOO": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } + } + }, "/query-with-body": { "query": { "tags": [ diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index a4b193822a4e..6b19265f6e69 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1654,6 +1654,37 @@ } } }, + "/unsupported": { + "x-oai-additionalOperations": { + "FOO": { + "tags": [ + "Test" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CurrentWeather" + } + } + } + } + } + } + } + }, "/query-with-body": { "x-oai-additionalOperations": { "QUERY": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs index 4ee5327fe3d0..bfa583b535cb 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.DependencyInjection; public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase { @@ -71,7 +73,46 @@ await VerifyOpenApiDocument(builder, document => }); } + [Fact] + public async Task CustomMethod_AppearsInDocumentForMvcAction() + { + var action = CreateActionDescriptor(nameof(ActionWithCustomMethod)); + + await VerifyOpenApiDocument(action, document => + { + var path = document.Paths["/api/custom"]; + Assert.True(path.Operations.ContainsKey(new HttpMethod("FOO"))); + Assert.DoesNotContain(HttpMethod.Post, path.Operations.Keys); + }); + } + + [Fact] + public async Task CustomMethod_IsVisitedByForEachOperationAsync() + { + var builder = CreateBuilder(); + var action = CreateActionDescriptor(nameof(ActionWithCustomMethod)); + var documentService = CreateDocumentService(builder, action); + using var scopedService = builder.ServiceProvider.CreateScope(); + var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider); + + await documentService.ForEachOperationAsync(document, (operation, context, cancellationToken) => + { + operation.Description = context.Description.HttpMethod; + return Task.CompletedTask; + }, CancellationToken.None); + + var operation = document.Paths["/api/custom"].Operations[new HttpMethod("FOO")]; + Assert.Equal("FOO", operation.Description); + } + #nullable enable private record TodoItem(int Id, string Title, bool Completed); #nullable restore + + [Route("/api/custom")] + [HttpFoo] + private ActionResult ActionWithCustomMethod() + => new OkObjectResult(new TodoItem(100, "Title", true)); + + private sealed class HttpFooAttribute() : HttpMethodAttribute(["FOO"]); }