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/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index 3fdf20c32fbe..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, @@ -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/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs index e3bf473c6080..91952fa9c278 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Extensions/ApiDescriptionExtensionsTests.cs @@ -68,6 +68,7 @@ public static class HttpMethodTestData new object[] { "HEAD", HttpMethod.Head }, new object[] { "OPTIONS", HttpMethod.Options }, new object[] { "TRACE", HttpMethod.Trace }, + 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..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 @@ -124,6 +124,88 @@ } } } + }, + "/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" + } + } + } + } + } + } + } + }, + "/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 36fa03d8e378..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 @@ -124,6 +124,88 @@ } } } + }, + "/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" + } + } + } + } + } + } + } + }, + "/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 e37a837ab7d0..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 @@ -124,6 +124,84 @@ } } } + }, + "/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" + } + } + } + } + } + } + }, + "/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 afe121791de1..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 @@ -1517,6 +1517,88 @@ } } } + }, + "/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" + } + } + } + } + } + } + } + }, + "/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..4ee5327fe3d0 --- /dev/null +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.QueryMethod.cs @@ -0,0 +1,77 @@ +// 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; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task QueryMethod_AppearsInDocument() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapMethods("/api/search", [HttpMethods.Query], () => Results.Ok()); + + // 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]; + Assert.NotNull(operation); + }); + } + + [Fact] + public async Task QueryMethod_SupportsRequestBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapMethods("/api/search", [HttpMethods.Query], (TodoItem todo) => Results.Ok(todo)); + + // 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]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json", content.Key); + }); + } + + [Fact] + public async Task QueryMethod_WithQueryParameters() + { + // Arrange + 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]; + Assert.Null(operation.RequestBody); + Assert.NotNull(operation.Parameters); + var parameter = Assert.Single(operation.Parameters); + Assert.Equal(ParameterLocation.Query, parameter.In); + }); + } + + #nullable enable + private record TodoItem(int Id, string Title, bool Completed); +#nullable restore +}