diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs
index d4bf012fc..517145667 100644
--- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs
+++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs
@@ -1,5 +1,4 @@
-
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Net.Http;
namespace Microsoft.OpenApi;
diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs
index 140f5e4df..b87c9079d 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs
@@ -792,6 +792,11 @@ public static class OpenApiConstants
///
public static readonly Version version2_0 = new(2, 0);
+ ///
+ /// Field: AdditionalOperations
+ ///
+ public const string AdditionalOperations = "additionalOperations";
+
///
/// Field: BasePath
///
diff --git a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs
index fe3322837..8260863d0 100644
--- a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs
+++ b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Net.Http;
namespace Microsoft.OpenApi
@@ -97,15 +98,18 @@ public virtual void SerializeAsV2(IOpenApiWriter writer)
// operations except "trace"
if (Operations != null)
{
- foreach (var operation in Operations)
+
+ foreach (var operation in Operations.Where(o => _standardHttp2MethodsNames.Contains(o.Key.Method, StringComparer.OrdinalIgnoreCase)))
{
- if (operation.Key != HttpMethod.Trace)
- {
- writer.WriteOptionalObject(
- operation.Key.Method.ToLowerInvariant(),
- operation.Value,
- (w, o) => o.SerializeAsV2(w));
- }
+ writer.WriteOptionalObject(
+ operation.Key.Method.ToLowerInvariant(),
+ operation.Value,
+ (w, o) => o.SerializeAsV2(w));
+ }
+ var nonStandardOperations = Operations.Where(o => !_standardHttp2MethodsNames.Contains(o.Key.Method, StringComparer.OrdinalIgnoreCase)).ToDictionary(static o => o.Key.Method, static o => o.Value);
+ if (nonStandardOperations.Count > 0)
+ {
+ writer.WriteRequiredMap($"x-oai-{OpenApiConstants.AdditionalOperations}", nonStandardOperations, (w, o) => o.SerializeAsV2(w));
}
}
@@ -126,6 +130,31 @@ public virtual void SerializeAsV2(IOpenApiWriter writer)
writer.WriteEndObject();
}
+ internal static readonly HashSet _standardHttp2MethodsNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "get",
+ "put",
+ "post",
+ "delete",
+ "options",
+ "head",
+ "patch",
+ };
+
+ internal static readonly HashSet _standardHttp30MethodsNames = new(_standardHttp2MethodsNames, StringComparer.OrdinalIgnoreCase)
+ {
+ "trace",
+ };
+
+ internal static readonly HashSet _standardHttp31MethodsNames = new(_standardHttp30MethodsNames, StringComparer.OrdinalIgnoreCase)
+ {
+ };
+
+ internal static readonly HashSet _standardHttp32MethodsNames = new(_standardHttp31MethodsNames, StringComparer.OrdinalIgnoreCase)
+ {
+ "query",
+ };
+
internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version,
Action callback)
{
@@ -139,16 +168,35 @@ internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersio
// description
writer.WriteProperty(OpenApiConstants.Description, Description);
+ var standardMethodsNames = version switch
+ {
+ OpenApiSpecVersion.OpenApi2_0 => _standardHttp2MethodsNames,
+ OpenApiSpecVersion.OpenApi3_0 => _standardHttp30MethodsNames,
+ OpenApiSpecVersion.OpenApi3_1 => _standardHttp31MethodsNames,
+ OpenApiSpecVersion.OpenApi3_2 or _ => _standardHttp32MethodsNames,
+ };
+
// operations
if (Operations != null)
{
- foreach (var operation in Operations)
+ foreach (var operation in Operations.Where(o => standardMethodsNames.Contains(o.Key.Method, StringComparer.OrdinalIgnoreCase)))
{
writer.WriteOptionalObject(
operation.Key.Method.ToLowerInvariant(),
operation.Value,
callback);
}
+ var nonStandardOperations = Operations.Where(o => !standardMethodsNames.Contains(o.Key.Method, StringComparer.OrdinalIgnoreCase)).ToDictionary(static o => o.Key.Method, static o => o.Value);
+ if (nonStandardOperations.Count > 0)
+ {
+ var additionalOperationsPropertyName = version switch
+ {
+ OpenApiSpecVersion.OpenApi2_0 or OpenApiSpecVersion.OpenApi3_0 or OpenApiSpecVersion.OpenApi3_1 =>
+ $"x-oai-{OpenApiConstants.AdditionalOperations}",
+ _ => OpenApiConstants.AdditionalOperations,
+ };
+ writer.WriteRequiredMap(additionalOperationsPropertyName, nonStandardOperations, (w, o) => o.SerializeAsV32(w));
+ }
}
// servers
diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs
index e5a4a7e5f..9b49d2c9e 100644
--- a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs
+++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs
@@ -1,4 +1,6 @@
-using System;
+using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Net.Http;
namespace Microsoft.OpenApi.Reader.V32
@@ -35,11 +37,33 @@ internal static partial class OpenApiV32Deserializer
#else
{"patch", (o, n, t) => o.AddOperation(new HttpMethod("PATCH"), LoadOperation(n, t))},
#endif
+ {"query", (o, n, t) => o.AddOperation(new HttpMethod("QUERY"), LoadOperation(n, t))},
{"trace", (o, n, t) => o.AddOperation(HttpMethod.Trace, LoadOperation(n, t))},
{"servers", (o, n, t) => o.Servers = n.CreateList(LoadServer, t)},
- {"parameters", (o, n, t) => o.Parameters = n.CreateList(LoadParameter, t)}
+ {"parameters", (o, n, t) => o.Parameters = n.CreateList(LoadParameter, t)},
+ {OpenApiConstants.AdditionalOperations, LoadAdditionalOperations }
};
+
+
+ private static void LoadAdditionalOperations(OpenApiPathItem o, ParseNode n, OpenApiDocument t)
+ {
+ if (n is null)
+ {
+ return;
+ }
+
+ var mapNode = n.CheckMapNode(OpenApiConstants.AdditionalOperations);
+
+ foreach (var property in mapNode.Where(p => !OpenApiPathItem._standardHttp32MethodsNames.Contains(p.Name)))
+ {
+ var operationType = property.Name;
+
+ var httpMethod = new HttpMethod(operationType);
+ o.AddOperation(httpMethod, LoadOperation(property.Value, t));
+ }
+ }
+
private static readonly PatternFieldMap _pathItemPatternFields =
new()
{
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs
new file mode 100644
index 000000000..48de4d075
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.V31Tests;
+
+public class OpenApiPathItemDeserializerTests
+{
+ private const string SampleFolderPath = "V31Tests/Samples/OpenApiPathItem/";
+
+ [Fact]
+ public async Task ExtraneousOperationsAreParsedAsExtensionsIn31()
+ {
+ // Arrange & Act
+ var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings);
+ var pathItem = result.Document.Paths["/pets"];
+
+ // Assert
+ Assert.Equal("Pet operations", pathItem.Summary);
+ Assert.Equal("Operations available for pets", pathItem.Description);
+
+ // Regular operations
+ var getOp = Assert.Contains(HttpMethod.Get, pathItem.Operations);
+ Assert.Equal("getPets", getOp.OperationId);
+
+ var postOp = Assert.Contains(HttpMethod.Post, pathItem.Operations);
+ Assert.Equal("createPet", postOp.OperationId);
+
+ // Query operation should now be on one of the operations
+ // Since the YAML structure changed, we need to check which operation has the query
+ Assert.DoesNotContain(new HttpMethod("Query"), pathItem.Operations);
+ Assert.DoesNotContain(new HttpMethod("notify"), pathItem.Operations);
+ Assert.DoesNotContain(new HttpMethod("subscribe"), pathItem.Operations);
+
+ var additionalPathsExt = Assert.IsType(Assert.Contains("x-oai-additionalOperations", pathItem.Extensions));
+
+ var additionalOpsNode = Assert.IsType(additionalPathsExt.Node);
+ Assert.Contains("query", additionalOpsNode);
+ Assert.Contains("notify", additionalOpsNode);
+ Assert.Contains("subscribe", additionalOpsNode);
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml
new file mode 100644
index 000000000..8e7db3731
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml
@@ -0,0 +1,77 @@
+openapi: 3.1.0
+info:
+ title: PathItem with Query and AdditionalOperations
+ version: 1.0.0
+paths:
+ /pets:
+ summary: Pet operations
+ description: Operations available for pets
+ get:
+ summary: Get pets
+ operationId: getPets
+ responses:
+ '200':
+ description: List of pets
+ x-oai-additionalOperations:
+ query:
+ summary: Query pets with complex filters
+ operationId: queryPets
+ parameters:
+ - name: filter
+ in: query
+ description: Complex filter expression
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Filtered pets
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ notify:
+ summary: Notify about pet updates
+ operationId: notifyPetUpdates
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ petId:
+ type: string
+ event:
+ type: string
+ responses:
+ '200':
+ description: Notification sent
+ subscribe:
+ summary: Subscribe to pet events
+ operationId: subscribePetEvents
+ parameters:
+ - name: events
+ in: query
+ description: Event types to subscribe to
+ schema:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ description: Subscription created
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ subscriptionId:
+ type: string
+ post:
+ summary: Create pet
+ operationId: createPet
+ responses:
+ '201':
+ description: Pet created
\ No newline at end of file
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs
new file mode 100644
index 000000000..66d2364c2
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.V32Tests;
+
+public class OpenApiPathItemDeserializerTests
+{
+ private const string SampleFolderPath = "V32Tests/Samples/OpenApiPathItem/";
+
+ [Fact]
+ public async Task ParsePathItemWithQueryAndAdditionalOperationsV32Works()
+ {
+ // Arrange & Act
+ var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings);
+ var pathItem = result.Document.Paths["/pets"];
+
+ // Assert
+ Assert.Equal("Pet operations", pathItem.Summary);
+ Assert.Equal("Operations available for pets", pathItem.Description);
+
+ // Regular operations
+ var getOp = Assert.Contains(HttpMethod.Get, pathItem.Operations);
+ Assert.Equal("getPets", getOp.OperationId);
+
+ var postOp = Assert.Contains(HttpMethod.Post, pathItem.Operations);
+ Assert.Equal("createPet", postOp.OperationId);
+
+ // Query operation should now be on one of the operations
+ // Since the YAML structure changed, we need to check which operation has the query
+ var queryOp = Assert.Contains(new HttpMethod("Query"), pathItem.Operations);
+ Assert.Equal("Query pets with complex filters", queryOp.Summary);
+ Assert.Equal("queryPets", queryOp.OperationId);
+ Assert.Single(queryOp.Parameters);
+ Assert.Equal("filter", queryOp.Parameters[0].Name);
+
+ var notifyOp = Assert.Contains(new HttpMethod("notify"), pathItem.Operations);
+ Assert.Equal("Notify about pet updates", notifyOp.Summary);
+ Assert.Equal("notifyPetUpdates", notifyOp.OperationId);
+ Assert.NotNull(notifyOp.RequestBody);
+
+ var subscribeOp = Assert.Contains(new HttpMethod("subscribe"), pathItem.Operations);
+ Assert.Equal("Subscribe to pet events", subscribeOp.Summary);
+ Assert.Equal("subscribePetEvents", subscribeOp.OperationId);
+ Assert.Single(subscribeOp.Parameters);
+ Assert.Equal("events", subscribeOp.Parameters[0].Name);
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml
new file mode 100644
index 000000000..83ba9c354
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml
@@ -0,0 +1,77 @@
+openapi: 3.2.0
+info:
+ title: PathItem with Query and AdditionalOperations
+ version: 1.0.0
+paths:
+ /pets:
+ summary: Pet operations
+ description: Operations available for pets
+ get:
+ summary: Get pets
+ operationId: getPets
+ responses:
+ '200':
+ description: List of pets
+ query:
+ summary: Query pets with complex filters
+ operationId: queryPets
+ parameters:
+ - name: filter
+ in: query
+ description: Complex filter expression
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Filtered pets
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ additionalOperations:
+ notify:
+ summary: Notify about pet updates
+ operationId: notifyPetUpdates
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ petId:
+ type: string
+ event:
+ type: string
+ responses:
+ '200':
+ description: Notification sent
+ subscribe:
+ summary: Subscribe to pet events
+ operationId: subscribePetEvents
+ parameters:
+ - name: events
+ in: query
+ description: Event types to subscribe to
+ schema:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ description: Subscription created
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ subscriptionId:
+ type: string
+ post:
+ summary: Create pet
+ operationId: createPet
+ responses:
+ '201':
+ description: Pet created
\ No newline at end of file
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs
new file mode 100644
index 000000000..c91f73df5
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license.
+
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.OpenApi.Readers.Tests.V3Tests;
+
+public class OpenApiPathItemDeserializerTests
+{
+ private const string SampleFolderPath = "V3Tests/Samples/OpenApiPathItem/";
+
+ [Fact]
+ public async Task ExtraneousOperationsAreParsedAsExtensionsIn30()
+ {
+ // Arrange & Act
+ var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithQueryAndAdditionalOperations.yaml", SettingsFixture.ReaderSettings);
+ var pathItem = result.Document.Paths["/pets"];
+
+ // Assert
+ Assert.Equal("Pet operations", pathItem.Summary);
+ Assert.Equal("Operations available for pets", pathItem.Description);
+
+ // Regular operations
+ var getOp = Assert.Contains(HttpMethod.Get, pathItem.Operations);
+ Assert.Equal("getPets", getOp.OperationId);
+
+ var postOp = Assert.Contains(HttpMethod.Post, pathItem.Operations);
+ Assert.Equal("createPet", postOp.OperationId);
+
+ // Query operation should now be on one of the operations
+ // Since the YAML structure changed, we need to check which operation has the query
+ Assert.DoesNotContain(new HttpMethod("Query"), pathItem.Operations);
+ Assert.DoesNotContain(new HttpMethod("notify"), pathItem.Operations);
+ Assert.DoesNotContain(new HttpMethod("subscribe"), pathItem.Operations);
+
+ var additionalPathsExt = Assert.IsType(Assert.Contains("x-oai-additionalOperations", pathItem.Extensions));
+
+ var additionalOpsNode = Assert.IsType(additionalPathsExt.Node);
+ Assert.Contains("query", additionalOpsNode);
+ Assert.Contains("notify", additionalOpsNode);
+ Assert.Contains("subscribe", additionalOpsNode);
+ }
+}
diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml
new file mode 100644
index 000000000..a91cef330
--- /dev/null
+++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml
@@ -0,0 +1,77 @@
+openapi: 3.0.0
+info:
+ title: PathItem with Query and AdditionalOperations
+ version: 1.0.0
+paths:
+ /pets:
+ summary: Pet operations
+ description: Operations available for pets
+ get:
+ summary: Get pets
+ operationId: getPets
+ responses:
+ '200':
+ description: List of pets
+ x-oai-additionalOperations:
+ query:
+ summary: Query pets with complex filters
+ operationId: queryPets
+ parameters:
+ - name: filter
+ in: query
+ description: Complex filter expression
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Filtered pets
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ notify:
+ summary: Notify about pet updates
+ operationId: notifyPetUpdates
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ petId:
+ type: string
+ event:
+ type: string
+ responses:
+ '200':
+ description: Notification sent
+ subscribe:
+ summary: Subscribe to pet events
+ operationId: subscribePetEvents
+ parameters:
+ - name: events
+ in: query
+ description: Event types to subscribe to
+ schema:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ description: Subscription created
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ subscriptionId:
+ type: string
+ post:
+ summary: Create pet
+ operationId: createPet
+ responses:
+ '201':
+ description: Pet created
\ No newline at end of file
diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs
index 5aa25fb60..5f76693a3 100644
--- a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs
+++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
using System.Collections.Generic;
@@ -400,4 +400,498 @@ public async Task SerializeAsV31JsonWorks()
// Then
Assert.True(JsonNode.DeepEquals(parsedExpectedJson, parsedActualJson));
}
+
+ [Fact]
+ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks()
+ {
+ var pathItem = new OpenApiPathItem
+ {
+ Summary = "summary",
+ Description = "description",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "get operation",
+ Description = "get description",
+ OperationId = "getOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "success"
+ }
+ }
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "query operation",
+ Description = "query description",
+ OperationId = "queryOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "query success"
+ }
+ }
+ },
+ [new HttpMethod("Notify")] = new OpenApiOperation
+ {
+ Summary = "notify operation",
+ Description = "notify description",
+ OperationId = "notifyOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "notify success"
+ }
+ }
+ },
+ [new HttpMethod("Custom")] = new OpenApiOperation
+ {
+ Summary = "custom operation",
+ Description = "custom description",
+ OperationId = "customOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "custom success"
+ }
+ },
+ }
+ }
+ };
+
+ var expectedJson =
+ """
+ {
+ "summary": "summary",
+ "description": "description",
+ "get": {
+ "summary": "get operation",
+ "description": "get description",
+ "operationId": "getOperation",
+ "responses": {
+ "200": {
+ "description": "success"
+ }
+ }
+ },
+ "query": {
+ "summary": "query operation",
+ "description": "query description",
+ "operationId": "queryOperation",
+ "responses": {
+ "200": {
+ "description": "query success"
+ }
+ }
+ },
+ "additionalOperations": {
+ "Notify": {
+ "summary": "notify operation",
+ "description": "notify description",
+ "operationId": "notifyOperation",
+ "responses": {
+ "200": {
+ "description": "notify success"
+ }
+ }
+ },
+ "Custom": {
+ "summary": "custom operation",
+ "description": "custom description",
+ "operationId": "customOperation",
+ "responses": {
+ "200": {
+ "description": "custom success"
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ var parsedExpectedJson = JsonNode.Parse(expectedJson);
+ // When
+ var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2);
+ var parsedActualJson = JsonNode.Parse(actualJson);
+
+ // Then
+ Assert.True(JsonNode.DeepEquals(parsedExpectedJson, parsedActualJson));
+ }
+
+ [Fact]
+ public async Task SerializeV32FeaturesAsExtensionsInV31Works()
+ {
+ var pathItem = new OpenApiPathItem
+ {
+ Summary = "summary",
+ Description = "description",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "get operation",
+ OperationId = "getOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "success"
+ }
+ }
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "query operation",
+ OperationId = "queryOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "query success"
+ }
+ }
+ },
+ [new HttpMethod("Notify")] = new OpenApiOperation
+ {
+ Summary = "notify operation",
+ OperationId = "notifyOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "notify success"
+ }
+ }
+ },
+
+ }
+ };
+
+ // When
+ var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1);
+ var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson));
+
+ // Then - should contain x-oai- prefixed extensions in the operation
+ var additionalOperationsElement = Assert.IsType(Assert.Contains("x-oai-additionalOperations", parsedActualJson));
+ var queryElement = Assert.IsType(Assert.Contains("Query", additionalOperationsElement));
+ Assert.Equal("query operation", queryElement["summary"]!.GetValue());
+ Assert.Equal("notify operation", additionalOperationsElement["Notify"]!["summary"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task SerializeV32FeaturesAsExtensionsInV3Works()
+ {
+ var pathItem = new OpenApiPathItem
+ {
+ Summary = "summary",
+ Description = "description",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "get operation",
+ OperationId = "getOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "success"
+ }
+ }
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "query operation",
+ OperationId = "queryOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "query success"
+ }
+ }
+ },
+ [new HttpMethod("Notify")] = new OpenApiOperation
+ {
+ Summary = "notify operation",
+ OperationId = "notifyOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "notify success"
+ }
+ }
+ }
+ },
+ };
+
+ // When
+ var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0);
+ var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson));
+
+ // Then - should contain x-oai- prefixed extensions in the operation
+ var additionalOperationsElement = Assert.IsType(Assert.Contains("x-oai-additionalOperations", parsedActualJson));
+ var queryElement = Assert.IsType(Assert.Contains("Query", additionalOperationsElement));
+ Assert.Equal("query operation", queryElement["summary"]!.GetValue());
+ Assert.Equal("notify operation", additionalOperationsElement["Notify"]!["summary"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task SerializeV32FeaturesAsExtensionsInV2Works()
+ {
+ var pathItem = new OpenApiPathItem
+ {
+ Summary = "summary",
+ Description = "description",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "get operation",
+ OperationId = "getOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "success"
+ }
+ },
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "query operation",
+ OperationId = "queryOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "query success"
+ }
+ }
+ },
+ [new HttpMethod("Notify")] = new OpenApiOperation
+ {
+ Summary = "notify operation",
+ OperationId = "notifyOperation",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse
+ {
+ Description = "notify success"
+ }
+ }
+ }
+ }
+ };
+
+ // When
+ var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0);
+ var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson));
+
+ // Then - should contain x-oai- prefixed extensions in the operation
+ var additionalOperationsElement = Assert.IsType(Assert.Contains("x-oai-additionalOperations", parsedActualJson));
+ var queryElement = Assert.IsType(Assert.Contains("Query", additionalOperationsElement));
+ Assert.Equal("query operation", queryElement["summary"]!.GetValue());
+ Assert.Equal("notify operation", additionalOperationsElement["Notify"]!["summary"]!.GetValue());
+ }
+
+ [Fact]
+ public void CopyConstructorCopiesQueryAndAdditionalOperations()
+ {
+ // Arrange
+ var original = new OpenApiPathItem
+ {
+ Summary = "summary",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "get operation",
+ OperationId = "getOperation",
+ Responses = new OpenApiResponses()
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "query operation",
+ OperationId = "queryOperation"
+ },
+ [new HttpMethod("Notify")] = new OpenApiOperation
+ {
+ Summary = "notify operation",
+ OperationId = "notifyOperation"
+ }
+ }
+ };
+
+ // Act
+ var copy = new OpenApiPathItem(original);
+
+ // Assert
+ Assert.NotNull(original.Operations);
+ Assert.NotNull(copy.Operations);
+ Assert.Contains(HttpMethod.Get, original.Operations);
+ Assert.Contains(HttpMethod.Get, copy.Operations);
+
+ var copyQueryOp = Assert.Contains(new HttpMethod("Query"), copy.Operations);
+ var originalQueryOp = Assert.Contains(new HttpMethod("Query"), original.Operations);
+ Assert.Equal(originalQueryOp.Summary, copyQueryOp.Summary);
+ Assert.Equal(originalQueryOp.OperationId, copyQueryOp.OperationId);
+
+ var originalNotifyOp = Assert.Contains(new HttpMethod("Notify"), original.Operations);
+ var copyNotifyOp = Assert.Contains(new HttpMethod("Notify"), copy.Operations);
+
+ Assert.Equal(originalNotifyOp.Summary, copyNotifyOp.Summary);
+ }
+
+ [Fact]
+ public async Task SerializeV32PathItemToV31ProducesExtensions()
+ {
+ // Arrange
+ var pathItem = new OpenApiPathItem
+ {
+ Summary = "Test path",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "Get operation",
+ OperationId = "getOp",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "Query operation",
+ OperationId = "queryOp",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ },
+ [new HttpMethod("notify")] = new OpenApiOperation
+ {
+ Summary = "Notify operation",
+ OperationId = "notifyOp",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ }
+ }
+ };
+
+ // Act
+ var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1);
+
+ // Assert
+ Assert.Contains("Query:", yaml);
+ Assert.Contains("x-oai-additionalOperations:", yaml);
+ Assert.Contains("queryOp", yaml);
+ Assert.Contains("notifyOp", yaml);
+ }
+
+ [Fact]
+ public async Task SerializeV32PathItemToV3ProducesExtensions()
+ {
+ // Arrange
+ var pathItem = new OpenApiPathItem
+ {
+ Summary = "Test path",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "Get operation",
+ OperationId = "getOp",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "Query operation",
+ OperationId = "queryOp",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ },
+ [new HttpMethod("notify")] = new OpenApiOperation
+ {
+ Summary = "Notify operation",
+ OperationId = "notifyOp",
+ Responses = new OpenApiResponses
+ {
+ ["200"] = new OpenApiResponse { Description = "Success" }
+ }
+ }
+ }
+ };
+
+ // Act
+ var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0);
+
+ // Assert
+ Assert.Contains("Query:", yaml);
+ Assert.Contains("x-oai-additionalOperations:", yaml);
+ Assert.Contains("queryOp", yaml);
+ Assert.Contains("notifyOp", yaml);
+ }
+
+ [Fact]
+ public void PathItemShallowCopyIncludesV32Fields()
+ {
+ // Arrange
+ var original = new OpenApiPathItem
+ {
+ Summary = "Original",
+ Operations = new Dictionary
+ {
+ [HttpMethod.Get] = new OpenApiOperation
+ {
+ Summary = "Get operation",
+ OperationId = "getOp",
+ Responses = new OpenApiResponses()
+ },
+ [new HttpMethod("Query")] = new OpenApiOperation
+ {
+ Summary = "Query operation",
+ OperationId = "queryOp"
+ },
+ [new HttpMethod("notify")] = new OpenApiOperation
+ {
+ Summary = "Notify operation",
+ OperationId = "notifyOp"
+ }
+ }
+ };
+
+ // Act
+ var copy = original.CreateShallowCopy();
+
+ // Assert
+ Assert.NotNull(original.Operations);
+ Assert.NotNull(copy.Operations);
+ Assert.Contains(HttpMethod.Get, original.Operations);
+ Assert.Contains(HttpMethod.Get, copy.Operations);
+
+ var copyQueryOp = Assert.Contains(new HttpMethod("Query"), copy.Operations);
+ Assert.Contains(new HttpMethod("Query"), original.Operations);
+ Assert.Equal("Query operation", copyQueryOp.Summary);
+ Assert.Equal("queryOp", copyQueryOp.OperationId);
+ Assert.Contains(new HttpMethod("notify"), original.Operations);
+ var copyNotifyOp = Assert.Contains(new HttpMethod("notify"), copy.Operations);
+ Assert.Equal("Notify operation", copyNotifyOp.Summary);
+ Assert.Equal("notifyOp", copyNotifyOp.OperationId);
+ }
}
diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt
index 88d8332c5..ab6f8d89b 100644
--- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt
+++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt
@@ -434,6 +434,7 @@ namespace Microsoft.OpenApi
public static class OpenApiConstants
{
public const string AccessCode = "accessCode";
+ public const string AdditionalOperations = "additionalOperations";
public const string AdditionalProperties = "additionalProperties";
public const string AllOf = "allOf";
public const string AllowEmptyValue = "allowEmptyValue";