Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
93d8a47
add new v32 properties for Path Items
kilifu Oct 1, 2025
9f0d2c1
Merge branch 'feat/oai-3-2-support' into feat/5-new-fields-path-item
baywet Oct 2, 2025
1cac1a2
move query and AdditionalOperations to OpenApiOperation
kilifu Oct 3, 2025
e3ae554
Merge branch 'feat/5-new-fields-path-item' of https://github.com/Bink…
kilifu Oct 3, 2025
40a22f8
Merge branch 'feat/oai-3-2-support' into feat/5-new-fields-path-item
kilifu Oct 3, 2025
a256053
chore: reverts some undesired changes
baywet Oct 3, 2025
f7dc7ee
feat: adds support for additional operations in OAI 3.2
baywet Oct 3, 2025
d608cdc
chore: linting
baywet Oct 3, 2025
1a104d9
chore: fixes test files for path item additional operations
baywet Oct 3, 2025
c6b1b0e
chore: updates public export
baywet Oct 3, 2025
4801b06
chore: make new unit tests build
baywet Oct 3, 2025
0520443
chore: refactoring
baywet Oct 3, 2025
4f47916
tests: fixes additional operation test file
baywet Oct 3, 2025
71e51ea
tests: fixes test definitions
baywet Oct 3, 2025
cbc2934
fix: do not serialized extraneous operations in v2
baywet Oct 3, 2025
39f300d
tests: fixes unit tests for path item serialization
baywet Oct 3, 2025
30227ef
Merge branch 'feat/oai-3-2-support' into feat/5-new-fields-path-item
baywet Oct 3, 2025
fefec09
test: adds tests for 30 deserialization of path items with extra ops
baywet Oct 5, 2025
0781a89
chore: moves tests definitions to the right location
baywet Oct 5, 2025
49f9560
tests: adds deserialization tests for 3.1 path item extra operations
baywet Oct 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/Microsoft.OpenApi/Models/Interfaces/IOpenApiPathItem.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@

using System.Collections.Generic;
using System.Collections.Generic;
using System.Net.Http;

namespace Microsoft.OpenApi;
Expand Down
5 changes: 5 additions & 0 deletions src/Microsoft.OpenApi/Models/OpenApiConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,11 @@ public static class OpenApiConstants
/// </summary>
public static readonly Version version2_0 = new(2, 0);

/// <summary>
/// Field: AdditionalOperations
/// </summary>
public const string AdditionalOperations = "additionalOperations";

/// <summary>
/// Field: BasePath
/// </summary>
Expand Down
47 changes: 46 additions & 1 deletion src/Microsoft.OpenApi/Models/OpenApiPathItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;

namespace Microsoft.OpenApi
Expand Down Expand Up @@ -126,6 +127,31 @@
writer.WriteEndObject();
}

internal static readonly HashSet<string> _standardHttp2MethodsNames = new(StringComparer.OrdinalIgnoreCase)
{
"get",
"put",
"post",
"delete",
"options",
"head",
"patch",
};

internal static readonly HashSet<string> _standardHttp30MethodsNames = new(_standardHttp2MethodsNames, StringComparer.OrdinalIgnoreCase)
{
"trace",
};

internal static readonly HashSet<string> _standardHttp31MethodsNames = new(_standardHttp30MethodsNames, StringComparer.OrdinalIgnoreCase)
{
};

internal static readonly HashSet<string> _standardHttp32MethodsNames = new(_standardHttp31MethodsNames, StringComparer.OrdinalIgnoreCase)
{
"query",
};

internal virtual void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version,
Action<IOpenApiWriter, IOpenApiSerializable> callback)
{
Expand All @@ -139,16 +165,35 @@
// 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
Expand Down
28 changes: 26 additions & 2 deletions src/Microsoft.OpenApi/Reader/V32/OpenApiPathItemDeserializer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;

namespace Microsoft.OpenApi.Reader.V32
Expand Down Expand Up @@ -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<OpenApiPathItem> _pathItemPatternFields =
new()
{
Expand Down
246 changes: 246 additions & 0 deletions test/Microsoft.OpenApi.Readers.Tests/V32Tests/OpenApiPathItemTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// 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));
var getOp = pathItem.Operations[HttpMethod.Get];
Assert.Equal("getPets", getOp.OperationId);

Assert.True(pathItem.Operations?.ContainsKey(HttpMethod.Post));
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(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(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);
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
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);
}

[Fact]
public async Task SerializeV32PathItemToV31ProducesExtensions()
{
// Arrange
var pathItem = new OpenApiPathItem
{
Summary = "Test path",
Operations = new Dictionary<HttpMethod, OpenApiOperation>
{
[HttpMethod.Get] = new OpenApiOperation
{
Summary = "Get operation",
OperationId = "getOp",
Query = new OpenApiOperation
{
Summary = "Query operation",
OperationId = "queryOp",
Responses = new OpenApiResponses
{
["200"] = new OpenApiResponse { Description = "Success" }
}
},
AdditionalOperations = new Dictionary<string, OpenApiOperation>
{
["notify"] = new OpenApiOperation
{
Summary = "Notify operation",
OperationId = "notifyOp",
Responses = new OpenApiResponses
{
["200"] = new OpenApiResponse { Description = "Success" }
}
}
},
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, OpenApiOperation>
{
[HttpMethod.Get] = new OpenApiOperation
{
Summary = "Get operation",
OperationId = "getOp",
Query = new OpenApiOperation
{
Summary = "Query operation",
OperationId = "queryOp",
Responses = new OpenApiResponses
{
["200"] = new OpenApiResponse { Description = "Success" }
}
},
AdditionalOperations = new Dictionary<string, OpenApiOperation>
{
["notify"] = new OpenApiOperation
{
Summary = "Notify operation",
OperationId = "notifyOp",
Responses = new OpenApiResponses
{
["200"] = new OpenApiResponse { Description = "Success" }
}
}
},
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, OpenApiOperation>
{
[HttpMethod.Get] = new OpenApiOperation
{
Summary = "Get operation",
OperationId = "getOp",
Query = new OpenApiOperation
{
Summary = "Query operation",
OperationId = "queryOp"
},
AdditionalOperations = new Dictionary<string, OpenApiOperation>
{
["notify"] = new OpenApiOperation
{
Summary = "Notify operation",
OperationId = "notifyOp"
}
},
Responses = new OpenApiResponses()
}
}
};

// Act
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);
}
}
}
Loading