From c6bec9aa344cf908f4d844d77b9c692de68558e0 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Mon, 9 Mar 2026 11:00:59 -0400 Subject: [PATCH 1/8] Support HTTP QUERY --- .../Extensions/ApiDescriptionExtensions.cs | 1 + src/OpenApi/src/Services/OpenApiConstants.cs | 3 +- .../src/Services/OpenApiDocumentService.cs | 25 ++++++++------- src/OpenApi/src/Services/OpenApiGenerator.cs | 3 +- .../ApiDescriptionExtensionsTests.cs | 19 +++++++++++- ...ment_documentName=controllers.verified.txt | 31 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 31 +++++++++++++++++++ ...ment_documentName=controllers.verified.txt | 29 +++++++++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 31 +++++++++++++++++++ 9 files changed, 158 insertions(+), 15 deletions(-) diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index 3fdf20c32fbe..2d395ac17123 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -29,6 +29,7 @@ internal static class ApiDescriptionExtensions "HEAD" => HttpMethod.Head, "OPTIONS" => HttpMethod.Options, "TRACE" => HttpMethod.Trace, + "QUERY" => HttpMethod.Query, _ => null, }; diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs index d000587e81f1..47e1d67e53bc 100644 --- a/src/OpenApi/src/Services/OpenApiConstants.cs +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -31,7 +31,8 @@ internal static class OpenApiConstants HttpMethod.Options, HttpMethod.Head, HttpMethod.Patch, - HttpMethod.Trace + HttpMethod.Trace, + HttpMethod.Query ]; // Represents primitive types that should never be represented as // a schema reference and always inlined. diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 89a33513afa6..a8ccb8a135b9 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -91,6 +91,7 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop // Sort schemas by key name for better readability and consistency // This works around an API change in OpenAPI.NET document.Components.Schemas = new Dictionary( + document.Components.Schemas.OrderBy(kvp => kvp.Key), StringComparer.Ordinal); } @@ -418,7 +419,7 @@ private async Task GetResponseAsync( var response = new OpenApiResponse { Description = apiResponseType.Description ?? ReasonPhrases.GetReasonPhrase(statusCode), - Content = new Dictionary() + Content = new Dictionary () }; // ApiResponseFormats aggregates information about the supported response content types @@ -444,7 +445,7 @@ private async Task GetResponseAsync( // looks for when generating ApiResponseFormats above so we need to pull the content // types defined there separately. var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata - .OfType() + .OfType () .SelectMany(attr => attr.ContentTypes); foreach (var contentType in explicitContentTypes) { @@ -567,7 +568,7 @@ private async Task GetFormRequestBody( { // Assume "application/x-www-form-urlencoded" as the default media type // to match the default assumed in IFormFeature. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" } ]; } var requestBody = new OpenApiRequestBody @@ -576,7 +577,7 @@ private async Task GetFormRequestBody( // serializing a form collection from an empty body. Instead, requiredness // must be set on a per-parameter basis. See below. Required = true, - Content = new Dictionary() + Content = new Dictionary () }; var schema = new OpenApiSchema { Type = JsonSchemaType.Object, Properties = new Dictionary() }; @@ -633,9 +634,9 @@ private async Task GetFormRequestBody( // Resolve complex type state from endpoint metadata when checking for // minimal API types to use trim friendly code paths. var isComplexType = endpointMetadata - .OfType() - .SingleOrDefault(parameter => parameter.Name == description.Name)? - .HasTryParse == false; + .OfType () + .SingleOrDefault(parameter => parameter.Name == description.Name)?. + HasTryParse == false; if (hasMultipleFormParameters) { // Here and below: POCOs do not need to be need under their parameter name in the grouping. @@ -731,26 +732,26 @@ private async Task GetJsonRequestBody( { // Assume "application/octet-stream" as the default media type // for stream-based parameter types. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" } ]; } else if (bodyParameter.Type.IsJsonPatchDocument()) { // Assume "application/json-patch+json" as the default media type // for JSON Patch documents. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" }]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" } ]; } else { // Assume "application/json" as the default media type // for everything else. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" } ]; } } var requestBody = new OpenApiRequestBody { Required = IsRequired(bodyParameter), - Content = new Dictionary(), + Content = new Dictionary (), Description = GetParameterDescriptionFromAttribute(bodyParameter) }; @@ -782,7 +783,7 @@ private async Task GetJsonRequestBody( private static Type GetTargetType(ApiDescription description, ApiParameterDescription parameter) { var bindingMetadata = description.ActionDescriptor.EndpointMetadata - .OfType() + .OfType () .SingleOrDefault(metadata => metadata.Name == parameter.Name); var parameterType = parameter.Type is not null ? Nullable.GetUnderlyingType(parameter.Type) ?? parameter.Type diff --git a/src/OpenApi/src/Services/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs index db8a1ba35ec1..839909e3205c 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -84,12 +84,13 @@ private OpenApiOperation GetOperation(string httpMethod, MethodInfo methodInfo, static bool ShouldDisableInferredBody(string method) { - // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies + // GET, DELETE, HEAD, CONNECT, TRACE, QUERY and OPTIONS normally do not contain bodies return method.Equals(HttpMethods.Get, StringComparison.Ordinal) || method.Equals(HttpMethods.Delete, StringComparison.Ordinal) || method.Equals(HttpMethods.Head, StringComparison.Ordinal) || method.Equals(HttpMethods.Options, StringComparison.Ordinal) || method.Equals(HttpMethods.Trace, StringComparison.Ordinal) || + method.Equals(HttpMethods.Query, StringComparison.Ordinal) || method.Equals(HttpMethods.Connect, StringComparison.Ordinal); } } 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 e3bf473c6080..fa3e804c1b24 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs @@ -68,7 +68,8 @@ public static class HttpMethodTestData new object[] { "HEAD", HttpMethod.Head }, new object[] { "OPTIONS", HttpMethod.Options }, new object[] { "TRACE", HttpMethod.Trace }, - new object[] { "gEt", HttpMethod.Get }, // Test case-insensitivity + new object[] { "QUERY", HttpMethod.Query }, + new object[] { "gEt", HttpMethod.Get }, // Test case-insensitivity }; } @@ -88,4 +89,20 @@ public void GetHttpMethod_ReturnsHttpMethodForApiDescription(string httpMethod, // Assert Assert.Equal(expectedHttpMethod, result); } + + [Fact] + public void GetHttpMethod_ReturnsNullForUnsupportedMethod() + { + // Arrange - Test that unsupported HTTP methods return null + var apiDescription = new ApiDescription + { + HttpMethod = "UNSUPPORTED" + }; + + // 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 07aaa0b07c41..0272961487f1 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,37 @@ } } } + }, + "/query": { + "x-oai-additionalOperations": { + "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 36fa03d8e378..9df5521fd511 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,37 @@ } } } + }, + "/query": { + "x-oai-additionalOperations": { + "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_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 e37a837ab7d0..a45d550ae8c0 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 @@ -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 afe121791de1..3e55e2231cb7 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 @@ -1517,6 +1517,37 @@ } } } + }, + "/query": { + "x-oai-additionalOperations": { + "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": { From aeeaf9fbf6cad19ed6a68e58e290e6dced57bf34 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Mon, 9 Mar 2026 15:18:30 -0400 Subject: [PATCH 2/8] update comment to 3.2 --- src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index 2d395ac17123..55e8d3ed765b 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -20,7 +20,7 @@ internal static class ApiDescriptionExtensions public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription) => apiDescription.HttpMethod?.ToUpperInvariant() switch { - // Only add methods documented in the OpenAPI spec: https://spec.openapis.org/oas/v3.1.1.html#path-item-object + // 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, From 53970f3ca931cf13b3d06b4f686d16b154c28739 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Tue, 10 Mar 2026 08:32:02 -0400 Subject: [PATCH 3/8] fix spacing --- src/OpenApi/src/Services/OpenApiDocumentService.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index a8ccb8a135b9..f38cc09d2d7c 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -91,7 +91,6 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop // Sort schemas by key name for better readability and consistency // This works around an API change in OpenAPI.NET document.Components.Schemas = new Dictionary( - document.Components.Schemas.OrderBy(kvp => kvp.Key), StringComparer.Ordinal); } @@ -419,7 +418,7 @@ private async Task GetResponseAsync( var response = new OpenApiResponse { Description = apiResponseType.Description ?? ReasonPhrases.GetReasonPhrase(statusCode), - Content = new Dictionary () + Content = new Dictionary() }; // ApiResponseFormats aggregates information about the supported response content types @@ -568,7 +567,7 @@ private async Task GetFormRequestBody( { // Assume "application/x-www-form-urlencoded" as the default media type // to match the default assumed in IFormFeature. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" } ]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }]; } var requestBody = new OpenApiRequestBody @@ -635,8 +634,7 @@ private async Task GetFormRequestBody( // minimal API types to use trim friendly code paths. var isComplexType = endpointMetadata .OfType () - .SingleOrDefault(parameter => parameter.Name == description.Name)?. - HasTryParse == false; + .SingleOrDefault(parameter => parameter.Name == description.Name)?.HasTryParse == false; if (hasMultipleFormParameters) { // Here and below: POCOs do not need to be need under their parameter name in the grouping. From 7c2716044742cf85eda2a5f4294631f5aa04078f Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Tue, 10 Mar 2026 08:45:54 -0400 Subject: [PATCH 4/8] fix spacing --- src/OpenApi/src/Services/OpenApiDocumentService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index f38cc09d2d7c..4f926ee76833 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -444,7 +444,7 @@ private async Task GetResponseAsync( // looks for when generating ApiResponseFormats above so we need to pull the content // types defined there separately. var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata - .OfType () + .OfType() .SelectMany(attr => attr.ContentTypes); foreach (var contentType in explicitContentTypes) { @@ -576,7 +576,7 @@ private async Task GetFormRequestBody( // serializing a form collection from an empty body. Instead, requiredness // must be set on a per-parameter basis. See below. Required = true, - Content = new Dictionary () + Content = new Dictionary() }; var schema = new OpenApiSchema { Type = JsonSchemaType.Object, Properties = new Dictionary() }; @@ -633,7 +633,7 @@ private async Task GetFormRequestBody( // Resolve complex type state from endpoint metadata when checking for // minimal API types to use trim friendly code paths. var isComplexType = endpointMetadata - .OfType () + .OfType() .SingleOrDefault(parameter => parameter.Name == description.Name)?.HasTryParse == false; if (hasMultipleFormParameters) { @@ -749,7 +749,7 @@ private async Task GetJsonRequestBody( var requestBody = new OpenApiRequestBody { Required = IsRequired(bodyParameter), - Content = new Dictionary (), + Content = new Dictionary(), Description = GetParameterDescriptionFromAttribute(bodyParameter) }; @@ -781,7 +781,7 @@ private async Task GetJsonRequestBody( private static Type GetTargetType(ApiDescription description, ApiParameterDescription parameter) { var bindingMetadata = description.ActionDescriptor.EndpointMetadata - .OfType () + .OfType() .SingleOrDefault(metadata => metadata.Name == parameter.Name); var parameterType = parameter.Type is not null ? Nullable.GetUnderlyingType(parameter.Type) ?? parameter.Type From 811f19c5b85e7393c7e08de1cfdffd843178eb05 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Tue, 10 Mar 2026 09:36:01 -0400 Subject: [PATCH 5/8] fix spacing --- src/OpenApi/src/Services/OpenApiDocumentService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 4f926ee76833..17255f3bd31b 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -730,19 +730,19 @@ private async Task GetJsonRequestBody( { // Assume "application/octet-stream" as the default media type // for stream-based parameter types. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" } ]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/octet-stream" }]; } else if (bodyParameter.Type.IsJsonPatchDocument()) { // Assume "application/json-patch+json" as the default media type // for JSON Patch documents. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" } ]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json-patch+json" }]; } else { // Assume "application/json" as the default media type // for everything else. - supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" } ]; + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }]; } } From a0e4a3a19bb4a8da052308f2f309a600a5ab11cd Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Tue, 10 Mar 2026 14:26:14 -0400 Subject: [PATCH 6/8] revert --- src/OpenApi/src/Services/OpenApiDocumentService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 17255f3bd31b..89a33513afa6 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -634,7 +634,8 @@ private async Task GetFormRequestBody( // minimal API types to use trim friendly code paths. var isComplexType = endpointMetadata .OfType() - .SingleOrDefault(parameter => parameter.Name == description.Name)?.HasTryParse == false; + .SingleOrDefault(parameter => parameter.Name == description.Name)? + .HasTryParse == false; if (hasMultipleFormParameters) { // Here and below: POCOs do not need to be need under their parameter name in the grouping. From f2f32a7b0d1e5166dc50dbb02a62afa986ea7555 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Wed, 11 Mar 2026 10:06:17 -0400 Subject: [PATCH 7/8] QUERY support body --- .../sample/Controllers/TestController.cs | 5 + src/OpenApi/src/Services/OpenApiGenerator.cs | 3 +- ...ment_documentName=controllers.verified.txt | 51 +++++++++++ ...ment_documentName=controllers.verified.txt | 51 +++++++++++ ...ment_documentName=controllers.verified.txt | 49 ++++++++++ ...ifyOpenApiDocumentIsInvariant.verified.txt | 51 +++++++++++ ...OpenApiDocumentServiceTests.QueryMethod.cs | 91 +++++++++++++++++++ 7 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index 43a1e22250f9..93803212f4b3 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -54,6 +54,11 @@ public ActionResult HttpQueryMethod() public ActionResult UnsupportedHttpMethod() => Ok(new CurrentWeather(100)); + [HttpQuery] + [Route("/query-with-body")] + public ActionResult HttpQueryWithBodyMethod([FromBody] MvcTodo todo) + => Ok(todo); + public class HttpQuery() : HttpMethodAttribute(["QUERY"]); public class HttpFoo() : HttpMethodAttribute(["FOO"]); diff --git a/src/OpenApi/src/Services/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs index 839909e3205c..db8a1ba35ec1 100644 --- a/src/OpenApi/src/Services/OpenApiGenerator.cs +++ b/src/OpenApi/src/Services/OpenApiGenerator.cs @@ -84,13 +84,12 @@ private OpenApiOperation GetOperation(string httpMethod, MethodInfo methodInfo, static bool ShouldDisableInferredBody(string method) { - // GET, DELETE, HEAD, CONNECT, TRACE, QUERY and OPTIONS normally do not contain bodies + // GET, DELETE, HEAD, CONNECT, TRACE, and OPTIONS normally do not contain bodies return method.Equals(HttpMethods.Get, StringComparison.Ordinal) || method.Equals(HttpMethods.Delete, StringComparison.Ordinal) || method.Equals(HttpMethods.Head, StringComparison.Ordinal) || method.Equals(HttpMethods.Options, StringComparison.Ordinal) || method.Equals(HttpMethods.Trace, StringComparison.Ordinal) || - method.Equals(HttpMethods.Query, StringComparison.Ordinal) || method.Equals(HttpMethods.Connect, StringComparison.Ordinal); } } 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 0272961487f1..c148c897cdf2 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 @@ -155,6 +155,57 @@ } } } + }, + "/query-with-body": { + "x-oai-additionalOperations": { + "QUERY": { + "tags": [ + "Test" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } + } } }, "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 9df5521fd511..9677247fb9aa 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 @@ -155,6 +155,57 @@ } } } + }, + "/query-with-body": { + "x-oai-additionalOperations": { + "QUERY": { + "tags": [ + "Test" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } + } } }, "components": { 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 a45d550ae8c0..49a6f4a92768 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 @@ -153,6 +153,55 @@ } } } + }, + "/query-with-body": { + "query": { + "tags": [ + "Test" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } } }, "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 3e55e2231cb7..03d8d6e71007 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 @@ -1548,6 +1548,57 @@ } } } + }, + "/query-with-body": { + "x-oai-additionalOperations": { + "QUERY": { + "tags": [ + "Test" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MvcTodo" + } + } + } + } + } + } + } } }, "components": { 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 new file mode 100644 index 000000000000..d5cc91067647 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs @@ -0,0 +1,91 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task QueryMethod_AppearsInDocument() + { + // Arrange + var action = CreateActionDescriptor(nameof(ActionWithQueryMethod)); + + // Assert + await VerifyOpenApiDocument(action, document => + { + var path = Assert.Single(document.Paths.Values); + // Check that QUERY method operation exists + Assert.True(path.Operations.ContainsKey(HttpMethod.Query)); + var operation = path.Operations[HttpMethod.Query]; + Assert.NotNull(operation); + }); + } + + [Fact] + public async Task QueryMethod_SupportsRequestBody() + { + // Arrange + var action = CreateActionDescriptor(nameof(ActionWithQueryMethodAndBody)); + + // Assert + await VerifyOpenApiDocument(action, document => + { + var path = Assert.Single(document.Paths.Values); + Assert.True(path.Operations.ContainsKey(HttpMethod.Query)); + var operation = path.Operations[HttpMethod.Query]; + + // QUERY should support request bodies + Assert.NotNull(operation.RequestBody); + Assert.True(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json", content.Key); + }); + } + + [Fact] + public async Task QueryMethod_WithoutBody_StillWorks() + { + // Arrange - using minimal API approach for testing query parameters + var builder = CreateBuilder(); + + // Act + builder.MapMethods("/api/search", [HttpMethods.Query], (string query) => Results.Ok(query)); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var path = Assert.Single(document.Paths.Values); + Assert.True(path.Operations.ContainsKey(HttpMethod.Query)); + var operation = path.Operations[HttpMethod.Query]; + + // QUERY without body should have query parameters + Assert.Null(operation.RequestBody); + Assert.NotNull(operation.Parameters); + var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); + }); + } + + public class HttpQuery : HttpMethodAttribute + { + public HttpQuery() : base(["QUERY"]) { } + } + + [HttpQuery] + [Route("/query")] + private void ActionWithQueryMethod() { } + + [HttpQuery] + [Route("/query-with-body")] + private void ActionWithQueryMethodAndBody([FromBody] TodoItem todo) { } + +#nullable enable + private record TodoItem(int Id, string Title, bool Completed); +#nullable restore +} From df4b071c869cb9a2464ded92c6cf80922f6dbf0b Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Wed, 11 Mar 2026 10:38:14 -0400 Subject: [PATCH 8/8] refacto for consistency --- .../ApiDescriptionExtensionsTests.cs | 2 +- ...OpenApiDocumentServiceTests.QueryMethod.cs | 40 ++++++------------- 2 files changed, 14 insertions(+), 28 deletions(-) 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 fa3e804c1b24..91952fa9c278 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs @@ -69,7 +69,7 @@ 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 }, // Test case-insensitivity }; } 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 d5cc91067647..4ee5327fe3d0 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,7 +5,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Routing; public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase { @@ -13,13 +12,15 @@ public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBas public async Task QueryMethod_AppearsInDocument() { // Arrange - var action = CreateActionDescriptor(nameof(ActionWithQueryMethod)); + var builder = CreateBuilder(); + + // Act + builder.MapMethods("/api/search", [HttpMethods.Query], () => Results.Ok()); // Assert - await VerifyOpenApiDocument(action, document => + await VerifyOpenApiDocument(builder, document => { var path = Assert.Single(document.Paths.Values); - // Check that QUERY method operation exists Assert.True(path.Operations.ContainsKey(HttpMethod.Query)); var operation = path.Operations[HttpMethod.Query]; Assert.NotNull(operation); @@ -30,18 +31,18 @@ await VerifyOpenApiDocument(action, document => public async Task QueryMethod_SupportsRequestBody() { // Arrange - var action = CreateActionDescriptor(nameof(ActionWithQueryMethodAndBody)); + var builder = CreateBuilder(); + + // Act + builder.MapMethods("/api/search", [HttpMethods.Query], (TodoItem todo) => Results.Ok(todo)); // Assert - await VerifyOpenApiDocument(action, document => + await VerifyOpenApiDocument(builder, document => { var path = Assert.Single(document.Paths.Values); Assert.True(path.Operations.ContainsKey(HttpMethod.Query)); var operation = path.Operations[HttpMethod.Query]; - - // QUERY should support request bodies Assert.NotNull(operation.RequestBody); - Assert.True(operation.RequestBody.Required); Assert.NotNull(operation.RequestBody.Content); var content = Assert.Single(operation.RequestBody.Content); Assert.Equal("application/json", content.Key); @@ -49,9 +50,9 @@ await VerifyOpenApiDocument(action, document => } [Fact] - public async Task QueryMethod_WithoutBody_StillWorks() + public async Task QueryMethod_WithQueryParameters() { - // Arrange - using minimal API approach for testing query parameters + // Arrange var builder = CreateBuilder(); // Act @@ -63,8 +64,6 @@ await VerifyOpenApiDocument(builder, document => var path = Assert.Single(document.Paths.Values); Assert.True(path.Operations.ContainsKey(HttpMethod.Query)); var operation = path.Operations[HttpMethod.Query]; - - // QUERY without body should have query parameters Assert.Null(operation.RequestBody); Assert.NotNull(operation.Parameters); var parameter = Assert.Single(operation.Parameters); @@ -72,20 +71,7 @@ await VerifyOpenApiDocument(builder, document => }); } - public class HttpQuery : HttpMethodAttribute - { - public HttpQuery() : base(["QUERY"]) { } - } - - [HttpQuery] - [Route("/query")] - private void ActionWithQueryMethod() { } - - [HttpQuery] - [Route("/query-with-body")] - private void ActionWithQueryMethodAndBody([FromBody] TodoItem todo) { } - -#nullable enable + #nullable enable private record TodoItem(int Id, string Title, bool Completed); #nullable restore }