From 913fb5a4839b76291f4e2c9d9e75a17393c1d1b7 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 Aug 2025 12:40:16 +0100 Subject: [PATCH 1/2] [OpenApi] Ignore unknown HTTP methods Exclude unsupported HTTP methods from the OpenAPI document, rather than throwing an exception. Resolves #60914. --- .../sample/Controllers/TestController.cs | 19 +++++++++++++++++++ .../Extensions/ApiDescriptionExtensions.cs | 6 +++--- .../src/Services/OpenApiDocumentService.cs | 16 ++++++++++++++-- .../ApiDescriptionExtensionsTests.cs | 16 ---------------- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 79784263fb0f..623ef7593c2d 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; [ApiController] [Route("[controller]")] @@ -39,6 +40,24 @@ public Ok GetCurrentWeather() return TypedResults.Ok(new CurrentWeather(1.0f)); } + [Route("/nohttpmethod")] + public ActionResult NoHttpMethod() + => Ok(new CurrentWeather(-100)); + + [HttpQuery] // See https://github.com/dotnet/aspnetcore/issues/61089 + [Route("/query")] + public ActionResult HttpQueryMethod() + => Ok(new CurrentWeather(0)); + + [HttpFoo] // See https://github.com/dotnet/aspnetcore/issues/60914 + [Route("/unsupported")] + public ActionResult UnsupportedHttpMethod() + => Ok(new CurrentWeather(100)); + + public class HttpQuery() : HttpMethodAttribute(["QUERY"]); + + public class HttpFoo() : HttpMethodAttribute(["FOO"]); + public class RouteParamsContainer { [FromRoute] diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index eab68b3dfb56..d686f2ece768 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -16,8 +16,8 @@ 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 . - public static HttpMethod GetHttpMethod(this ApiDescription apiDescription) => + /// The associated with the given , if known. + public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription) => apiDescription.HttpMethod?.ToUpperInvariant() switch { "GET" => HttpMethod.Get, @@ -28,7 +28,7 @@ public static HttpMethod GetHttpMethod(this ApiDescription apiDescription) => "HEAD" => HttpMethod.Head, "OPTIONS" => HttpMethod.Options, "TRACE" => HttpMethod.Trace, - _ => throw new InvalidOperationException($"Unsupported HTTP method: {apiDescription.HttpMethod}"), + _ => null, }; /// diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index d8676530f00e..33bd9d5fc02a 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -250,8 +250,13 @@ private async Task GetOpenApiPathsAsync( foreach (var descriptions in descriptionsByPath) { Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null."); - paths.Add(descriptions.Key, new OpenApiPathItem { Operations = await GetOperationsAsync(descriptions, document, scopedServiceProvider, operationTransformers, schemaTransformers, cancellationToken) }); + var operations = await GetOperationsAsync(descriptions, document, scopedServiceProvider, operationTransformers, schemaTransformers, cancellationToken); + if (operations.Count > 0) + { + paths.Add(descriptions.Key, new OpenApiPathItem { Operations = operations }); + } } + return paths; } @@ -280,7 +285,14 @@ private async Task> GetOperationsAsync( }; _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, operationContext); - operations[description.GetHttpMethod()] = operation; + + if (description.GetHttpMethod() is not { } method) + { + // Skip unsupported HTTP methods + continue; + } + + operations[method] = operation; // Use index-based for loop to avoid allocating an enumerator with a foreach. for (var i = 0; i < operationTransformers.Length; i++) 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 d016dcf0ca25..0fbbce9ac27a 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs @@ -63,20 +63,4 @@ public void GetHttpMethod_ReturnsHttpMethodForApiDescription(string httpMethod, // Assert Assert.Equal(expectedHttpMethod, result); } - - [Theory] - [InlineData("UNKNOWN")] - [InlineData("unknown")] - public void GetHttpMethod_ThrowsForUnknownHttpMethod(string methodName) - { - // Arrange - var apiDescription = new ApiDescription - { - HttpMethod = methodName - }; - - // Act & Assert - var exception = Assert.Throws(() => apiDescription.GetHttpMethod()); - Assert.Equal($"Unsupported HTTP method: {methodName}", exception.Message); - } } From a3aa1f2fe103b8132df8be3c47218f305098e13f Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 Aug 2025 18:11:48 +0100 Subject: [PATCH 2/2] [OpenApi] Support HTTP QUERY Support generating OpenAPI documentation for HTTP QUERY endpoints. Contributes to #61089. --- .../sample/Controllers/TestController.cs | 2 +- .../Extensions/ApiDescriptionExtensions.cs | 1 + ...ment_documentName=controllers.verified.txt | 29 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 29 +++++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 29 +++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 623ef7593c2d..c3c011c61385 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -44,7 +44,7 @@ public Ok GetCurrentWeather() public ActionResult NoHttpMethod() => Ok(new CurrentWeather(-100)); - [HttpQuery] // See https://github.com/dotnet/aspnetcore/issues/61089 + [HttpQuery] [Route("/query")] public ActionResult HttpQueryMethod() => Ok(new CurrentWeather(0)); diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index d686f2ece768..20c19c02a258 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -28,6 +28,7 @@ internal static class ApiDescriptionExtensions "HEAD" => HttpMethod.Head, "OPTIONS" => HttpMethod.Options, "TRACE" => HttpMethod.Trace, + "QUERY" => HttpMethod.Query, _ => null, }; 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 5728ecec9c3c..5f9f4d1dd9e6 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 @@ -124,6 +124,35 @@ } } } + }, + "/query": { + "query": { + "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" + } + } + } + } + } + } } }, "components": { 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 b6e1a4692d89..f41a4ddb6a43 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 @@ -124,6 +124,35 @@ } } } + }, + "/query": { + "query": { + "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" + } + } + } + } + } + } } }, "components": { 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 2567b12e4e02..fd95e9bd6240 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 @@ -1233,6 +1233,35 @@ } } } + }, + "/query": { + "query": { + "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" + } + } + } + } + } + } } }, "components": {