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..c18813859 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Commands/CancellableAsyncCommand.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; + +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(); + + 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..bb7e72995 --- /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.DefaultLambdaRuntimeEmulatorPort)] + public int Port { get; set; } = Constants.DefaultLambdaRuntimeEmulatorPort; + + /// + /// 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..b93e15b47 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"; - public const int DefaultPort = 5050; + + /// + /// The default port used by the Lambda Test Tool for the Lambda Runtime API and the Web Interface. + /// + public const int DefaultLambdaRuntimeEmulatorPort = 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/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/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..a9f237558 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs @@ -0,0 +1,82 @@ +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 Gateway emulator. +/// +public class ApiGatewayEmulatorProcess +{ + /// + /// The service provider that will contain all the registered services. + /// + public required IServiceProvider Services { get; init; } + + /// + /// The API Gateway emulator task that was started. + /// + 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 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("/__lambda_test_tool_apigateway_health__"); + + app.Map("/{**catchAll}", (HttpContext context, IApiGatewayRouteConfigService routeConfigService) => + { + 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" }); + } + + 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..bff1e9fe7 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs @@ -0,0 +1,430 @@ +using System.Collections; +using System.Text.Json; +using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.Services.IO; + +namespace Amazon.Lambda.TestTool.Services; + +/// +public class ApiGatewayRouteConfigService : IApiGatewayRouteConfigService +{ + private readonly ILogger _logger; + private readonly IEnvironmentManager _environmentManager; + private List _routeConfigs = new(); + + /// + /// Constructs an instance of . + /// + /// A service to access environment variables. + /// The logger instance for + public ApiGatewayRouteConfigService( + IEnvironmentManager environmentManager, + ILogger logger) + { + _logger = logger; + _environmentManager = environmentManager; + + LoadLambdaConfigurationFromEnvironmentVariables(); + } + + /// + /// 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); + if (!(key.Equals(Constants.LambdaConfigEnvironmentVariablePrefix) || + key.StartsWith($"{Constants.LambdaConfigEnvironmentVariablePrefix}_"))) + { + _logger.LogDebug("Skipping environment variable: {VariableName}", key); + continue; + } + + var jsonValue = entry.Value?.ToString()?.Trim(); + _logger.LogDebug("Environment variable value: {VariableValue}", jsonValue); + if (string.IsNullOrEmpty(jsonValue)) + continue; + + try + { + if (jsonValue.StartsWith('[')) + { + _logger.LogDebug("Environment variable value starts with '['. Attempting to deserialize as a List."); + var configs = JsonSerializer.Deserialize>(jsonValue); + if (configs != null) + { + 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 + { + _logger.LogDebug("Environment variable was not properly deserialized and will be skipped."); + } + } + else + { + _logger.LogDebug("Environment variable value does not start with '['."); + var config = JsonSerializer.Deserialize(jsonValue); + if (config != null) + { + 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 + { + _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); + } + } + } + + /// + /// Applies some validity checks for Lambda route configuration. + /// + /// Lambda route configuration + /// true if route is valid, false if not + private bool IsRouteConfigValid(ApiGatewayRouteConfig routeConfig) + { + var occurrences = routeConfig.Path.Split("{proxy+}").Length - 1; + if (occurrences > 1) + { + _logger.LogDebug("The route config {Method} {Path} cannot have multiple greedy variables {{proxy+}}.", + routeConfig.HttpMethod, routeConfig.Path); + return false; + } + + 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; + } + + return true; + } + + /// + /// 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) + { + var requestSegments = path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + var candidates = new List(); + + foreach (var route in _routeConfigs) + { + _logger.LogDebug("{RequestMethod} {RequestPath}: Checking if matches with {TemplateMethod} {TemplatePath}.", + httpMethod, path, route.HttpMethod, route.Path); + + // 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; + } + + _logger.LogDebug("{RequestMethod} {RequestPath}: The HTTP method matches. Checking the route {TemplatePath}.", + httpMethod, path, route.Path); + + var routeSegments = route.Path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + var matchDetail = MatchRoute(routeSegments, requestSegments); + if (matchDetail.Matched) + { + candidates.Add(new MatchResult + { + Route = route, + LiteralMatches = matchDetail.LiteralMatches, + GreedyVariables = matchDetail.GreedyCount, + NormalVariables = matchDetail.VariableCount, + TotalSegments = routeSegments.Length, + MatchedSegmentsBeforeGreedy = matchDetail.MatchedSegmentsBeforeGreedy + }); + } + } + + 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)) + { + 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++; + } + } + 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++; + } + } + + // If request has leftover segments that aren't matched + if (matched && requestPathIndex < requestSegments.Length) + { + matched = false; + } + + 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/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..2d262b667 --- /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 { 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}"; + + // Act + var runningTask = command.ExecuteAsync(context, settings, cancellationSource); + var isApiRunning = await TestHelpers.WaitForApiToStartAsync(apiUrl); + await cancellationSource.CancelAsync(); + + // 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 { 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}/__lambda_test_tool_apigateway_health__"; + + // Act + var runningTask = command.ExecuteAsync(context, settings, cancellationSource); + var isApiRunning = await TestHelpers.WaitForApiToStartAsync(apiUrl); + await cancellationSource.CancelAsync(); + + // 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..dd82f4ed9 --- /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.DefaultLambdaRuntimeEmulatorPort, 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..68bed10d7 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 testToolProcess = TestToolProcess.Startup(options); + var cancellationTokenSource = new CancellationTokenSource(); + var options = new RunCommandSettings(); + options.Port = 9000; + 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; } } 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..be1954532 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs @@ -0,0 +1,280 @@ +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); + } + + [Fact] + public void ProperlyMatchRouteConfigs() + { + // 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" + }, + 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+}" + } + }; + + _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"); + 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); + 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); + Assert.Equal("F9", result15?.LambdaResourceName); + Assert.Equal("F10", result16?.LambdaResourceName); + Assert.Equal("F11", result17?.LambdaResourceName); + } +} \ No newline at end of file