From 3d009aee09a3a4dbe011406ee359295c4d01764e Mon Sep 17 00:00:00 2001 From: Mahdi Golestan Date: Sat, 15 Aug 2026 17:24:43 +0330 Subject: [PATCH] feat(document): add GetOperationById to search operations across paths and webhooks --- .../Models/OpenApiDocument.cs | 27 +++ src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 1 + .../Models/OpenApiDocumentTests.cs | 193 ++++++++++++++++++ 3 files changed, 221 insertions(+) diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 68259a618..d9152952c 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -857,6 +857,33 @@ static bool AddToDictionary(IDictionary dict, string key // Register only if it was actually added to the collection return added && (Workspace?.RegisterComponentForDocument(this, componentToRegister, id) ?? false); } + + /// + /// Finds an operation in the document by its operation ID. + /// + /// The operation ID to search for. + /// The matching , or if not found. + public OpenApiOperation? GetOperationById(string operationId) + { + Utils.CheckArgumentNullOrEmpty(operationId); + + var allPathItems = Webhooks is not null + ? Paths.Values.Concat(Webhooks.Values) + : Paths.Values; + + foreach (var pathItem in allPathItems) + { + if (pathItem.Operations is not null) + { + foreach (var operation in pathItem.Operations.Values) + { + if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal)) + return operation; + } + } + } + return null; + } } internal class FindSchemaReferences : OpenApiVisitorBase diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..cc7b76897 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.OpenApi.OpenApiDocument.GetOperationById(string! operationId) -> Microsoft.OpenApi.OpenApiOperation? diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs index 3e543cc70..8b4629664 100644 --- a/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs @@ -2504,5 +2504,198 @@ public async Task SerializeDocumentWithSelfPropertyAsV30WritesAsExtension() // Assert Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); } + + [Fact] + public void GetOperationById_ReturnsMatchingOperation() + { + var operation = new OpenApiOperation { OperationId = "getUser" }; + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users/{id}"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = operation + } + } + } + }; + + var result = doc.GetOperationById("getUser"); + + Assert.Same(operation, result); + } + + [Fact] + public void GetOperationById_ReturnsNullWhenNotFound() + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "listUsers" } + } + } + } + }; + + var result = doc.GetOperationById("nonExistentId"); + + Assert.Null(result); + } + + [Fact] + public void GetOperationById_SearchesWebhooks() + { + var webhookOperation = new OpenApiOperation { OperationId = "onUserCreated" }; + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = [], + Webhooks = new Dictionary + { + ["userCreated"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = webhookOperation + } + } + } + }; + + var result = doc.GetOperationById("onUserCreated"); + + Assert.Same(webhookOperation, result); + } + + [Fact] + public void GetOperationById_IsCaseSensitive() + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new OpenApiOperation { OperationId = "getUser" } + } + } + } + }; + + Assert.NotNull(doc.GetOperationById("getUser")); + Assert.Null(doc.GetOperationById("GetUser")); + Assert.Null(doc.GetOperationById("GETUSER")); + } + + [Fact] + public void GetOperationById_ResolvesOperationThroughPathItemReference() + { + const string yaml = """ + openapi: '3.1.0' + info: + title: Test + version: 1.0.0 + paths: + /users: + $ref: '#/components/pathItems/userPathItem' + components: + pathItems: + userPathItem: + get: + operationId: listUsers + responses: + '200': + description: OK + """; + + var doc = OpenApiDocument.Parse(yaml, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings).Document; + doc.Workspace.RegisterComponents(doc); + + var result = doc.GetOperationById("listUsers"); + + Assert.NotNull(result); + Assert.Equal("listUsers", result.OperationId); + } + + [Fact] + public void GetOperationById_DuplicateIdReturnsFirstMatch() + { + // operationId must be unique per spec, but if not, Paths takes priority over Webhooks + var pathsOperation = new OpenApiOperation { OperationId = "duplicateId" }; + var webhooksOperation = new OpenApiOperation { OperationId = "duplicateId" }; + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = pathsOperation + } + } + }, + Webhooks = new Dictionary + { + ["userEvent"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = webhooksOperation + } + } + } + }; + + var result = doc.GetOperationById("duplicateId"); + + Assert.Same(pathsOperation, result); + } + + [Fact] + public void GetOperationById_UnresolvedPathItemReferenceIsSkipped() + { + // An unresolved $ref has Target = null, so Operations = null — should be skipped gracefully + var unresolvedRef = new OpenApiPathItemReference("nonExistentPathItem", null); + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/users"] = unresolvedRef + } + }; + + var result = doc.GetOperationById("anyId"); + + Assert.Null(result); + } + + [Fact] + public void GetOperationById_ThrowsOnNullOrEmptyId() + { + var doc = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + Paths = [] + }; + + Assert.Throws(() => doc.GetOperationById(null!)); + Assert.Throws(() => doc.GetOperationById(string.Empty)); + } } }