Skip to content

Commit 3d009ae

Browse files
committed
feat(document): add GetOperationById to search operations across paths and webhooks
1 parent 3f012bc commit 3d009ae

3 files changed

Lines changed: 221 additions & 0 deletions

File tree

src/Microsoft.OpenApi/Models/OpenApiDocument.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,33 @@ static bool AddToDictionary<TValue>(IDictionary<string, TValue> dict, string key
857857
// Register only if it was actually added to the collection
858858
return added && (Workspace?.RegisterComponentForDocument(this, componentToRegister, id) ?? false);
859859
}
860+
861+
/// <summary>
862+
/// Finds an operation in the document by its operation ID.
863+
/// </summary>
864+
/// <param name="operationId">The operation ID to search for.</param>
865+
/// <returns>The matching <see cref="OpenApiOperation"/>, or <see langword="null"/> if not found.</returns>
866+
public OpenApiOperation? GetOperationById(string operationId)
867+
{
868+
Utils.CheckArgumentNullOrEmpty(operationId);
869+
870+
var allPathItems = Webhooks is not null
871+
? Paths.Values.Concat(Webhooks.Values)
872+
: Paths.Values;
873+
874+
foreach (var pathItem in allPathItems)
875+
{
876+
if (pathItem.Operations is not null)
877+
{
878+
foreach (var operation in pathItem.Operations.Values)
879+
{
880+
if (string.Equals(operation.OperationId, operationId, StringComparison.Ordinal))
881+
return operation;
882+
}
883+
}
884+
}
885+
return null;
886+
}
860887
}
861888

862889
internal class FindSchemaReferences : OpenApiVisitorBase
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
Microsoft.OpenApi.OpenApiDocument.GetOperationById(string! operationId) -> Microsoft.OpenApi.OpenApiOperation?

