diff --git a/AwsLambda.Host.sln b/AwsLambda.Host.sln index 14a1145b..5978adc2 100644 --- a/AwsLambda.Host.sln +++ b/AwsLambda.Host.sln @@ -78,7 +78,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Envelopes.Cl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Testing", "src\AwsLambda.Host.Testing\AwsLambda.Host.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Examples.Testing", "examples\AwsLambda.Host.Examples.Testing\AwsLambda.Host.Examples.Testing.csproj", "{884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AwsLambda.Host.Example.Testing", "AwsLambda.Host.Example.Testing", "{26B446C5-76B0-4190-9022-1BF514737E2E}" + ProjectSection(SolutionItems) = preProject + examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json = examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "examples\AwsLambda.Host.Example.Testing\Tests\Tests.csproj", "{0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lambda", "examples\AwsLambda.Host.Example.Testing\Lambda\Lambda.csproj", "{6DB827E6-C534-4C6A-9128-05F6A05DD6C5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -366,18 +373,30 @@ Global {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x64.Build.0 = Release|Any CPU {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.ActiveCfg = Release|Any CPU {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -407,6 +426,8 @@ Global {DA647025-1B7B-425A-9405-8E015F6DA723} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} {6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} {9FA188D7-CF5F-4F87-B292-2AF69994FF12} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {26B446C5-76B0-4190-9022-1BF514737E2E} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818} = {26B446C5-76B0-4190-9022-1BF514737E2E} + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5} = {26B446C5-76B0-4190-9022-1BF514737E2E} EndGlobalSection EndGlobal diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj new file mode 100644 index 00000000..cfcc1781 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj @@ -0,0 +1,31 @@ + + + Exe + net10.0 + preview + enable + enable + true + Lambda + + true + + true + $(InterceptorsNamespaces);AwsLambda.Host + false + + + + + + + + + PreserveNewest + + + diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs new file mode 100644 index 00000000..fc3329b0 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -0,0 +1,22 @@ +using AwsLambda.Host.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +// Create the application builder +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.ConfigureLambdaHostOptions(options => +{ + options.ClearLambdaOutputFormatting = true; +}); + +// Build the Lambda application +var lambda = builder.Build(); + +// Map your handler - the event is automatically injected +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +// Run the Lambda +await lambda.RunAsync(); + +public partial class Program; diff --git a/examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json similarity index 100% rename from examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json rename to examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json diff --git a/examples/AwsLambda.Host.Examples.Testing/appsettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json similarity index 100% rename from examples/AwsLambda.Host.Examples.Testing/appsettings.json rename to examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs new file mode 100644 index 00000000..9d736874 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -0,0 +1,139 @@ +using AwsLambda.Host.Options; +using AwsLambda.Host.Testing; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Lambda.Host.Example.HelloWorld; + +[TestSubject(typeof(Program))] +public class LambdaHostTest +{ + [Fact] + public async Task LambdaHost_CanStartWithoutError() + { + await using var factory = new LambdaApplicationFactory(); + + var client = factory.CreateClient(); + // No need to wait for next request - server handles this automatically + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } + + [Fact] + public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() + { + await using var factory = new LambdaApplicationFactory().WithWebHostBuilder( + builder => + { + builder.ConfigureServices( + (_, services) => + { + services.Configure(options => + { + options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; + }); + } + ); + } + ); + + var client = factory.CreateClient(); + // No need to wait for next request - server handles this automatically + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } + + [Fact] + public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() + { + await using var factory = new LambdaApplicationFactory(); + var client = factory.CreateClient(); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + client.InvokeAsync( + $"User{i}", + TestContext.Current.CancellationToken + ) + ) + .ToArray(); + + var responses = await Task.WhenAll(tasks); + + // All should complete successfully + Assert.All(responses, r => Assert.True(r.WasSuccess)); + Assert.Equal("Hello User1!", responses[0].Response); + Assert.Equal("Hello User2!", responses[1].Response); + Assert.Equal("Hello User3!", responses[2].Response); + Assert.Equal("Hello User4!", responses[3].Response); + Assert.Equal("Hello User5!", responses[4].Response); + } + + [Fact] + public async Task InvokeAsync_WithInvalidPayload_ReturnsError() + { + await using var factory = new LambdaApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.InvokeAsync( + 123, + TestContext.Current.CancellationToken + ); + + Assert.False(response.WasSuccess); + Assert.NotNull(response.Error); + Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() + { + await using var factory = new LambdaApplicationFactory(); + var client = factory.CreateClient(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => + client.InvokeAsync("Jonas", cts.Token) + ); + } + + [Fact] + public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => + await Assert.ThrowsAsync(async () => + { + try + { + await using var factory = new LambdaApplicationFactory(); + var client = factory + .CreateClient() + .ConfigureOptions(options => + options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero + ); + + await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + } + catch (Exception e) + { + Console.WriteLine(e.GetType().FullName); + throw; + } + }); +} diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj new file mode 100644 index 00000000..b686ce26 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj deleted file mode 100644 index 89192b1d..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj +++ /dev/null @@ -1,56 +0,0 @@ - - - Exe - net10.0 - preview - disable - enable - true - Lambda - - true - - true - $(InterceptorsNamespaces);AwsLambda.Host - Lambda.Host.Example.HelloWorld - false - - - - PreserveNewest - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - PreserveNewest - - - diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs deleted file mode 100644 index 492a8ced..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Threading.Tasks; -using AwsLambda.Host.Testing; -using JetBrains.Annotations; -using Xunit; - -namespace Lambda.Host.Example.HelloWorld; - -[TestSubject(typeof(Program))] -public class LambdaHostTest -{ - [Fact] - public async Task LambdaHost_CanStartWithoutError() - { - await using var factory = new WebApplicationFactory(); - - var client = factory.CreateClient(); - await client.WaitForNextRequestAsync(); - var response = await client.InvokeAsync("Jonas"); - Assert.True(response.WasSuccess); - Assert.NotNull(response); - Assert.Equal("Hello Jonas!", response.Response); - } -} diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs deleted file mode 100644 index cc93ac71..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/Program.cs +++ /dev/null @@ -1,106 +0,0 @@ -using AwsLambda.Host.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -// Create the application builder -var builder = LambdaApplication.CreateBuilder(); - -builder.Services.ConfigureLambdaHostOptions(options => -{ - // options.BootstrapHttpClient = new HttpClient(new LoggingHttpHandler()); - options.ClearLambdaOutputFormatting = true; - // options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; -}); - -// Build the Lambda application -var lambda = builder.Build(); - -// Map your handler - the event is automatically injected -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); - -// Run the Lambda -await lambda.RunAsync(); - -public partial class Program; - -// public class LoggingHttpHandler : DelegatingHandler -// { -// public LoggingHttpHandler(HttpMessageHandler innerHandler) => -// InnerHandler = innerHandler ?? throw new ArgumentNullException(nameof(innerHandler)); -// -// // Convenience constructor for default handler -// public LoggingHttpHandler() -// : this(new HttpClientHandler()) { } -// -// protected override async Task SendAsync( -// HttpRequestMessage request, -// CancellationToken cancellationToken -// ) -// { -// // Log the request -// Console.WriteLine("========== HTTP REQUEST =========="); -// Console.WriteLine($"{request.Method} {request.RequestUri}"); -// Console.WriteLine($"Version: {request.Version}"); -// -// Console.WriteLine("\nHeaders:"); -// foreach (var header in request.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// if (request.Content != null) -// { -// Console.WriteLine("\nContent Headers:"); -// foreach (var header in request.Content.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// // Buffer the content so we can read it without consuming it -// await request.Content.LoadIntoBufferAsync(); -// var requestBody = await request.Content.ReadAsStringAsync(); -// -// Console.WriteLine("\nBody:"); -// Console.WriteLine(requestBody); -// } -// -// // Send the request through the inner handler (via base.SendAsync) -// var response = await base.SendAsync(request, cancellationToken); -// -// // Log the response -// Console.WriteLine("\n========== HTTP RESPONSE =========="); -// Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); -// Console.WriteLine($"Version: {response.Version}"); -// -// Console.WriteLine("\nHeaders:"); -// foreach (var header in response.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// if (response.Content != null) -// { -// Console.WriteLine("\nContent Headers:"); -// foreach (var header in response.Content.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// // Read and log response body, then restore it -// var responseBody = await response.Content.ReadAsStringAsync(); -// Console.WriteLine("\nBody:"); -// Console.WriteLine(responseBody); -// -// // Restore the content so it can be read again by the caller -// var originalContentHeaders = response.Content.Headers.ToList(); -// response.Content = new StringContent( -// responseBody, -// Encoding.UTF8, -// response.Content.Headers.ContentType?.MediaType ?? "application/json" -// ); -// -// // Restore all content headers -// foreach (var header in originalContentHeaders) -// { -// response.Content.Headers.Remove(header.Key); -// response.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); -// } -// } -// -// Console.WriteLine("===================================\n"); -// -// return response; -// } -// } diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 06e8f127..7fe11895 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -16,11 +16,6 @@ namespace AwsLambda.Host.Testing; // ConfigureHostBuilder internal sealed class DeferredHostBuilder : IHostBuilder { - public IDictionary Properties { get; } = new Dictionary(); - - private Action _configure; - private Func? _hostFactory; - private readonly ConfigurationManager _hostConfiguration = new(); // This task represents a call to IHost.Start, we create it here preemptively in case the @@ -30,6 +25,9 @@ internal sealed class DeferredHostBuilder : IHostBuilder TaskCreationOptions.RunContinuationsAsynchronously ); + private Action _configure; + private Func? _hostFactory; + public DeferredHostBuilder() => _configure = b => { @@ -39,6 +37,8 @@ public DeferredHostBuilder() => b.Properties[pair.Key] = pair.Value; }; + public IDictionary Properties { get; } = new Dictionary(); + public IHost Build() { // Hosting configuration is being provided by args so that @@ -114,31 +114,24 @@ public void EntryPointCompleted(Exception? exception) // If the entry point completed we'll set the tcs just in case the application doesn't call // IHost.Start/StartAsync. if (exception is not null) + { _hostStartTcs.TrySetException(exception); + } else + { _hostStartTcs.TrySetResult(); + } } public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; - private sealed class DeferredHost : IHost, IAsyncDisposable + private sealed class DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) + : IHost, + IAsyncDisposable { - private readonly IHost _host; - private readonly TaskCompletionSource _hostStartedTcs; - - public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) - { - _host = host; - _hostStartedTcs = hostStartedTcs; - } - - public IServiceProvider Services => _host.Services; - - public void Dispose() => _host.Dispose(); - public async ValueTask DisposeAsync() { - if (_host is IAsyncDisposable disposable) + if (host is IAsyncDisposable disposable) { await disposable.DisposeAsync().ConfigureAwait(false); return; @@ -147,28 +140,32 @@ public async ValueTask DisposeAsync() Dispose(); } + public IServiceProvider Services => host.Services; + + public void Dispose() => host.Dispose(); + public async Task StartAsync(CancellationToken cancellationToken = default) { // Wait on the existing host to start running and have this call wait on that. This // avoids starting the actual host too early and // leaves the application in charge of calling start. - using var reg = cancellationToken.UnsafeRegister( - _ => _hostStartedTcs.TrySetCanceled(), + await using var reg = cancellationToken.UnsafeRegister( + _ => hostStartedTcs.TrySetCanceled(), null ); // REVIEW: This will deadlock if the application creates the host but never calls start. // This is mitigated by the cancellationToken // but it's rarely a valid token for Start - using var reg2 = _host + await using var reg2 = host .Services.GetRequiredService() - .ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null); + .ApplicationStarted.UnsafeRegister(_ => hostStartedTcs.TrySetResult(), null); - await _hostStartedTcs.Task.ConfigureAwait(false); + await hostStartedTcs.Task.ConfigureAwait(false); } public Task StopAsync(CancellationToken cancellationToken = default) => - _host.StopAsync(cancellationToken); + host.StopAsync(cancellationToken); } } diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index e9c02cff..ae4fca38 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -1,6 +1,6 @@ // Portions of this file are derived from aspnetcore // Source: -// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/LambdaApplicationFactory.cs // Copyright (c) .NET Foundation and Contributors // Licensed under the MIT License // See THIRD-PARTY-LICENSES.txt file in the project root or visit @@ -24,24 +24,23 @@ namespace AwsLambda.Host.Testing; /// /// A type in the entry point assembly of the application. /// Typically the Startup or Program classes can be used. -public partial class WebApplicationFactory : IDisposable, IAsyncDisposable +public partial class LambdaApplicationFactory : IDisposable, IAsyncDisposable where TEntryPoint : class { + // private readonly List _clients = []; + private readonly List> _derivedFactories = []; + private Action _configuration; private bool _disposed; private bool _disposedAsync; - private LambdaTestServer? _server; private IHost? _host; - private Action _configuration; - - // private readonly List _clients = []; - private readonly List> _derivedFactories = []; + private LambdaTestServer? _server; /// /// - /// Creates an instance of . This factory can be used to + /// Creates an instance of . This factory can be used to /// create a instance using the MVC application defined by /// and one or more instances used to send to the . - /// The will find the entry point class of + /// The will find the entry point class of /// assembly and initialize the application by calling IHostBuilder CreateWebHostBuilder(string [] args) /// on . /// @@ -49,7 +48,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// This constructor will infer the application content root path by searching for a /// on the assembly containing the functional tests with /// a key equal to the assembly . - /// In case an attribute with the right key can't be found, + /// In case an attribute with the right key can't be found, /// will fall back to searching for a solution file (*.sln) and then appending assembly name /// to the solution directory. The application root directory will be used to discover views and content files. /// @@ -59,15 +58,23 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// will be loaded as application assemblies. /// /// - public WebApplicationFactory() => _configuration = ConfigureWebHost; + public LambdaApplicationFactory() => _configuration = ConfigureWebHost; /// - /// Finalizes an instance of the class. + /// Gets the used by . /// - ~WebApplicationFactory() => Dispose(false); + public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); /// - /// Gets the created by this . + /// Gets the of factories created from this factory + /// by further customizing the when calling + /// . + /// + public IReadOnlyList> Factories => + _derivedFactories.AsReadOnly(); + + /// + /// Gets the created by this . /// internal LambdaTestServer Server { @@ -79,7 +86,7 @@ internal LambdaTestServer Server } /// - /// Gets the created by the server associated with this . + /// Gets the created by the server associated with this . /// public virtual IServiceProvider Services { @@ -91,45 +98,72 @@ public virtual IServiceProvider Services } } - public LambdaClient CreateClient() + /// + public virtual async ValueTask DisposeAsync() { - EnsureServer(); - return _server!.CreateLambdaClient( - new JsonSerializerOptions(), - new LambdaRuntimeRouteManager() - ); + if (_disposed) + return; + + if (_disposedAsync) + return; + + // foreach (var client in _clients) + // client.Dispose(); + + foreach (var factory in _derivedFactories) + await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); + + if (_server != null) + await _server.DisposeAsync().ConfigureAwait(false); + + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); + } + + _disposedAsync = true; + + Dispose(true); + + GC.SuppressFinalize(this); } - /// - /// Gets the of factories created from this factory - /// by further customizing the when calling - /// . - /// - public IReadOnlyList> Factories => - _derivedFactories.AsReadOnly(); + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } /// - /// Gets the used by . + /// Finalizes an instance of the class. /// - public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); + ~LambdaApplicationFactory() => Dispose(false); + + public LambdaClient CreateClient() + { + EnsureServer(); + return _server!.CreateLambdaClient(); + } /// - /// Creates a new with a + /// Creates a new with a /// that is further customized by . /// /// /// An to configure the . /// - /// A new . - public WebApplicationFactory WithWebHostBuilder( + /// A new . + public LambdaApplicationFactory WithWebHostBuilder( Action configuration ) => WithWebHostBuilderCore(configuration); - internal virtual WebApplicationFactory WithWebHostBuilderCore( + internal virtual LambdaApplicationFactory WithWebHostBuilderCore( Action configuration ) { - var factory = new DelegatedWebApplicationFactory( + var factory = new DelegatedLambdaApplicationFactory( ClientOptions, // CreateServer, CreateHost, @@ -203,19 +237,15 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = new LambdaTestServer(); + var serializerOptions = new JsonSerializerOptions(); + var routeManager = new LambdaRuntimeRouteManager(); + + _server = new LambdaTestServer(serializerOptions, routeManager); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => { - services.AddLambdaBootstrapHttpClient( - (_, _) => - { - var client = new HttpClient(_server.CreateTestingHandler()); - client.BaseAddress = new Uri("localhost:8080"); - return client; - } - ); + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); services.PostConfigure(options => { @@ -225,6 +255,9 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) }); _host = CreateHost(hostBuilder); + + // Start the server's background processing loop after host is ready + _server.Start(); } private void SetContentRoot(IHostBuilder builder) @@ -291,9 +324,6 @@ string solutionRelativePath return contentRoot == "~" ? AppContext.BaseDirectory : contentRoot; } - [JsonSerializable(typeof(IDictionary))] - private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private string? GetContentRootFromAssembly() { var metadataAttributes = GetContentRootMetadataAttributes( @@ -450,7 +480,7 @@ protected virtual IHost CreateHost(IHostBuilder builder) protected virtual void ConfigureWebHost(IHostBuilder builder) { } /// - /// Configures instances created by this . + /// Configures instances created by this . /// /// The instance getting configured. protected virtual void ConfigureClient(HttpClient client) @@ -460,13 +490,6 @@ protected virtual void ConfigureClient(HttpClient client) client.BaseAddress = new Uri("http://localhost"); } - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -477,9 +500,7 @@ public void Dispose() protected virtual void Dispose(bool disposing) { if (_disposed) - { return; - } if (disposing) if (!_disposedAsync) @@ -488,47 +509,19 @@ protected virtual void Dispose(bool disposing) _disposed = true; } - /// - public virtual async ValueTask DisposeAsync() - { - if (_disposed) - return; - - if (_disposedAsync) - { - return; - } - - // foreach (var client in _clients) - // client.Dispose(); - - foreach (var factory in _derivedFactories) - await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); - - _server?.Dispose(); - - if (_host != null) - { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); - } - - _disposedAsync = true; - - Dispose(true); - - GC.SuppressFinalize(this); - } + [JsonSerializable(typeof(IDictionary))] + private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private sealed class DelegatedWebApplicationFactory : WebApplicationFactory + private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory { + private readonly Action _configureClient; + // private readonly Func _createServer; private readonly Func _createHost; private readonly Func _createHostBuilder; private readonly Func> _getTestAssemblies; - private readonly Action _configureClient; - public DelegatedWebApplicationFactory( + public DelegatedLambdaApplicationFactory( LambdaApplicationFactoryClientOptions options, // Func createServer, Func createHost, @@ -557,10 +550,10 @@ Action configureWebHost protected override void ConfigureClient(HttpClient client) => _configureClient(client); - internal override WebApplicationFactory WithWebHostBuilderCore( + internal override LambdaApplicationFactory WithWebHostBuilderCore( Action configuration ) => - new DelegatedWebApplicationFactory( + new DelegatedLambdaApplicationFactory( ClientOptions, // _createServer, _createHost, diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs index 6ff50425..f577c417 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs @@ -12,13 +12,13 @@ namespace AwsLambda.Host.Testing; /// -/// Metadata that uses to find out the content +/// Metadata that uses to find out the content /// root for the web application represented by TEntryPoint. -/// will iterate over all the instances of +/// will iterate over all the instances of /// , filter the instances whose /// is equal to TEntryPoint , /// order them by in ascending order. -/// will check for the existence of the marker +/// will check for the existence of the marker /// in Path.Combine(, Path.GetFileName())" /// and if the file exists it will set the content root to . /// @@ -30,7 +30,7 @@ public sealed class LambdaApplicationFactoryContentRootAttribute : Attribute /// /// /// The key of this . This - /// key is used by to determine what of the + /// key is used by to determine what of the /// instances on the test assembly should be used /// to match a given TEntryPoint class. /// @@ -67,11 +67,6 @@ out var parsedPriority Priority = parsedPriority; } - /// - /// Gets the key for the content root associated with this project. Typically . - /// - public string Key { get; } - /// /// Gets the content root path for a given project. This content root can be relative or absolute. If it is a /// relative path, it will be combined with . @@ -83,6 +78,11 @@ out var parsedPriority /// public string ContentRootTest { get; } + /// + /// Gets the key for the content root associated with this project. Typically . + /// + public string Key { get; } + /// /// Gets a number for determining the probing order when multiple /// instances with the same key are present on the test . diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 5ec6e3f3..cb370a24 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -2,33 +2,30 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; -using System.Threading.Channels; namespace AwsLambda.Host.Testing; +/// +/// Client for invoking Lambda functions in tests. +/// Provides a clean API that abstracts HTTP details. +/// public class LambdaClient { private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly LambdaClientOptions _lambdaClientOptions; - private readonly Channel _requestChanel; - private readonly Channel _responseChanel; - private readonly ILambdaRuntimeRouteManager _routeManager; + private readonly LambdaTestServer _server; private int _requestCounter; - internal LambdaClient( - Channel requestChanel, - Channel responseChanel, - JsonSerializerOptions jsonSerializerOptions, - ILambdaRuntimeRouteManager routeManager - ) + internal LambdaClient(LambdaTestServer server, JsonSerializerOptions jsonSerializerOptions) { - _requestChanel = requestChanel; - _responseChanel = responseChanel; + _server = server; _jsonSerializerOptions = jsonSerializerOptions; - _routeManager = routeManager; _lambdaClientOptions = new LambdaClientOptions(); } + /// + /// Configures client options for invocation headers. + /// public LambdaClient ConfigureOptions(Action configureOptions) { ArgumentNullException.ThrowIfNull(configureOptions); @@ -38,36 +35,67 @@ public LambdaClient ConfigureOptions(Action configureOption return this; } - public async Task WaitForNextRequestAsync(CancellationToken cancellationToken = default) - { - var request = await WaitForRequestAsync(cancellationToken); - - if (request.RequestType != RequestType.GetNextInvocation) - throw new InvalidOperationException( - $"Unexpected request received during bootstrap: {request.RequestType.ToString()}" - ); - } - - private async Task WaitForRequestAsync( + /// + /// Invokes the Lambda function with the given event and waits for the response. + /// + public async Task> InvokeAsync( + TEvent invokeEvent, CancellationToken cancellationToken = default ) { - var request = await _requestChanel.Reader.ReadAsync(cancellationToken); + // Generate unique request ID + var requestId = GetRequestId(); + + // Create the event response with Lambda headers + var eventResponse = CreateEventResponse(invokeEvent, requestId); + var deadlineUtc = DateTimeOffset.UtcNow.Add( + _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout + ); - if (!_routeManager.TryMatch(request, out var routeType, out var routeValue)) - throw new InvalidOperationException( - $"Unexpected request received: {request.Method} {request.RequestUri?.PathAndQuery ?? "(no URI)"}" - ); + // Queue invocation and wait for Bootstrap to process it + var waitTimeout = _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout; - return new LambdaBootstrapRequest + using var timeoutCts = new CancellationTokenSource(waitTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var completion = await _server.QueueInvocationAsync( + requestId, + eventResponse, + deadlineUtc, + linkedCts.Token + ); + + var responseMessage = completion.Request; + var wasSuccess = completion.RequestType == RequestType.PostResponse; + + var invocationResponse = new InvocationResponse { - RequestType = routeType!.Value, - RequestMessage = request, - RouteValue = routeValue!, + WasSuccess = wasSuccess, + Response = wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(default) + ) + : default, + Error = !wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(null) + ) + : null, }; + + return invocationResponse; } - private HttpResponseMessage CreateRequest(TEvent invokeEvent) + private HttpResponseMessage CreateEventResponse(TEvent invokeEvent, string requestId) { var response = new HttpResponseMessage(HttpStatusCode.OK) { @@ -92,10 +120,7 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.FunctionTimeout) .ToUnixTimeMilliseconds(); response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); - - // Generate request ID with proper padding (12 digits, zero-padded) - response.Headers.Add("Lambda-Runtime-Aws-Request-Id", GetRequestId()); - + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); response.Headers.Add( "Lambda-Runtime-Trace-Id", _lambdaClientOptions.InvocationHeaderOptions.TraceId @@ -114,62 +139,4 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) private string GetRequestId() => Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); - - public async Task> InvokeAsync( - TEvent invokeEvent, - CancellationToken cancellationToken = default - ) - { - var response = CreateRequest(invokeEvent); - - await _responseChanel.Writer.WriteAsync(response, cancellationToken); - - var request = await WaitForRequestAsync(cancellationToken); - - if (request.RequestType == RequestType.GetNextInvocation) - throw new InvalidOperationException( - $"Expected PostResponse or PostError request, but received {request.RequestType}" - ); - - var wasSuccess = request.RequestType == RequestType.PostResponse; - - var invocationResponse = new InvocationResponse - { - WasSuccess = wasSuccess, - Response = wasSuccess - ? await ( - request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(default) - ) - : default, - Error = !wasSuccess - ? await ( - request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(null) - ) - : null, - }; - - await _responseChanel.Writer.WriteAsync( - new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent( - JsonSerializer.Serialize( - new Dictionary { ["status"] = "success" }, - _jsonSerializerOptions - ), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - }, - cancellationToken - ); - - return invocationResponse; - } } diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index af2d534a..773dceb3 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,26 +1,419 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Channels; +using Microsoft.AspNetCore.Routing; namespace AwsLambda.Host.Testing; -internal class LambdaTestServer : IDisposable +/// +/// Test server that processes HTTP transactions from Lambda Bootstrap. +/// Routes requests, queues invocations, and manages request-response correlation. +/// +internal class LambdaTestServer : IAsyncDisposable { - private readonly Channel _requestChanel; - private readonly Channel _responseChanel; + private const int TransactionChannelCapacity = 1024; + private const int NextRequestChannelCapacity = 1024; + private static readonly OperationCanceledException DisposedException = new( + "LambdaTestServer disposed" + ); - public LambdaTestServer() + private readonly LambdaClient _client; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ConcurrentQueue _pendingInvocationIds; + private readonly ConcurrentDictionary _pendingInvocations; + private readonly Channel _queuedNextRequests; + private readonly ILambdaRuntimeRouteManager _routeManager; + private readonly CancellationTokenSource _shutdownCts; + private readonly Channel _transactionChannel; + private Task? _processingTask; + + internal LambdaTestServer( + JsonSerializerOptions? jsonSerializerOptions = null, + ILambdaRuntimeRouteManager? routeManager = null + ) + { + _transactionChannel = Channel.CreateBounded( + new BoundedChannelOptions(TransactionChannelCapacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + } + ); + _pendingInvocationIds = new ConcurrentQueue(); + _pendingInvocations = new ConcurrentDictionary(); + _queuedNextRequests = Channel.CreateBounded( + new BoundedChannelOptions(NextRequestChannelCapacity) + { + SingleReader = false, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + } + ); + _routeManager = routeManager ?? new LambdaRuntimeRouteManager(); + _jsonSerializerOptions = jsonSerializerOptions ?? new JsonSerializerOptions(); + _shutdownCts = new CancellationTokenSource(); + + // Create client that communicates with this server + _client = new LambdaClient(this, _jsonSerializerOptions); + } + + public async ValueTask DisposeAsync() { - _requestChanel = Channel.CreateUnbounded(); - _responseChanel = Channel.CreateUnbounded(); + await _shutdownCts.CancelAsync(); + + _transactionChannel.Writer.TryComplete(); + _queuedNextRequests.Writer.TryComplete(); + + // Cancel any transactions waiting for work + while (_queuedNextRequests.Reader.TryRead(out var queuedTransaction)) + queuedTransaction.Fail(DisposedException); + + // Fail any in-flight transactions that haven't been processed yet + while (_transactionChannel.Reader.TryRead(out var transaction)) + transaction.Fail(DisposedException); + + // Cancel any pending invocations waiting for bootstrap responses + foreach (var pendingInvocation in _pendingInvocations.Values) + pendingInvocation.ResponseTcs.TrySetCanceled(_shutdownCts.Token); + + if (_processingTask != null) + { + try + { + await _processingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when task is canceled + } + } + + _shutdownCts.Dispose(); } + /// + /// Creates the HTTP handler for Lambda Bootstrap to use. + /// internal HttpMessageHandler CreateTestingHandler() => - new LambdaTestingHttpHandler(_requestChanel, _responseChanel); + new LambdaTestingHttpHandler(_transactionChannel); + + /// + /// Gets the client for test code to invoke Lambda functions. + /// + internal LambdaClient CreateLambdaClient() => _client; + + /// + /// Starts the background processing loop. + /// Called automatically by LambdaApplicationFactory after host starts. + /// + internal void Start() + { + if (_processingTask != null) + throw new InvalidOperationException("Server already started"); + + _processingTask = Task.Run(ProcessTransactionsAsync); + } + + /// + /// Queues a new invocation to be processed by Lambda Bootstrap. + /// Called by LambdaClient.InvokeAsync(). + /// + internal async Task QueueInvocationAsync( + string requestId, + HttpResponseMessage eventResponse, + DateTimeOffset deadlineUtc, + CancellationToken cancellationToken + ) + { + var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); + + if (!_pendingInvocations.TryAdd(requestId, pending)) + throw new InvalidOperationException($"Duplicate request ID: {requestId}"); + + _pendingInvocationIds.Enqueue(requestId); + + // If there's a queued /next request, serve it immediately + if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) + RespondToNextRequest(nextTransaction); + + using var cancellationRegistration = cancellationToken.Register(() => + CancelPendingInvocation(requestId, cancellationToken) + ); + + // Wait for Bootstrap to process and respond + return await pending.ResponseTcs.Task.WaitAsync(cancellationToken); + } + + /// + /// Background loop that processes transactions from the handler. + /// + private async Task ProcessTransactionsAsync() + { + await foreach ( + var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) + ) + try + { + await ProcessTransactionAsync(transaction); + } + catch (Exception ex) + { + // Fail the transaction and continue processing + transaction.Fail(ex); + } + } + + /// + /// Routes a single transaction based on request type. + /// + private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) + { + if (!_routeManager.TryMatch(transaction.Request, out var requestType, out var routeValues)) + throw new InvalidOperationException( + $"Unexpected request: {transaction.Request.Method} {transaction.Request.RequestUri}" + ); + + switch (requestType!.Value) + { + case RequestType.GetNextInvocation: + await HandleGetNextInvocationAsync(transaction); + break; + + case RequestType.PostResponse: + await HandlePostResponseAsync(transaction, routeValues!); + break; + + case RequestType.PostError: + await HandlePostErrorAsync(transaction, routeValues!); + break; + + default: + throw new InvalidOperationException( + $"Unexpected request type {requestType} for {transaction.Request.RequestUri}" + ); + } + } + + /// + /// Handles GET /invocation/next - Bootstrap polling for work. + /// + private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + { + // Try to dequeue next pending invocation (FIFO) + if (!TryDequeuePendingInvocation(out var pending)) + { + // No work available - queue this /next request + try + { + await _queuedNextRequests.Writer.WriteAsync(transaction, _shutdownCts.Token); + } + catch (OperationCanceledException) + { + transaction.Fail(DisposedException); + } + + return; + } + + RespondToNextRequest(transaction, pending); + } - internal LambdaClient CreateLambdaClient( - JsonSerializerOptions jsonSerializerOptions, - ILambdaRuntimeRouteManager routeManager - ) => new(_requestChanel, _responseChanel, jsonSerializerOptions, routeManager); + /// + /// Responds to a /next request with a pending invocation. + /// + private void RespondToNextRequest(LambdaHttpTransaction transaction, PendingInvocation pending) + { + // Respond with the event payload and Lambda headers + if (transaction.Respond(pending.EventResponse)) + return; + + // Request was already canceled; re-enqueue invocation to avoid dropping it + _pendingInvocationIds.Enqueue(pending.RequestId); + } + + /// + /// Responds to a /next request with a pending invocation by looking up the next one. + /// + private void RespondToNextRequest(LambdaHttpTransaction transaction) + { + // Try to dequeue next pending invocation (FIFO) + if (!TryDequeuePendingInvocation(out var pending)) + { + // This shouldn't happen, but if it does, respond with error + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("No pending invocations available"), + } + ); + return; + } + + RespondToNextRequest(transaction, pending); + } + + /// + /// Handles POST /invocation/{requestId}/response - successful function execution. + /// + private async Task HandlePostResponseAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + var requestId = routeValues["requestId"]?.ToString(); + + if ( + string.IsNullOrEmpty(requestId) + || !_pendingInvocations.TryRemove(requestId, out var pending) + ) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + string.IsNullOrEmpty(requestId) + ? "Missing requestId" + : $"No pending invocation for request ID: {requestId}" + ), + } + ); + return; + } - public void Dispose() { } + // Complete the invocation with the response from Bootstrap + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostResponse, transaction.Request) + ); + + // Acknowledge to Bootstrap + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary { ["status"] = "success" }, + _jsonSerializerOptions + ), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + } + ); + + return; + } + + /// + /// Handles POST /invocation/{requestId}/error - function execution failed. + /// + private async Task HandlePostErrorAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + var requestId = routeValues["requestId"]?.ToString(); + + if ( + string.IsNullOrEmpty(requestId) + || !_pendingInvocations.TryRemove(requestId, out var pending) + ) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + string.IsNullOrEmpty(requestId) + ? "Missing requestId" + : $"No pending invocation for request ID: {requestId}" + ), + } + ); + return; + } + + // Complete the invocation with the error response from Bootstrap + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostError, transaction.Request) + ); + + // Acknowledge to Bootstrap + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + "{\"status\":\"success\"}", + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + } + ); + + return; + } + + private bool TryDequeuePendingInvocation(out PendingInvocation pendingInvocation) + { + var now = DateTimeOffset.UtcNow; + + while (_pendingInvocationIds.TryDequeue(out var requestId)) + { + if (_pendingInvocations.TryGetValue(requestId, out pendingInvocation)) + { + if (pendingInvocation.DeadlineUtc <= now) + { + if (_pendingInvocations.TryRemove(requestId, out var expiredInvocation)) + expiredInvocation.ResponseTcs.TrySetCanceled(); + + continue; + } + + return true; + } + } + + pendingInvocation = null!; + return false; + } + + private void CancelPendingInvocation(string requestId, CancellationToken cancellationToken) + { + if (_pendingInvocations.TryRemove(requestId, out var pendingInvocation)) + pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); + } + + private static async Task CreateCompletionAsync( + RequestType requestType, + HttpRequestMessage sourceRequest + ) + { + var clonedRequest = new HttpRequestMessage(sourceRequest.Method, sourceRequest.RequestUri) + { + Version = sourceRequest.Version, + VersionPolicy = sourceRequest.VersionPolicy, + }; + + foreach (var header in sourceRequest.Headers) + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + + foreach (var option in sourceRequest.Options) + clonedRequest.Options.TryAdd(option.Key, option.Value); + + if (sourceRequest.Content != null) + { + var contentBytes = await sourceRequest.Content.ReadAsByteArrayAsync(); + var clonedContent = new ByteArrayContent(contentBytes); + + foreach (var header in sourceRequest.Content.Headers) + clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + + clonedRequest.Content = clonedContent; + } + + return new InvocationCompletion { Request = clonedRequest, RequestType = requestType }; + } } diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index e712a800..bf7cc1a9 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -2,20 +2,55 @@ namespace AwsLambda.Host.Testing; -internal class LambdaTestingHttpHandler( - Channel requestChanel, - Channel responseChanel -) : HttpMessageHandler +/// +/// HTTP message handler that intercepts Lambda Bootstrap HTTP calls and +/// routes them through the test server via transactions. +/// +internal class LambdaTestingHttpHandler(Channel transactionChannel) + : HttpMessageHandler { protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken ) { - // pass the request out to the client - await requestChanel.Writer.WriteAsync(request, cancellationToken); + // Buffer the content to make it re-readable for downstream consumers + if (request.Content != null) + { + var originalContent = request.Content; + var bytes = await originalContent.ReadAsByteArrayAsync(cancellationToken); + var bufferedContent = new ByteArrayContent(bytes); - // block here until the client sends a response. There may not be a response. - return await responseChanel.Reader.ReadAsync(cancellationToken); + foreach (var header in originalContent.Headers) + bufferedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + + request.Content = bufferedContent; + originalContent.Dispose(); + } + + // Create transaction with request and completion mechanism + var transaction = LambdaHttpTransaction.Create(request); + + // Register cancellation to cancel the transaction TCS + using var registration = cancellationToken.Register(() => transaction.Cancel()); + + // Send transaction to server + try + { + await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); + } + catch (ChannelClosedException) + { + // Server is shutting down; propagate cancellation to caller + var canceled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + canceled.TrySetCanceled(); + return await canceled.Task; + } + + // Wait for server to complete the transaction + var response = await transaction.ResponseTcs.Task; + return response; } } diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs index ada60032..540fd345 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -8,28 +8,28 @@ namespace AwsLambda.Host.Testing; public class ErrorResponse { /// - /// The type of error that occurred. + /// The underlying cause of this error, if any. /// - [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } /// /// The error message describing what went wrong. /// [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } + public required string ErrorMessage { get; set; } /// - /// The stack trace showing where the error occurred. + /// The type of error that occurred. /// - [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + [JsonPropertyName("errorType")] + public required string ErrorType { get; set; } /// - /// The underlying cause of this error, if any. + /// The stack trace showing where the error occurred. /// - [JsonPropertyName("cause")] - public ErrorCause? Cause { get; set; } + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } = []; /// /// Represents the cause of an error, which can have its own nested cause. @@ -37,27 +37,27 @@ public class ErrorResponse public class ErrorCause { /// - /// The type of error that occurred. + /// The underlying cause of this error, if any. /// - [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } /// /// The error message describing what went wrong. /// [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } + public required string ErrorMessage { get; set; } /// - /// The stack trace showing where the error occurred. + /// The type of error that occurred. /// - [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } /// - /// The underlying cause of this error, if any. + /// The stack trace showing where the error occurred. /// - [JsonPropertyName("cause")] - public ErrorCause? Cause { get; set; } + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } = []; } } diff --git a/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs b/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs new file mode 100644 index 00000000..9bcc72b9 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs @@ -0,0 +1,10 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents the completion of a Lambda invocation with metadata about the outcome. +/// +internal class InvocationCompletion +{ + internal required HttpRequestMessage Request { get; init; } + internal required RequestType RequestType { get; init; } +} diff --git a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs index de8fbf79..68d1ab5e 100644 --- a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs @@ -2,7 +2,7 @@ namespace AwsLambda.Host.Testing; public class InvocationResponse { - public bool WasSuccess { get; internal set; } - public TResponse? Response { get; internal set; } public ErrorResponse? Error { get; internal set; } + public TResponse? Response { get; internal set; } + public bool WasSuccess { get; internal set; } } diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs index 62f84525..b4ab21da 100644 --- a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs +++ b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs @@ -4,10 +4,6 @@ namespace AwsLambda.Host.Testing; internal class LambdaBootstrapRequest { - internal required RequestType RequestType { get; init; } - internal required HttpRequestMessage RequestMessage { get; init; } - internal required RouteValueDictionary RouteValue { get; init; } - internal string? RequestId { get @@ -21,4 +17,8 @@ internal string? RequestId return field; } } + + internal required HttpRequestMessage RequestMessage { get; init; } + internal required RequestType RequestType { get; init; } + internal required RouteValueDictionary RouteValue { get; init; } } diff --git a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs new file mode 100644 index 00000000..0d061a16 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs @@ -0,0 +1,46 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents a single HTTP transaction from the Lambda Bootstrap. +/// Bundles the request with its response completion mechanism for automatic correlation. +/// +internal class LambdaHttpTransaction +{ + /// + /// The HTTP request from Lambda Bootstrap. + /// + internal required HttpRequestMessage Request { get; init; } + + /// + /// Task completion source for the HTTP response. + /// Completing this sends the response back to Bootstrap. + /// + internal required TaskCompletionSource ResponseTcs { get; init; } + + /// + /// Creates a new transaction with RunContinuationsAsynchronously to prevent deadlocks. + /// + internal static LambdaHttpTransaction Create(HttpRequestMessage request) => + new() + { + Request = request, + ResponseTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + }; + + /// + /// Completes the transaction with a successful HTTP response. + /// + internal bool Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response); + + /// + /// Completes the transaction with an exception. + /// + internal bool Fail(Exception exception) => ResponseTcs.TrySetException(exception); + + /// + /// Completes the transaction with cancellation. + /// + internal bool Cancel() => ResponseTcs.TrySetCanceled(); +} diff --git a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs new file mode 100644 index 00000000..f9c203c2 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs @@ -0,0 +1,52 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents a pending Lambda invocation waiting for Bootstrap to process and respond. +/// +internal class PendingInvocation +{ + /// + /// Timestamp when this invocation was created (for timeout tracking). + /// + internal DateTime CreatedAt { get; init; } = DateTime.UtcNow; + + /// + /// The HTTP response containing the serialized event payload and Lambda headers + /// to send to Bootstrap when it polls for the next invocation. + /// + internal required HttpResponseMessage EventResponse { get; init; } + + /// + /// Unique request ID for this invocation (will be in Lambda-Runtime-Aws-Request-Id header). + /// + internal required string RequestId { get; init; } + + /// + /// Task completion source for the invocation result. + /// Completed when Bootstrap posts response or error with the HTTP request containing the result payload. + /// + internal required TaskCompletionSource ResponseTcs { get; init; } + + /// + /// Absolute time when this invocation should be considered expired. + /// + internal required DateTimeOffset DeadlineUtc { get; init; } + + /// + /// Creates a pending invocation with proper TCS configuration. + /// + internal static PendingInvocation Create( + string requestId, + HttpResponseMessage eventResponse, + DateTimeOffset deadlineUtc + ) => + new() + { + RequestId = requestId, + EventResponse = eventResponse, + ResponseTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + DeadlineUtc = deadlineUtc, + }; +} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs index 2f540e6c..45379cc0 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -32,6 +32,23 @@ public class LambdaInvocationHeaderOptions /// public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); + /// + /// Gets or sets the maximum amount of time to wait for a response from the Lambda bootstrap. + /// Defaults to the function timeout plus a small buffer. + /// + public TimeSpan ClientWaitTimeout { get; set; } = + TimeSpan.FromMinutes(15).Add(TimeSpan.FromSeconds(5)); + + /// + /// Legacy property for client wait timeout. Prefer . + /// + [Obsolete("Use ClientWaitTimeout instead.")] + public TimeSpan InvocationTimeout + { + get => ClientWaitTimeout; + set => ClientWaitTimeout = value; + } + /// /// Gets or sets the AWS X-Ray trace ID for distributed tracing. /// Maps to the Lambda-Runtime-Trace-Id header. diff --git a/src/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md index 303f128f..afb866ba 100644 --- a/src/AwsLambda.Host.Testing/README.md +++ b/src/AwsLambda.Host.Testing/README.md @@ -176,4 +176,131 @@ Headers: Accept: application/json -``` \ No newline at end of file +``` + +# In-Memory Lambda Testing Client Implementation Summary + +## Overview + +This implementation provides an in-memory testing infrastructure for AWS Lambda functions using .NET. It intercepts HTTP requests from the Lambda Bootstrap and allows test code to simulate the Lambda Runtime API without any network calls. + +## Core Components + +### 1. LambdaHttpTransaction + +A simple class that bundles an HTTP request with its response mechanism: + +- **Request**: The `HttpRequestMessage` sent by Lambda Bootstrap +- **ResponseTcs**: A `TaskCompletionSource` that the test infrastructure completes when ready +- **Convenience methods**: `Respond()` and `Fail()` for easy response handling + +The key insight is that each transaction carries its own completion mechanism, providing automatic correlation between requests and responses without needing IDs or separate channels. + +### 2. LambdaTestingHttpHandler + +A custom `HttpMessageHandler` that intercepts all HTTP calls from Lambda Bootstrap: + +- Creates a `Channel` for outbound communication +- On `SendAsync()`: + 1. Wraps the request in a `LambdaHttpTransaction` + 2. Writes it to the channel + 3. Awaits the transaction's `TaskCompletionSource` +- Handles cancellation by registering a callback that cancels the TCS + +This replaces the original two-channel design (request channel + response channel) with a single channel carrying self-contained transactions. + +### 3. LambdaTestServer + +The intermediary that processes HTTP transactions from the handler: + +- Reads transactions from the handler's channel +- Routes requests based on the Lambda Runtime API paths +- Manages queued `/next` requests when no invocations are pending +- Matches response posts back to pending invocations by request ID + +### 4. LambdaTestClient + +The user-facing API that abstracts away all HTTP details: + +- Exposes a clean `InvokeAsync()` method +- Communicates with the server to queue invocations +- Tracks pending invocations in a `ConcurrentDictionary` keyed by request ID +- Returns typed `InvocationResponse` to callers + +## Request Flow +```mermaid +sequenceDiagram + participant User as User Code + participant Client as LambdaTestClient + participant Server as LambdaTestServer + participant Handler as LambdaTestingHttpHandler + participant Bootstrap as Lambda Bootstrap + + User->>Client: InvokeAsync(event) + Client->>Server: Queue pending invocation + + Bootstrap->>Handler: GET /invocation/next + Handler->>Server: Transaction via Channel + Server->>Server: Match with pending invocation + Server->>Handler: Respond(event payload) + Handler->>Bootstrap: HTTP 200 + event + headers + + Note over Bootstrap: Lambda function executes + + Bootstrap->>Handler: POST /invocation/{id}/response + Handler->>Server: Transaction via Channel + Server->>Server: Find pending by request ID + Server->>Handler: Respond(HTTP 202) + Handler->>Bootstrap: HTTP 202 Accepted + Server->>Client: Complete invocation TCS + Client->>User: InvocationResponse +``` + +## Concurrency Handling + +The implementation correctly handles multiple concurrent invocations: + +1. **Correlation via TCS**: Each `LambdaHttpTransaction` has its own `TaskCompletionSource`, so responses automatically route to the correct caller regardless of completion order. + +2. **Request ID tracking**: Each invocation gets a unique GUID. The server tracks pending invocations by this ID and matches Bootstrap's response posts back to the original caller. + +3. **Queued /next requests**: If Bootstrap polls for work before any invocations are pending, the request is queued and served when an invocation arrives. + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Single channel with TCS | Eliminates correlation problem inherent in separate request/response channels | +| `TaskCreationOptions.RunContinuationsAsynchronously` | Prevents deadlocks and stack dives when completing the TCS | +| Background processing loop in server | Decouples user's invoke calls from Bootstrap's polling pattern | +| `ConcurrentDictionary` for pending invocations | Thread-safe tracking for concurrent invoke calls | + +## Usage Example +```csharp +// Setup +var handler = new LambdaTestingHttpHandler(); +var server = new LambdaTestServer(handler); +var client = new LambdaTestClient(server); + +server.Start(); + +// Wire handler to Lambda Bootstrap's HttpClient +// ... bootstrap configuration ... + +// Invoke - user only sees events and responses, no HTTP +var response = await client.InvokeAsync( + new MyEvent { UserId = 123 }); + +if (response.IsSuccess) +{ + Console.WriteLine(response.Response.Result); +} +``` + +## Benefits + +- **Clean API**: Users work with typed events and responses, not HTTP +- **Correct concurrency**: Multiple simultaneous invocations work correctly +- **Testable**: No network, no ports, no external dependencies +- **Faithful simulation**: Follows the actual Lambda Runtime API contract +- **Faithful simulation**: Follows the actual Lambda Runtime API contract \ No newline at end of file