diff --git a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs index f7a436c..1e4080e 100644 --- a/src/OpenApiValidate/Helpers/OpenApiExtensions.cs +++ b/src/OpenApiValidate/Helpers/OpenApiExtensions.cs @@ -67,6 +67,7 @@ out IOpenApiPathItem path { var requestPathString = new PathString(requestPath); + TemplateMatchScore bestMatchScore = TemplateMatchScore.Min; IOpenApiPathItem? matchingTemplatePathItem = null; foreach (var kvp in paths) @@ -80,7 +81,12 @@ out IOpenApiPathItem path if (isTemplatePath) { - matchingTemplatePathItem = kvp.Value; + var matchScore = GetTemplateMatchScore(specPath); + if (matchScore.BetterThan(bestMatchScore)) + { + bestMatchScore = matchScore; + matchingTemplatePathItem = kvp.Value; + } continue; } @@ -98,6 +104,29 @@ out IOpenApiPathItem path return false; } + private static TemplateMatchScore GetTemplateMatchScore(PathString specPath) + { + 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++; + } + } + + return new TemplateMatchScore(literalSegmentCount, literalPrefixCount); + } + private static bool IsPathMatch( PathString specPath, PathString requestPath, @@ -115,19 +144,14 @@ out bool isTemplatePath { var segment = specPath.Segments[i]; - if (segment.StartsWith('{') && segment.EndsWith('}')) + if (IsTemplateSegment(segment)) { // Is template parameter, so skip checking isTemplatePath = true; continue; } - if ( - !segment.Equals( - requestPath.Segments[i], - StringComparison.InvariantCultureIgnoreCase - ) - ) + if (!segment.Equals(requestPath.Segments[i], StringComparison.OrdinalIgnoreCase)) { isTemplatePath = false; return false; @@ -136,4 +160,26 @@ out bool isTemplatePath return true; } + + private static bool IsTemplateSegment(string segment) + { + return segment.StartsWith('{') && segment.EndsWith('}'); + } + + 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; + + public bool BetterThan(TemplateMatchScore other) + { + return LiteralSegmentCount > other.LiteralSegmentCount + || ( + LiteralSegmentCount == other.LiteralSegmentCount + && LiteralPrefixCount > other.LiteralPrefixCount + ); + } + } } 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..7dc4429 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