test/Microsoft.OpenApi.Tests/Models/OpenApiDocumentTests.cs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,5 +2504,198 @@ public async Task SerializeDocumentWithSelfPropertyAsV30WritesAsExtension()
25042504
// Assert
25052505
Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral());
25062506
}
2507+
2508+
[Fact]
2509+
public void GetOperationById_ReturnsMatchingOperation()
2510+
{
2511+
var operation = new OpenApiOperation { OperationId = "getUser" };
2512+
var doc = new OpenApiDocument
2513+
{
2514+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2515+
Paths = new OpenApiPaths
2516+
{
2517+
["/users/{id}"] = new OpenApiPathItem
2518+
{
2519+
Operations = new Dictionary<HttpMethod, OpenApiOperation>
2520+
{
2521+
[HttpMethod.Get] = operation
2522+
}
2523+
}
2524+
}
2525+
};
2526+
2527+
var result = doc.GetOperationById("getUser");
2528+
2529+
Assert.Same(operation, result);
2530+
}
2531+
2532+
[Fact]
2533+
public void GetOperationById_ReturnsNullWhenNotFound()
2534+
{
2535+
var doc = new OpenApiDocument
2536+
{
2537+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2538+
Paths = new OpenApiPaths
2539+
{
2540+
["/users"] = new OpenApiPathItem
2541+
{
2542+
Operations = new Dictionary<HttpMethod, OpenApiOperation>
2543+
{
2544+
[HttpMethod.Get] = new OpenApiOperation { OperationId = "listUsers" }
2545+
}
2546+
}
2547+
}
2548+
};
2549+
2550+
var result = doc.GetOperationById("nonExistentId");
2551+
2552+
Assert.Null(result);
2553+
}
2554+
2555+
[Fact]
2556+
public void GetOperationById_SearchesWebhooks()
2557+
{
2558+
var webhookOperation = new OpenApiOperation { OperationId = "onUserCreated" };
2559+
var doc = new OpenApiDocument
2560+
{
2561+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2562+
Paths = [],
2563+
Webhooks = new Dictionary<string, IOpenApiPathItem>
2564+
{
2565+
["userCreated"] = new OpenApiPathItem
2566+
{
2567+
Operations = new Dictionary<HttpMethod, OpenApiOperation>
2568+
{
2569+
[HttpMethod.Post] = webhookOperation
2570+
}
2571+
}
2572+
}
2573+
};
2574+
2575+
var result = doc.GetOperationById("onUserCreated");
2576+
2577+
Assert.Same(webhookOperation, result);
2578+
}
2579+
2580+
[Fact]
2581+
public void GetOperationById_IsCaseSensitive()
2582+
{
2583+
var doc = new OpenApiDocument
2584+
{
2585+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2586+
Paths = new OpenApiPaths
2587+
{
2588+
["/users"] = new OpenApiPathItem
2589+
{
2590+
Operations = new Dictionary<HttpMethod, OpenApiOperation>
2591+
{
2592+
[HttpMethod.Get] = new OpenApiOperation { OperationId = "getUser" }
2593+
}
2594+
}
2595+
}
2596+
};
2597+
2598+
Assert.NotNull(doc.GetOperationById("getUser"));
2599+
Assert.Null(doc.GetOperationById("GetUser"));
2600+
Assert.Null(doc.GetOperationById("GETUSER"));
2601+
}
2602+
2603+
[Fact]
2604+
public void GetOperationById_ResolvesOperationThroughPathItemReference()
2605+
{
2606+
const string yaml = """
2607+
openapi: '3.1.0'
2608+
info:
2609+
title: Test
2610+
version: 1.0.0
2611+
paths:
2612+
/users:
2613+
$ref: '#/components/pathItems/userPathItem'
2614+
components:
2615+
pathItems:
2616+
userPathItem:
2617+
get:
2618+
operationId: listUsers
2619+
responses:
2620+
'200':
2621+
description: OK
2622+
""";
2623+
2624+
var doc = OpenApiDocument.Parse(yaml, OpenApiConstants.Yaml, SettingsFixture.ReaderSettings).Document;
2625+
doc.Workspace.RegisterComponents(doc);
2626+
2627+
var result = doc.GetOperationById("listUsers");
2628+
2629+
Assert.NotNull(result);
2630+
Assert.Equal("listUsers", result.OperationId);
2631+
}
2632+
2633+
[Fact]
2634+
public void GetOperationById_DuplicateIdReturnsFirstMatch()
2635+
{
2636+
// operationId must be unique per spec, but if not, Paths takes priority over Webhooks
2637+
var pathsOperation = new OpenApiOperation { OperationId = "duplicateId" };
2638+
var webhooksOperation = new OpenApiOperation { OperationId = "duplicateId" };
2639+
var doc = new OpenApiDocument
2640+
{
2641+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2642+
Paths = new OpenApiPaths
2643+
{
2644+
["/users"] = new OpenApiPathItem
2645+
{
2646+
Operations = new Dictionary<HttpMethod, OpenApiOperation>
2647+
{
2648+
[HttpMethod.Get] = pathsOperation
2649+
}
2650+
}
2651+
},
2652+
Webhooks = new Dictionary<string, IOpenApiPathItem>
2653+
{
2654+
["userEvent"] = new OpenApiPathItem
2655+
{
2656+
Operations = new Dictionary<HttpMethod, OpenApiOperation>
2657+
{
2658+
[HttpMethod.Post] = webhooksOperation
2659+
}
2660+
}
2661+
}
2662+
};
2663+
2664+
var result = doc.GetOperationById("duplicateId");
2665+
2666+
Assert.Same(pathsOperation, result);
2667+
}
2668+
2669+
[Fact]
2670+
public void GetOperationById_UnresolvedPathItemReferenceIsSkipped()
2671+
{
2672+
// An unresolved $ref has Target = null, so Operations = null — should be skipped gracefully
2673+
var unresolvedRef = new OpenApiPathItemReference("nonExistentPathItem", null);
2674+
var doc = new OpenApiDocument
2675+
{
2676+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2677+
Paths = new OpenApiPaths
2678+
{
2679+
["/users"] = unresolvedRef
2680+
}
2681+
};
2682+
2683+
var result = doc.GetOperationById("anyId");
2684+
2685+
Assert.Null(result);
2686+
}
2687+
2688+
[Fact]
2689+
public void GetOperationById_ThrowsOnNullOrEmptyId()
2690+
{
2691+
var doc = new OpenApiDocument
2692+
{
2693+
Info = new OpenApiInfo { Title = "Test", Version = "1.0" },
2694+
Paths = []
2695+
};
2696+
2697+
Assert.Throws<ArgumentNullException>(() => doc.GetOperationById(null!));
2698+
Assert.Throws<ArgumentNullException>(() => doc.GetOperationById(string.Empty));
2699+
}
25072700
}
25082701
}

0 commit comments

Comments
 (0)