diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs index ab837a27b..243a1d147 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs @@ -24,19 +24,4 @@ public class ApiGatewayRouteConfig /// The API Gateway HTTP Path of the Lambda function /// public required string Path { get; set; } - - /// - /// The type of API Gateway Route. This is used to determine the priority of the route when there is route overlap. - /// - internal ApiGatewayRouteType ApiGatewayRouteType { get; set; } - - /// - /// The number of characters preceding a greedy path variable {proxy+}. This is used to determine the priority of the route when there is route overlap. - /// - internal int LengthBeforeProxy { get; set; } - - /// - /// The number of parameters in a path. This is used to determine the priority of the route when there is route overlap. - /// - internal int ParameterCount { get; set; } } \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs index 7b30ca538..bff1e9fe7 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Amazon.Lambda.TestTool.Models; using Amazon.Lambda.TestTool.Services.IO; -using Microsoft.AspNetCore.Routing.Template; namespace Amazon.Lambda.TestTool.Services; @@ -26,7 +25,6 @@ public ApiGatewayRouteConfigService( _environmentManager = environmentManager; LoadLambdaConfigurationFromEnvironmentVariables(); - UpdateRouteConfigMetadataAndSorting(); } /// @@ -61,10 +59,21 @@ private void LoadLambdaConfigurationFromEnvironmentVariables() if (jsonValue.StartsWith('[')) { _logger.LogDebug("Environment variable value starts with '['. Attempting to deserialize as a List."); - var config = JsonSerializer.Deserialize>(jsonValue); - if (config != null) + var configs = JsonSerializer.Deserialize>(jsonValue); + if (configs != null) { - _routeConfigs.AddRange(config); + foreach (var config in configs) + { + if (IsRouteConfigValid(config)) + { + _routeConfigs.Add(config); + _logger.LogDebug("Environment variable deserialized and added to the existing configuration."); + } + else + { + _logger.LogDebug("The route config {Method} {Path} is not valid. It will be skipped.", config.HttpMethod, config.Path); + } + } _logger.LogDebug("Environment variable deserialized and added to the existing configuration."); } else @@ -78,8 +87,15 @@ private void LoadLambdaConfigurationFromEnvironmentVariables() var config = JsonSerializer.Deserialize(jsonValue); if (config != null) { - _routeConfigs.Add(config); - _logger.LogDebug("Environment variable deserialized and added to the existing configuration."); + if (IsRouteConfigValid(config)) + { + _routeConfigs.Add(config); + _logger.LogDebug("Environment variable deserialized and added to the existing configuration."); + } + else + { + _logger.LogDebug("The route config {Method} {Path} is not valid. It will be skipped.", config.HttpMethod, config.Path); + } } else { @@ -96,68 +112,28 @@ private void LoadLambdaConfigurationFromEnvironmentVariables() } /// - /// API Gateway selects the route with the most-specific match, using the following priorities: - /// 1. Full match for a route and method. - /// 2. Match for a route and method with path variable. - /// 3. Match for a route and method with a greedy path variable ({proxy+}). - /// - /// For example, this is the order for the following example routes: - /// 1. GET /pets/dog/1 - /// 2. GET /pets/dog/{id} - /// 3. GET /pets/{proxy+} - /// 4. ANY /{proxy+} - /// - /// For more info: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-routes.html + /// Applies some validity checks for Lambda route configuration. /// - private void UpdateRouteConfigMetadataAndSorting() + /// Lambda route configuration + /// true if route is valid, false if not + private bool IsRouteConfigValid(ApiGatewayRouteConfig routeConfig) { - _logger.LogDebug("Updating the metadata needed to properly sort the Lambda config"); - foreach (var routeConfig in _routeConfigs) + var occurrences = routeConfig.Path.Split("{proxy+}").Length - 1; + if (occurrences > 1) { - if (routeConfig.Path.Contains("{proxy+}")) - { - routeConfig.ApiGatewayRouteType = ApiGatewayRouteType.Proxy; - routeConfig.LengthBeforeProxy = routeConfig.Path.IndexOf("{proxy+}", StringComparison.InvariantCultureIgnoreCase); - _logger.LogDebug("{Method} {Route} uses a proxy variable which starts at position {Position}.", - routeConfig.HttpMethod, - routeConfig.Path, - routeConfig.LengthBeforeProxy); - } - else if (routeConfig.Path.Contains("{") && routeConfig.Path.Contains("}")) - { - routeConfig.ApiGatewayRouteType = ApiGatewayRouteType.Variable; - routeConfig.LengthBeforeProxy = int.MaxValue; - - var template = TemplateParser.Parse(routeConfig.Path); - routeConfig.ParameterCount = template.Parameters.Count; - - _logger.LogDebug("{Method} {Route} uses {ParameterCount} path variable(s).", - routeConfig.HttpMethod, - routeConfig.Path, - routeConfig.ParameterCount); - } - else - { - routeConfig.ApiGatewayRouteType = ApiGatewayRouteType.Exact; - routeConfig.LengthBeforeProxy = int.MaxValue; - - _logger.LogDebug("{Method} {Route} is an exact route with no variables.", - routeConfig.HttpMethod, - routeConfig.Path); - } + _logger.LogDebug("The route config {Method} {Path} cannot have multiple greedy variables {{proxy+}}.", + routeConfig.HttpMethod, routeConfig.Path); + return false; } - _logger.LogDebug("Sorting the Lambda configs based on the updated metadata"); + if (occurrences == 1 && !routeConfig.Path.EndsWith("/{proxy+}")) + { + _logger.LogDebug("The route config {Method} {Path} uses a greedy variable {{proxy+}} but does not end with it.", + routeConfig.HttpMethod, routeConfig.Path); + return false; + } - // The sorting will be as follows: - // 1. Exact paths first - // 2. Paths with variables (the less the number of variables, the more exact the path is which means higher priority) - // 3. Paths with greedy path variable {proxy+} (the more characters before {proxy+}, the more specific the path is, the higher the priority) - _routeConfigs = _routeConfigs - .OrderBy(x => x.ApiGatewayRouteType) - .ThenBy(x => x.ParameterCount) - .ThenByDescending(x => x.LengthBeforeProxy) - .ToList(); + return true; } /// @@ -183,39 +159,272 @@ private void UpdateRouteConfigMetadataAndSorting() /// An corresponding to Lambda function with an API Gateway HTTP Method and Path. public ApiGatewayRouteConfig? GetRouteConfig(string httpMethod, string path) { - foreach (var routeConfig in _routeConfigs) + var requestSegments = path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + var candidates = new List(); + + foreach (var route in _routeConfigs) { - _logger.LogDebug("Checking if '{Path}' matches '{Template}'.", path, routeConfig.Path); + _logger.LogDebug("{RequestMethod} {RequestPath}: Checking if matches with {TemplateMethod} {TemplatePath}.", + httpMethod, path, route.HttpMethod, route.Path); - // ASP.NET has similar functionality as API Gateway which supports a greedy path variable. - // Replace the API Gateway greedy parameter with ASP.NET catch-all parameter - var transformedPath = routeConfig.Path.Replace("{proxy+}", "{*proxy}"); + // Must match HTTP method or be ANY + if (!route.HttpMethod.Equals("ANY", StringComparison.InvariantCultureIgnoreCase) && + !route.HttpMethod.Equals(httpMethod, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.LogDebug("{RequestMethod} {RequestPath}: The HTTP method does not match.", + httpMethod, path); + continue; + } - var template = TemplateParser.Parse(transformedPath); + _logger.LogDebug("{RequestMethod} {RequestPath}: The HTTP method matches. Checking the route {TemplatePath}.", + httpMethod, path, route.Path); - var matcher = new TemplateMatcher(template, new RouteValueDictionary()); + var routeSegments = route.Path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); - var routeValueDictionary = new RouteValueDictionary(); - if (!matcher.TryMatch(path, routeValueDictionary)) + var matchDetail = MatchRoute(routeSegments, requestSegments); + if (matchDetail.Matched) { - _logger.LogDebug("'{Path}' does not match '{Template}'.", path, routeConfig.Path); - continue; + candidates.Add(new MatchResult + { + Route = route, + LiteralMatches = matchDetail.LiteralMatches, + GreedyVariables = matchDetail.GreedyCount, + NormalVariables = matchDetail.VariableCount, + TotalSegments = routeSegments.Length, + MatchedSegmentsBeforeGreedy = matchDetail.MatchedSegmentsBeforeGreedy + }); } - - _logger.LogDebug("'{Path}' matches '{Template}'. Now checking the HTTP Method.", path, routeConfig.Path); - - if (!routeConfig.HttpMethod.Equals("ANY") && - !routeConfig.HttpMethod.Equals(httpMethod, StringComparison.InvariantCultureIgnoreCase)) + } + + if (candidates.Count == 0) + { + _logger.LogDebug("{RequestMethod} {RequestPath}: The HTTP path does not match any configured route.", + httpMethod, path); + return null; + } + + _logger.LogDebug("{RequestMethod} {RequestPath}: The following routes matched: {Routes}.", + httpMethod, path, string.Join(", ", candidates.Select(x => x.Route.Path))); + + var best = candidates + .OrderByDescending(c => c.LiteralMatches) + .ThenByDescending(c => c.MatchedSegmentsBeforeGreedy) + .ThenBy(c => c.GreedyVariables) + .ThenBy(c => c.NormalVariables) + .ThenBy(c => c.TotalSegments) + .First(); + + _logger.LogDebug("{RequestMethod} {RequestPath}: Matched with the following route: {Routes}.", + httpMethod, path, best.Route.Path); + + return best.Route; + } + + /// + /// Attempts to match a given request path against a route template. + /// + /// The array of route template segments, which may include literal segments, normal variable segments, and greedy variable segments. + /// The array of request path segments to be matched against the route template. + /// + /// A tuple containing the following elements: + /// + /// + /// Matched + /// true if the entire route template can be matched against the given request path segments; false otherwise. + /// + /// + /// LiteralMatches + /// The number of literal segments in the route template that exactly matched the corresponding request path segments. + /// + /// + /// VariableCount + /// The total number of normal variable segments matched during the process. + /// + /// + /// GreedyCount + /// The total number of greedy variable segments matched during the process. + /// + /// + /// MatchedSegmentsBeforeGreedy + /// The number of segments (literal or normal variable) that were matched before encountering any greedy variable segment. A higher number indicates a more specific match before resorting to greedily matching the remainder of the path. + /// + /// + /// + private ( + bool Matched, + int LiteralMatches, + int VariableCount, + int GreedyCount, + int MatchedSegmentsBeforeGreedy) + MatchRoute(string[] routeSegments, string[] requestSegments) + { + var routeTemplateIndex = 0; + var requestPathIndex = 0; + var literalMatches = 0; + var variableCount = 0; + var greedyCount = 0; + var matched = true; + + var matchedSegmentsBeforeGreedy = 0; + var encounteredGreedy = false; + + while ( + matched && + routeTemplateIndex < routeSegments.Length && + requestPathIndex < requestSegments.Length) + { + var routeTemplateSegment = routeSegments[routeTemplateIndex]; + var requestPathSegment = requestSegments[requestPathIndex]; + + if (IsVariableSegment(routeTemplateSegment)) { - _logger.LogDebug("HTTP Method of '{Path}' is {HttpMethod} and does not match the method of '{Template}' which is {TemplateMethod}.", path, httpMethod, routeConfig.Path, routeConfig.HttpMethod); - continue; + if (IsGreedyVariable(routeTemplateSegment)) + { + // Greedy variable must match at least one segment + // Check if we have at least one segment remaining + if (requestPathIndex >= requestSegments.Length) + { + // No segments left to match the greedy variable + matched = false; + } + else + { + greedyCount++; + encounteredGreedy = true; + routeTemplateIndex++; + // Greedy matches all remaining segments + requestPathIndex = requestSegments.Length; + } + } + else + { + variableCount++; + if (!encounteredGreedy) matchedSegmentsBeforeGreedy++; + routeTemplateIndex++; + requestPathIndex++; + } } - - _logger.LogDebug("{HttpMethod} {Path} matches the existing configuration {TemplateMethod} {Template}.", httpMethod, path, routeConfig.HttpMethod, routeConfig.Path); + else + { + if (!routeTemplateSegment.Equals(requestPathSegment, StringComparison.OrdinalIgnoreCase)) + { + matched = false; + } + else + { + literalMatches++; + if (!encounteredGreedy) matchedSegmentsBeforeGreedy++; + routeTemplateIndex++; + requestPathIndex++; + } + } + } + + // If there are leftover route segments + while (matched && routeTemplateIndex < routeSegments.Length) + { + var rs = routeSegments[routeTemplateIndex]; + if (IsVariableSegment(rs)) + { + if (IsGreedyVariable(rs)) + { + // Greedy variable must match at least one segment + // At this point, j points to the next request segment to match. + if (requestPathIndex >= requestSegments.Length) + { + // No segments left for greedy variable + matched = false; + } + else + { + greedyCount++; + encounteredGreedy = true; + // Greedy consumes all remaining segments + routeTemplateIndex++; + requestPathIndex = requestSegments.Length; + } + } + else + { + // Normal variable with no corresponding request segment is not allowed + matched = false; + routeTemplateIndex++; + } + } + else + { + // Literal not matched + matched = false; + routeTemplateIndex++; + } + } - return routeConfig; + // If request has leftover segments that aren't matched + if (matched && requestPathIndex < requestSegments.Length) + { + matched = false; } - return null; + return (matched, literalMatches, variableCount, greedyCount, matchedSegmentsBeforeGreedy); + } + + /// + /// Determines if a given segment represents a variable segment. + /// + /// The route template segment to check. + /// true if the segment is a variable segment; false otherwise. + private bool IsVariableSegment(string segment) + { + return segment.StartsWith("{") && segment.EndsWith("}"); + } + + /// + /// Determines if a given segment represents a greedy variable segment. + /// Greedy variables match one or more segments at the end of the route. + /// + /// The route template segment to check. + /// true if the segment is a greedy variable segment; false otherwise. + private bool IsGreedyVariable(string segment) + { + return segment.Equals("{proxy+}", StringComparison.InvariantCultureIgnoreCase); + } + + /// + /// Represents a match result for a particular route configuration. + /// Contains information about how closely it matched, such as how many literal segments were matched, + /// how many greedy and normal variables were used, and how many segments were matched before any greedy variable. + /// + private class MatchResult + { + /// + /// The route configuration that this match result corresponds to. + /// + public required ApiGatewayRouteConfig Route { get; set; } + + /// + /// The number of literal segments matched. + /// + public int LiteralMatches { get; set; } + + /// + /// The number of greedy variables matched. + /// + public int GreedyVariables { get; set; } + + /// + /// The number of normal variables matched. + /// + public int NormalVariables { get; set; } + + /// + /// The total number of segments in the route template. + /// + public int TotalSegments { get; set; } + + /// + /// The number of segments (literal or normal variable) matched before encountering any greedy variable. + /// + public int MatchedSegmentsBeforeGreedy { get; set; } } } \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs index 3880fdb5c..be1954532 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs @@ -157,7 +157,7 @@ public void Constructor_LoadsAndParsesListOfConfigs() } [Fact] - public void ProperlySortRouteConfigs() + public void ProperlyMatchRouteConfigs() { // Arrange var routeConfigs = new List @@ -209,6 +209,24 @@ public void ProperlySortRouteConfigs() LambdaResourceName = "F8", HttpMethod = "GET", Path = "/pets/dog/cat/1" + }, + new ApiGatewayRouteConfig + { + LambdaResourceName = "F9", + HttpMethod = "GET", + Path = "/resource/{id}/subsegment/{proxy+}" + }, + new ApiGatewayRouteConfig + { + LambdaResourceName = "F10", + HttpMethod = "GET", + Path = "/resource/{id}/subsegment/{id2}/{proxy+}" + }, + new ApiGatewayRouteConfig + { + LambdaResourceName = "F11", + HttpMethod = "GET", + Path = "/resource/1/subsegment/3/{proxy+}" } }; @@ -236,6 +254,9 @@ public void ProperlySortRouteConfigs() var result12 = service.GetRouteConfig("GET", "/pet/dog/cat/2"); var result13 = service.GetRouteConfig("GET", "/pet/cat/dog/1"); var result14 = service.GetRouteConfig("GET", "/pet/dog/1/2/3/4"); + var result15 = service.GetRouteConfig("GET", "/resource/1/subsegment/more"); + var result16 = service.GetRouteConfig("GET", "/resource/1/subsegment/2/more"); + var result17 = service.GetRouteConfig("GET", "/resource/1/subsegment/3/more"); // Assert Assert.Equal("F8", result1?.LambdaResourceName); @@ -252,5 +273,8 @@ public void ProperlySortRouteConfigs() Assert.Equal("F1", result12?.LambdaResourceName); Assert.Equal("F1", result13?.LambdaResourceName); Assert.Equal("F1", result14?.LambdaResourceName); + Assert.Equal("F9", result15?.LambdaResourceName); + Assert.Equal("F10", result16?.LambdaResourceName); + Assert.Equal("F11", result17?.LambdaResourceName); } } \ No newline at end of file