Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions src/Microsoft.OpenApi/Models/OpenApiDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,33 @@ static bool AddToDictionary<TValue>(IDictionary<string, TValue> dict, string key
// Register only if it was actually added to the collection
return added && (Workspace?.RegisterComponentForDocument(this, componentToRegister, id) ?? false);
}

/// <summary>
/// Finds an operation in the document by its operation ID.
/// </summary>
/// <param name="operationId">The operation ID to search for.</param>
/// <returns>The matching <see cref="OpenApiOperation"/>, or <see langword="null"/> if not found.</returns>
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is running at o(n) (n being the number of operations) and going to be really slow for larger API descriptions. Could you look into optimizing the search please?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! To address the O(n) concern, I'm thinking of building a lazy-initialized Dictionary<string, OpenApiOperation> index on the first call to GetOperationById, making subsequent lookups O(1). The trade-off is that the cache becomes stale if Paths or Webhooks are mutated after the index is built — but since OpenApiDocument is typically read-only after parsing, this seems acceptable.

Does this approach work for you, or do you have a different optimization in mind?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should work, but before we read into that index, we should be able to correlate whether the sources have changed since the last time we built the index and rebuild it if required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification! To implement change detection, I need to understand how to track mutations to Paths and Webhooks. A few options I'm considering:

Count-based: Compare Paths.Count and Webhooks?.Count — simple but misses replacements (same count, different items)
Version counter: Increment a counter whenever Paths or Webhooks is mutated — but OpenApiPaths doesn't expose change notifications today
Wrap collections: Replace OpenApiPaths with an observable/versioned collection that notifies the document on change

Do you have a preferred approach, or is there existing infrastructure in the codebase I should leverage?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have you considered storing the hash code for those values next to the index. And ahead of querying the index, if the current hash codes don't match, recompute + update the values. ??

@Mahdigln Mahdigln Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before implementing, one question: how should the hash be computed for Paths and Webhooks? Should I use the existing GetHashCodeAsync on the document, or compute something lighter like combining Paths.Count, Webhooks?.Count, and the hash codes of the keys?

{
if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
return operation;
}
}
}
return null;
}
}

internal class FindSchemaReferences : OpenApiVisitorBase
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.OpenApi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
Microsoft.OpenApi.OpenApiDocument.GetOperationById(string! operationId) -> Microsoft.OpenApi.OpenApiOperation?
193 changes: 193 additions & 0 deletions test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, OpenApiOperation>
{
[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, OpenApiOperation>
{
[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<string, IOpenApiPathItem>
{
["userCreated"] = new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation>
{
[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, OpenApiOperation>
{
[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, OpenApiOperation>
{
[HttpMethod.Get] = pathsOperation
}
}
},
Webhooks = new Dictionary<string, IOpenApiPathItem>
{
["userEvent"] = new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation>
{
[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<ArgumentNullException>(() => doc.GetOperationById(null!));
Assert.Throws<ArgumentNullException>(() => doc.GetOperationById(string.Empty));
}
}
}