diff --git a/Tools/LambdaTestTool/aws-lambda-test-tool-netcore.sln b/Tools/LambdaTestTool/aws-lambda-test-tool-netcore.sln index 024055f54..dcf87a526 100644 --- a/Tools/LambdaTestTool/aws-lambda-test-tool-netcore.sln +++ b/Tools/LambdaTestTool/aws-lambda-test-tool-netcore.sln @@ -46,6 +46,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorExample", "t EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.TestTool.Tests.NET6", "tests\Amazon.Lambda.TestTool.Tests.NET6\Amazon.Lambda.TestTool.Tests.NET6.csproj", "{2C69BEB2-858E-43E3-9951-74E780FEB1BF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreetingFunc", "tests\LambdaFunctions\net6\GreetingFunc\GreetingFunc.csproj", "{A88F4366-07FA-47B3-9DA1-F7A4C6C0EA09}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -104,6 +106,10 @@ Global {2C69BEB2-858E-43E3-9951-74E780FEB1BF}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C69BEB2-858E-43E3-9951-74E780FEB1BF}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C69BEB2-858E-43E3-9951-74E780FEB1BF}.Release|Any CPU.Build.0 = Release|Any CPU + {A88F4366-07FA-47B3-9DA1-F7A4C6C0EA09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A88F4366-07FA-47B3-9DA1-F7A4C6C0EA09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A88F4366-07FA-47B3-9DA1-F7A4C6C0EA09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A88F4366-07FA-47B3-9DA1-F7A4C6C0EA09}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -126,6 +132,7 @@ Global {F08BF489-BD05-4DC1-9772-AB5E137B87B8} = {BFD718DB-4526-4BED-B2B0-BB446EDFFEA1} {9F8D7697-46FC-45E4-B795-11CCDA2B68B3} = {F08BF489-BD05-4DC1-9772-AB5E137B87B8} {2C69BEB2-858E-43E3-9951-74E780FEB1BF} = {28C935E3-4FB4-4B09-A9DB-26A1EB04CDE0} + {A88F4366-07FA-47B3-9DA1-F7A4C6C0EA09} = {F08BF489-BD05-4DC1-9772-AB5E137B87B8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E6C77567-6F16-4EE3-8743-ADE6B68434FD} diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Controllers/InvokeApiController.cs b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Controllers/InvokeApiController.cs new file mode 100644 index 000000000..b16df5d08 --- /dev/null +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Controllers/InvokeApiController.cs @@ -0,0 +1,239 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Amazon.Lambda.Core; +using Amazon.Lambda.TestTool.Runtime; +using Amazon.Lambda.TestTool.Runtime.LambdaMocks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Amazon.Lambda.TestTool.BlazorTester.Controllers +{ + [Route("[controller]")] + public class InvokeApiController : ControllerBase + { + private readonly LocalLambdaOptions _lambdaOptions; + private LambdaConfigInfo _lambdaConfig; + + public InvokeApiController(LocalLambdaOptions lambdaOptions) + { + _lambdaOptions = lambdaOptions; + } + + [HttpPost("execute")] + public async Task ExecuteFunction() + { + if (!TryGetConfigFile(out var lambdaConfig)) + { + return StatusCode((int)HttpStatusCode.InternalServerError, + new InternalException("ServiceException", "Error while loading function configuration")); + } + + if (lambdaConfig.FunctionInfos.Count == 0) + { + return NotFound(new InternalException("ResourceNotFoundException", "Default function not found")); + } + + return Ok(await ExecuteFunctionInternal(lambdaConfig, lambdaConfig.FunctionInfos[0])); + } + + [HttpPost("execute/{functionName}")] + [HttpPost("2015-03-31/functions/{functionName}/invocations")] + public async Task ExecuteFunction(string functionName) + { + if (!TryGetConfigFile(out var lambdaConfig)) + { + return StatusCode((int)HttpStatusCode.InternalServerError, + new InternalException("ServiceException", "Error while loading function configuration")); + } + + var functionInfo = lambdaConfig.FunctionInfos.FirstOrDefault(f => f.Name == functionName); + if (functionInfo == null) + { + return NotFound(new InternalException("ResourceNotFoundException", + $"Function not found: {functionName}")); + } + + return Ok(await ExecuteFunctionInternal(lambdaConfig, functionInfo)); + } + + private bool TryGetConfigFile(out LambdaConfigInfo configInfo) + { + configInfo = null; + + if (_lambdaConfig != null) + { + configInfo = _lambdaConfig; + return true; + } + + if (_lambdaOptions.LambdaConfigFiles.Count == 0) + { + Console.Error.WriteLine("LambdaConfigFiles list is empty"); + return false; + } + + var configPath = _lambdaOptions.LambdaConfigFiles[0]; + try + { + configInfo = LambdaDefaultsConfigFileParser.LoadFromFile(configPath); + _lambdaConfig = configInfo; + } + catch (Exception e) + { + Console.Error.WriteLine("Error loading lambda config from '{0}'", configPath); + Console.Error.WriteLine(e.ToString()); + } + + return true; + } + + private async Task ExecuteFunctionInternal(LambdaConfigInfo lambdaConfig, + LambdaFunctionInfo functionInfo) + { + var requestReader = new LambdaRequestReader(Request); + var function = _lambdaOptions.LoadLambdaFuntion(lambdaConfig, functionInfo.Handler); + + var request = new ExecutionRequest + { + Function = function, + AWSProfile = lambdaConfig.AWSProfile, + AWSRegion = lambdaConfig.AWSRegion, + Payload = await requestReader.ReadPayload(), + ClientContext = requestReader.ReadClientContext() + }; + + var response = await _lambdaOptions.LambdaRuntime.ExecuteLambdaFunctionAsync(request); + var responseWriter = new LambdaResponseWriter(Response); + + if (requestReader.ReadLogType() == "Tail") + { + responseWriter.WriteLogs(response.Logs); + } + + if (!response.IsSuccess) + { + responseWriter.WriteError(); + return new LambdaException(response.Error); + } + + return response.Response; + } + + private class LambdaRequestReader + { + private const string LogTypeHeader = "X-Amz-Log-Type"; + private const string ClientContextHeader = "X-Amz-Client-Context"; + + private readonly HttpRequest _request; + + public LambdaRequestReader(HttpRequest request) + { + _request = request; + } + + public async Task ReadPayload() + { + using var reader = new StreamReader(_request.Body); + return await reader.ReadToEndAsync(); + } + + public string ReadLogType() + { + return _request.Headers.TryGetValue(LogTypeHeader, out var value) + ? value.ToString() + : string.Empty; + } + + public IClientContext ReadClientContext() + { + if (!_request.Headers.TryGetValue(ClientContextHeader, out var contextString)) + { + return null; + } + + var clientContext = JsonSerializer.Deserialize( + Convert.FromBase64String(contextString), + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return clientContext; + } + } + + private class LambdaResponseWriter + { + private const string FunctionErrorHeader = "X-Amz-Function-Error"; + private const string LogResultHeader = "X-Amz-Log-Result"; + + private readonly HttpResponse _response; + + public LambdaResponseWriter(HttpResponse response) + { + _response = response; + } + + public void WriteError() + { + _response.Headers[FunctionErrorHeader] = "Unhandled"; + } + + public void WriteLogs(string logs) + { + _response.Headers[LogResultHeader] = Convert.ToBase64String(Encoding.UTF8.GetBytes(logs)); + } + } + + private class InternalException + { + public string ErrorCode { get; } + + public string ErrorMessage { get; } + + public InternalException(string errorCode, string errorMessage) + { + ErrorCode = errorCode; + ErrorMessage = errorMessage; + } + } + + private class LambdaException + { + public string ErrorType { get; } + + public string ErrorMessage { get; } + + public string[] StackTrace { get; } + + public LambdaException(string error) + { + var errorLines = error.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (errorLines.Length == 0) + { + StackTrace = Array.Empty(); + return; + } + + StackTrace = errorLines.Skip(1).Select(s => s.Trim()).ToArray(); + + var errorMessage = errorLines[0]; + var errorMessageParts = errorMessage.Split(':'); + if (errorMessageParts.Length > 1) + { + ErrorType = errorMessageParts[0].Trim(); + ErrorMessage = errorMessageParts[1].Trim(); + } + else + { + ErrorMessage = errorMessage; + } + } + } + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/ExecutionRequest.cs b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/ExecutionRequest.cs index 599ec64b2..e01110e91 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/ExecutionRequest.cs +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/ExecutionRequest.cs @@ -1,4 +1,7 @@ -namespace Amazon.Lambda.TestTool.Runtime +using Amazon.Lambda.Core; +using Amazon.Lambda.TestTool.Runtime.LambdaMocks; + +namespace Amazon.Lambda.TestTool.Runtime { /// /// The information used to execute the Lambda function within the test tool @@ -24,6 +27,11 @@ public class ExecutionRequest /// The JSON payload that will be the input of the Lambda function. /// public string Payload { get; set; } + + /// + /// ClientContext that pass to Lambda function as a part of ILambdaContext + /// + public IClientContext ClientContext { get; set; } } } \ No newline at end of file diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaExecutor.cs b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaExecutor.cs index 7968c5254..9a7985e9b 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaExecutor.cs +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaExecutor.cs @@ -40,7 +40,8 @@ public async Task ExecuteAsync(ExecutionRequest request) var context = new LocalLambdaContext() { - Logger = logger + Logger = logger, + ClientContext = request.ClientContext }; object instance = null; diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaMocks/LocalClientContext.cs b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaMocks/LocalClientContext.cs new file mode 100644 index 000000000..a40b05ba2 --- /dev/null +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/Runtime/LambdaMocks/LocalClientContext.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Amazon.Lambda.Core; + +namespace Amazon.Lambda.TestTool.Runtime.LambdaMocks +{ + public class LocalClientContext: IClientContext + { + public IDictionary Environment { get; set; } + + public IClientApplication Client { get; set; } + + public IDictionary Custom { get; set; } + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/Amazon.Lambda.TestTool.BlazorTester.Tests.csproj b/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/Amazon.Lambda.TestTool.BlazorTester.Tests.csproj index babfcd5ed..cb5ec42ba 100644 --- a/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/Amazon.Lambda.TestTool.BlazorTester.Tests.csproj +++ b/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/Amazon.Lambda.TestTool.BlazorTester.Tests.csproj @@ -6,6 +6,7 @@ + @@ -20,6 +21,7 @@ + diff --git a/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/InvokeApiControllerTests.cs b/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/InvokeApiControllerTests.cs new file mode 100644 index 000000000..fd38d75fd --- /dev/null +++ b/Tools/LambdaTestTool/tests/Amazon.Lambda.TestTool.BlazorTester.Tests/InvokeApiControllerTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.Model; +using Amazon.Lambda.TestTool.Runtime; +using Amazon.Runtime.Endpoints; +using GreetingFunc; +using Microsoft.AspNetCore.Hosting; +using Xunit; + +namespace Amazon.Lambda.TestTool.BlazorTester.Tests; + +public class InvokeApiControllerTests +{ + private const string FunctionFileName = "GreetingFunc.json"; + private const string FunctionName = "GreetingFunc::GreetingFunc.Function::FunctionHandler"; + private const int FunctionPort = 10222; + + [Fact] + public async Task InvokeFunctionSuccessfully() + { + const string expectedUserName = "John"; + var expectedOutput = Output.BuildGreeting(expectedUserName); + + using var session = await CreateSession(); + var input = new Input(expectedUserName); + var response = await session.Client.PostAsJsonAsync($"invokeapi/execute/{FunctionName}", input); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var actualOutput = await response.Content.ReadFromJsonAsync(); + Assert.Equal(expectedOutput.GreetingMessage, actualOutput!.GreetingMessage); + } + + [Fact] + public async Task InvokeFunctionUsingAwsSdkSuccessfully() + { + const string expectedUserName = "John"; + var expectedOutput = Output.BuildGreeting(expectedUserName); + + using var session = await CreateSession(); + var input = new Input(expectedUserName); + + var lambdaClient = new AmazonLambdaClient(new AmazonLambdaConfig + { + EndpointProvider = new StaticEndpointProvider(session.HostUri + "/invokeapi/") + }); + + var response = await lambdaClient.InvokeAsync(new InvokeRequest + { + FunctionName = FunctionName, + Payload = JsonSerializer.Serialize(input), + LogType = LogType.Tail + }); + + Assert.Equal(HttpStatusCode.OK, response.HttpStatusCode); + + var actualOutput = JsonSerializer.Deserialize(response.Payload); + Assert.Equal(expectedOutput.GreetingMessage, actualOutput!.GreetingMessage); + + var logs = Encoding.UTF8.GetString(Convert.FromBase64String(response.LogResult)); + Assert.False(string.IsNullOrEmpty(logs)); + } + + [Fact] + public async Task InvokeFunctionUsingAwsSdkWithFailure() + { + const string expectedUserName = ""; + + using var session = await CreateSession(); + var input = new Input(expectedUserName); + + var lambdaClient = new AmazonLambdaClient(new AmazonLambdaConfig + { + EndpointProvider = new StaticEndpointProvider(session.HostUri + "/invokeapi/") + }); + + var response = await lambdaClient.InvokeAsync(new InvokeRequest + { + FunctionName = FunctionName, + Payload = JsonSerializer.Serialize(input), + LogType = LogType.Tail + }); + + Assert.Equal(HttpStatusCode.OK, response.HttpStatusCode); + Assert.Equal("Unhandled", response.FunctionError); + + var error = JsonSerializer.Deserialize(response.Payload, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + Assert.False(string.IsNullOrEmpty(error!.ErrorType)); + Assert.False(string.IsNullOrEmpty(error!.ErrorMessage)); + Assert.NotEmpty(error!.StackTrace); + } + + private async Task CreateSession(string functionFile = FunctionFileName) => + await TestSession.CreateSessionAsync("*", FunctionPort, functionFile); + + private record LambdaException(string ErrorCode, string ErrorType, string ErrorMessage, string[] StackTrace); + + private class TestSession : IDisposable + { + private bool _disposedValue; + + private CancellationTokenSource Source { get; set; } + + private IWebHost WebHost { get; set; } + + public string HostUri { get; private set; } + + public HttpClient Client { get; private set; } + + public static async Task CreateSessionAsync(string host, int port, string configFile) + { + var session = new TestSession + { + Source = new CancellationTokenSource() + }; + + var lambdaOptions = new LocalLambdaOptions + { + Host = host, + Port = port, + LambdaConfigFiles = new List { configFile }, + LambdaRuntime = LocalLambdaRuntime.Initialize(Directory.GetCurrentDirectory()) + }; + + session.WebHost = await Startup.StartWebTesterAsync(lambdaOptions, false, session.Source.Token); + + session.HostUri = Utils.DetermineLaunchUrl(host, port, Constants.DEFAULT_HOST); + session.Client = new HttpClient + { + BaseAddress = new Uri(session.HostUri) + }; + + return session; + } + + protected virtual void Dispose(bool disposing) + { + if (_disposedValue) + { + return; + } + + if (disposing) + { + Client.Dispose(); + Source.Cancel(); + WebHost.StopAsync().GetAwaiter().GetResult(); + } + + _disposedValue = true; + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/Function.cs b/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/Function.cs new file mode 100644 index 000000000..adebe913c --- /dev/null +++ b/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/Function.cs @@ -0,0 +1,25 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace GreetingFunc; + +public record Input(string UserName); + +public record Output(string GreetingMessage) +{ + public static Output BuildGreeting(string userName) => new($"Hello, {userName}!"); +} + +public class Function +{ + [LambdaSerializer(typeof(DefaultLambdaJsonSerializer))] + public static Output FunctionHandler(Input input, ILambdaContext context) + { + context.Logger.LogLine($"Executing greeting for user: {input.UserName}"); + + if (string.IsNullOrEmpty(input.UserName)) + throw new Exception("User name is empty"); + + return Output.BuildGreeting(input.UserName); + } +} \ No newline at end of file diff --git a/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/GreetingFunc.csproj b/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/GreetingFunc.csproj new file mode 100644 index 000000000..7d985b8a2 --- /dev/null +++ b/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/GreetingFunc.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + true + Lambda + + true + + true + + + + + + + + Always + + + + diff --git a/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/GreetingFunc.json b/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/GreetingFunc.json new file mode 100644 index 000000000..43668454d --- /dev/null +++ b/Tools/LambdaTestTool/tests/LambdaFunctions/net6/GreetingFunc/GreetingFunc.json @@ -0,0 +1,4 @@ +{ + "profile": "default", + "function-handler": "GreetingFunc::GreetingFunc.Function::FunctionHandler" +}