From 2665ab104fe94785d3df7016262ec9d7ae6671a2 Mon Sep 17 00:00:00 2001 From: Andrew Butler <1628649+AButler@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:18:41 +0100 Subject: [PATCH 1/3] Initial fix for template path matching --- .../Helpers/OpenApiExtensions.cs | 60 +++++++++++-- .../ResponseValidatorTests.cs | 42 +++++++++ .../TestData/LiteralAndTemplatedPath.yaml | 35 +++++++- .../LiteralAndTemplatedPathReversed.yaml | 85 +++++++++++++++++++ 4 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPathReversed.yaml diff --git a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs index f7a436c..3d2a40d 100644 --- a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs +++ b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs @@ -67,7 +67,7 @@ out IOpenApiPathItem path { var requestPathString = new PathString(requestPath); - IOpenApiPathItem? matchingTemplatePathItem = null; + var matchingPaths = new OpenApiPaths(); foreach (var kvp in paths) { @@ -80,7 +80,7 @@ out IOpenApiPathItem path if (isTemplatePath) { - matchingTemplatePathItem = kvp.Value; + matchingPaths.Add(kvp.Key, kvp.Value); continue; } @@ -88,9 +88,10 @@ out IOpenApiPathItem path return true; } - if (matchingTemplatePathItem is not null) + if (matchingPaths.Count > 0) { - path = matchingTemplatePathItem; + var bestMatch = GetBestMatch(matchingPaths); + path = bestMatch; return true; } @@ -98,6 +99,50 @@ out IOpenApiPathItem path return false; } + private static IOpenApiPathItem GetBestMatch(OpenApiPaths matchingPaths) + { + IOpenApiPathItem? bestMatch = null; + var bestLiteralSegmentCount = -1; + var bestLiteralPrefixCount = -1; + + foreach (var kvp in matchingPaths) + { + var specPath = new PathString(kvp.Key); + var literalSegmentCount = 0; + var literalPrefixCount = 0; + + for (var i = 0; i < specPath.Segments.Length; i++) + { + if (IsTemplateSegment(specPath.Segments[i])) + { + continue; + } + + literalSegmentCount++; + + if (literalPrefixCount == i) + { + literalPrefixCount++; + } + } + + if ( + literalSegmentCount > bestLiteralSegmentCount + || ( + literalSegmentCount == bestLiteralSegmentCount + && literalPrefixCount > bestLiteralPrefixCount + ) + ) + { + bestMatch = kvp.Value; + bestLiteralSegmentCount = literalSegmentCount; + bestLiteralPrefixCount = literalPrefixCount; + } + } + + return bestMatch!; + } + private static bool IsPathMatch( PathString specPath, PathString requestPath, @@ -115,7 +160,7 @@ out bool isTemplatePath { var segment = specPath.Segments[i]; - if (segment.StartsWith('{') && segment.EndsWith('}')) + if (IsTemplateSegment(segment)) { // Is template parameter, so skip checking isTemplatePath = true; @@ -136,4 +181,9 @@ out bool isTemplatePath return true; } + + private static bool IsTemplateSegment(string segment) + { + return segment.StartsWith('{') && segment.EndsWith('}'); + } } diff --git a/test/OpenApiValidate.Tests/ResponseValidatorTests.cs b/test/OpenApiValidate.Tests/ResponseValidatorTests.cs index 8cc1ff0..94e4e44 100644 --- a/test/OpenApiValidate.Tests/ResponseValidatorTests.cs +++ b/test/OpenApiValidate.Tests/ResponseValidatorTests.cs @@ -265,6 +265,48 @@ public async Task LiteralAndTemplatedPath_DeleteMeUser() validateAction.ShouldNotThrow(); } + [Fact] + public async Task LiteralAndTemplatedPath_DeleteMeUserRole() + { + var openApiDocument = await GetDocument("TestData/LiteralAndTemplatedPath.yaml"); + + var validator = new OpenApiValidator(openApiDocument); + + var request = new Request( + "DELETE", + new Uri("http://api.example.com/v1/user/me/role/admin") + ); + var response = new Response(204); + + var validateAction = () => + { + validator.Validate(request, response); + }; + + validateAction.ShouldNotThrow(); + } + + [Fact] + public async Task LiteralAndTemplatedPath_DeleteMeUserRole_ReversedDefinitionOrder() + { + var openApiDocument = await GetDocument("TestData/LiteralAndTemplatedPathReversed.yaml"); + + var validator = new OpenApiValidator(openApiDocument); + + var request = new Request( + "DELETE", + new Uri("http://api.example.com/v1/user/me/role/admin") + ); + var response = new Response(204); + + var validateAction = () => + { + validator.Validate(request, response); + }; + + validateAction.ShouldNotThrow(); + } + private static async Task GetDocument(string filename) { var settings = new OpenApiReaderSettings(); diff --git a/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml b/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml index 114b025..86e16b0 100644 --- a/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml +++ b/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml @@ -49,4 +49,37 @@ paths: summary: Deletes my user. responses: "204": - description: User deleted successfully \ No newline at end of file + description: User deleted successfully + + /user/me/role/{roleId}: + get: + summary: Returns a single role + responses: + "200": + description: A role object + content: + application/json: + schema: + type: object + properties: + id: + type: string + delete: + summary: Deletes a role + responses: + "204": + description: Role deleted successfully + + /user/{userId}/role/{roleId}: + get: + summary: Returns a single role + responses: + "200": + description: A role object + content: + application/json: + schema: + type: object + properties: + id: + type: string \ No newline at end of file diff --git a/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPathReversed.yaml b/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPathReversed.yaml new file mode 100644 index 0000000..571fef1 --- /dev/null +++ b/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPathReversed.yaml @@ -0,0 +1,85 @@ +openapi: 3.0.0 + +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 + +servers: + - url: http://api.example.com/v1 + description: Optional server description, e.g. Main (production) server + - url: http://staging-api.example.com + description: Optional server description, e.g. Internal staging server for testing + +paths: + /user/{UserId}: + get: + summary: Returns a single user + parameters: + - name: UserId + in: path + required: true + schema: + type: string + responses: + "200": + description: A user object + content: + application/json: + schema: + type: object + properties: + id: + type: string + + /user/me: + get: + summary: Returns my user. + responses: + "200": + description: A user object + content: + application/json: + schema: + type: object + properties: + id: + type: string + delete: + summary: Deletes my user. + responses: + "204": + description: User deleted successfully + + /user/{userId}/role/{roleId}: + get: + summary: Returns a single role + responses: + "200": + description: A role object + content: + application/json: + schema: + type: object + properties: + id: + type: string + + /user/me/role/{roleId}: + get: + summary: Returns a single role + responses: + "200": + description: A role object + content: + application/json: + schema: + type: object + properties: + id: + type: string + delete: + summary: Deletes a role + responses: + "204": + description: Role deleted successfully From 5c2ac4d810358220cee0ad26645765a78a593e78 Mon Sep 17 00:00:00 2001 From: Andrew Butler <1628649+AButler@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:27:08 +0100 Subject: [PATCH 2/3] Refactor to simplify logic --- .../Helpers/OpenApiExtensions.cs | 73 +++++++++---------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs index 3d2a40d..f290d90 100644 --- a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs +++ b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs @@ -67,7 +67,8 @@ out IOpenApiPathItem path { var requestPathString = new PathString(requestPath); - var matchingPaths = new OpenApiPaths(); + TemplateMatchScore bestMatchScore = new(0, 0); + IOpenApiPathItem? matchingTemplatePathItem = null; foreach (var kvp in paths) { @@ -80,7 +81,12 @@ out IOpenApiPathItem path if (isTemplatePath) { - matchingPaths.Add(kvp.Key, kvp.Value); + var matchScore = GetTemplateMatchScore(specPath); + if (matchScore.BetterThan(bestMatchScore)) + { + bestMatchScore = matchScore; + matchingTemplatePathItem = kvp.Value; + } continue; } @@ -88,10 +94,9 @@ out IOpenApiPathItem path return true; } - if (matchingPaths.Count > 0) + if (matchingTemplatePathItem is not null) { - var bestMatch = GetBestMatch(matchingPaths); - path = bestMatch; + path = matchingTemplatePathItem; return true; } @@ -99,48 +104,27 @@ out IOpenApiPathItem path return false; } - private static IOpenApiPathItem GetBestMatch(OpenApiPaths matchingPaths) + private static TemplateMatchScore GetTemplateMatchScore(PathString specPath) { - IOpenApiPathItem? bestMatch = null; - var bestLiteralSegmentCount = -1; - var bestLiteralPrefixCount = -1; + var literalSegmentCount = 0; + var literalPrefixCount = 0; - foreach (var kvp in matchingPaths) + for (var i = 0; i < specPath.Segments.Length; i++) { - var specPath = new PathString(kvp.Key); - var literalSegmentCount = 0; - var literalPrefixCount = 0; - - for (var i = 0; i < specPath.Segments.Length; i++) + if (IsTemplateSegment(specPath.Segments[i])) { - if (IsTemplateSegment(specPath.Segments[i])) - { - continue; - } - - literalSegmentCount++; - - if (literalPrefixCount == i) - { - literalPrefixCount++; - } + continue; } - if ( - literalSegmentCount > bestLiteralSegmentCount - || ( - literalSegmentCount == bestLiteralSegmentCount - && literalPrefixCount > bestLiteralPrefixCount - ) - ) + literalSegmentCount++; + + if (literalPrefixCount == i) { - bestMatch = kvp.Value; - bestLiteralSegmentCount = literalSegmentCount; - bestLiteralPrefixCount = literalPrefixCount; + literalPrefixCount++; } } - return bestMatch!; + return new TemplateMatchScore(literalSegmentCount, literalPrefixCount); } private static bool IsPathMatch( @@ -186,4 +170,19 @@ private static bool IsTemplateSegment(string segment) { return segment.StartsWith('{') && segment.EndsWith('}'); } + + private class TemplateMatchScore(int literalSegmentCount, int literalPrefixCount) + { + public int LiteralSegmentCount { get; } = literalSegmentCount; + public int LiteralPrefixCount { get; } = literalPrefixCount; + + public bool BetterThan(TemplateMatchScore other) + { + return LiteralSegmentCount > other.LiteralSegmentCount + || ( + LiteralSegmentCount == other.LiteralSegmentCount + && LiteralPrefixCount > other.LiteralPrefixCount + ); + } + } } From d4c4416666949f8c81455d3a1de5e0ce4da85ce6 Mon Sep 17 00:00:00 2001 From: Andrew Butler <1628649+AButler@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:36:28 +0100 Subject: [PATCH 3/3] Review suggestions --- src/OpenApiValidate/Helpers/OpenApiExtensions.cs | 11 ++++------- .../TestData/LiteralAndTemplatedPath.yaml | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs index f290d90..1e4080e 100644 --- a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs +++ b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs @@ -67,7 +67,7 @@ out IOpenApiPathItem path { var requestPathString = new PathString(requestPath); - TemplateMatchScore bestMatchScore = new(0, 0); + TemplateMatchScore bestMatchScore = TemplateMatchScore.Min; IOpenApiPathItem? matchingTemplatePathItem = null; foreach (var kvp in paths) @@ -151,12 +151,7 @@ out bool isTemplatePath continue; } - if ( - !segment.Equals( - requestPath.Segments[i], - StringComparison.InvariantCultureIgnoreCase - ) - ) + if (!segment.Equals(requestPath.Segments[i], StringComparison.OrdinalIgnoreCase)) { isTemplatePath = false; return false; @@ -173,6 +168,8 @@ private static bool IsTemplateSegment(string segment) private class TemplateMatchScore(int literalSegmentCount, int literalPrefixCount) { + public static readonly TemplateMatchScore Min = new(int.MinValue, int.MinValue); + public int LiteralSegmentCount { get; } = literalSegmentCount; public int LiteralPrefixCount { get; } = literalPrefixCount; diff --git a/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml b/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml index 86e16b0..7dc4429 100644 --- a/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml +++ b/test/OpenApiValidate.Tests/TestData/LiteralAndTemplatedPath.yaml @@ -54,7 +54,7 @@ paths: /user/me/role/{roleId}: get: summary: Returns a single role - responses: + responses: "200": description: A role object content: