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";