From 1af7cf42323d0f272e4e3b2a7e104fbbf4eb08a1 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Tue, 10 Dec 2024 12:13:25 -0500 Subject: [PATCH 1/6] feat: add api gateway emulator skeleton --- .../Commands/CancellableAsyncCommand.cs | 26 +++++ .../Commands/RunCommand.cs | 68 ++++++------- .../Commands/Settings/RunCommandSettings.cs | 71 ++++++++++++++ .../Components/Pages/Home.razor | 1 + .../Components/Pages/SaveRequestDialog.razor | 1 + .../src/Amazon.Lambda.TestTool/Constants.cs | 75 +++++++++++++- .../Extensions/ExceptionExtensions.cs | 6 ++ .../Extensions/ServiceCollectionExtensions.cs | 17 ++++ .../Models/ApiGatewayEmulatorMode.cs | 22 +++++ .../Models/ApiGatewayRouteConfig.cs | 27 +++++ .../Models/Exceptions.cs | 5 + .../Processes/ApiGatewayEmulatorProcess.cs | 78 +++++++++++++++ .../Processes/TestToolProcess.cs | 56 ++++------- .../Services/ApiGatewayRouteConfigService.cs | 98 +++++++++++++++++++ .../Services/ConfigureStaticFilesOptions.cs | 32 ++++++ .../Services/ConsoleInteractiveService.cs | 16 ++- .../Services/IApiGatewayRouteConfigService.cs | 18 ++++ .../Services/IDirectoryManager.cs | 6 -- .../Services/{ => IO}/DirectoryManager.cs | 4 +- .../Services/IO/EnvironmentManager.cs | 10 ++ .../Services/IO/IDirectoryManager.cs | 13 +++ .../Services/IO/IEnvironmentManager.cs | 14 +++ .../Services/IToolInteractiveService.cs | 12 +++ .../Services/TypeRegistrar.cs | 19 ++-- .../Services/TypeResolver.cs | 13 ++- .../Amazon.Lambda.TestTool/Utilities/Utils.cs | 35 ++----- .../Amazon.Lambda.TestTool.UnitTests.csproj | 2 + .../Commands/RunCommandTests.cs | 59 +++++++++++ .../Settings/RunCommandSettingsTests.cs | 76 ++++++++++++++ .../Helpers/TestHelpers.cs | 35 +++++++ .../RuntimeApiTests.cs | 10 +- 31 files changed, 795 insertions(+), 130 deletions(-) create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayEmulatorMode.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConfigureStaticFilesOptions.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IApiGatewayRouteConfigService.cs delete mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IDirectoryManager.cs rename Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/{ => IO}/DirectoryManager.cs (53%) create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/EnvironmentManager.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IDirectoryManager.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IEnvironmentManager.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Helpers/TestHelpers.cs diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs new file mode 100644 index 000000000..6e3922d9e --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices; + +namespace Spectre.Console.Cli; + +public abstract class CancellableAsyncCommand : AsyncCommand where TSettings : CommandSettings +{ + public abstract Task ExecuteAsync(CommandContext context, TSettings settings, CancellationTokenSource cancellationTokenSource); + + public sealed override async Task ExecuteAsync(CommandContext context, TSettings settings) + { + using var cancellationSource = new CancellationTokenSource(); + + using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, onSignal); + using var sigQuit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, onSignal); + using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, onSignal); + + var cancellable = ExecuteAsync(context, settings, cancellationSource); + return await cancellable; + + void onSignal(PosixSignalContext context) + { + context.Cancel = true; + cancellationSource.Cancel(); + } + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/RunCommand.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/RunCommand.cs index 0857dd8d0..1334331b5 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/RunCommand.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/RunCommand.cs @@ -1,5 +1,5 @@ -using System.ComponentModel; using System.Diagnostics; +using Amazon.Lambda.TestTool.Commands.Settings; using Amazon.Lambda.TestTool.Extensions; using Amazon.Lambda.TestTool.Models; using Amazon.Lambda.TestTool.Processes; @@ -8,41 +8,24 @@ namespace Amazon.Lambda.TestTool.Commands; +/// +/// The default command of the application which is responsible for launching the Lambda Runtime API and the API Gateway Emulator. +/// public sealed class RunCommand( - IToolInteractiveService toolInteractiveService) : AsyncCommand + IToolInteractiveService toolInteractiveService) : CancellableAsyncCommand { - public sealed class Settings : CommandSettings - { - [CommandOption("--host ")] - [Description( - "The hostname or IP address used for the test tool's web interface. Any host other than an explicit IP address or localhost (e.g. '*', '+' or 'example.com') binds to all public IPv4 and IPv6 addresses.")] - [DefaultValue(Constants.DefaultHost)] - public string Host { get; set; } = Constants.DefaultHost; - - [CommandOption("-p|--port ")] - [Description("The port number used for the test tool's web interface.")] - [DefaultValue(Constants.DefaultPort)] - public int Port { get; set; } = Constants.DefaultPort; - - [CommandOption("--no-launch-window")] - [Description("Disable auto launching the test tool's web interface in a browser.")] - public bool NoLaunchWindow { get; set; } - - [CommandOption("--pause-exit")] - [Description("If set to true the test tool will pause waiting for a key input before exiting. The is useful when executing from an IDE so you can avoid having the output window immediately disappear after executing the Lambda code. The default value is true.")] - public bool PauseExit { get; set; } - - [CommandOption("--disable-logs")] - [Description("Disables logging in the application")] - public bool DisableLogs { get; set; } - } - - public override async Task ExecuteAsync(CommandContext context, Settings settings) + /// + /// The method responsible for executing the . + /// + public override async Task ExecuteAsync(CommandContext context, RunCommandSettings settings, CancellationTokenSource cancellationTokenSource) { try { - var process = TestToolProcess.Startup(settings); - + var tasks = new List(); + + var testToolProcess = TestToolProcess.Startup(settings, cancellationTokenSource.Token); + tasks.Add(testToolProcess.RunningTask); + if (!settings.NoLaunchWindow) { try @@ -50,7 +33,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se var info = new ProcessStartInfo { UseShellExecute = true, - FileName = process.ServiceUrl + FileName = testToolProcess.ServiceUrl }; Process.Start(info); } @@ -59,16 +42,23 @@ public override async Task ExecuteAsync(CommandContext context, Settings se toolInteractiveService.WriteErrorLine($"Error launching browser: {e.Message}"); } } - - await process.RunningTask; - + + if (settings.ApiGatewayEmulatorMode is not null) + { + var apiGatewayEmulatorProcess = + ApiGatewayEmulatorProcess.Startup(settings, cancellationTokenSource.Token); + tasks.Add(apiGatewayEmulatorProcess.RunningTask); + } + + await Task.WhenAny(tasks); + return CommandReturnCodes.Success; } catch (Exception e) when (e.IsExpectedException()) { toolInteractiveService.WriteErrorLine(string.Empty); toolInteractiveService.WriteErrorLine(e.Message); - + return CommandReturnCodes.UserError; } catch (Exception e) @@ -79,8 +69,12 @@ public override async Task ExecuteAsync(CommandContext context, Settings se $"This is a bug.{Environment.NewLine}" + $"Please copy the stack trace below and file a bug at {Constants.LinkGithubRepo}. " + e.PrettyPrint()); - + return CommandReturnCodes.UnhandledException; } + finally + { + await cancellationTokenSource.CancelAsync(); + } } } \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs new file mode 100644 index 000000000..5a0f759a7 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs @@ -0,0 +1,71 @@ +using Amazon.Lambda.TestTool.Models; +using Spectre.Console.Cli; +using System.ComponentModel; + +namespace Amazon.Lambda.TestTool.Commands.Settings; + +/// +/// Represents the settings for configuring the , which is the default command. +/// +public sealed class RunCommandSettings : CommandSettings +{ + /// + /// The hostname or IP address used for the test tool's web interface. + /// Any host other than an explicit IP address or localhost (e.g. '*', '+' or 'example.com') binds to all public IPv4 and IPv6 addresses. + /// + [CommandOption("--host ")] + [Description( + "The hostname or IP address used for the test tool's web interface. Any host other than an explicit IP address or localhost (e.g. '*', '+' or 'example.com') binds to all public IPv4 and IPv6 addresses.")] + [DefaultValue(Constants.DefaultHost)] + public string Host { get; set; } = Constants.DefaultHost; + + /// + /// The port number used for the test tool's web interface. + /// + [CommandOption("-p|--port ")] + [Description("The port number used for the test tool's web interface.")] + [DefaultValue(Constants.DefaultPort)] + public int Port { get; set; } = Constants.DefaultPort; + + /// + /// Disable auto launching the test tool's web interface in a browser. + /// + [CommandOption("--no-launch-window")] + [Description("Disable auto launching the test tool's web interface in a browser.")] + public bool NoLaunchWindow { get; set; } + + /// + /// If set to true the test tool will pause waiting for a key input before exiting. + /// The is useful when executing from an IDE so you can avoid having the output window immediately disappear after executing the Lambda code. + /// The default value is true. + /// + [CommandOption("--pause-exit")] + [Description("If set to true the test tool will pause waiting for a key input before exiting. The is useful when executing from an IDE so you can avoid having the output window immediately disappear after executing the Lambda code. The default value is true.")] + public bool PauseExit { get; set; } + + /// + /// Disables logging in the application + /// + [CommandOption("--disable-logs")] + [Description("Disables logging in the application")] + public bool DisableLogs { get; set; } + + /// + /// The API Gateway Emulator Mode specifies the format of the event that API Gateway sends to a Lambda integration, + /// and how API Gateway interprets the response from Lambda. + /// The available modes are: Rest, HttpV1, HttpV2. + /// + [CommandOption("--api-gateway-emulator ")] + [Description( + "The API Gateway Emulator Mode specifies the format of the event that API Gateway sends to a Lambda integration, and how API Gateway interprets the response from Lambda. " + + "The available modes are: Rest, HttpV1, HttpV2.")] + public ApiGatewayEmulatorMode? ApiGatewayEmulatorMode { get; set; } + + /// + /// The port number used for the test tool's API Gateway emulator. + /// + [CommandOption("--api-gateway-emulator-port ")] + [Description("The port number used for the test tool's API Gateway emulator.")] + [DefaultValue(Constants.DefaultApiGatewayEmulatorPort)] + public int? ApiGatewayEmulatorPort { get; set; } = Constants.DefaultApiGatewayEmulatorPort; +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/Home.razor b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/Home.razor index 5b933013f..0aa74ac50 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/Home.razor +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/Home.razor @@ -4,6 +4,7 @@ @using Amazon.Lambda.TestTool.Services @using Amazon.Lambda.TestTool.Models @using Amazon.Lambda.TestTool.SampleRequests; +@using Amazon.Lambda.TestTool.Services.IO @using Amazon.Lambda.TestTool.Utilities @using Microsoft.AspNetCore.Http; diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/SaveRequestDialog.razor b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/SaveRequestDialog.razor index 52f4444c4..9cabab373 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/SaveRequestDialog.razor +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Components/Pages/SaveRequestDialog.razor @@ -1,6 +1,7 @@ @using Amazon.Lambda.TestTool.Commands @using Amazon.Lambda.TestTool.SampleRequests; @using Amazon.Lambda.TestTool.Services +@using Amazon.Lambda.TestTool.Services.IO @inject IModalService ModalService @inject IDirectoryManager DirectoryManager diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs index 3e2926941..f875016b8 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs @@ -1,23 +1,94 @@ +using Amazon.Lambda.TestTool.Models; + namespace Amazon.Lambda.TestTool; +/// +/// Provides constant values used across the application. +/// public abstract class Constants { + /// + /// The name of the dotnet CLI tool + /// public const string ToolName = "lambda-test-tool"; + + /// + /// The default port used by the Lambda Test Tool for the Lambda Runtime API and the Web Interface. + /// public const int DefaultPort = 5050; + + /// + /// The default port used by the API Gateway Emulator. + /// + public const int DefaultApiGatewayEmulatorPort = 5051; + + /// + /// The default hostname used for the Lambda Test Tool. + /// public const string DefaultHost = "localhost"; + /// + /// The default mode for the API Gateway Emulator. + /// + public const ApiGatewayEmulatorMode DefaultApiGatewayEmulatorMode = ApiGatewayEmulatorMode.HttpV2; + + /// + /// The prefix for environment variables used to configure the Lambda functions. + /// + public const string LambdaConfigEnvironmentVariablePrefix = "APIGATEWAY_EMULATOR_ROUTE_CONFIG"; + + /// + /// The product name displayed for the Lambda Test Tool. + /// public const string ProductName = "AWS .NET Mock Lambda Test Tool"; + /// + /// The CSS style used for successful responses in the tool's UI. + /// public const string ResponseSuccessStyle = "white-space: pre-wrap; height: min-content; font-size: 75%; color: black"; + + /// + /// The CSS style used for error responses in the tool's UI. + /// public const string ResponseErrorStyle = "white-space: pre-wrap; height: min-content; font-size: 75%; color: red"; + /// + /// The CSS style used for successful responses in the tool's UI when a size constraint is applied. + /// public const string ResponseSuccessStyleSizeConstraint = "white-space: pre-wrap; height: 300px; font-size: 75%; color: black"; + + /// + /// The CSS style used for error responses in the tool's UI when a size constraint is applied. + /// public const string ResponseErrorStyleSizeConstraint = "white-space: pre-wrap; height: 300px; font-size: 75%; color: red"; + /// + /// The GitHub repository link for the AWS Lambda .NET repository. + /// public const string LinkGithubRepo = "https://github.com/aws/aws-lambda-dotnet"; - public const string LinkGithubTestTool = "https://github.com/aws/aws-lambda-dotnet/tree/master/Tools/LambdaTestTool"; + + /// + /// The GitHub link for the Lambda Test Tool. + /// + public const string LinkGithubTestTool = "https://github.com/aws/aws-lambda-dotnet/tree/master/Tools/LambdaTestTool-v2"; + + /// + /// The GitHub link for the Lambda Test Tool's installation and running instructions. + /// public const string LinkGithubTestToolInstallAndRun = "https://github.com/aws/aws-lambda-dotnet/tree/master/Tools/LambdaTestTool#installing-and-running"; + + /// + /// The AWS Developer Guide link for Dead Letter Queues in AWS Lambda. + /// public const string LinkDlqDeveloperGuide = "https://docs.aws.amazon.com/lambda/latest/dg/dlq.html"; + + /// + /// The Microsoft documentation link for the class. + /// public const string LinkMsdnAssemblyLoadContext = "https://docs.microsoft.com/en-us/dotnet/api/system.runtime.loader.assemblyloadcontext"; - public const string LinkVsToolkitMarketplace = "https://marketplace.visualstudio.com/items?itemName=AmazonWebServices.AWSToolkitforVisualStudio2017"; + + /// + /// The Visual Studio Marketplace link for the AWS Toolkit for Visual Studio. + /// + public const string LinkVsToolkitMarketplace = "https://marketplace.visualstudio.com/items?itemName=AmazonWebServices.AWSToolkitforVisualStudio2022"; } diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs index 136e33409..7aa9e6b38 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs @@ -2,6 +2,9 @@ namespace Amazon.Lambda.TestTool.Extensions; +/// +/// A class that contains extension methods for the class. +/// public static class ExceptionExtensions { /// @@ -11,6 +14,9 @@ public static class ExceptionExtensions public static bool IsExpectedException(this Exception e) => e is TestToolException; + /// + /// Prints an exception in a user-friendly way. + /// public static string PrettyPrint(this Exception? e) { if (null == e) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs index 529c46236..09c3ad19e 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs @@ -1,14 +1,31 @@ using Amazon.Lambda.TestTool.Services; +using Amazon.Lambda.TestTool.Services.IO; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Amazon.Lambda.TestTool.Extensions; +/// +/// A class that contains extension methods for the interface. +/// public static class ServiceCollectionExtensions { + /// + /// Adds a set of services for the .NET CLI portion of this application. + /// public static void AddCustomServices(this IServiceCollection serviceCollection, ServiceLifetime lifetime = ServiceLifetime.Singleton) { serviceCollection.TryAdd(new ServiceDescriptor(typeof(IToolInteractiveService), typeof(ConsoleInteractiveService), lifetime)); serviceCollection.TryAdd(new ServiceDescriptor(typeof(IDirectoryManager), typeof(DirectoryManager), lifetime)); + } + + /// + /// Adds a set of services for the API Gateway emulator portion of this application. + /// + public static void AddApiGatewayEmulatorServices(this IServiceCollection serviceCollection, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + serviceCollection.TryAdd(new ServiceDescriptor(typeof(IApiGatewayRouteConfigService), typeof(ApiGatewayRouteConfigService), lifetime)); + serviceCollection.TryAdd(new ServiceDescriptor(typeof(IEnvironmentManager), typeof(EnvironmentManager), lifetime)); } } \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayEmulatorMode.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayEmulatorMode.cs new file mode 100644 index 000000000..f91a6719f --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayEmulatorMode.cs @@ -0,0 +1,22 @@ +namespace Amazon.Lambda.TestTool.Models; + +/// +/// Represents the different API Gateway modes. +/// +public enum ApiGatewayEmulatorMode +{ + /// + /// Represents the REST API Gateway mode. + /// + Rest, + + /// + /// Represents the HTTP API v1 Gateway mode. + /// + HttpV1, + + /// + /// Represents the HTTP API v2 Gateway mode. + /// + HttpV2 +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs new file mode 100644 index 000000000..243a1d147 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs @@ -0,0 +1,27 @@ +namespace Amazon.Lambda.TestTool.Models; + +/// +/// Represents the configuration of a Lambda function +/// +public class ApiGatewayRouteConfig +{ + /// + /// The name of the Lambda function + /// + public required string LambdaResourceName { get; set; } + + /// + /// The endpoint of the local Lambda Runtime API + /// + public string? Endpoint { get; set; } + + /// + /// The HTTP Method for the API Gateway endpoint + /// + public required string HttpMethod { get; set; } + + /// + /// The API Gateway HTTP Path of the Lambda function + /// + public required string Path { get; set; } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/Exceptions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/Exceptions.cs index e35789f94..f9ea62b18 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/Exceptions.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/Exceptions.cs @@ -1,4 +1,9 @@ namespace Amazon.Lambda.TestTool.Models; +/// +/// Represents a base exception that is thrown by the test tool. +/// +/// +/// public abstract class TestToolException(string message, Exception? innerException = null) : Exception(message, innerException); \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs new file mode 100644 index 000000000..a714292d7 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs @@ -0,0 +1,78 @@ +using Amazon.Lambda.TestTool.Commands.Settings; +using Amazon.Lambda.TestTool.Extensions; +using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.Services; + +namespace Amazon.Lambda.TestTool.Processes; + +/// +/// A process that runs the API Gatewat emulator. +/// +public class ApiGatewayEmulatorProcess +{ + /// + /// The service provider that will contain all the registered services. + /// + public required IServiceProvider Services { get; init; } + + /// + /// The API Gatewat emulator task that was started. + /// + public required Task RunningTask { get; init; } + + /// + /// The endpoint of the API Gatewat emulator. + /// + public required string ServiceUrl { get; init; } + + /// + /// Creates the Web API and runs it in the background. + /// + public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, CancellationToken cancellationToken = default) + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddApiGatewayEmulatorServices(); + + var serviceUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}"; + builder.WebHost.UseUrls(serviceUrl); + builder.WebHost.SuppressStatusMessages(true); + + builder.Services.AddHealthChecks(); + + var app = builder.Build(); + + app.UseHttpsRedirection(); + + app.MapHealthChecks("/health"); + + app.Map("/{**catchAll}", (HttpContext context, IApiGatewayRouteConfigService routeConfigService) => + { + var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path); + if (routeConfig == null) + { + return Results.NotFound("Route not found"); + } + + if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2)) + { + // TODO: Translate to APIGatewayHttpApiV2ProxyRequest + } + else + { + // TODO: Translate to APIGatewayProxyRequest + } + + return Results.Ok(); + }); + + var runTask = app.RunAsync(cancellationToken); + + return new ApiGatewayEmulatorProcess + { + Services = app.Services, + RunningTask = runTask, + ServiceUrl = serviceUrl + }; + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs index 55677b1c6..4e9c7a8af 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs @@ -1,28 +1,36 @@ -using Amazon.Lambda.TestTool.Commands; +using Amazon.Lambda.TestTool.Commands.Settings; using Amazon.Lambda.TestTool.Components; using Amazon.Lambda.TestTool.Services; +using Amazon.Lambda.TestTool.Services.IO; using Blazored.Modal; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; namespace Amazon.Lambda.TestTool.Processes; +/// +/// A process that runs the local Lambda Runtime API and its web interface. +/// public class TestToolProcess { + /// + /// The service provider that will contain all the registered services. + /// public required IServiceProvider Services { get; init; } + /// + /// The Lambda Runtime API task that was started. + /// public required Task RunningTask { get; init; } + /// + /// The endpoint of the Lambda Runtime API. + /// public required string ServiceUrl { get; init; } - public required CancellationTokenSource CancellationTokenSource { get; init; } - - private TestToolProcess() - { - - } - - public static TestToolProcess Startup(RunCommand.Settings settings) + /// + /// Creates the Web Application and runs it in the background. + /// + public static TestToolProcess Startup(RunCommandSettings settings, CancellationToken cancellationToken = default) { var builder = WebApplication.CreateBuilder(); @@ -56,41 +64,15 @@ public static TestToolProcess Startup(RunCommand.Settings settings) _ = new LambdaRuntimeApi(app, app.Services.GetService()!); - var cancellationTokenSource = new CancellationTokenSource(); - var runTask = app.RunAsync(cancellationTokenSource.Token); + var runTask = app.RunAsync(cancellationToken); var startup = new TestToolProcess { Services = app.Services, RunningTask = runTask, - CancellationTokenSource = cancellationTokenSource, ServiceUrl = serviceUrl }; return startup; } - - internal class ConfigureStaticFilesOptions : IPostConfigureOptions - { - public ConfigureStaticFilesOptions(IWebHostEnvironment environment) - { - Environment = environment; - } - - public IWebHostEnvironment Environment { get; } - - public void PostConfigure(string? name, StaticFileOptions options) - { - name = name ?? throw new ArgumentNullException(nameof(name)); - options = options ?? throw new ArgumentNullException(nameof(options)); - - if (name != Options.DefaultName) - { - return; - } - - var fileProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot"); - Environment.WebRootFileProvider = new CompositeFileProvider(fileProvider, Environment.WebRootFileProvider); - } - } } diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs new file mode 100644 index 000000000..79e1ecd8d --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs @@ -0,0 +1,98 @@ +using System.Collections; +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; + +/// +public class ApiGatewayRouteConfigService : IApiGatewayRouteConfigService +{ + private readonly List _routeConfigs = new(); + + /// + /// Constructs an instance of + /// which loads and parses environment variables that match a specific prefix. + /// + /// A service to access environment variables. + public ApiGatewayRouteConfigService( + IEnvironmentManager environmentManager) + { + var environmentVariables = environmentManager.GetEnvironmentVariables(); + + foreach (DictionaryEntry entry in environmentVariables) + { + var key = entry.Key.ToString(); + if (key is null) + continue; + if (!(key.Equals(Constants.LambdaConfigEnvironmentVariablePrefix) || + key.StartsWith($"{Constants.LambdaConfigEnvironmentVariablePrefix}_"))) + continue; + + var jsonValue = entry.Value?.ToString(); + if (string.IsNullOrEmpty(jsonValue)) + continue; + try + { + var config = JsonSerializer.Deserialize(jsonValue); + if (config != null) + { + _routeConfigs.Add(config); + } + } + catch (Exception) + { + try + { + var config = JsonSerializer.Deserialize>(jsonValue); + if (config != null) + { + _routeConfigs.AddRange(config); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error deserializing environment variable {key}: {ex.Message}"); + } + } + } + } + + /// + public ApiGatewayRouteConfig? GetRouteConfig(string httpMethod, string path) + { + foreach (var routeConfig in _routeConfigs) + { + var template = TemplateParser.Parse(routeConfig.Path); + + var matcher = new TemplateMatcher(template, GetDefaults(template)); + + var routeValueDictionary = new RouteValueDictionary(); + if (!matcher.TryMatch(path, routeValueDictionary)) + continue; + + if (!routeConfig.HttpMethod.Equals(httpMethod, StringComparison.InvariantCultureIgnoreCase)) + continue; + + return routeConfig; + } + + 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; + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConfigureStaticFilesOptions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConfigureStaticFilesOptions.cs new file mode 100644 index 000000000..7fc75af6e --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConfigureStaticFilesOptions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; + +namespace Amazon.Lambda.TestTool.Services; + +/// +/// Configures static file options for the application by setting up a composite file provider +/// that includes embedded resources from the assembly and the existing web root file provider. +/// +internal class ConfigureStaticFilesOptions(IWebHostEnvironment environment) + : IPostConfigureOptions +{ + /// + /// Configures the for the application. + /// + /// The name of the options instance being configured. + /// The options instance to configure. + /// Thrown when or is null. + public void PostConfigure(string? name, StaticFileOptions options) + { + name = name ?? throw new ArgumentNullException(nameof(name)); + options = options ?? throw new ArgumentNullException(nameof(options)); + + if (name != Options.DefaultName) + { + return; + } + + var fileProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot"); + environment.WebRootFileProvider = new CompositeFileProvider(fileProvider, environment.WebRootFileProvider); + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConsoleInteractiveService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConsoleInteractiveService.cs index 57c3e3c45..0193b3fdd 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConsoleInteractiveService.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ConsoleInteractiveService.cs @@ -1,17 +1,25 @@ namespace Amazon.Lambda.TestTool.Services; +/// +/// Provides an implementation of that interacts with the console. +/// public class ConsoleInteractiveService : IToolInteractiveService { + /// + /// Initializes a new instance of the class. + /// public ConsoleInteractiveService() { Console.Title = Constants.ProductName; - } - + } + + /// public void WriteLine(string? message) { Console.WriteLine(message); - } - + } + + /// public void WriteErrorLine(string? message) { var color = Console.ForegroundColor; diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IApiGatewayRouteConfigService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IApiGatewayRouteConfigService.cs new file mode 100644 index 000000000..17f7c72c2 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IApiGatewayRouteConfigService.cs @@ -0,0 +1,18 @@ +using Amazon.Lambda.TestTool.Models; + +namespace Amazon.Lambda.TestTool.Services; + +/// +/// A service responsible for returning the +/// of a specific Lambda function. +/// +public interface IApiGatewayRouteConfigService +{ + /// + /// A method to match an HTTP Method and HTTP Path with an existing . + /// + /// An HTTP Method + /// An HTTP Path + /// An corresponding to Lambda function with an API Gateway HTTP Method and Path. + ApiGatewayRouteConfig? GetRouteConfig(string httpMethod, string path); +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IDirectoryManager.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IDirectoryManager.cs deleted file mode 100644 index 28705a6ab..000000000 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IDirectoryManager.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Amazon.Lambda.TestTool.Services; - -public interface IDirectoryManager -{ - string GetCurrentDirectory(); -} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/DirectoryManager.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/DirectoryManager.cs similarity index 53% rename from Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/DirectoryManager.cs rename to Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/DirectoryManager.cs index 8956bcd7a..c555ff342 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/DirectoryManager.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/DirectoryManager.cs @@ -1,6 +1,8 @@ -namespace Amazon.Lambda.TestTool.Services; +namespace Amazon.Lambda.TestTool.Services.IO; +/// public class DirectoryManager : IDirectoryManager { + /// public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); } \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/EnvironmentManager.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/EnvironmentManager.cs new file mode 100644 index 000000000..1d04c496e --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/EnvironmentManager.cs @@ -0,0 +1,10 @@ +using System.Collections; + +namespace Amazon.Lambda.TestTool.Services.IO; + +/// +public class EnvironmentManager : IEnvironmentManager +{ + /// + public IDictionary GetEnvironmentVariables() => Environment.GetEnvironmentVariables(); +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IDirectoryManager.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IDirectoryManager.cs new file mode 100644 index 000000000..0957a7770 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IDirectoryManager.cs @@ -0,0 +1,13 @@ +namespace Amazon.Lambda.TestTool.Services.IO; + +/// +/// Provides functionality to manage and retrieve directory-related information. +/// +public interface IDirectoryManager +{ + /// + /// Gets the current working directory of the application. + /// + /// The full path of the current working directory. + string GetCurrentDirectory(); +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IEnvironmentManager.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IEnvironmentManager.cs new file mode 100644 index 000000000..3c4332f1a --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IO/IEnvironmentManager.cs @@ -0,0 +1,14 @@ +using System.Collections; + +namespace Amazon.Lambda.TestTool.Services.IO; + +/// +/// Defines methods for managing and retrieving environment-related information. +/// +public interface IEnvironmentManager +{ + /// + /// Retrieves all environment variables for the current process. + /// + IDictionary GetEnvironmentVariables(); +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IToolInteractiveService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IToolInteractiveService.cs index d3e488b8d..c2166565f 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IToolInteractiveService.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/IToolInteractiveService.cs @@ -1,7 +1,19 @@ namespace Amazon.Lambda.TestTool.Services; +/// +/// Defines methods for interacting with a tool's user interface through messages and error outputs. +/// public interface IToolInteractiveService { + /// + /// Writes a message to the standard output. + /// + /// The message to write. If null, a blank line is written. void WriteLine(string? message); + + /// + /// Writes an error message to the standard error output. + /// + /// The error message to write. If null, a blank line is written to the error output. void WriteErrorLine(string? message); } \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeRegistrar.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeRegistrar.cs index 343aa8895..f0dcf6dbd 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeRegistrar.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeRegistrar.cs @@ -2,23 +2,30 @@ namespace Amazon.Lambda.TestTool.Services; +/// +/// Provides functionality to register types and instances with an for dependency injection. +/// public sealed class TypeRegistrar(IServiceCollection builder) : ITypeRegistrar { + /// public ITypeResolver Build() { return new TypeResolver(builder.BuildServiceProvider()); - } - + } + + /// public void Register(Type service, Type implementation) { builder.AddSingleton(service, implementation); - } - + } + + /// public void RegisterInstance(Type service, object implementation) { builder.AddSingleton(service, implementation); - } - + } + + /// public void RegisterLazy(Type service, Func func) { if (func is null) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeResolver.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeResolver.cs index 083bd4859..5c3159c7d 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeResolver.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/TypeResolver.cs @@ -2,10 +2,14 @@ namespace Amazon.Lambda.TestTool.Services; +/// +/// Provides functionality to resolve types from an and manages the disposal of the provider if required. +/// public sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDisposable { - private readonly IServiceProvider _provider = provider ?? throw new ArgumentNullException(nameof(provider)); - + private readonly IServiceProvider _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + + /// public object? Resolve(Type? type) { if (type == null) @@ -14,8 +18,9 @@ public sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDi } return _provider.GetService(type); - } - + } + + /// public void Dispose() { if (_provider is IDisposable disposable) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs index bec8b9bae..2f9cd6ab7 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs @@ -1,14 +1,16 @@ -using System.Net; -using System.Reflection; -using System.Text; +using System.Reflection; using System.Text.Json; namespace Amazon.Lambda.TestTool.Utilities; +/// +/// A utility class that encapsulates common functionlity. +/// public static class Utils { - public const string DefaultConfigFile = "aws-lambda-tools-defaults.json"; - + /// + /// Determines the version of the tool. + /// public static string DetermineToolVersion() { const string unknownVersion = "Unknown"; @@ -37,20 +39,6 @@ public static string DetermineToolVersion() return version ?? unknownVersion; } - - - public static void PrintToolTitle(string productName) - { - var sb = new StringBuilder(productName); - var version = Utils.DetermineToolVersion(); - if (!string.IsNullOrEmpty(version)) - { - sb.Append($" ({version})"); - } - - Console.WriteLine(sb.ToString()); - } - /// /// Attempt to pretty print the input string. If pretty print fails return back the input string in its original form. /// @@ -75,13 +63,4 @@ public static string TryPrettyPrintJson(string? data) return data ?? string.Empty; } } - - public static string DetermineLaunchUrl(string host, int port, string defaultHost) - { - if (!IPAddress.TryParse(host, out _)) - // Any host other than explicit IP will be redirected to default host (i.e. localhost) - return $"http://{defaultHost}:{port}"; - - return $"http://{host}:{port}"; - } } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Amazon.Lambda.TestTool.UnitTests.csproj b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Amazon.Lambda.TestTool.UnitTests.csproj index 1dccda17f..c3089a967 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Amazon.Lambda.TestTool.UnitTests.csproj +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Amazon.Lambda.TestTool.UnitTests.csproj @@ -18,6 +18,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + all diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs new file mode 100644 index 000000000..ce22f00a5 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs @@ -0,0 +1,59 @@ +using Amazon.Lambda.TestTool.Commands.Settings; +using Amazon.Lambda.TestTool.Commands; +using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.Services; +using Spectre.Console.Cli; +using Moq; +using Amazon.Lambda.TestTool.UnitTests.Helpers; + +namespace Amazon.Lambda.TestTool.UnitTests.Commands; + +public class RunCommandTests +{ + private readonly Mock _mockInteractiveService = new Mock(); + private readonly Mock _mockRemainingArgs = new Mock(); + + [Fact] + public async Task ExecuteAsync_LambdaRuntimeApi_SuccessfulLaunch() + { + // Arrange + var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(5000); + var settings = new RunCommandSettings { NoLaunchWindow = true }; + var command = new RunCommand(_mockInteractiveService.Object); + var context = new CommandContext(new List(), _mockRemainingArgs.Object, "run", null); + var apiUrl = $"http://{settings.Host}:{settings.Port}"; + + // Act + var runningTask = command.ExecuteAsync(context, settings, cancellationSource); + var isApiRunning = await TestHelpers.WaitForApiToStartAsync(apiUrl); + cancellationSource.Cancel(); + + // Assert + var result = await runningTask; + Assert.Equal(CommandReturnCodes.Success, result); + Assert.True(isApiRunning); + } + + [Fact] + public async Task ExecuteAsync_ApiGatewayEmulator_SuccessfulLaunch() + { + // Arrange + var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(5000); + var settings = new RunCommandSettings { ApiGatewayEmulatorMode = ApiGatewayEmulatorMode.HttpV2, NoLaunchWindow = true}; + var command = new RunCommand(_mockInteractiveService.Object); + var context = new CommandContext(new List(), _mockRemainingArgs.Object, "run", null); + var apiUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}/health"; + + // Act + var runningTask = command.ExecuteAsync(context, settings, cancellationSource); + var isApiRunning = await TestHelpers.WaitForApiToStartAsync(apiUrl); + cancellationSource.Cancel(); + + // Assert + var result = await runningTask; + Assert.Equal(CommandReturnCodes.Success, result); + Assert.True(isApiRunning); + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs new file mode 100644 index 000000000..9a64e7288 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs @@ -0,0 +1,76 @@ +using Amazon.Lambda.TestTool.Commands.Settings; + +namespace Amazon.Lambda.TestTool.UnitTests.Commands.Settings; + +public class RunCommandSettingsTests +{ + [Fact] + public void DefaultHost_IsSetToConstantsDefaultHost() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.Equal(Constants.DefaultHost, settings.Host); + } + + [Fact] + public void DefaultPort_IsSetToConstantsDefaultPort() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.Equal(Constants.DefaultPort, settings.Port); + } + + [Fact] + public void ApiGatewayEmulatorPort_IsSetToConstantsDefaultApiGatewayEmulatorPort() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.Equal(Constants.DefaultApiGatewayEmulatorPort, settings.ApiGatewayEmulatorPort); + } + + [Fact] + public void NoLaunchWindow_DefaultsToFalse() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.False(settings.NoLaunchWindow); + } + + [Fact] + public void DisableLogs_DefaultsToFalse() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.False(settings.DisableLogs); + } + + [Fact] + public void PauseExit_DefaultsToFalse() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.False(settings.PauseExit); + } + + [Fact] + public void ApiGatewayEmulatorMode_DefaultsToNull() + { + // Arrange + var settings = new RunCommandSettings(); + + // Assert + Assert.Null(settings.ApiGatewayEmulatorMode); + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Helpers/TestHelpers.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Helpers/TestHelpers.cs new file mode 100644 index 000000000..fd46e9bf7 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Helpers/TestHelpers.cs @@ -0,0 +1,35 @@ +namespace Amazon.Lambda.TestTool.UnitTests.Helpers; + +internal static class TestHelpers +{ + internal static async Task WaitForApiToStartAsync(string url, int maxRetries = 5, int delayMilliseconds = 1000) + { + using (var client = new HttpClient()) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + var response = await client.GetAsync(url); + if (response.IsSuccessStatusCode) + { + return true; + } + } + catch + { + // Ignore exceptions, as the API might not yet be available + } + + await Task.Delay(delayMilliseconds); + } + + return false; + } + } + + internal static async Task CancelAndWaitAsync(Task executeTask) + { + await Task.Delay(1000); + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs index 74f817e3a..39de43623 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs @@ -4,9 +4,8 @@ using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; using Amazon.Lambda.Core; -using Amazon.Lambda.TestTool.Commands; -using Amazon.Lambda.TestTool.Models; using Amazon.Lambda.TestTool.Processes; +using Amazon.Lambda.TestTool.Commands.Settings; namespace Amazon.Lambda.TestTool.UnitTests; @@ -21,9 +20,10 @@ public RuntimeApiTests() [Fact] public async Task AddEventToDataStore() { - var options = new RunCommand.Settings(); + var cancellationTokenSource = new CancellationTokenSource(); + var options = new RunCommandSettings(); - var testToolProcess = TestToolProcess.Startup(options); + var testToolProcess = TestToolProcess.Startup(options, cancellationTokenSource.Token); try { var lambdaClient = ConstructLambdaServiceClient(testToolProcess.ServiceUrl); @@ -55,7 +55,7 @@ public async Task AddEventToDataStore() } finally { - testToolProcess.CancellationTokenSource.Cancel(); + await cancellationTokenSource.CancelAsync(); await testToolProcess.RunningTask; } } From cd3607e5da8527117d79ed93531823c2ace3d5e8 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Wed, 11 Dec 2024 10:20:17 -0500 Subject: [PATCH 2/6] reply to comments --- .../Commands/CancellableAsyncCommand.cs | 10 ++ .../Commands/Settings/RunCommandSettings.cs | 4 +- .../src/Amazon.Lambda.TestTool/Constants.cs | 46 ++--- .../Processes/ApiGatewayEmulatorProcess.cs | 38 ++--- .../Services/ApiGatewayRouteConfigService.cs | 61 +++++-- .../Commands/RunCommandTests.cs | 10 +- .../Settings/RunCommandSettingsTests.cs | 2 +- .../RuntimeApiTests.cs | 2 +- .../ApiGatewayRouteConfigServiceTests.cs | 158 ++++++++++++++++++ 9 files changed, 268 insertions(+), 63 deletions(-) create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs index 6e3922d9e..c18813859 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs @@ -2,10 +2,20 @@ namespace Spectre.Console.Cli; +/// +/// Provides an abstract base class for asynchronous commands that support cancellation. +/// +/// The type of the settings used for the command. public abstract class CancellableAsyncCommand : AsyncCommand where TSettings : CommandSettings { + /// + /// Executes the command asynchronously, with support for cancellation. + /// public abstract Task ExecuteAsync(CommandContext context, TSettings settings, CancellationTokenSource cancellationTokenSource); + /// + /// Executes the command asynchronously with built-in cancellation handling. + /// public sealed override async Task ExecuteAsync(CommandContext context, TSettings settings) { using var cancellationSource = new CancellationTokenSource(); diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs index 5a0f759a7..bb7e72995 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/Settings/RunCommandSettings.cs @@ -24,8 +24,8 @@ public sealed class RunCommandSettings : CommandSettings /// [CommandOption("-p|--port ")] [Description("The port number used for the test tool's web interface.")] - [DefaultValue(Constants.DefaultPort)] - public int Port { get; set; } = Constants.DefaultPort; + [DefaultValue(Constants.DefaultLambdaRuntimeEmulatorPort)] + public int Port { get; set; } = Constants.DefaultLambdaRuntimeEmulatorPort; /// /// Disable auto launching the test tool's web interface in a browser. diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs index f875016b8..b93e15b47 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Constants.cs @@ -15,7 +15,7 @@ public abstract class Constants /// /// The default port used by the Lambda Test Tool for the Lambda Runtime API and the Web Interface. /// - public const int DefaultPort = 5050; + public const int DefaultLambdaRuntimeEmulatorPort = 5050; /// /// The default port used by the API Gateway Emulator. @@ -35,60 +35,60 @@ public abstract class Constants /// /// The prefix for environment variables used to configure the Lambda functions. /// - public const string LambdaConfigEnvironmentVariablePrefix = "APIGATEWAY_EMULATOR_ROUTE_CONFIG"; - + public const string LambdaConfigEnvironmentVariablePrefix = "APIGATEWAY_EMULATOR_ROUTE_CONFIG"; + /// /// The product name displayed for the Lambda Test Tool. /// public const string ProductName = "AWS .NET Mock Lambda Test Tool"; - /// - /// The CSS style used for successful responses in the tool's UI. + /// + /// The CSS style used for successful responses in the tool's UI. /// public const string ResponseSuccessStyle = "white-space: pre-wrap; height: min-content; font-size: 75%; color: black"; - /// - /// The CSS style used for error responses in the tool's UI. + /// + /// The CSS style used for error responses in the tool's UI. /// public const string ResponseErrorStyle = "white-space: pre-wrap; height: min-content; font-size: 75%; color: red"; - /// - /// The CSS style used for successful responses in the tool's UI when a size constraint is applied. + /// + /// The CSS style used for successful responses in the tool's UI when a size constraint is applied. /// public const string ResponseSuccessStyleSizeConstraint = "white-space: pre-wrap; height: 300px; font-size: 75%; color: black"; - /// - /// The CSS style used for error responses in the tool's UI when a size constraint is applied. + /// + /// The CSS style used for error responses in the tool's UI when a size constraint is applied. /// public const string ResponseErrorStyleSizeConstraint = "white-space: pre-wrap; height: 300px; font-size: 75%; color: red"; - /// - /// The GitHub repository link for the AWS Lambda .NET repository. + /// + /// The GitHub repository link for the AWS Lambda .NET repository. /// public const string LinkGithubRepo = "https://github.com/aws/aws-lambda-dotnet"; - /// - /// The GitHub link for the Lambda Test Tool. + /// + /// The GitHub link for the Lambda Test Tool. /// public const string LinkGithubTestTool = "https://github.com/aws/aws-lambda-dotnet/tree/master/Tools/LambdaTestTool-v2"; - /// - /// The GitHub link for the Lambda Test Tool's installation and running instructions. + /// + /// The GitHub link for the Lambda Test Tool's installation and running instructions. /// public const string LinkGithubTestToolInstallAndRun = "https://github.com/aws/aws-lambda-dotnet/tree/master/Tools/LambdaTestTool#installing-and-running"; - /// - /// The AWS Developer Guide link for Dead Letter Queues in AWS Lambda. + /// + /// The AWS Developer Guide link for Dead Letter Queues in AWS Lambda. /// public const string LinkDlqDeveloperGuide = "https://docs.aws.amazon.com/lambda/latest/dg/dlq.html"; - /// - /// The Microsoft documentation link for the class. + /// + /// The Microsoft documentation link for the class. /// public const string LinkMsdnAssemblyLoadContext = "https://docs.microsoft.com/en-us/dotnet/api/system.runtime.loader.assemblyloadcontext"; - /// - /// The Visual Studio Marketplace link for the AWS Toolkit for Visual Studio. + /// + /// The Visual Studio Marketplace link for the AWS Toolkit for Visual Studio. /// public const string LinkVsToolkitMarketplace = "https://marketplace.visualstudio.com/items?itemName=AmazonWebServices.AWSToolkitforVisualStudio2022"; } diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs index a714292d7..99e00bdab 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs @@ -6,27 +6,27 @@ namespace Amazon.Lambda.TestTool.Processes; /// -/// A process that runs the API Gatewat emulator. +/// A process that runs the API Gateway emulator. /// public class ApiGatewayEmulatorProcess { /// /// The service provider that will contain all the registered services. /// - public required IServiceProvider Services { get; init; } - + public required IServiceProvider Services { get; init; } + /// - /// The API Gatewat emulator task that was started. + /// The API Gateway emulator task that was started. /// - public required Task RunningTask { get; init; } - - /// - /// The endpoint of the API Gatewat emulator. + public required Task RunningTask { get; init; } + + /// + /// The endpoint of the API Gateway emulator. /// - public required string ServiceUrl { get; init; } - - /// - /// Creates the Web API and runs it in the background. + public required string ServiceUrl { get; init; } + + /// + /// Creates the Web API and runs it in the background. /// public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, CancellationToken cancellationToken = default) { @@ -36,15 +36,15 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can var serviceUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}"; builder.WebHost.UseUrls(serviceUrl); - builder.WebHost.SuppressStatusMessages(true); - - builder.Services.AddHealthChecks(); - + builder.WebHost.SuppressStatusMessages(true); + + builder.Services.AddHealthChecks(); + var app = builder.Build(); - app.UseHttpsRedirection(); - - app.MapHealthChecks("/health"); + app.UseHttpsRedirection(); + + app.MapHealthChecks("/__lambda_test_tool_apigateway_health__"); app.Map("/{**catchAll}", (HttpContext context, IApiGatewayRouteConfigService routeConfigService) => { 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 79e1ecd8d..d09779ccc 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs @@ -9,6 +9,7 @@ namespace Amazon.Lambda.TestTool.Services; /// public class ApiGatewayRouteConfigService : IApiGatewayRouteConfigService { + private readonly ILogger _logger; private readonly List _routeConfigs = new(); /// @@ -16,46 +17,71 @@ public class ApiGatewayRouteConfigService : IApiGatewayRouteConfigService /// which loads and parses environment variables that match a specific prefix. /// /// A service to access environment variables. + /// The logger instance for public ApiGatewayRouteConfigService( - IEnvironmentManager environmentManager) + IEnvironmentManager environmentManager, + ILogger logger) { + _logger = logger; + + 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); if (!(key.Equals(Constants.LambdaConfigEnvironmentVariablePrefix) || key.StartsWith($"{Constants.LambdaConfigEnvironmentVariablePrefix}_"))) + { + logger.LogDebug("Skipping environment variable: {VariableName}", key); continue; + } var jsonValue = entry.Value?.ToString(); + logger.LogDebug("Environment variable value: {VariableValue}", jsonValue); if (string.IsNullOrEmpty(jsonValue)) continue; + try { - var config = JsonSerializer.Deserialize(jsonValue); - if (config != null) - { - _routeConfigs.Add(config); - } - } - catch (Exception) - { - try + if (jsonValue.StartsWith('[')) { + logger.LogDebug("Environment variable value starts with '['. Attempting to deserialize as a List."); var config = JsonSerializer.Deserialize>(jsonValue); if (config != null) { _routeConfigs.AddRange(config); + logger.LogDebug("Environment variable deserialized and added to the existing configuration."); + } + else + { + logger.LogDebug("Environment variable was not properly deserialized and will be skipped."); } } - catch (Exception ex) + else { - Console.WriteLine($"Error deserializing environment variable {key}: {ex.Message}"); + logger.LogDebug("Environment variable value does not start with '['."); + var config = JsonSerializer.Deserialize(jsonValue); + if (config != null) + { + _routeConfigs.Add(config); + logger.LogDebug("Environment variable deserialized and added to the existing configuration."); + } + else + { + 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); + } } } @@ -64,16 +90,27 @@ public ApiGatewayRouteConfigService( { foreach (var routeConfig in _routeConfigs) { + _logger.LogDebug("Checking if '{Path}' matches '{Template}'.", path, routeConfig.Path); var template = TemplateParser.Parse(routeConfig.Path); var matcher = new TemplateMatcher(template, GetDefaults(template)); var routeValueDictionary = new RouteValueDictionary(); if (!matcher.TryMatch(path, routeValueDictionary)) + { + _logger.LogDebug("'{Path}' does not match '{Template}'.", path, routeConfig.Path); continue; + } + + _logger.LogDebug("'{Path}' matches '{Template}'. Now checking the HTTP Method.", path, routeConfig.Path); if (!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; + } + + _logger.LogDebug("{HttpMethod} {Path} matches the existing configuration {TemplateMethod} {Template}.", httpMethod, path, routeConfig.HttpMethod, routeConfig.Path); return routeConfig; } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs index ce22f00a5..2d262b667 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/RunCommandTests.cs @@ -19,7 +19,7 @@ public async Task ExecuteAsync_LambdaRuntimeApi_SuccessfulLaunch() // Arrange var cancellationSource = new CancellationTokenSource(); cancellationSource.CancelAfter(5000); - var settings = new RunCommandSettings { NoLaunchWindow = true }; + var settings = new RunCommandSettings { Port = 9001, NoLaunchWindow = true }; var command = new RunCommand(_mockInteractiveService.Object); var context = new CommandContext(new List(), _mockRemainingArgs.Object, "run", null); var apiUrl = $"http://{settings.Host}:{settings.Port}"; @@ -27,7 +27,7 @@ public async Task ExecuteAsync_LambdaRuntimeApi_SuccessfulLaunch() // Act var runningTask = command.ExecuteAsync(context, settings, cancellationSource); var isApiRunning = await TestHelpers.WaitForApiToStartAsync(apiUrl); - cancellationSource.Cancel(); + await cancellationSource.CancelAsync(); // Assert var result = await runningTask; @@ -41,15 +41,15 @@ public async Task ExecuteAsync_ApiGatewayEmulator_SuccessfulLaunch() // Arrange var cancellationSource = new CancellationTokenSource(); cancellationSource.CancelAfter(5000); - var settings = new RunCommandSettings { ApiGatewayEmulatorMode = ApiGatewayEmulatorMode.HttpV2, NoLaunchWindow = true}; + var settings = new RunCommandSettings { Port = 9002, ApiGatewayEmulatorMode = ApiGatewayEmulatorMode.HttpV2, NoLaunchWindow = true}; var command = new RunCommand(_mockInteractiveService.Object); var context = new CommandContext(new List(), _mockRemainingArgs.Object, "run", null); - var apiUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}/health"; + var apiUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}/__lambda_test_tool_apigateway_health__"; // Act var runningTask = command.ExecuteAsync(context, settings, cancellationSource); var isApiRunning = await TestHelpers.WaitForApiToStartAsync(apiUrl); - cancellationSource.Cancel(); + await cancellationSource.CancelAsync(); // Assert var result = await runningTask; diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs index 9a64e7288..dd82f4ed9 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Commands/Settings/RunCommandSettingsTests.cs @@ -21,7 +21,7 @@ public void DefaultPort_IsSetToConstantsDefaultPort() var settings = new RunCommandSettings(); // Assert - Assert.Equal(Constants.DefaultPort, settings.Port); + Assert.Equal(Constants.DefaultLambdaRuntimeEmulatorPort, settings.Port); } [Fact] diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs index 39de43623..68bed10d7 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs @@ -22,7 +22,7 @@ public async Task AddEventToDataStore() { var cancellationTokenSource = new CancellationTokenSource(); var options = new RunCommandSettings(); - + options.Port = 9000; var testToolProcess = TestToolProcess.Startup(options, cancellationTokenSource.Token); try { 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 new file mode 100644 index 000000000..55b6cfe9a --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.Services; +using Amazon.Lambda.TestTool.Services.IO; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Amazon.Lambda.TestTool.UnitTests.Services; + +public class ApiGatewayRouteConfigServiceTests +{ + private readonly Mock _mockEnvironmentManager = new Mock(); + private readonly Mock> _mockLogger = new Mock>(); + + [Fact] + public void Constructor_LoadsAndParsesValidEnvironmentVariables() + { + // Arrange + var validConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + HttpMethod = "GET", + Path = "/test/{id}" + }; + var environmentVariables = new Dictionary + { + { Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(validConfig) } + }; + + _mockEnvironmentManager + .Setup(m => m.GetEnvironmentVariables()) + .Returns(environmentVariables); + + // Act + var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object); + + // Assert + var routeConfig = service.GetRouteConfig("GET", "/test/123"); + Assert.NotNull(routeConfig); + Assert.Equal("TestLambdaFunction", routeConfig.LambdaResourceName); + } + + [Fact] + public void Constructor_IgnoresInvalidEnvironmentVariables() + { + // Arrange + var invalidJson = "{ invalid json }"; + var environmentVariables = new Dictionary + { + { Constants.LambdaConfigEnvironmentVariablePrefix, invalidJson } + }; + + _mockEnvironmentManager + .Setup(m => m.GetEnvironmentVariables()) + .Returns(environmentVariables); + + // Act + var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object); + + // Assert + var routeConfig = service.GetRouteConfig("GET", "/test/123"); + Assert.Null(routeConfig); + } + + [Fact] + public void GetRouteConfig_ReturnsNullForNonMatchingHttpMethod() + { + // Arrange + var routeConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + HttpMethod = "POST", + Path = "/test/{id}" + }; + + _mockEnvironmentManager + .Setup(m => m.GetEnvironmentVariables()) + .Returns(new Dictionary + { + { Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfig) } + }); + + var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object); + + // Act + var result = service.GetRouteConfig("GET", "/test/123"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetRouteConfig_ReturnsNullForNonMatchingPath() + { + // Arrange + var routeConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + HttpMethod = "GET", + Path = "/test/{id}" + }; + + _mockEnvironmentManager + .Setup(m => m.GetEnvironmentVariables()) + .Returns(new Dictionary + { + { Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfig) } + }); + + var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object); + + // Act + var result = service.GetRouteConfig("GET", "/nonexistent/123"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Constructor_LoadsAndParsesListOfConfigs() + { + // Arrange + var routeConfigs = new List + { + new ApiGatewayRouteConfig + { + LambdaResourceName = "Function1", + HttpMethod = "GET", + Path = "/path1" + }, + new ApiGatewayRouteConfig + { + LambdaResourceName = "Function2", + HttpMethod = "POST", + Path = "/path2" + } + }; + + _mockEnvironmentManager + .Setup(m => m.GetEnvironmentVariables()) + .Returns(new Dictionary + { + { Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfigs) } + }); + + var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object); + + // Act + var result1 = service.GetRouteConfig("GET", "/path1"); + var result2 = service.GetRouteConfig("POST", "/path2"); + + // Assert + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.Equal("Function1", result1.LambdaResourceName); + Assert.Equal("Function2", result2.LambdaResourceName); + } +} \ No newline at end of file From 65267f3a32774cf428798f1cdb5681c888f71498 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Wed, 11 Dec 2024 10:38:21 -0500 Subject: [PATCH 3/6] add trimming to the environment var parsing logic --- .../Services/ApiGatewayRouteConfigService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d09779ccc..51750c675 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs @@ -41,7 +41,7 @@ public ApiGatewayRouteConfigService( continue; } - var jsonValue = entry.Value?.ToString(); + var jsonValue = entry.Value?.ToString()?.Trim(); logger.LogDebug("Environment variable value: {VariableValue}", jsonValue); if (string.IsNullOrEmpty(jsonValue)) continue; From 693fcfaf4e479007484de0f39d2ec91c0fa021b4 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Wed, 11 Dec 2024 16:57:24 -0500 Subject: [PATCH 4/6] update return when config not found --- .../Processes/ApiGatewayEmulatorProcess.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs index 99e00bdab..7d89cc333 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs @@ -51,7 +51,9 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path); if (routeConfig == null) { - return Results.NotFound("Route not found"); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.Headers.Append("x-amzn-errortype", "MissingAuthenticationTokenException"); + return Results.Json(new { message = "Missing Authentication Token" }); } if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2)) From 253d032e6616c25d51c71d39ae7d516073a2235d Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Thu, 12 Dec 2024 12:39:33 -0500 Subject: [PATCH 5/6] added priority to routes based on API Gateway logic --- .../Models/ApiGatewayRouteConfig.cs | 15 ++ .../Models/ApiGatewayRouteType.cs | 22 +++ .../Processes/ApiGatewayEmulatorProcess.cs | 2 + .../Services/ApiGatewayRouteConfigService.cs | 158 ++++++++++++++---- .../ApiGatewayRouteConfigServiceTests.cs | 98 +++++++++++ 5 files changed, 259 insertions(+), 36 deletions(-) create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteType.cs 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 243a1d147..ab837a27b 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs @@ -24,4 +24,19 @@ 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/Models/ApiGatewayRouteType.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteType.cs new file mode 100644 index 000000000..51c000a93 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteType.cs @@ -0,0 +1,22 @@ +namespace Amazon.Lambda.TestTool.Models; + +/// +/// The type of API Gateway Route. This is used to determine the priority of the route when there is route overlap. +/// +public enum ApiGatewayRouteType +{ + /// + /// An exact route with no path variables. + /// + Exact = 0, + + /// + /// A route with path variables, but not a greedy variable {proxy+}. + /// + Variable = 1, + + /// + /// A route with a greedy path variables. + /// + Proxy = 2 +} \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs index 7d89cc333..a9f237558 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs @@ -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" }); 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 51750c675..7b30ca538 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs @@ -10,11 +10,11 @@ namespace Amazon.Lambda.TestTool.Services; public class ApiGatewayRouteConfigService : IApiGatewayRouteConfigService { private readonly ILogger _logger; - private readonly List _routeConfigs = new(); + private readonly IEnvironmentManager _environmentManager; + private List _routeConfigs = new(); /// - /// Constructs an instance of - /// which loads and parses environment variables that match a specific prefix. + /// Constructs an instance of . /// /// A service to access environment variables. /// The logger instance for @@ -23,26 +23,36 @@ public ApiGatewayRouteConfigService( ILogger 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"); + /// + /// Loads and parses environment variables that match a specific prefix. + /// + 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; @@ -50,50 +60,140 @@ public ApiGatewayRouteConfigService( { 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>(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(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); } } } - /// + /// + /// 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 + /// + 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(); + } + + /// + /// A method to match an HTTP Method and HTTP Path with an existing . + /// 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 + /// + /// An HTTP Method + /// An HTTP Path + /// 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) { _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)) @@ -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; @@ -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; - } } \ 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 55b6cfe9a..3880fdb5c 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 @@ -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 + { + 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 + { + { 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); + } } \ No newline at end of file From 37aa2b75619a06f5e37ab0bb4ec52087d5c1fcc8 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Fri, 13 Dec 2024 12:54:57 -0500 Subject: [PATCH 6/6] updated the algorithm for finding the best match --- .../Models/ApiGatewayRouteConfig.cs | 15 - .../Services/ApiGatewayRouteConfigService.cs | 379 ++++++++++++++---- .../ApiGatewayRouteConfigServiceTests.cs | 26 +- 3 files changed, 319 insertions(+), 101 deletions(-) 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