Skip to content

Commit

Permalink
added priority to routes based on API Gateway logic
Browse files Browse the repository at this point in the history
  • Loading branch information
philasmar committed Dec 12, 2024
1 parent 693fcfa commit 253d032
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,19 @@ public class ApiGatewayRouteConfig
/// The API Gateway HTTP Path of the Lambda function
/// </summary>
public required string Path { get; set; }

/// <summary>
/// The type of API Gateway Route. This is used to determine the priority of the route when there is route overlap.
/// </summary>
internal ApiGatewayRouteType ApiGatewayRouteType { get; set; }

/// <summary>
/// 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.
/// </summary>
internal int LengthBeforeProxy { get; set; }

/// <summary>
/// The number of parameters in a path. This is used to determine the priority of the route when there is route overlap.
/// </summary>
internal int ParameterCount { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Amazon.Lambda.TestTool.Models;

/// <summary>
/// The type of API Gateway Route. This is used to determine the priority of the route when there is route overlap.
/// </summary>
public enum ApiGatewayRouteType
{
/// <summary>
/// An exact route with no path variables.
/// </summary>
Exact = 0,

/// <summary>
/// A route with path variables, but not a greedy variable {proxy+}.
/// </summary>
Variable = 1,

/// <summary>
/// A route with a greedy path variables.
/// </summary>
Proxy = 2
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can
var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path);
if (routeConfig == null)
{
app.Logger.LogInformation("Unable to find a configured Lambda route for the specified method and path: {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.Headers.Append("x-amzn-errortype", "MissingAuthenticationTokenException");
return Results.Json(new { message = "Missing Authentication Token" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ namespace Amazon.Lambda.TestTool.Services;
public class ApiGatewayRouteConfigService : IApiGatewayRouteConfigService
{
private readonly ILogger<ApiGatewayRouteConfigService> _logger;
private readonly List<ApiGatewayRouteConfig> _routeConfigs = new();
private readonly IEnvironmentManager _environmentManager;
private List<ApiGatewayRouteConfig> _routeConfigs = new();

/// <summary>
/// Constructs an instance of <see cref="ApiGatewayRouteConfigService"/>
/// which loads and parses environment variables that match a specific prefix.
/// Constructs an instance of <see cref="ApiGatewayRouteConfigService"/>.
/// </summary>
/// <param name="environmentManager">A service to access environment variables.</param>
/// <param name="logger">The logger instance for <see cref="ApiGatewayRouteConfigService"/></param>
Expand All @@ -23,77 +23,177 @@ public ApiGatewayRouteConfigService(
ILogger<ApiGatewayRouteConfigService> logger)
{
_logger = logger;

logger.LogDebug("Retrieving all environment variables");
var environmentVariables = environmentManager.GetEnvironmentVariables();
_environmentManager = environmentManager;

LoadLambdaConfigurationFromEnvironmentVariables();
UpdateRouteConfigMetadataAndSorting();
}

logger.LogDebug("Looping over the retrieved environment variables");
/// <summary>
/// Loads and parses environment variables that match a specific prefix.
/// </summary>
private void LoadLambdaConfigurationFromEnvironmentVariables()
{
_logger.LogDebug("Retrieving all environment variables");
var environmentVariables = _environmentManager.GetEnvironmentVariables();

_logger.LogDebug("Looping over the retrieved environment variables");
foreach (DictionaryEntry entry in environmentVariables)
{
var key = entry.Key.ToString();
if (key is null)
continue;
logger.LogDebug("Environment variables: {VariableName}", key);
_logger.LogDebug("Environment variables: {VariableName}", key);
if (!(key.Equals(Constants.LambdaConfigEnvironmentVariablePrefix) ||
key.StartsWith($"{Constants.LambdaConfigEnvironmentVariablePrefix}_")))
{
logger.LogDebug("Skipping environment variable: {VariableName}", key);
_logger.LogDebug("Skipping environment variable: {VariableName}", key);
continue;
}

var jsonValue = entry.Value?.ToString()?.Trim();
logger.LogDebug("Environment variable value: {VariableValue}", jsonValue);
_logger.LogDebug("Environment variable value: {VariableValue}", jsonValue);
if (string.IsNullOrEmpty(jsonValue))
continue;

try
{
if (jsonValue.StartsWith('['))
{
logger.LogDebug("Environment variable value starts with '['. Attempting to deserialize as a List.");
_logger.LogDebug("Environment variable value starts with '['. Attempting to deserialize as a List.");
var config = JsonSerializer.Deserialize<List<ApiGatewayRouteConfig>>(jsonValue);
if (config != null)
{
_routeConfigs.AddRange(config);
logger.LogDebug("Environment variable deserialized and added to the existing configuration.");
_logger.LogDebug("Environment variable deserialized and added to the existing configuration.");
}
else
{
logger.LogDebug("Environment variable was not properly deserialized and will be skipped.");
_logger.LogDebug("Environment variable was not properly deserialized and will be skipped.");
}
}
else
{
logger.LogDebug("Environment variable value does not start with '['.");
_logger.LogDebug("Environment variable value does not start with '['.");
var config = JsonSerializer.Deserialize<ApiGatewayRouteConfig>(jsonValue);
if (config != null)
{
_routeConfigs.Add(config);
logger.LogDebug("Environment variable deserialized and added to the existing configuration.");
_logger.LogDebug("Environment variable deserialized and added to the existing configuration.");
}
else
{
logger.LogDebug("Environment variable was not properly deserialized and will be skipped.");
_logger.LogDebug("Environment variable was not properly deserialized and will be skipped.");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error deserializing environment variable {key}: {ex.Message}");
logger.LogDebug("Error deserializing environment variable {Key}: {Message}", key, ex.Message);
_logger.LogDebug("Error deserializing environment variable {Key}: {Message}", key, ex.Message);
}
}
}

/// <inheritdoc />
/// <summary>
/// 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
/// </summary>
private void UpdateRouteConfigMetadataAndSorting()
{
_logger.LogDebug("Updating the metadata needed to properly sort the Lambda config");
foreach (var routeConfig in _routeConfigs)
{
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("Sorting the Lambda configs based on the updated metadata");

// 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();
}

/// <summary>
/// A method to match an HTTP Method and HTTP Path with an existing <see cref="ApiGatewayRouteConfig"/>.
/// Given that route templates could contain variables as well as greedy path variables.
/// API Gateway matches incoming routes in a certain order.
///
/// 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
/// </summary>
/// <param name="httpMethod">An HTTP Method</param>
/// <param name="path">An HTTP Path</param>
/// <returns>An <see cref="ApiGatewayRouteConfig"/> corresponding to Lambda function with an API Gateway HTTP Method and Path.</returns>
public ApiGatewayRouteConfig? GetRouteConfig(string httpMethod, string path)
{
foreach (var routeConfig in _routeConfigs)
{
_logger.LogDebug("Checking if '{Path}' matches '{Template}'.", path, routeConfig.Path);
var template = TemplateParser.Parse(routeConfig.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}");

var template = TemplateParser.Parse(transformedPath);

var matcher = new TemplateMatcher(template, GetDefaults(template));
var matcher = new TemplateMatcher(template, new RouteValueDictionary());

var routeValueDictionary = new RouteValueDictionary();
if (!matcher.TryMatch(path, routeValueDictionary))
Expand All @@ -104,7 +204,8 @@ public ApiGatewayRouteConfigService(

_logger.LogDebug("'{Path}' matches '{Template}'. Now checking the HTTP Method.", path, routeConfig.Path);

if (!routeConfig.HttpMethod.Equals(httpMethod, StringComparison.InvariantCultureIgnoreCase))
if (!routeConfig.HttpMethod.Equals("ANY") &&
!routeConfig.HttpMethod.Equals(httpMethod, StringComparison.InvariantCultureIgnoreCase))
{
_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;
Expand All @@ -117,19 +218,4 @@ public ApiGatewayRouteConfigService(

return null;
}

private RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate)
{
var result = new RouteValueDictionary();

foreach (var parameter in parsedTemplate.Parameters)
{
if (parameter.DefaultValue != null)
{
if (parameter.Name != null) result.Add(parameter.Name, parameter.DefaultValue);
}
}

return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,102 @@ public void Constructor_LoadsAndParsesListOfConfigs()
Assert.Equal("Function1", result1.LambdaResourceName);
Assert.Equal("Function2", result2.LambdaResourceName);
}

[Fact]
public void ProperlySortRouteConfigs()
{
// Arrange
var routeConfigs = new List<ApiGatewayRouteConfig>
{
new ApiGatewayRouteConfig
{
LambdaResourceName = "F1",
HttpMethod = "ANY",
Path = "/{proxy+}"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F2",
HttpMethod = "GET",
Path = "/pe/{proxy+}"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F3",
HttpMethod = "GET",
Path = "/pets/{proxy+}"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F4",
HttpMethod = "GET",
Path = "/pets/dog/{id}/{id2}/{id3}"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F5",
HttpMethod = "GET",
Path = "/pets/{dog}/{id}"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F6",
HttpMethod = "GET",
Path = "/pets/dog/{id}"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F7",
HttpMethod = "GET",
Path = "/pets/dog/1"
},
new ApiGatewayRouteConfig
{
LambdaResourceName = "F8",
HttpMethod = "GET",
Path = "/pets/dog/cat/1"
}
};

_mockEnvironmentManager
.Setup(m => m.GetEnvironmentVariables())
.Returns(new Dictionary<string, string>
{
{ Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfigs) }
});

var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object);

// Act
var result1 = service.GetRouteConfig("GET", "/pets/dog/cat/1");
var result2 = service.GetRouteConfig("GET", "/pets/dog/1");
var result3 = service.GetRouteConfig("GET", "/pets/dog/cat/2");
var result4 = service.GetRouteConfig("GET", "/pets/dog/2");
var result5 = service.GetRouteConfig("GET", "/pets/cat/dog");
var result6 = service.GetRouteConfig("GET", "/pets/cat/dog/1");
var result7 = service.GetRouteConfig("GET", "/pets/dog/1/2/3");
var result8 = service.GetRouteConfig("GET", "/pets/dog/1/2/3/4");
var result9 = service.GetRouteConfig("GET", "/pe/dog/cat/2");
var result10 = service.GetRouteConfig("GET", "/pe/cat/dog/1");
var result11 = service.GetRouteConfig("GET", "/pe/dog/1/2/3/4");
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");

// Assert
Assert.Equal("F8", result1?.LambdaResourceName);
Assert.Equal("F7", result2?.LambdaResourceName);
Assert.Equal("F3", result3?.LambdaResourceName);
Assert.Equal("F6", result4?.LambdaResourceName);
Assert.Equal("F5", result5?.LambdaResourceName);
Assert.Equal("F3", result6?.LambdaResourceName);
Assert.Equal("F4", result7?.LambdaResourceName);
Assert.Equal("F3", result8?.LambdaResourceName);
Assert.Equal("F2", result9?.LambdaResourceName);
Assert.Equal("F2", result10?.LambdaResourceName);
Assert.Equal("F2", result11?.LambdaResourceName);
Assert.Equal("F1", result12?.LambdaResourceName);
Assert.Equal("F1", result13?.LambdaResourceName);
Assert.Equal("F1", result14?.LambdaResourceName);
}
}

0 comments on commit 253d032

Please sign in to comment.