From 93d8a4797f379873b6d7d35cbfeea815218a3101 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Wed, 1 Oct 2025 11:09:43 -0400 Subject: [PATCH 01/16] add new v32 properties for Path Items --- .../Models/Interfaces/IOpenApiPathItem.cs | 14 +- .../Models/OpenApiConstants.cs | 10 + .../Models/OpenApiPathItem.cs | 55 +++- .../References/OpenApiPathItemReference.cs | 6 + .../Reader/V3/OpenApiPathItemDeserializer.cs | 2 + .../Reader/V31/OpenApiPathItemDeserializer.cs | 2 + .../Reader/V32/OpenApiPathItemDeserializer.cs | 6 +- .../V32Tests/OpenApiPathItemTests.cs | 205 ++++++++++++ ...hItemWithQueryAndAdditionalOperations.yaml | 77 +++++ .../pathItemWithV32Extensions.yaml | 27 ++ .../Models/OpenApiPathItemTests.cs | 308 +++++++++++++++++- .../PublicApi/PublicApi.approved.txt | 8 + 12 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs index d4bf012fc..2d3e3a3e7 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; @@ -25,4 +24,15 @@ public interface IOpenApiPathItem : IOpenApiDescribedElement, IOpenApiSummarized /// These parameters can be overridden at the operation level, but cannot be removed there. /// public IList? Parameters { get; } + + /// + /// Gets the query operation for this path (OpenAPI 3.2). + /// + public OpenApiOperation? Query { get; } + + /// + /// Gets the additional operations for this path (OpenAPI 3.2). + /// A map of additional operations that are not one of the standard HTTP methods (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE). + /// + public IDictionary? AdditionalOperations { get; } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index a1a9d5fdd..84a6c9392 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -205,6 +205,16 @@ public static class OpenApiConstants /// public const string RequestBody = "requestBody"; + /// + /// Field: Query (OpenAPI 3.2) + /// + public const string Query = "query"; + + /// + /// Field: AdditionalOperations (OpenAPI 3.2) + /// + public const string AdditionalOperations = "additionalOperations"; + /// /// Field: ExtensionFieldNamePrefix /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs index fe3322837..54a3f6fb8 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs @@ -27,6 +27,12 @@ public class OpenApiPathItem : IOpenApiExtensible, IOpenApiPathItem /// public IList? Parameters { get; set; } + /// + public OpenApiOperation? Query { get; set; } + + /// + public IDictionary? AdditionalOperations { get; set; } + /// public IDictionary? Extensions { get; set; } @@ -57,6 +63,8 @@ internal OpenApiPathItem(IOpenApiPathItem pathItem) Operations = pathItem.Operations != null ? new Dictionary(pathItem.Operations) : null; Servers = pathItem.Servers != null ? [.. pathItem.Servers] : null; Parameters = pathItem.Parameters != null ? [.. pathItem.Parameters] : null; + Query = pathItem.Query != null ? new OpenApiOperation(pathItem.Query) : null; + AdditionalOperations = pathItem.AdditionalOperations != null ? new Dictionary(pathItem.AdditionalOperations) : null; Extensions = pathItem.Extensions != null ? new Dictionary(pathItem.Extensions) : null; } @@ -73,7 +81,7 @@ public virtual void SerializeAsV32(IOpenApiWriter writer) /// public virtual void SerializeAsV31(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer)); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer), downgradeFrom32: true); } /// @@ -81,7 +89,7 @@ public virtual void SerializeAsV31(IOpenApiWriter writer) /// public virtual void SerializeAsV3(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer), downgradeFrom32: true); } /// @@ -120,6 +128,9 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) OpenApiConstants.ExtensionFieldNamePrefix + OpenApiConstants.Description, Description); + // Write Query and AdditionalOperations as extensions when downgrading to v2 + WriteV32FieldsAsExtensions(writer); + // specification extensions writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); @@ -127,7 +138,7 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) } internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, - Action callback) + Action callback, bool downgradeFrom32 = false) { Utils.CheckArgumentNull(writer); @@ -151,6 +162,21 @@ internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersio } } + // OpenAPI 3.2 specific fields + if (version == OpenApiSpecVersion.OpenApi3_2) + { + // query operation + writer.WriteOptionalObject(OpenApiConstants.Query, Query, callback); + + // additional operations + writer.WriteOptionalMap(OpenApiConstants.AdditionalOperations, AdditionalOperations, callback); + } + else if (downgradeFrom32) + { + // When downgrading from 3.2 to 3.1/3.0, serialize as extensions + WriteV32FieldsAsExtensions(writer); + } + // servers writer.WriteOptionalCollection(OpenApiConstants.Servers, Servers, callback); @@ -163,6 +189,29 @@ internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersio writer.WriteEndObject(); } + /// + /// Writes OpenAPI 3.2 specific fields as extensions when downgrading to older versions + /// + private void WriteV32FieldsAsExtensions(IOpenApiWriter writer) + { + if (Query != null) + { + writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.Query); + Query.SerializeAsV31(writer); + } + + if (AdditionalOperations != null && AdditionalOperations.Count > 0) + { + writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.AdditionalOperations); + writer.WriteStartObject(); + foreach (var kvp in AdditionalOperations) + { + writer.WriteOptionalObject(kvp.Key, kvp.Value, (w, o) => o.SerializeAsV31(w)); + } + writer.WriteEndObject(); + } + } + /// public IOpenApiPathItem CreateShallowCopy() { diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs index 291c75308..c16da6f6d 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs @@ -58,6 +58,12 @@ public string? Description /// public IList? Parameters { get => Target?.Parameters; } + /// + public OpenApiOperation? Query { get => Target?.Query; } + + /// + public IDictionary? AdditionalOperations { get => Target?.AdditionalOperations; } + /// public IDictionary? Extensions { get => Target?.Extensions; } diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs index 45f525484..fb13edabf 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs @@ -41,6 +41,8 @@ internal static partial class OpenApiV3Deserializer private static readonly PatternFieldMap _pathItemPatternFields = new() { + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))} }; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs index 892c1e8a1..bc446f911 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs @@ -43,6 +43,8 @@ internal static partial class OpenApiV31Deserializer private static readonly PatternFieldMap _pathItemPatternFields = new() { + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))} }; diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs index e5a4a7e5f..befb24d4d 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; namespace Microsoft.OpenApi.Reader.V32 @@ -36,8 +36,10 @@ internal static partial class OpenApiV32Deserializer {"patch", (o, n, t) => o.AddOperation(new HttpMethod("PATCH"), LoadOperation(n, t))}, #endif {"trace", (o, n, t) => o.AddOperation(HttpMethod.Trace, LoadOperation(n, t))}, + {"query", (o, n, t) => o.Query = 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)}, + {"additionalOperations", (o, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)} }; private static readonly PatternFieldMap _pathItemPatternFields = diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs new file mode 100644 index 000000000..91feba329 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs @@ -0,0 +1,205 @@ +// 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 FluentAssertions; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.Tests; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V32Tests +{ + public class OpenApiPathItemTests + { + 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 + Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); + Assert.Equal("getPets", pathItem.Operations[HttpMethod.Get].OperationId); + Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Post)); + Assert.Equal("createPet", pathItem.Operations[HttpMethod.Post].OperationId); + + // Query operation + Assert.NotNull(pathItem.Query); + Assert.Equal("Query pets with complex filters", pathItem.Query.Summary); + Assert.Equal("queryPets", pathItem.Query.OperationId); + Assert.Single(pathItem.Query.Parameters); + Assert.Equal("filter", pathItem.Query.Parameters[0].Name); + + // Additional operations + Assert.NotNull(pathItem.AdditionalOperations); + Assert.Equal(2, pathItem.AdditionalOperations.Count); + + Assert.True(pathItem.AdditionalOperations.ContainsKey("notify")); + var notifyOp = pathItem.AdditionalOperations["notify"]; + Assert.Equal("Notify about pet updates", notifyOp.Summary); + Assert.Equal("notifyPetUpdates", notifyOp.OperationId); + Assert.NotNull(notifyOp.RequestBody); + + Assert.True(pathItem.AdditionalOperations.ContainsKey("subscribe")); + var subscribeOp = pathItem.AdditionalOperations["subscribe"]; + Assert.Equal("Subscribe to pet events", subscribeOp.Summary); + Assert.Equal("subscribePetEvents", subscribeOp.OperationId); + Assert.Single(subscribeOp.Parameters); + Assert.Equal("events", subscribeOp.Parameters[0].Name); + } + + [Fact] + public async Task ParsePathItemWithV32ExtensionsWorks() + { + // Arrange & Act + var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithV32Extensions.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 + Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); + Assert.Equal("getPets", pathItem.Operations[HttpMethod.Get].OperationId); + + // Query operation from extension + Assert.NotNull(pathItem.Query); + Assert.Equal("Query pets with complex filters", pathItem.Query.Summary); + Assert.Equal("queryPets", pathItem.Query.OperationId); + + // Additional operations from extension + Assert.NotNull(pathItem.AdditionalOperations); + Assert.Single(pathItem.AdditionalOperations); + Assert.True(pathItem.AdditionalOperations.ContainsKey("notify")); + var notifyOp = pathItem.AdditionalOperations["notify"]; + Assert.Equal("Notify about pet updates", notifyOp.Summary); + Assert.Equal("notifyPetUpdates", notifyOp.OperationId); + } + + [Fact] + public async Task SerializeV32PathItemToV31ProducesExtensions() + { + // Arrange + var pathItem = new OpenApiPathItem + { + Summary = "Test path", + Query = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + AdditionalOperations = new Dictionary + { + ["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("x-oas-query:", yaml); + Assert.Contains("x-oas-additionalOperations:", yaml); + Assert.Contains("queryOp", yaml); + Assert.Contains("notifyOp", yaml); + } + + [Fact] + public async Task SerializeV32PathItemToV3ProducesExtensions() + { + // Arrange + var pathItem = new OpenApiPathItem + { + Summary = "Test path", + Query = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + AdditionalOperations = new Dictionary + { + ["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("x-oas-query:", yaml); + Assert.Contains("x-oas-additionalOperations:", yaml); + Assert.Contains("queryOp", yaml); + Assert.Contains("notifyOp", yaml); + } + + [Fact] + public void PathItemShallowCopyIncludesV32Fields() + { + // Arrange + var original = new OpenApiPathItem + { + Summary = "Original", + Query = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp" + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp" + } + } + }; + + // Act + var copy = original.CreateShallowCopy(); + + // Assert + Assert.NotNull(copy.Query); + Assert.Equal("Query operation", copy.Query.Summary); + Assert.Equal("queryOp", copy.Query.OperationId); + + Assert.NotNull(copy.AdditionalOperations); + Assert.Single(copy.AdditionalOperations); + Assert.Equal("Notify operation", copy.AdditionalOperations["notify"].Summary); + Assert.Equal("notifyOp", copy.AdditionalOperations["notify"].OperationId); + } + } +} 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..134dbaabf --- /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 + post: + summary: Create pet + operationId: createPet + responses: + '201': + description: Pet created + 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 \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml new file mode 100644 index 000000000..78784d6bc --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: PathItem with V32 Extensions + 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-oas-query: + summary: Query pets with complex filters + operationId: queryPets + responses: + '200': + description: Filtered pets + x-oas-additionalOperations: + notify: + summary: Notify about pet updates + operationId: notifyPetUpdates + responses: + '200': + description: Notification sent \ 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 887d33893..7b549491c 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,310 @@ 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" + } + } + } + }, + Query = new OpenApiOperation + { + Summary = "query operation", + Description = "query description", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + Description = "notify description", + OperationId = "notifyOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "notify success" + } + } + }, + ["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", + Query = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["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 = JsonNode.Parse(actualJson); + + // Then - should contain x-oas- prefixed extensions + Assert.True(parsedActualJson!["x-oas-query"] != null); + Assert.True(parsedActualJson!["x-oas-additionalOperations"] != null); + Assert.Equal("query operation", parsedActualJson!["x-oas-query"]!["summary"]!.GetValue()); + Assert.Equal("notify operation", parsedActualJson!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + } + + [Fact] + public async Task SerializeV32FeaturesAsExtensionsInV3Works() + { + var pathItem = new OpenApiPathItem + { + Summary = "summary", + Description = "description", + Query = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["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 = JsonNode.Parse(actualJson); + + // Then - should contain x-oas- prefixed extensions + Assert.True(parsedActualJson!["x-oas-query"] != null); + Assert.True(parsedActualJson!["x-oas-additionalOperations"] != null); + Assert.Equal("query operation", parsedActualJson!["x-oas-query"]!["summary"]!.GetValue()); + Assert.Equal("notify operation", parsedActualJson!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + } + + [Fact] + public async Task SerializeV32FeaturesAsExtensionsInV2Works() + { + var pathItem = new OpenApiPathItem + { + Summary = "summary", + Description = "description", + Query = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["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 = JsonNode.Parse(actualJson); + + // Then - should contain x-oas- prefixed extensions + Assert.True(parsedActualJson!["x-oas-query"] != null); + Assert.True(parsedActualJson!["x-oas-additionalOperations"] != null); + Assert.Equal("query operation", parsedActualJson!["x-oas-query"]!["summary"]!.GetValue()); + Assert.Equal("notify operation", parsedActualJson!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + } + + [Fact] + public void CopyConstructorCopiesQueryAndAdditionalOperations() + { + // Arrange + var original = new OpenApiPathItem + { + Summary = "summary", + Query = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation" + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation" + } + } + }; + + // Act + var copy = new OpenApiPathItem(original); + + // Assert + Assert.NotNull(copy.Query); + Assert.Equal(original.Query.Summary, copy.Query.Summary); + Assert.Equal(original.Query.OperationId, copy.Query.OperationId); + + Assert.NotNull(copy.AdditionalOperations); + Assert.Equal(original.AdditionalOperations.Count, copy.AdditionalOperations.Count); + Assert.Equal(original.AdditionalOperations["notify"].Summary, copy.AdditionalOperations["notify"].Summary); + + // Verify it's a deep copy + copy.Query.Summary = "modified"; + Assert.NotEqual(original.Query.Summary, copy.Query.Summary); + } } diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index a0deaacfb..9f5ac35e3 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -165,8 +165,10 @@ namespace Microsoft.OpenApi } public interface IOpenApiPathItem : Microsoft.OpenApi.IOpenApiDescribedElement, Microsoft.OpenApi.IOpenApiElement, Microsoft.OpenApi.IOpenApiReadOnlyExtensible, Microsoft.OpenApi.IOpenApiReferenceable, Microsoft.OpenApi.IOpenApiSerializable, Microsoft.OpenApi.IOpenApiSummarizedElement, Microsoft.OpenApi.IShallowCopyable { + System.Collections.Generic.IDictionary? AdditionalOperations { get; } System.Collections.Generic.Dictionary? Operations { get; } System.Collections.Generic.IList? Parameters { get; } + Microsoft.OpenApi.OpenApiOperation? Query { get; } System.Collections.Generic.IList? Servers { get; } } public interface IOpenApiReadOnlyDescribedElement : Microsoft.OpenApi.IOpenApiElement @@ -417,6 +419,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"; @@ -523,6 +526,7 @@ namespace Microsoft.OpenApi public const string Properties = "properties"; public const string PropertyName = "propertyName"; public const string Put = "put"; + public const string Query = "query"; public const string ReadOnly = "readOnly"; public const string RecursiveAnchor = "$recursiveAnchor"; public const string RecursiveRef = "$recursiveRef"; @@ -1009,10 +1013,12 @@ namespace Microsoft.OpenApi public class OpenApiPathItem : Microsoft.OpenApi.IOpenApiDescribedElement, Microsoft.OpenApi.IOpenApiElement, Microsoft.OpenApi.IOpenApiExtensible, Microsoft.OpenApi.IOpenApiPathItem, Microsoft.OpenApi.IOpenApiReadOnlyExtensible, Microsoft.OpenApi.IOpenApiReferenceable, Microsoft.OpenApi.IOpenApiSerializable, Microsoft.OpenApi.IOpenApiSummarizedElement, Microsoft.OpenApi.IShallowCopyable { public OpenApiPathItem() { } + public System.Collections.Generic.IDictionary? AdditionalOperations { get; set; } public string? Description { get; set; } public System.Collections.Generic.IDictionary? Extensions { get; set; } public System.Collections.Generic.Dictionary? Operations { get; set; } public System.Collections.Generic.IList? Parameters { get; set; } + public Microsoft.OpenApi.OpenApiOperation? Query { get; set; } public System.Collections.Generic.IList? Servers { get; set; } public string? Summary { get; set; } public void AddOperation(System.Net.Http.HttpMethod operationType, Microsoft.OpenApi.OpenApiOperation operation) { } @@ -1025,10 +1031,12 @@ namespace Microsoft.OpenApi public class OpenApiPathItemReference : Microsoft.OpenApi.BaseOpenApiReferenceHolder, Microsoft.OpenApi.IOpenApiDescribedElement, Microsoft.OpenApi.IOpenApiElement, Microsoft.OpenApi.IOpenApiPathItem, Microsoft.OpenApi.IOpenApiReadOnlyExtensible, Microsoft.OpenApi.IOpenApiReferenceable, Microsoft.OpenApi.IOpenApiSerializable, Microsoft.OpenApi.IOpenApiSummarizedElement, Microsoft.OpenApi.IShallowCopyable { public OpenApiPathItemReference(string referenceId, Microsoft.OpenApi.OpenApiDocument? hostDocument = null, string? externalResource = null) { } + public System.Collections.Generic.IDictionary? AdditionalOperations { get; } public string? Description { get; set; } public System.Collections.Generic.IDictionary? Extensions { get; } public System.Collections.Generic.Dictionary? Operations { get; } public System.Collections.Generic.IList? Parameters { get; } + public Microsoft.OpenApi.OpenApiOperation? Query { get; } public System.Collections.Generic.IList? Servers { get; } public string? Summary { get; set; } protected override Microsoft.OpenApi.OpenApiReferenceWithDescriptionAndSummary CopyReference(Microsoft.OpenApi.OpenApiReferenceWithDescriptionAndSummary sourceReference) { } From 1cac1a29a1b02ad23ab82d014f06b6d4940b2266 Mon Sep 17 00:00:00 2001 From: costabello matthieu Date: Fri, 3 Oct 2025 12:07:48 -0400 Subject: [PATCH 02/16] move query and AdditionalOperations to OpenApiOperation --- .../Models/Interfaces/IOpenApiPathItem.cs | 11 - .../Models/OpenApiOperation.cs | 61 +++- .../Models/OpenApiPathItem.cs | 59 +-- .../References/OpenApiPathItemReference.cs | 6 - .../Reader/V2/OpenApiOperationDeserializer.cs | 2 + .../Reader/V3/OpenApiOperationDeserializer.cs | 2 + .../Reader/V3/OpenApiPathItemDeserializer.cs | 2 - .../V31/OpenApiOperationDeserializer.cs | 2 + .../Reader/V31/OpenApiPathItemDeserializer.cs | 2 - .../V32/OpenApiOperationDeserializer.cs | 14 +- .../Reader/V32/OpenApiPathItemDeserializer.cs | 4 +- .../V32Tests/OpenApiPathItemTests.cs | 185 ++++++---- ...hItemWithQueryAndAdditionalOperations.yaml | 112 +++--- .../pathItemWithV32Extensions.yaml | 28 +- .../Models/OpenApiPathItemTests.cs | 344 ++++++++++-------- .../PublicApi/PublicApi.approved.txt | 8 +- 16 files changed, 471 insertions(+), 371 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs index 2d3e3a3e7..517145667 100644 --- a/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs +++ b/src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs @@ -24,15 +24,4 @@ public interface IOpenApiPathItem : IOpenApiDescribedElement, IOpenApiSummarized /// These parameters can be overridden at the operation level, but cannot be removed there. /// public IList? Parameters { get; } - - /// - /// Gets the query operation for this path (OpenAPI 3.2). - /// - public OpenApiOperation? Query { get; } - - /// - /// Gets the additional operations for this path (OpenAPI 3.2). - /// A map of additional operations that are not one of the standard HTTP methods (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE). - /// - public IDictionary? AdditionalOperations { get; } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiOperation.cs b/src/Microsoft.OpenApi/Models/OpenApiOperation.cs index e4625ff1d..4123a94d7 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiOperation.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiOperation.cs @@ -110,6 +110,17 @@ public ISet? Tags /// public IList? Servers { get; set; } + /// + /// Gets the query operation for this operation (OpenAPI 3.2). + /// + public OpenApiOperation? Query { get; set; } + + /// + /// Gets the additional operations for this operation (OpenAPI 3.2). + /// A map of additional operations that are not one of the standard HTTP methods (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE). + /// + public IDictionary? AdditionalOperations { get; set; } + /// /// This object MAY be extended with Specification Extensions. /// @@ -141,6 +152,9 @@ public OpenApiOperation(OpenApiOperation operation) Deprecated = operation.Deprecated; Security = operation.Security != null ? [.. operation.Security] : null; Servers = operation.Servers != null ? [.. operation.Servers] : null; + Query = operation.Query != null ? new OpenApiOperation(operation.Query) : null; + AdditionalOperations = operation.AdditionalOperations != null ? + new Dictionary(operation.AdditionalOperations.ToDictionary(kvp => kvp.Key, kvp => new OpenApiOperation(kvp.Value))) : null; Extensions = operation.Extensions != null ? new Dictionary(operation.Extensions) : null; Metadata = operation.Metadata != null ? new Dictionary(operation.Metadata) : null; } @@ -158,7 +172,7 @@ public virtual void SerializeAsV32(IOpenApiWriter writer) /// public virtual void SerializeAsV31(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer)); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer), downgradeFrom32: true); } /// @@ -166,13 +180,13 @@ public virtual void SerializeAsV31(IOpenApiWriter writer) /// public virtual void SerializeAsV3(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer), downgradeFrom32: true); } /// /// Serialize to Open Api v3.0. /// - private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) + private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback, bool downgradeFrom32 = false) { Utils.CheckArgumentNull(writer); @@ -208,6 +222,21 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // callbacks writer.WriteOptionalMap(OpenApiConstants.Callbacks, Callbacks, callback); + // OpenAPI 3.2 specific fields + if (version == OpenApiSpecVersion.OpenApi3_2) + { + // query operation + writer.WriteOptionalObject(OpenApiConstants.Query, Query, callback); + + // additional operations + writer.WriteOptionalMap(OpenApiConstants.AdditionalOperations, AdditionalOperations, callback); + } + else if (downgradeFrom32) + { + // When downgrading from 3.2 to 3.1/3.0, serialize as extensions + WriteV32FieldsAsExtensions(writer); + } + // deprecated writer.WriteProperty(OpenApiConstants.Deprecated, Deprecated, false); @@ -223,6 +252,29 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteEndObject(); } + /// + /// Writes OpenAPI 3.2 specific fields as extensions when downgrading to older versions + /// + private void WriteV32FieldsAsExtensions(IOpenApiWriter writer) + { + if (Query != null) + { + writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.Query); + Query.SerializeAsV31(writer); + } + + if (AdditionalOperations != null && AdditionalOperations.Count > 0) + { + writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.AdditionalOperations); + writer.WriteStartObject(); + foreach (var kvp in AdditionalOperations) + { + writer.WriteOptionalObject(kvp.Key, kvp.Value, (w, o) => o.SerializeAsV31(w)); + } + writer.WriteEndObject(); + } + } + /// /// Serialize to Open Api v2.0. /// @@ -349,6 +401,9 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) // security writer.WriteOptionalCollection(OpenApiConstants.Security, Security, (w, s) => s.SerializeAsV2(w)); + // Write Query and AdditionalOperations as extensions when downgrading to v2 + WriteV32FieldsAsExtensions(writer); + // specification extensions writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); diff --git a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs index 54a3f6fb8..9a2ac345d 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 @@ -27,12 +28,6 @@ public class OpenApiPathItem : IOpenApiExtensible, IOpenApiPathItem /// public IList? Parameters { get; set; } - /// - public OpenApiOperation? Query { get; set; } - - /// - public IDictionary? AdditionalOperations { get; set; } - /// public IDictionary? Extensions { get; set; } @@ -60,11 +55,10 @@ internal OpenApiPathItem(IOpenApiPathItem pathItem) Utils.CheckArgumentNull(pathItem); Summary = pathItem.Summary ?? Summary; Description = pathItem.Description ?? Description; - Operations = pathItem.Operations != null ? new Dictionary(pathItem.Operations) : null; + Operations = pathItem.Operations != null ? + new Dictionary(pathItem.Operations.ToDictionary(kvp => kvp.Key, kvp => new OpenApiOperation(kvp.Value))) : null; Servers = pathItem.Servers != null ? [.. pathItem.Servers] : null; Parameters = pathItem.Parameters != null ? [.. pathItem.Parameters] : null; - Query = pathItem.Query != null ? new OpenApiOperation(pathItem.Query) : null; - AdditionalOperations = pathItem.AdditionalOperations != null ? new Dictionary(pathItem.AdditionalOperations) : null; Extensions = pathItem.Extensions != null ? new Dictionary(pathItem.Extensions) : null; } @@ -81,7 +75,7 @@ public virtual void SerializeAsV32(IOpenApiWriter writer) /// public virtual void SerializeAsV31(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer), downgradeFrom32: true); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer)); } /// @@ -89,7 +83,7 @@ public virtual void SerializeAsV31(IOpenApiWriter writer) /// public virtual void SerializeAsV3(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer), downgradeFrom32: true); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); } /// @@ -128,9 +122,6 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) OpenApiConstants.ExtensionFieldNamePrefix + OpenApiConstants.Description, Description); - // Write Query and AdditionalOperations as extensions when downgrading to v2 - WriteV32FieldsAsExtensions(writer); - // specification extensions writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); @@ -138,7 +129,7 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) } internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, - Action callback, bool downgradeFrom32 = false) + Action callback) { Utils.CheckArgumentNull(writer); @@ -162,21 +153,6 @@ internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersio } } - // OpenAPI 3.2 specific fields - if (version == OpenApiSpecVersion.OpenApi3_2) - { - // query operation - writer.WriteOptionalObject(OpenApiConstants.Query, Query, callback); - - // additional operations - writer.WriteOptionalMap(OpenApiConstants.AdditionalOperations, AdditionalOperations, callback); - } - else if (downgradeFrom32) - { - // When downgrading from 3.2 to 3.1/3.0, serialize as extensions - WriteV32FieldsAsExtensions(writer); - } - // servers writer.WriteOptionalCollection(OpenApiConstants.Servers, Servers, callback); @@ -189,29 +165,6 @@ internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersio writer.WriteEndObject(); } - /// - /// Writes OpenAPI 3.2 specific fields as extensions when downgrading to older versions - /// - private void WriteV32FieldsAsExtensions(IOpenApiWriter writer) - { - if (Query != null) - { - writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.Query); - Query.SerializeAsV31(writer); - } - - if (AdditionalOperations != null && AdditionalOperations.Count > 0) - { - writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.AdditionalOperations); - writer.WriteStartObject(); - foreach (var kvp in AdditionalOperations) - { - writer.WriteOptionalObject(kvp.Key, kvp.Value, (w, o) => o.SerializeAsV31(w)); - } - writer.WriteEndObject(); - } - } - /// public IOpenApiPathItem CreateShallowCopy() { diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs index c16da6f6d..291c75308 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiPathItemReference.cs @@ -58,12 +58,6 @@ public string? Description /// public IList? Parameters { get => Target?.Parameters; } - /// - public OpenApiOperation? Query { get => Target?.Query; } - - /// - public IDictionary? AdditionalOperations { get => Target?.AdditionalOperations; } - /// public IDictionary? Extensions { get => Target?.Extensions; } diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs index 4adf036ba..fcc112eb6 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs @@ -100,6 +100,8 @@ internal static partial class OpenApiV2Deserializer private static readonly PatternFieldMap _operationPatternFields = new() { + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p, n))} }; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs index ae0c0e322..b3e5c9c3b 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs @@ -97,6 +97,8 @@ internal static partial class OpenApiV3Deserializer private static readonly PatternFieldMap _operationPatternFields = new() { + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))}, }; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs index fb13edabf..45f525484 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiPathItemDeserializer.cs @@ -41,8 +41,6 @@ internal static partial class OpenApiV3Deserializer private static readonly PatternFieldMap _pathItemPatternFields = new() { - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))} }; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs index 101b844c0..6b7197d0e 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs @@ -111,6 +111,8 @@ internal static partial class OpenApiV31Deserializer private static readonly PatternFieldMap _operationPatternFields = new() { + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, + {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))}, }; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs index bc446f911..892c1e8a1 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiPathItemDeserializer.cs @@ -43,8 +43,6 @@ internal static partial class OpenApiV31Deserializer private static readonly PatternFieldMap _pathItemPatternFields = new() { - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))} }; diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs index 370731bd5..6e7047eda 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Nodes; @@ -106,6 +106,18 @@ internal static partial class OpenApiV32Deserializer o.Servers = n.CreateList(LoadServer, t); } }, + { + "query", (o, n, t) => + { + o.Query = LoadOperation(n, t); + } + }, + { + "additionalOperations", (o, n, t) => + { + o.AdditionalOperations = n.CreateMap(LoadOperation, t); + } + }, }; private static readonly PatternFieldMap _operationPatternFields = diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs index befb24d4d..fc8fcdf23 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs @@ -36,10 +36,8 @@ internal static partial class OpenApiV32Deserializer {"patch", (o, n, t) => o.AddOperation(new HttpMethod("PATCH"), LoadOperation(n, t))}, #endif {"trace", (o, n, t) => o.AddOperation(HttpMethod.Trace, LoadOperation(n, t))}, - {"query", (o, n, t) => o.Query = LoadOperation(n, t)}, {"servers", (o, n, t) => o.Servers = n.CreateList(LoadServer, t)}, - {"parameters", (o, n, t) => o.Parameters = n.CreateList(LoadParameter, t)}, - {"additionalOperations", (o, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)} + {"parameters", (o, n, t) => o.Parameters = n.CreateList(LoadParameter, t)} }; private static readonly PatternFieldMap _pathItemPatternFields = diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs index 91feba329..5dbaba4d1 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs @@ -28,29 +28,33 @@ public async Task ParsePathItemWithQueryAndAdditionalOperationsV32Works() // Regular operations Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); - Assert.Equal("getPets", pathItem.Operations[HttpMethod.Get].OperationId); + var getOp = pathItem.Operations[HttpMethod.Get]; + Assert.Equal("getPets", getOp.OperationId); + Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Post)); - Assert.Equal("createPet", pathItem.Operations[HttpMethod.Post].OperationId); - - // Query operation - Assert.NotNull(pathItem.Query); - Assert.Equal("Query pets with complex filters", pathItem.Query.Summary); - Assert.Equal("queryPets", pathItem.Query.OperationId); - Assert.Single(pathItem.Query.Parameters); - Assert.Equal("filter", pathItem.Query.Parameters[0].Name); - - // Additional operations - Assert.NotNull(pathItem.AdditionalOperations); - Assert.Equal(2, pathItem.AdditionalOperations.Count); + var postOp = pathItem.Operations[HttpMethod.Post]; + 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.NotNull(getOp.Query); + Assert.Equal("Query pets with complex filters", getOp.Query.Summary); + Assert.Equal("queryPets", getOp.Query.OperationId); + Assert.Single(getOp.Query.Parameters); + Assert.Equal("filter", getOp.Query.Parameters[0].Name); + + // Additional operations should now be on one of the operations + Assert.NotNull(getOp.AdditionalOperations); + Assert.Equal(2, getOp.AdditionalOperations.Count); - Assert.True(pathItem.AdditionalOperations.ContainsKey("notify")); - var notifyOp = pathItem.AdditionalOperations["notify"]; + Assert.True(getOp.AdditionalOperations.ContainsKey("notify")); + var notifyOp = getOp.AdditionalOperations["notify"]; Assert.Equal("Notify about pet updates", notifyOp.Summary); Assert.Equal("notifyPetUpdates", notifyOp.OperationId); Assert.NotNull(notifyOp.RequestBody); - Assert.True(pathItem.AdditionalOperations.ContainsKey("subscribe")); - var subscribeOp = pathItem.AdditionalOperations["subscribe"]; + Assert.True(getOp.AdditionalOperations.ContainsKey("subscribe")); + var subscribeOp = getOp.AdditionalOperations["subscribe"]; Assert.Equal("Subscribe to pet events", subscribeOp.Summary); Assert.Equal("subscribePetEvents", subscribeOp.OperationId); Assert.Single(subscribeOp.Parameters); @@ -70,18 +74,19 @@ public async Task ParsePathItemWithV32ExtensionsWorks() // Regular operations Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); - Assert.Equal("getPets", pathItem.Operations[HttpMethod.Get].OperationId); - - // Query operation from extension - Assert.NotNull(pathItem.Query); - Assert.Equal("Query pets with complex filters", pathItem.Query.Summary); - Assert.Equal("queryPets", pathItem.Query.OperationId); - - // Additional operations from extension - Assert.NotNull(pathItem.AdditionalOperations); - Assert.Single(pathItem.AdditionalOperations); - Assert.True(pathItem.AdditionalOperations.ContainsKey("notify")); - var notifyOp = pathItem.AdditionalOperations["notify"]; + var getOp = pathItem.Operations[HttpMethod.Get]; + Assert.Equal("getPets", getOp.OperationId); + + // Query operation from extension should now be on the operation + Assert.NotNull(getOp.Query); + Assert.Equal("Query pets with complex filters", getOp.Query.Summary); + Assert.Equal("queryPets", getOp.Query.OperationId); + + // Additional operations from extension should now be on the operation + Assert.NotNull(getOp.AdditionalOperations); + Assert.Single(getOp.AdditionalOperations); + Assert.True(getOp.AdditionalOperations.ContainsKey("notify")); + var notifyOp = getOp.AdditionalOperations["notify"]; Assert.Equal("Notify about pet updates", notifyOp.Summary); Assert.Equal("notifyPetUpdates", notifyOp.OperationId); } @@ -93,21 +98,33 @@ public async Task SerializeV32PathItemToV31ProducesExtensions() var pathItem = new OpenApiPathItem { Summary = "Test path", - Query = new OpenApiOperation - { - Summary = "Query operation", - OperationId = "queryOp", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse { Description = "Success" } - } - }, - AdditionalOperations = new Dictionary + Operations = new Dictionary { - ["notify"] = new OpenApiOperation + [HttpMethod.Get] = new OpenApiOperation { - Summary = "Notify operation", - OperationId = "notifyOp", + Summary = "Get operation", + OperationId = "getOp", + Query = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + } + }, Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "Success" } @@ -133,21 +150,33 @@ public async Task SerializeV32PathItemToV3ProducesExtensions() var pathItem = new OpenApiPathItem { Summary = "Test path", - Query = new OpenApiOperation + Operations = new Dictionary { - Summary = "Query operation", - OperationId = "queryOp", - Responses = new OpenApiResponses + [HttpMethod.Get] = new OpenApiOperation { - ["200"] = new OpenApiResponse { Description = "Success" } - } - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "Notify operation", - OperationId = "notifyOp", + Summary = "Get operation", + OperationId = "getOp", + Query = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + } + }, Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "Success" } @@ -173,17 +202,26 @@ public void PathItemShallowCopyIncludesV32Fields() var original = new OpenApiPathItem { Summary = "Original", - Query = new OpenApiOperation - { - Summary = "Query operation", - OperationId = "queryOp" - }, - AdditionalOperations = new Dictionary + Operations = new Dictionary { - ["notify"] = new OpenApiOperation + [HttpMethod.Get] = new OpenApiOperation { - Summary = "Notify operation", - OperationId = "notifyOp" + Summary = "Get operation", + OperationId = "getOp", + Query = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp" + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp" + } + }, + Responses = new OpenApiResponses() } } }; @@ -192,14 +230,17 @@ public void PathItemShallowCopyIncludesV32Fields() var copy = original.CreateShallowCopy(); // Assert - Assert.NotNull(copy.Query); - Assert.Equal("Query operation", copy.Query.Summary); - Assert.Equal("queryOp", copy.Query.OperationId); - - Assert.NotNull(copy.AdditionalOperations); - Assert.Single(copy.AdditionalOperations); - Assert.Equal("Notify operation", copy.AdditionalOperations["notify"].Summary); - Assert.Equal("notifyOp", copy.AdditionalOperations["notify"].OperationId); + var originalGetOp = original.Operations![HttpMethod.Get]; + var copyGetOp = copy.Operations![HttpMethod.Get]; + + Assert.NotNull(copyGetOp.Query); + Assert.Equal("Query operation", copyGetOp.Query.Summary); + Assert.Equal("queryOp", copyGetOp.Query.OperationId); + + Assert.NotNull(copyGetOp.AdditionalOperations); + Assert.Single(copyGetOp.AdditionalOperations); + Assert.Equal("Notify operation", copyGetOp.AdditionalOperations["notify"].Summary); + Assert.Equal("notifyOp", copyGetOp.AdditionalOperations["notify"].OperationId); } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml index 134dbaabf..c0c32298a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml @@ -9,69 +9,69 @@ paths: get: summary: Get pets operationId: getPets - responses: - '200': - description: List of pets - post: - summary: Create pet - operationId: createPet - responses: - '201': - description: Pet created - 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 + query: + summary: Query pets with complex filters + operationId: queryPets parameters: - - name: events + - name: filter in: query - description: Event types to subscribe to + description: Complex filter expression schema: - type: array - items: - type: string + type: string responses: '200': - description: Subscription created + 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: - subscriptionId: - type: string \ No newline at end of file + 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 + responses: + '200': + description: List of pets + 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/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml index 78784d6bc..a90113d7a 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml @@ -9,19 +9,19 @@ paths: get: summary: Get pets operationId: getPets - responses: - '200': - description: List of pets - x-oas-query: - summary: Query pets with complex filters - operationId: queryPets - responses: - '200': - description: Filtered pets - x-oas-additionalOperations: - notify: - summary: Notify about pet updates - operationId: notifyPetUpdates + x-oas-query: + summary: Query pets with complex filters + operationId: queryPets responses: '200': - description: Notification sent \ No newline at end of file + description: Filtered pets + x-oas-additionalOperations: + notify: + summary: Notify about pet updates + operationId: notifyPetUpdates + responses: + '200': + description: Notification sent + responses: + '200': + description: List of pets \ 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 7b549491c..2a5c78ca6 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs @@ -415,53 +415,53 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() Summary = "get operation", Description = "get description", OperationId = "getOperation", - Responses = new OpenApiResponses + Query = new OpenApiOperation { - ["200"] = new OpenApiResponse + Summary = "query operation", + Description = "query description", + OperationId = "queryOperation", + Responses = new OpenApiResponses { - Description = "success" + ["200"] = new OpenApiResponse + { + Description = "query success" + } } - } - } - }, - Query = new OpenApiOperation - { - Summary = "query operation", - Description = "query description", - OperationId = "queryOperation", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse - { - Description = "query success" - } - } - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "notify operation", - Description = "notify description", - OperationId = "notifyOperation", - Responses = new OpenApiResponses + }, + AdditionalOperations = new Dictionary { - ["200"] = new OpenApiResponse + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + Description = "notify description", + OperationId = "notifyOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "notify success" + } + } + }, + ["custom"] = new OpenApiOperation { - Description = "notify success" + Summary = "custom operation", + Description = "custom description", + OperationId = "customOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "custom success" + } + } } - } - }, - ["custom"] = new OpenApiOperation - { - Summary = "custom operation", - Description = "custom description", - OperationId = "customOperation", + }, Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "custom success" + Description = "success" } } } @@ -481,36 +481,36 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() "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", + }, + "query": { + "summary": "query operation", + "description": "query description", + "operationId": "queryOperation", "responses": { "200": { - "description": "notify success" + "description": "query success" } } }, - "custom": { - "summary": "custom operation", - "description": "custom description", - "operationId": "customOperation", - "responses": { - "200": { - "description": "custom 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" + } } } } @@ -534,29 +534,44 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() { Summary = "summary", Description = "description", - Query = new OpenApiOperation + Operations = new Dictionary { - Summary = "query operation", - OperationId = "queryOperation", - Responses = new OpenApiResponses + [HttpMethod.Get] = new OpenApiOperation { - ["200"] = new OpenApiResponse + Summary = "get operation", + OperationId = "getOperation", + Query = new OpenApiOperation { - Description = "query success" - } - } - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "notify operation", - OperationId = "notifyOperation", + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "notify success" + } + } + } + }, Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "notify success" + Description = "success" } } } @@ -567,11 +582,12 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); var parsedActualJson = JsonNode.Parse(actualJson); - // Then - should contain x-oas- prefixed extensions - Assert.True(parsedActualJson!["x-oas-query"] != null); - Assert.True(parsedActualJson!["x-oas-additionalOperations"] != null); - Assert.Equal("query operation", parsedActualJson!["x-oas-query"]!["summary"]!.GetValue()); - Assert.Equal("notify operation", parsedActualJson!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + // Then - should contain x-oas- prefixed extensions in the operation + var getOperation = parsedActualJson!["get"]; + Assert.True(getOperation!["x-oas-query"] != null); + Assert.True(getOperation!["x-oas-additionalOperations"] != null); + Assert.Equal("query operation", getOperation!["x-oas-query"]!["summary"]!.GetValue()); + Assert.Equal("notify operation", getOperation!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); } [Fact] @@ -581,29 +597,44 @@ public async Task SerializeV32FeaturesAsExtensionsInV3Works() { Summary = "summary", Description = "description", - Query = new OpenApiOperation + Operations = new Dictionary { - Summary = "query operation", - OperationId = "queryOperation", - Responses = new OpenApiResponses + [HttpMethod.Get] = new OpenApiOperation { - ["200"] = new OpenApiResponse + Summary = "get operation", + OperationId = "getOperation", + Query = new OpenApiOperation { - Description = "query success" - } - } - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "notify operation", - OperationId = "notifyOperation", + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "notify success" + } + } + } + }, Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "notify success" + Description = "success" } } } @@ -614,11 +645,12 @@ public async Task SerializeV32FeaturesAsExtensionsInV3Works() var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); var parsedActualJson = JsonNode.Parse(actualJson); - // Then - should contain x-oas- prefixed extensions - Assert.True(parsedActualJson!["x-oas-query"] != null); - Assert.True(parsedActualJson!["x-oas-additionalOperations"] != null); - Assert.Equal("query operation", parsedActualJson!["x-oas-query"]!["summary"]!.GetValue()); - Assert.Equal("notify operation", parsedActualJson!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + // Then - should contain x-oas- prefixed extensions in the operation + var getOperation = parsedActualJson!["get"]; + Assert.True(getOperation!["x-oas-query"] != null); + Assert.True(getOperation!["x-oas-additionalOperations"] != null); + Assert.Equal("query operation", getOperation!["x-oas-query"]!["summary"]!.GetValue()); + Assert.Equal("notify operation", getOperation!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); } [Fact] @@ -628,29 +660,44 @@ public async Task SerializeV32FeaturesAsExtensionsInV2Works() { Summary = "summary", Description = "description", - Query = new OpenApiOperation + Operations = new Dictionary { - Summary = "query operation", - OperationId = "queryOperation", - Responses = new OpenApiResponses + [HttpMethod.Get] = new OpenApiOperation { - ["200"] = new OpenApiResponse + Summary = "get operation", + OperationId = "getOperation", + Query = new OpenApiOperation { - Description = "query success" - } - } - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "notify operation", - OperationId = "notifyOperation", + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "query success" + } + } + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "notify success" + } + } + } + }, Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "notify success" + Description = "success" } } } @@ -661,11 +708,12 @@ public async Task SerializeV32FeaturesAsExtensionsInV2Works() var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); var parsedActualJson = JsonNode.Parse(actualJson); - // Then - should contain x-oas- prefixed extensions - Assert.True(parsedActualJson!["x-oas-query"] != null); - Assert.True(parsedActualJson!["x-oas-additionalOperations"] != null); - Assert.Equal("query operation", parsedActualJson!["x-oas-query"]!["summary"]!.GetValue()); - Assert.Equal("notify operation", parsedActualJson!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + // Then - should contain x-oas- prefixed extensions in the operation + var getOperation = parsedActualJson!["get"]; + Assert.True(getOperation!["x-oas-query"] != null); + Assert.True(getOperation!["x-oas-additionalOperations"] != null); + Assert.Equal("query operation", getOperation!["x-oas-query"]!["summary"]!.GetValue()); + Assert.Equal("notify operation", getOperation!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); } [Fact] @@ -675,17 +723,26 @@ public void CopyConstructorCopiesQueryAndAdditionalOperations() var original = new OpenApiPathItem { Summary = "summary", - Query = new OpenApiOperation - { - Summary = "query operation", - OperationId = "queryOperation" - }, - AdditionalOperations = new Dictionary + Operations = new Dictionary { - ["notify"] = new OpenApiOperation + [HttpMethod.Get] = new OpenApiOperation { - Summary = "notify operation", - OperationId = "notifyOperation" + Summary = "get operation", + OperationId = "getOperation", + Query = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation" + }, + AdditionalOperations = new Dictionary + { + ["notify"] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation" + } + }, + Responses = new OpenApiResponses() } } }; @@ -694,16 +751,19 @@ public void CopyConstructorCopiesQueryAndAdditionalOperations() var copy = new OpenApiPathItem(original); // Assert - Assert.NotNull(copy.Query); - Assert.Equal(original.Query.Summary, copy.Query.Summary); - Assert.Equal(original.Query.OperationId, copy.Query.OperationId); + var originalGetOp = original.Operations![HttpMethod.Get]; + var copyGetOp = copy.Operations![HttpMethod.Get]; + + Assert.NotNull(copyGetOp.Query); + Assert.Equal(originalGetOp.Query!.Summary, copyGetOp.Query.Summary); + Assert.Equal(originalGetOp.Query.OperationId, copyGetOp.Query.OperationId); - Assert.NotNull(copy.AdditionalOperations); - Assert.Equal(original.AdditionalOperations.Count, copy.AdditionalOperations.Count); - Assert.Equal(original.AdditionalOperations["notify"].Summary, copy.AdditionalOperations["notify"].Summary); + Assert.NotNull(copyGetOp.AdditionalOperations); + Assert.Equal(originalGetOp.AdditionalOperations!.Count, copyGetOp.AdditionalOperations.Count); + Assert.Equal(originalGetOp.AdditionalOperations["notify"].Summary, copyGetOp.AdditionalOperations["notify"].Summary); // Verify it's a deep copy - copy.Query.Summary = "modified"; - Assert.NotEqual(original.Query.Summary, copy.Query.Summary); + copyGetOp.Query.Summary = "modified"; + Assert.NotEqual(originalGetOp.Query.Summary, copyGetOp.Query.Summary); } } diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index 9f5ac35e3..287df2731 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -165,10 +165,8 @@ namespace Microsoft.OpenApi } public interface IOpenApiPathItem : Microsoft.OpenApi.IOpenApiDescribedElement, Microsoft.OpenApi.IOpenApiElement, Microsoft.OpenApi.IOpenApiReadOnlyExtensible, Microsoft.OpenApi.IOpenApiReferenceable, Microsoft.OpenApi.IOpenApiSerializable, Microsoft.OpenApi.IOpenApiSummarizedElement, Microsoft.OpenApi.IShallowCopyable { - System.Collections.Generic.IDictionary? AdditionalOperations { get; } System.Collections.Generic.Dictionary? Operations { get; } System.Collections.Generic.IList? Parameters { get; } - Microsoft.OpenApi.OpenApiOperation? Query { get; } System.Collections.Generic.IList? Servers { get; } } public interface IOpenApiReadOnlyDescribedElement : Microsoft.OpenApi.IOpenApiElement @@ -940,6 +938,7 @@ namespace Microsoft.OpenApi { public OpenApiOperation() { } public OpenApiOperation(Microsoft.OpenApi.OpenApiOperation operation) { } + public System.Collections.Generic.IDictionary? AdditionalOperations { get; set; } public System.Collections.Generic.IDictionary? Callbacks { get; set; } public bool Deprecated { get; set; } public string? Description { get; set; } @@ -948,6 +947,7 @@ namespace Microsoft.OpenApi public System.Collections.Generic.IDictionary? Metadata { get; set; } public string? OperationId { get; set; } public System.Collections.Generic.IList? Parameters { get; set; } + public Microsoft.OpenApi.OpenApiOperation? Query { get; set; } public Microsoft.OpenApi.IOpenApiRequestBody? RequestBody { get; set; } public Microsoft.OpenApi.OpenApiResponses? Responses { get; set; } public System.Collections.Generic.IList? Security { get; set; } @@ -1013,12 +1013,10 @@ namespace Microsoft.OpenApi public class OpenApiPathItem : Microsoft.OpenApi.IOpenApiDescribedElement, Microsoft.OpenApi.IOpenApiElement, Microsoft.OpenApi.IOpenApiExtensible, Microsoft.OpenApi.IOpenApiPathItem, Microsoft.OpenApi.IOpenApiReadOnlyExtensible, Microsoft.OpenApi.IOpenApiReferenceable, Microsoft.OpenApi.IOpenApiSerializable, Microsoft.OpenApi.IOpenApiSummarizedElement, Microsoft.OpenApi.IShallowCopyable { public OpenApiPathItem() { } - public System.Collections.Generic.IDictionary? AdditionalOperations { get; set; } public string? Description { get; set; } public System.Collections.Generic.IDictionary? Extensions { get; set; } public System.Collections.Generic.Dictionary? Operations { get; set; } public System.Collections.Generic.IList? Parameters { get; set; } - public Microsoft.OpenApi.OpenApiOperation? Query { get; set; } public System.Collections.Generic.IList? Servers { get; set; } public string? Summary { get; set; } public void AddOperation(System.Net.Http.HttpMethod operationType, Microsoft.OpenApi.OpenApiOperation operation) { } @@ -1031,12 +1029,10 @@ namespace Microsoft.OpenApi public class OpenApiPathItemReference : Microsoft.OpenApi.BaseOpenApiReferenceHolder, Microsoft.OpenApi.IOpenApiDescribedElement, Microsoft.OpenApi.IOpenApiElement, Microsoft.OpenApi.IOpenApiPathItem, Microsoft.OpenApi.IOpenApiReadOnlyExtensible, Microsoft.OpenApi.IOpenApiReferenceable, Microsoft.OpenApi.IOpenApiSerializable, Microsoft.OpenApi.IOpenApiSummarizedElement, Microsoft.OpenApi.IShallowCopyable { public OpenApiPathItemReference(string referenceId, Microsoft.OpenApi.OpenApiDocument? hostDocument = null, string? externalResource = null) { } - public System.Collections.Generic.IDictionary? AdditionalOperations { get; } public string? Description { get; set; } public System.Collections.Generic.IDictionary? Extensions { get; } public System.Collections.Generic.Dictionary? Operations { get; } public System.Collections.Generic.IList? Parameters { get; } - public Microsoft.OpenApi.OpenApiOperation? Query { get; } public System.Collections.Generic.IList? Servers { get; } public string? Summary { get; set; } protected override Microsoft.OpenApi.OpenApiReferenceWithDescriptionAndSummary CopyReference(Microsoft.OpenApi.OpenApiReferenceWithDescriptionAndSummary sourceReference) { } From a25605399ff2a7cd488c7cb432374b601a141fc5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 15:17:21 -0400 Subject: [PATCH 03/16] chore: reverts some undesired changes Signed-off-by: Vincent Biret --- .../Models/OpenApiConstants.cs | 10 --- .../Models/OpenApiOperation.cs | 61 +------------------ .../Models/OpenApiPathItem.cs | 4 +- .../Reader/V2/OpenApiOperationDeserializer.cs | 2 - .../Reader/V3/OpenApiOperationDeserializer.cs | 2 - .../V31/OpenApiOperationDeserializer.cs | 2 - .../V32/OpenApiOperationDeserializer.cs | 14 +---- .../PublicApi/PublicApi.approved.txt | 4 -- 8 files changed, 5 insertions(+), 94 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 3e6315106..47ef07d53 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -210,16 +210,6 @@ public static class OpenApiConstants /// public const string RequestBody = "requestBody"; - /// - /// Field: Query (OpenAPI 3.2) - /// - public const string Query = "query"; - - /// - /// Field: AdditionalOperations (OpenAPI 3.2) - /// - public const string AdditionalOperations = "additionalOperations"; - /// /// Field: ExtensionFieldNamePrefix /// diff --git a/src/Microsoft.OpenApi/Models/OpenApiOperation.cs b/src/Microsoft.OpenApi/Models/OpenApiOperation.cs index 4123a94d7..e4625ff1d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiOperation.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiOperation.cs @@ -110,17 +110,6 @@ public ISet? Tags /// public IList? Servers { get; set; } - /// - /// Gets the query operation for this operation (OpenAPI 3.2). - /// - public OpenApiOperation? Query { get; set; } - - /// - /// Gets the additional operations for this operation (OpenAPI 3.2). - /// A map of additional operations that are not one of the standard HTTP methods (GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE). - /// - public IDictionary? AdditionalOperations { get; set; } - /// /// This object MAY be extended with Specification Extensions. /// @@ -152,9 +141,6 @@ public OpenApiOperation(OpenApiOperation operation) Deprecated = operation.Deprecated; Security = operation.Security != null ? [.. operation.Security] : null; Servers = operation.Servers != null ? [.. operation.Servers] : null; - Query = operation.Query != null ? new OpenApiOperation(operation.Query) : null; - AdditionalOperations = operation.AdditionalOperations != null ? - new Dictionary(operation.AdditionalOperations.ToDictionary(kvp => kvp.Key, kvp => new OpenApiOperation(kvp.Value))) : null; Extensions = operation.Extensions != null ? new Dictionary(operation.Extensions) : null; Metadata = operation.Metadata != null ? new Dictionary(operation.Metadata) : null; } @@ -172,7 +158,7 @@ public virtual void SerializeAsV32(IOpenApiWriter writer) /// public virtual void SerializeAsV31(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer), downgradeFrom32: true); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_1, (writer, element) => element.SerializeAsV31(writer)); } /// @@ -180,13 +166,13 @@ public virtual void SerializeAsV31(IOpenApiWriter writer) /// public virtual void SerializeAsV3(IOpenApiWriter writer) { - SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer), downgradeFrom32: true); + SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); } /// /// Serialize to Open Api v3.0. /// - private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback, bool downgradeFrom32 = false) + private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) { Utils.CheckArgumentNull(writer); @@ -222,21 +208,6 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version // callbacks writer.WriteOptionalMap(OpenApiConstants.Callbacks, Callbacks, callback); - // OpenAPI 3.2 specific fields - if (version == OpenApiSpecVersion.OpenApi3_2) - { - // query operation - writer.WriteOptionalObject(OpenApiConstants.Query, Query, callback); - - // additional operations - writer.WriteOptionalMap(OpenApiConstants.AdditionalOperations, AdditionalOperations, callback); - } - else if (downgradeFrom32) - { - // When downgrading from 3.2 to 3.1/3.0, serialize as extensions - WriteV32FieldsAsExtensions(writer); - } - // deprecated writer.WriteProperty(OpenApiConstants.Deprecated, Deprecated, false); @@ -252,29 +223,6 @@ private void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version writer.WriteEndObject(); } - /// - /// Writes OpenAPI 3.2 specific fields as extensions when downgrading to older versions - /// - private void WriteV32FieldsAsExtensions(IOpenApiWriter writer) - { - if (Query != null) - { - writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.Query); - Query.SerializeAsV31(writer); - } - - if (AdditionalOperations != null && AdditionalOperations.Count > 0) - { - writer.WritePropertyName(OpenApiConstants.ExtensionFieldNamePrefix + "oas-" + OpenApiConstants.AdditionalOperations); - writer.WriteStartObject(); - foreach (var kvp in AdditionalOperations) - { - writer.WriteOptionalObject(kvp.Key, kvp.Value, (w, o) => o.SerializeAsV31(w)); - } - writer.WriteEndObject(); - } - } - /// /// Serialize to Open Api v2.0. /// @@ -401,9 +349,6 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) // security writer.WriteOptionalCollection(OpenApiConstants.Security, Security, (w, s) => s.SerializeAsV2(w)); - // Write Query and AdditionalOperations as extensions when downgrading to v2 - WriteV32FieldsAsExtensions(writer); - // specification extensions writer.WriteExtensions(Extensions, OpenApiSpecVersion.OpenApi2_0); diff --git a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs index 9a2ac345d..fe3322837 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; namespace Microsoft.OpenApi @@ -55,8 +54,7 @@ internal OpenApiPathItem(IOpenApiPathItem pathItem) Utils.CheckArgumentNull(pathItem); Summary = pathItem.Summary ?? Summary; Description = pathItem.Description ?? Description; - Operations = pathItem.Operations != null ? - new Dictionary(pathItem.Operations.ToDictionary(kvp => kvp.Key, kvp => new OpenApiOperation(kvp.Value))) : null; + Operations = pathItem.Operations != null ? new Dictionary(pathItem.Operations) : null; Servers = pathItem.Servers != null ? [.. pathItem.Servers] : null; Parameters = pathItem.Parameters != null ? [.. pathItem.Parameters] : null; Extensions = pathItem.Extensions != null ? new Dictionary(pathItem.Extensions) : null; diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs index fcc112eb6..4adf036ba 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiOperationDeserializer.cs @@ -100,8 +100,6 @@ internal static partial class OpenApiV2Deserializer private static readonly PatternFieldMap _operationPatternFields = new() { - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p, n))} }; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs index b3e5c9c3b..ae0c0e322 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiOperationDeserializer.cs @@ -97,8 +97,6 @@ internal static partial class OpenApiV3Deserializer private static readonly PatternFieldMap _operationPatternFields = new() { - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))}, }; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs index 6b7197d0e..101b844c0 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiOperationDeserializer.cs @@ -111,8 +111,6 @@ internal static partial class OpenApiV31Deserializer private static readonly PatternFieldMap _operationPatternFields = new() { - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-query", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.Query = LoadOperation(n, t)}, - {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix + "oas-additionalOperations", StringComparison.OrdinalIgnoreCase), (o, p, n, t) => o.AdditionalOperations = n.CreateMap(LoadOperation, t)}, {s => s.StartsWith(OpenApiConstants.ExtensionFieldNamePrefix, StringComparison.OrdinalIgnoreCase), (o, p, n, _) => o.AddExtension(p, LoadExtension(p,n))}, }; diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs index 6e7047eda..370731bd5 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiOperationDeserializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Nodes; @@ -106,18 +106,6 @@ internal static partial class OpenApiV32Deserializer o.Servers = n.CreateList(LoadServer, t); } }, - { - "query", (o, n, t) => - { - o.Query = LoadOperation(n, t); - } - }, - { - "additionalOperations", (o, n, t) => - { - o.AdditionalOperations = n.CreateMap(LoadOperation, t); - } - }, }; private static readonly PatternFieldMap _operationPatternFields = diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index 0244954d1..f41874ab6 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -423,7 +423,6 @@ 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"; @@ -537,7 +536,6 @@ namespace Microsoft.OpenApi public const string Properties = "properties"; public const string PropertyName = "propertyName"; public const string Put = "put"; - public const string Query = "query"; public const string ReadOnly = "readOnly"; public const string RecursiveAnchor = "$recursiveAnchor"; public const string RecursiveRef = "$recursiveRef"; @@ -964,7 +962,6 @@ namespace Microsoft.OpenApi { public OpenApiOperation() { } public OpenApiOperation(Microsoft.OpenApi.OpenApiOperation operation) { } - public System.Collections.Generic.IDictionary? AdditionalOperations { get; set; } public System.Collections.Generic.IDictionary? Callbacks { get; set; } public bool Deprecated { get; set; } public string? Description { get; set; } @@ -973,7 +970,6 @@ namespace Microsoft.OpenApi public System.Collections.Generic.IDictionary? Metadata { get; set; } public string? OperationId { get; set; } public System.Collections.Generic.IList? Parameters { get; set; } - public Microsoft.OpenApi.OpenApiOperation? Query { get; set; } public Microsoft.OpenApi.IOpenApiRequestBody? RequestBody { get; set; } public Microsoft.OpenApi.OpenApiResponses? Responses { get; set; } public System.Collections.Generic.IList? Security { get; set; } From f7dc7ee852a69fae52b7984d25a518d938f6008d Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 15:51:22 -0400 Subject: [PATCH 04/16] feat: adds support for additional operations in OAI 3.2 fix: avoid serializing non-standard operations in 2, 3, and 3.1 Signed-off-by: Vincent Biret --- .../Models/OpenApiConstants.cs | 5 ++ .../Models/OpenApiPathItem.cs | 47 ++++++++++++++++++- .../Reader/V32/OpenApiPathItemDeserializer.cs | 26 +++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index 47ef07d53..c055cb2ea 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -787,6 +787,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..bd89100b0 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 @@ -126,6 +127,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 +165,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 fc8fcdf23..355d9dece 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.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)}, + {"additionalOperations", LoadAdditionalOperations } }; + + + private static void LoadAdditionalOperations(OpenApiPathItem o, ParseNode n, OpenApiDocument t) + { + if (n is null) + { + return; + } + + var mapNode = n.CheckMapNode("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() { From d608cdc89d3005297c4a68a726f8385e2f40a1b5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 15:52:21 -0400 Subject: [PATCH 05/16] chore: linting Signed-off-by: Vincent Biret --- .../Reader/V32/OpenApiPathItemDeserializer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs index 355d9dece..9b49d2c9e 100644 --- a/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs @@ -41,7 +41,7 @@ internal static partial class OpenApiV32Deserializer {"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)}, - {"additionalOperations", LoadAdditionalOperations } + {OpenApiConstants.AdditionalOperations, LoadAdditionalOperations } }; @@ -53,7 +53,7 @@ private static void LoadAdditionalOperations(OpenApiPathItem o, ParseNode n, Ope return; } - var mapNode = n.CheckMapNode("additionalOperations"); + var mapNode = n.CheckMapNode(OpenApiConstants.AdditionalOperations); foreach (var property in mapNode.Where(p => !OpenApiPathItem._standardHttp32MethodsNames.Contains(p.Name))) { From 1a104d95701751f3d3a4729ed241e58e7cbeae20 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 15:59:37 -0400 Subject: [PATCH 06/16] chore: fixes test files for path item additional operations Signed-off-by: Vincent Biret --- ...hItemWithQueryAndAdditionalOperations.yaml | 77 +++++++++++++++++++ ...hItemWithQueryAndAdditionalOperations.yaml | 42 +++++----- .../pathItemWithV32Extensions.yaml | 27 ------- ...hItemWithQueryAndAdditionalOperations.yaml | 77 +++++++++++++++++++ 4 files changed, 175 insertions(+), 48 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml delete mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml 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..ff9dac1c7 --- /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/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml index c0c32298a..930d35a43 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml @@ -9,24 +9,27 @@ paths: get: summary: Get pets operationId: getPets - 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 + 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 @@ -66,9 +69,6 @@ paths: properties: subscriptionId: type: string - responses: - '200': - description: List of pets post: summary: Create pet operationId: createPet diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml deleted file mode 100644 index a90113d7a..000000000 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithV32Extensions.yaml +++ /dev/null @@ -1,27 +0,0 @@ -openapi: 3.1.0 -info: - title: PathItem with V32 Extensions - version: 1.0.0 -paths: - /pets: - summary: Pet operations - description: Operations available for pets - get: - summary: Get pets - operationId: getPets - x-oas-query: - summary: Query pets with complex filters - operationId: queryPets - responses: - '200': - description: Filtered pets - x-oas-additionalOperations: - notify: - summary: Notify about pet updates - operationId: notifyPetUpdates - responses: - '200': - description: Notification sent - responses: - '200': - description: List of pets \ No newline at end of file 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..269347f4d --- /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 From c6b1b0e196e3c2ffa5a4a808ef1d4e097cc28b97 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:24:59 -0400 Subject: [PATCH 07/16] chore: updates public export Signed-off-by: Vincent Biret --- test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index f41874ab6..40b5284f9 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -423,6 +423,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"; From 4801b062c015f696872e3ae67afa115351a43f46 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:27:23 -0400 Subject: [PATCH 08/16] chore: make new unit tests build Signed-off-by: Vincent Biret --- .../V32Tests/OpenApiPathItemTests.cs | 167 +++++----- .../Models/OpenApiPathItemTests.cs | 303 +++++++++--------- 2 files changed, 231 insertions(+), 239 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs index 5dbaba4d1..f150a7b7c 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs @@ -27,34 +27,30 @@ public async Task ParsePathItemWithQueryAndAdditionalOperationsV32Works() Assert.Equal("Operations available for pets", pathItem.Description); // Regular operations - Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); var getOp = pathItem.Operations[HttpMethod.Get]; + Assert.NotNull(getOp); Assert.Equal("getPets", getOp.OperationId); - Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Post)); var postOp = pathItem.Operations[HttpMethod.Post]; + Assert.NotNull(postOp); 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.NotNull(getOp.Query); - Assert.Equal("Query pets with complex filters", getOp.Query.Summary); - Assert.Equal("queryPets", getOp.Query.OperationId); - Assert.Single(getOp.Query.Parameters); - Assert.Equal("filter", getOp.Query.Parameters[0].Name); - - // Additional operations should now be on one of the operations - Assert.NotNull(getOp.AdditionalOperations); - Assert.Equal(2, getOp.AdditionalOperations.Count); - - Assert.True(getOp.AdditionalOperations.ContainsKey("notify")); - var notifyOp = getOp.AdditionalOperations["notify"]; + var queryOp = pathItem.Operations[new HttpMethod("Query")]; + Assert.NotNull(queryOp); + 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); - - Assert.True(getOp.AdditionalOperations.ContainsKey("subscribe")); - var subscribeOp = getOp.AdditionalOperations["subscribe"]; + + var subscribeOp = pathItem.Operations[new HttpMethod("subscribe")]; + Assert.NotNull(subscribeOp); Assert.Equal("Subscribe to pet events", subscribeOp.Summary); Assert.Equal("subscribePetEvents", subscribeOp.OperationId); Assert.Single(subscribeOp.Parameters); @@ -78,15 +74,13 @@ public async Task ParsePathItemWithV32ExtensionsWorks() Assert.Equal("getPets", getOp.OperationId); // Query operation from extension should now be on the operation - Assert.NotNull(getOp.Query); - Assert.Equal("Query pets with complex filters", getOp.Query.Summary); - Assert.Equal("queryPets", getOp.Query.OperationId); + var queryOp = pathItem.Operations[new HttpMethod("Query")]; + Assert.NotNull(queryOp); + Assert.Equal("Query pets with complex filters", queryOp.Summary); + Assert.Equal("queryPets", queryOp.OperationId); // Additional operations from extension should now be on the operation - Assert.NotNull(getOp.AdditionalOperations); - Assert.Single(getOp.AdditionalOperations); - Assert.True(getOp.AdditionalOperations.ContainsKey("notify")); - var notifyOp = getOp.AdditionalOperations["notify"]; + var notifyOp = pathItem.Operations[new HttpMethod("notify")]; Assert.Equal("Notify about pet updates", notifyOp.Summary); Assert.Equal("notifyPetUpdates", notifyOp.OperationId); } @@ -104,27 +98,24 @@ public async Task SerializeV32PathItemToV31ProducesExtensions() { Summary = "Get operation", OperationId = "getOp", - Query = new OpenApiOperation + Responses = new OpenApiResponses { - Summary = "Query operation", - OperationId = "queryOp", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse { Description = "Success" } - } - }, - AdditionalOperations = new Dictionary + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp", + Responses = new OpenApiResponses { - ["notify"] = new OpenApiOperation - { - Summary = "Notify operation", - OperationId = "notifyOp", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse { Description = "Success" } - } - } - }, + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + [new HttpMethod("notify")] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "Success" } @@ -156,27 +147,24 @@ public async Task SerializeV32PathItemToV3ProducesExtensions() { Summary = "Get operation", OperationId = "getOp", - Query = new OpenApiOperation + Responses = new OpenApiResponses { - Summary = "Query operation", - OperationId = "queryOp", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse { Description = "Success" } - } - }, - AdditionalOperations = new Dictionary + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp", + Responses = new OpenApiResponses { - ["notify"] = new OpenApiOperation - { - Summary = "Notify operation", - OperationId = "notifyOp", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse { Description = "Success" } - } - } - }, + ["200"] = new OpenApiResponse { Description = "Success" } + } + }, + [new HttpMethod("notify")] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { Description = "Success" } @@ -208,20 +196,17 @@ public void PathItemShallowCopyIncludesV32Fields() { Summary = "Get operation", OperationId = "getOp", - Query = new OpenApiOperation - { - Summary = "Query operation", - OperationId = "queryOp" - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "Notify operation", - OperationId = "notifyOp" - } - }, Responses = new OpenApiResponses() + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "Query operation", + OperationId = "queryOp" + }, + [new HttpMethod("notify")] = new OpenApiOperation + { + Summary = "Notify operation", + OperationId = "notifyOp" } } }; @@ -230,17 +215,27 @@ public void PathItemShallowCopyIncludesV32Fields() var copy = original.CreateShallowCopy(); // Assert - var originalGetOp = original.Operations![HttpMethod.Get]; - var copyGetOp = copy.Operations![HttpMethod.Get]; - - Assert.NotNull(copyGetOp.Query); - Assert.Equal("Query operation", copyGetOp.Query.Summary); - Assert.Equal("queryOp", copyGetOp.Query.OperationId); - - Assert.NotNull(copyGetOp.AdditionalOperations); - Assert.Single(copyGetOp.AdditionalOperations); - Assert.Equal("Notify operation", copyGetOp.AdditionalOperations["notify"].Summary); - Assert.Equal("notifyOp", copyGetOp.AdditionalOperations["notify"].OperationId); + Assert.NotNull(original.Operations); + Assert.NotNull(copy.Operations); + var originalGetOp = original.Operations[HttpMethod.Get]; + var copyGetOp = copy.Operations[HttpMethod.Get]; + Assert.NotNull(originalGetOp); + Assert.NotNull(copyGetOp); + + var copyQueryOp = copy.Operations[new HttpMethod("Query")]; + var originalQueryOp = original.Operations[new HttpMethod("Query")]; + Assert.NotNull(originalQueryOp); + Assert.NotNull(copyQueryOp); + Assert.Equal("Query operation", copyQueryOp.Summary); + Assert.Equal("queryOp", copyQueryOp.OperationId); + + + var originalNotifyOp = original.Operations[new HttpMethod("notify")]; + var copyNotifyOp = copy.Operations[new HttpMethod("notify")]; + Assert.NotNull(originalNotifyOp); + Assert.NotNull(copyNotifyOp); + Assert.Equal("Notify operation", copyNotifyOp.Summary); + Assert.Equal("notifyOp", copyNotifyOp.OperationId); } } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs index 2a5c78ca6..ca62c939f 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs @@ -415,55 +415,52 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() Summary = "get operation", Description = "get description", OperationId = "getOperation", - Query = new OpenApiOperation + Responses = new OpenApiResponses { - Summary = "query operation", - Description = "query description", - OperationId = "queryOperation", - Responses = new OpenApiResponses + ["200"] = new OpenApiResponse { - ["200"] = new OpenApiResponse - { - Description = "query success" - } + Description = "success" } - }, - AdditionalOperations = new Dictionary + } + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "query operation", + Description = "query description", + OperationId = "queryOperation", + Responses = new OpenApiResponses { - ["notify"] = new OpenApiOperation - { - Summary = "notify operation", - Description = "notify description", - OperationId = "notifyOperation", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse - { - Description = "notify success" - } - } - }, - ["custom"] = new OpenApiOperation + ["200"] = new OpenApiResponse { - Summary = "custom operation", - Description = "custom description", - OperationId = "customOperation", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse - { - Description = "custom success" - } - } + Description = "query success" } - }, + } + }, + [new HttpMethod("Notify")] = new OpenApiOperation + { + Summary = "notify operation", + Description = "notify description", + OperationId = "notifyOperation", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "success" + 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" + } + }, } } }; @@ -481,36 +478,36 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() "200": { "description": "success" } - }, - "query": { - "summary": "query operation", - "description": "query description", - "operationId": "queryOperation", + } + }, + "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": "query success" + "description": "notify 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" - } + "custom": { + "summary": "custom operation", + "description": "custom description", + "operationId": "customOperation", + "responses": { + "200": { + "description": "custom success" } } } @@ -540,41 +537,39 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() { Summary = "get operation", OperationId = "getOperation", - Query = new OpenApiOperation + Responses = new OpenApiResponses { - Summary = "query operation", - OperationId = "queryOperation", - Responses = new OpenApiResponses + ["200"] = new OpenApiResponse { - ["200"] = new OpenApiResponse - { - Description = "query success" - } + Description = "success" } - }, - AdditionalOperations = new Dictionary + } + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses { - ["notify"] = new OpenApiOperation + ["200"] = new OpenApiResponse { - Summary = "notify operation", - OperationId = "notifyOperation", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse - { - Description = "notify success" - } - } + Description = "query success" } - }, + } + }, + [new HttpMethod("Notify")] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "success" + Description = "notify success" } } - } + }, + } }; @@ -603,42 +598,39 @@ public async Task SerializeV32FeaturesAsExtensionsInV3Works() { Summary = "get operation", OperationId = "getOperation", - Query = new OpenApiOperation + Responses = new OpenApiResponses { - Summary = "query operation", - OperationId = "queryOperation", - Responses = new OpenApiResponses + ["200"] = new OpenApiResponse { - ["200"] = new OpenApiResponse - { - Description = "query success" - } + Description = "success" } - }, - AdditionalOperations = new Dictionary + } + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses { - ["notify"] = new OpenApiOperation + ["200"] = new OpenApiResponse { - Summary = "notify operation", - OperationId = "notifyOperation", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse - { - Description = "notify success" - } - } + Description = "query success" } - }, + } + }, + [new HttpMethod("Notify")] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "success" + Description = "notify success" } } } - } + }, }; // When @@ -666,38 +658,35 @@ public async Task SerializeV32FeaturesAsExtensionsInV2Works() { Summary = "get operation", OperationId = "getOperation", - Query = new OpenApiOperation + Responses = new OpenApiResponses { - Summary = "query operation", - OperationId = "queryOperation", - Responses = new OpenApiResponses + ["200"] = new OpenApiResponse { - ["200"] = new OpenApiResponse - { - Description = "query success" - } + Description = "success" } }, - AdditionalOperations = new Dictionary + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation", + Responses = new OpenApiResponses { - ["notify"] = new OpenApiOperation + ["200"] = new OpenApiResponse { - Summary = "notify operation", - OperationId = "notifyOperation", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse - { - Description = "notify success" - } - } + Description = "query success" } - }, + } + }, + [new HttpMethod("Notify")] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse { - Description = "success" + Description = "notify success" } } } @@ -729,20 +718,17 @@ public void CopyConstructorCopiesQueryAndAdditionalOperations() { Summary = "get operation", OperationId = "getOperation", - Query = new OpenApiOperation - { - Summary = "query operation", - OperationId = "queryOperation" - }, - AdditionalOperations = new Dictionary - { - ["notify"] = new OpenApiOperation - { - Summary = "notify operation", - OperationId = "notifyOperation" - } - }, Responses = new OpenApiResponses() + }, + [new HttpMethod("Query")] = new OpenApiOperation + { + Summary = "query operation", + OperationId = "queryOperation" + }, + [new HttpMethod("Notify")] = new OpenApiOperation + { + Summary = "notify operation", + OperationId = "notifyOperation" } } }; @@ -751,19 +737,30 @@ public void CopyConstructorCopiesQueryAndAdditionalOperations() var copy = new OpenApiPathItem(original); // Assert - var originalGetOp = original.Operations![HttpMethod.Get]; - var copyGetOp = copy.Operations![HttpMethod.Get]; - - Assert.NotNull(copyGetOp.Query); - Assert.Equal(originalGetOp.Query!.Summary, copyGetOp.Query.Summary); - Assert.Equal(originalGetOp.Query.OperationId, copyGetOp.Query.OperationId); + Assert.NotNull(original.Operations); + Assert.NotNull(copy.Operations); + var originalGetOp = original.Operations[HttpMethod.Get]; + var copyGetOp = copy.Operations[HttpMethod.Get]; + + Assert.NotNull(originalGetOp); + Assert.NotNull(copyGetOp); + + var copyQueryOp = copy.Operations[new HttpMethod("Query")]; + var originalQueryOp = original.Operations[new HttpMethod("Query")]; + Assert.NotNull(copyQueryOp); + Assert.NotNull(originalQueryOp); + Assert.Equal(originalQueryOp!.Summary, copyQueryOp.Summary); + Assert.Equal(originalQueryOp.OperationId, copyQueryOp.OperationId); + + var originalNotifyOp = original.Operations[new HttpMethod("Notify")]; + var copyNotifyOp = copy.Operations[new HttpMethod("Notify")]; + Assert.NotNull(originalNotifyOp); + Assert.NotNull(copyNotifyOp); - Assert.NotNull(copyGetOp.AdditionalOperations); - Assert.Equal(originalGetOp.AdditionalOperations!.Count, copyGetOp.AdditionalOperations.Count); - Assert.Equal(originalGetOp.AdditionalOperations["notify"].Summary, copyGetOp.AdditionalOperations["notify"].Summary); + Assert.Equal(originalNotifyOp.Summary, copyNotifyOp.Summary); // Verify it's a deep copy - copyGetOp.Query.Summary = "modified"; - Assert.NotEqual(originalGetOp.Query.Summary, copyGetOp.Query.Summary); + copyQueryOp.Summary = "modified"; + Assert.NotEqual(originalQueryOp.Summary, copyQueryOp.Summary); } } From 05204435c7276df39d63a86492ee18f6de1dad43 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:35:16 -0400 Subject: [PATCH 09/16] chore: refactoring Signed-off-by: Vincent Biret --- .../OpenApiPathItemDeserializerTests.cs | 224 ++++++++++++++++ .../V32Tests/OpenApiPathItemTests.cs | 241 ------------------ 2 files changed, 224 insertions(+), 241 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs delete mode 100644 test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs 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..c36d4044c --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs @@ -0,0 +1,224 @@ +// 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); + } + + [Fact] + public async Task ParsePathItemWithV32ExtensionsWorks() + { + // Arrange & Act + var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithV32Extensions.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 + Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); + var getOp = Assert.Contains(HttpMethod.Get, pathItem.Operations); + Assert.Equal("getPets", getOp.OperationId); + + // Query operation from extension should now be on the operation + var queryOp = Assert.Contains(new HttpMethod("Query"), pathItem.Operations); + Assert.Equal("Query pets with complex filters", queryOp.Summary); + Assert.Equal("queryPets", queryOp.OperationId); + + // Additional operations from extension should now be on the operation + var notifyOp = Assert.Contains(new HttpMethod("notify"), pathItem.Operations); + Assert.Equal("Notify about pet updates", notifyOp.Summary); + Assert.Equal("notifyPetUpdates", notifyOp.OperationId); + } + + [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("x-oas-query:", yaml); + Assert.Contains("x-oas-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("x-oas-query:", yaml); + Assert.Contains("x-oas-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.Readers.Tests/V32Tests/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs deleted file mode 100644 index f150a7b7c..000000000 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs +++ /dev/null @@ -1,241 +0,0 @@ -// 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 FluentAssertions; -using Microsoft.OpenApi.Reader; -using Microsoft.OpenApi.Tests; -using Xunit; - -namespace Microsoft.OpenApi.Readers.Tests.V32Tests -{ - public class OpenApiPathItemTests - { - 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 = pathItem.Operations[HttpMethod.Get]; - Assert.NotNull(getOp); - Assert.Equal("getPets", getOp.OperationId); - - var postOp = pathItem.Operations[HttpMethod.Post]; - Assert.NotNull(postOp); - 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 = pathItem.Operations[new HttpMethod("Query")]; - Assert.NotNull(queryOp); - 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 = pathItem.Operations[new HttpMethod("subscribe")]; - Assert.NotNull(subscribeOp); - Assert.Equal("Subscribe to pet events", subscribeOp.Summary); - Assert.Equal("subscribePetEvents", subscribeOp.OperationId); - Assert.Single(subscribeOp.Parameters); - Assert.Equal("events", subscribeOp.Parameters[0].Name); - } - - [Fact] - public async Task ParsePathItemWithV32ExtensionsWorks() - { - // Arrange & Act - var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithV32Extensions.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 - Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); - var getOp = pathItem.Operations[HttpMethod.Get]; - Assert.Equal("getPets", getOp.OperationId); - - // Query operation from extension should now be on the operation - var queryOp = pathItem.Operations[new HttpMethod("Query")]; - Assert.NotNull(queryOp); - Assert.Equal("Query pets with complex filters", queryOp.Summary); - Assert.Equal("queryPets", queryOp.OperationId); - - // Additional operations from extension should now be on the operation - var notifyOp = pathItem.Operations[new HttpMethod("notify")]; - Assert.Equal("Notify about pet updates", notifyOp.Summary); - Assert.Equal("notifyPetUpdates", notifyOp.OperationId); - } - - [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("x-oas-query:", yaml); - Assert.Contains("x-oas-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("x-oas-query:", yaml); - Assert.Contains("x-oas-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); - var originalGetOp = original.Operations[HttpMethod.Get]; - var copyGetOp = copy.Operations[HttpMethod.Get]; - Assert.NotNull(originalGetOp); - Assert.NotNull(copyGetOp); - - var copyQueryOp = copy.Operations[new HttpMethod("Query")]; - var originalQueryOp = original.Operations[new HttpMethod("Query")]; - Assert.NotNull(originalQueryOp); - Assert.NotNull(copyQueryOp); - Assert.Equal("Query operation", copyQueryOp.Summary); - Assert.Equal("queryOp", copyQueryOp.OperationId); - - - var originalNotifyOp = original.Operations[new HttpMethod("notify")]; - var copyNotifyOp = copy.Operations[new HttpMethod("notify")]; - Assert.NotNull(originalNotifyOp); - Assert.NotNull(copyNotifyOp); - Assert.Equal("Notify operation", copyNotifyOp.Summary); - Assert.Equal("notifyOp", copyNotifyOp.OperationId); - } - } -} From 4f47916f096ada4335ccf1b4a44154bfa98e1c64 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:36:43 -0400 Subject: [PATCH 10/16] tests: fixes additional operation test file Signed-off-by: Vincent Biret --- ...hItemWithQueryAndAdditionalOperations.yaml | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml index 930d35a43..83ba9c354 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml @@ -30,45 +30,45 @@ paths: type: array items: type: object - additionalOperations: - notify: - summary: Notify about pet updates - operationId: notifyPetUpdates - requestBody: - required: true + 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: - petId: - type: string - event: + subscriptionId: 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 From 71e51eaf8459a657fae9e2706dd00b0a4a88a850 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:40:18 -0400 Subject: [PATCH 11/16] tests: fixes test definitions Signed-off-by: Vincent Biret --- .../OpenApiPathItemDeserializerTests.cs | 35 +++---------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs index c36d4044c..6029672b8 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs @@ -50,33 +50,6 @@ public async Task ParsePathItemWithQueryAndAdditionalOperationsV32Works() Assert.Equal("events", subscribeOp.Parameters[0].Name); } - [Fact] - public async Task ParsePathItemWithV32ExtensionsWorks() - { - // Arrange & Act - var result = await OpenApiDocument.LoadAsync($"{SampleFolderPath}pathItemWithV32Extensions.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 - Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Get)); - var getOp = Assert.Contains(HttpMethod.Get, pathItem.Operations); - Assert.Equal("getPets", getOp.OperationId); - - // Query operation from extension should now be on the operation - var queryOp = Assert.Contains(new HttpMethod("Query"), pathItem.Operations); - Assert.Equal("Query pets with complex filters", queryOp.Summary); - Assert.Equal("queryPets", queryOp.OperationId); - - // Additional operations from extension should now be on the operation - var notifyOp = Assert.Contains(new HttpMethod("notify"), pathItem.Operations); - Assert.Equal("Notify about pet updates", notifyOp.Summary); - Assert.Equal("notifyPetUpdates", notifyOp.OperationId); - } - [Fact] public async Task SerializeV32PathItemToV31ProducesExtensions() { @@ -120,8 +93,8 @@ public async Task SerializeV32PathItemToV31ProducesExtensions() var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_1); // Assert - Assert.Contains("x-oas-query:", yaml); - Assert.Contains("x-oas-additionalOperations:", yaml); + Assert.Contains("Query:", yaml); + Assert.Contains("x-oai-additionalOperations:", yaml); Assert.Contains("queryOp", yaml); Assert.Contains("notifyOp", yaml); } @@ -169,8 +142,8 @@ public async Task SerializeV32PathItemToV3ProducesExtensions() var yaml = await pathItem.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_0); // Assert - Assert.Contains("x-oas-query:", yaml); - Assert.Contains("x-oas-additionalOperations:", yaml); + Assert.Contains("Query:", yaml); + Assert.Contains("x-oai-additionalOperations:", yaml); Assert.Contains("queryOp", yaml); Assert.Contains("notifyOp", yaml); } From cbc2934f9385bd687e7548838436a060e61bb045 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:53:38 -0400 Subject: [PATCH 12/16] fix: do not serialized extraneous operations in v2 Signed-off-by: Vincent Biret --- .../Models/OpenApiPathItem.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs index bd89100b0..8260863d0 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiPathItem.cs @@ -98,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))) + { + 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) { - if (operation.Key != HttpMethod.Trace) - { - writer.WriteOptionalObject( - operation.Key.Method.ToLowerInvariant(), - operation.Value, - (w, o) => o.SerializeAsV2(w)); - } + writer.WriteRequiredMap($"x-oai-{OpenApiConstants.AdditionalOperations}", nonStandardOperations, (w, o) => o.SerializeAsV2(w)); } } From 39f300d26f667be736ac4ba5a551837861cc2c87 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 3 Oct 2025 16:56:11 -0400 Subject: [PATCH 13/16] tests: fixes unit tests for path item serialization Signed-off-by: Vincent Biret --- .../Models/OpenApiPathItemTests.cs | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs index ca62c939f..ab3ece2ba 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs @@ -483,7 +483,7 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() "query": { "summary": "query operation", "description": "query description", - "operationId": "queryOperation", + "operationId": "queryOperation", "responses": { "200": { "description": "query success" @@ -491,7 +491,7 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() } }, "additionalOperations": { - "notify": { + "Notify": { "summary": "notify operation", "description": "notify description", "operationId": "notifyOperation", @@ -501,9 +501,9 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() } } }, - "custom": { + "Custom": { "summary": "custom operation", - "description": "custom description", + "description": "custom description", "operationId": "customOperation", "responses": { "200": { @@ -575,14 +575,13 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() // When var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); - var parsedActualJson = JsonNode.Parse(actualJson); + var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson)); - // Then - should contain x-oas- prefixed extensions in the operation - var getOperation = parsedActualJson!["get"]; - Assert.True(getOperation!["x-oas-query"] != null); - Assert.True(getOperation!["x-oas-additionalOperations"] != null); - Assert.Equal("query operation", getOperation!["x-oas-query"]!["summary"]!.GetValue()); - Assert.Equal("notify operation", getOperation!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + // 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] @@ -635,14 +634,13 @@ public async Task SerializeV32FeaturesAsExtensionsInV3Works() // When var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0); - var parsedActualJson = JsonNode.Parse(actualJson); + var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson)); - // Then - should contain x-oas- prefixed extensions in the operation - var getOperation = parsedActualJson!["get"]; - Assert.True(getOperation!["x-oas-query"] != null); - Assert.True(getOperation!["x-oas-additionalOperations"] != null); - Assert.Equal("query operation", getOperation!["x-oas-query"]!["summary"]!.GetValue()); - Assert.Equal("notify operation", getOperation!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + // 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] @@ -695,14 +693,13 @@ public async Task SerializeV32FeaturesAsExtensionsInV2Works() // When var actualJson = await pathItem.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0); - var parsedActualJson = JsonNode.Parse(actualJson); + var parsedActualJson = Assert.IsType(JsonNode.Parse(actualJson)); - // Then - should contain x-oas- prefixed extensions in the operation - var getOperation = parsedActualJson!["get"]; - Assert.True(getOperation!["x-oas-query"] != null); - Assert.True(getOperation!["x-oas-additionalOperations"] != null); - Assert.Equal("query operation", getOperation!["x-oas-query"]!["summary"]!.GetValue()); - Assert.Equal("notify operation", getOperation!["x-oas-additionalOperations"]!["notify"]!["summary"]!.GetValue()); + // 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] @@ -739,28 +736,17 @@ public void CopyConstructorCopiesQueryAndAdditionalOperations() // Assert Assert.NotNull(original.Operations); Assert.NotNull(copy.Operations); - var originalGetOp = original.Operations[HttpMethod.Get]; - var copyGetOp = copy.Operations[HttpMethod.Get]; + Assert.Contains(HttpMethod.Get, original.Operations); + Assert.Contains(HttpMethod.Get, copy.Operations); - Assert.NotNull(originalGetOp); - Assert.NotNull(copyGetOp); - - var copyQueryOp = copy.Operations[new HttpMethod("Query")]; - var originalQueryOp = original.Operations[new HttpMethod("Query")]; - Assert.NotNull(copyQueryOp); - Assert.NotNull(originalQueryOp); - Assert.Equal(originalQueryOp!.Summary, copyQueryOp.Summary); + 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 = original.Operations[new HttpMethod("Notify")]; - var copyNotifyOp = copy.Operations[new HttpMethod("Notify")]; - Assert.NotNull(originalNotifyOp); - Assert.NotNull(copyNotifyOp); + var originalNotifyOp = Assert.Contains(new HttpMethod("Notify"), original.Operations); + var copyNotifyOp = Assert.Contains(new HttpMethod("Notify"), copy.Operations); Assert.Equal(originalNotifyOp.Summary, copyNotifyOp.Summary); - - // Verify it's a deep copy - copyQueryOp.Summary = "modified"; - Assert.NotEqual(originalQueryOp.Summary, copyQueryOp.Summary); } } From fefec092c6b01e0b90357bde41dfb3a16a7a7cb8 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Sun, 5 Oct 2025 16:48:53 -0400 Subject: [PATCH 14/16] test: adds tests for 30 deserialization of path items with extra ops Signed-off-by: Vincent Biret --- .../OpenApiPathItemDeserializerTests.cs | 47 +++++++++++++ ...hItemWithQueryAndAdditionalOperations.yaml | 68 +++++++++---------- 2 files changed, 81 insertions(+), 34 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiPathItemDeserializerTests.cs 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 index 269347f4d..a91cef330 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml @@ -12,8 +12,8 @@ paths: responses: '200': description: List of pets - x-oai-additionalOperations: - query: + x-oai-additionalOperations: + query: summary: Query pets with complex filters operationId: queryPets parameters: @@ -31,44 +31,44 @@ paths: type: array items: type: object - notify: - summary: Notify about pet updates - operationId: notifyPetUpdates - requestBody: - required: true + 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: - petId: + subscriptionId: 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 From 0781a89056210eb4eea53d6a38530564db999551 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Sun, 5 Oct 2025 16:51:50 -0400 Subject: [PATCH 15/16] chore: moves tests definitions to the right location Signed-off-by: Vincent Biret --- .../OpenApiPathItemDeserializerTests.cs | 145 ---------------- .../Models/OpenApiPathItemTests.cs | 155 +++++++++++++++++- 2 files changed, 150 insertions(+), 150 deletions(-) diff --git a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs index 6029672b8..66d2364c2 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemDeserializerTests.cs @@ -49,149 +49,4 @@ public async Task ParsePathItemWithQueryAndAdditionalOperationsV32Works() Assert.Single(subscribeOp.Parameters); Assert.Equal("events", subscribeOp.Parameters[0].Name); } - - [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/Models/OpenApiPathItemTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs index fabd974de..5f76693a3 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiPathItemTests.cs @@ -426,7 +426,7 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() [new HttpMethod("Query")] = new OpenApiOperation { Summary = "query operation", - Description = "query description", + Description = "query description", OperationId = "queryOperation", Responses = new OpenApiResponses { @@ -440,7 +440,7 @@ public async Task SerializeAsV32JsonWithQueryAndAdditionalOperationsWorks() { Summary = "notify operation", Description = "notify description", - OperationId = "notifyOperation", + OperationId = "notifyOperation", Responses = new OpenApiResponses { ["200"] = new OpenApiResponse @@ -530,7 +530,7 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() var pathItem = new OpenApiPathItem { Summary = "summary", - Description = "description", + Description = "description", Operations = new Dictionary { [HttpMethod.Get] = new OpenApiOperation @@ -569,7 +569,7 @@ public async Task SerializeV32FeaturesAsExtensionsInV31Works() } } }, - + } }; @@ -589,7 +589,7 @@ public async Task SerializeV32FeaturesAsExtensionsInV3Works() { var pathItem = new OpenApiPathItem { - Summary = "summary", + Summary = "summary", Description = "description", Operations = new Dictionary { @@ -749,4 +749,149 @@ public void CopyConstructorCopiesQueryAndAdditionalOperations() 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); + } } From 49f9560b38009d32a9dc43f99cdeb4bd2c3e8b42 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Sun, 5 Oct 2025 16:45:54 -0400 Subject: [PATCH 16/16] tests: adds deserialization tests for 3.1 path item extra operations Signed-off-by: Vincent Biret --- .../OpenApiPathItemDeserializerTests.cs | 47 +++++++++++++ ...hItemWithQueryAndAdditionalOperations.yaml | 68 +++++++++---------- 2 files changed, 81 insertions(+), 34 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiPathItemDeserializerTests.cs 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 index ff9dac1c7..8e7db3731 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiPathItem/pathItemWithQueryAndAdditionalOperations.yaml @@ -12,8 +12,8 @@ paths: responses: '200': description: List of pets - x-oai-additionalOperations: - query: + x-oai-additionalOperations: + query: summary: Query pets with complex filters operationId: queryPets parameters: @@ -31,44 +31,44 @@ paths: type: array items: type: object - notify: - summary: Notify about pet updates - operationId: notifyPetUpdates - requestBody: - required: true + 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: - petId: + subscriptionId: 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