diff --git a/.github/workflows/update-changelog.yaml b/.github/workflows/update-changelog.yaml index a6b7d41d..4b85125e 100644 --- a/.github/workflows/update-changelog.yaml +++ b/.github/workflows/update-changelog.yaml @@ -33,7 +33,7 @@ jobs: run: perl -i -pe 'BEGIN{undef $/} s/(## (?:\[)?v[\d.]+(?:\](?:\([^)]+\))?)? - \d{4}-\d{2}-\d{2}\n)[\s\S]*?### Changes\n+/$1\n/g' CHANGELOG.md - name: Create Pull Request - id: cpr + id: cpr uses: peter-evans/create-pull-request@v7 with: branch: changelog/${{ github.event.release.tag_name }} @@ -48,7 +48,6 @@ jobs: commit-message: "chore: update changelog for ${{ github.event.release.tag_name }}" labels: "automated,changelog,chore" delete-branch: true - sign-commits: true - name: Enable Auto-merge if: steps.cpr.outputs.pull-request-number diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c82c0a..2ffe12b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,38 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## [Unreleased](https://github.com/j-d-ha/aws-lambda-host/compare/v1.2.1...HEAD) +## [Unreleased](https://github.com/j-d-ha/aws-lambda-host/compare/v1.3.1...HEAD) + +## [v1.3.1](https://github.com/j-d-ha/aws-lambda-host/compare/v1.3.0...v1.3.1) - 2025-12-10 + +### πŸ› Bug Fixes + +* fix(core): add missing TenantId and TraceId properties to DefaultLambdaHostContext (#224) @j-d-ha +* fix(ci): remove sign-commits from changelog workflow (#223) @j-d-ha + +## [v1.3.0](https://github.com/j-d-ha/aws-lambda-host/compare/v1.2.1...v1.3.0) - 2025-12-10 + +## πŸš€ Features + +* feat(source-generators): support multiple MapHandler invocations with custom feature providers (#214) @j-d-ha +* docs: update MkDocs palette toggle configuration (#211) @j-d-ha + +## πŸ› Bug Fixes + +* fix: update third-party license attributions (#217) @j-d-ha + +## πŸ“š Documentation + +* docs: add comprehensive getting started guide and restructure documentation (#209) @j-d-ha + +## πŸ”„ Refactoring + +* refactor(host): migrate BootstrapHttpClient from options to dependency injection (#219) @j-d-ha +* refactor(docs): replace ASPNETCORE_ENVIRONMENT with DOTNET_ENVIRONMENT (#216) @j-d-ha + +## πŸ”§ Maintenance + +* ci(github): optimize workflow triggers for draft PRs (#215) @j-d-ha ## [v1.2.1](https://github.com/j-d-ha/aws-lambda-host/compare/v1.2.0...v1.2.1) - 2025-11-30 diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs index fc3329b0..e641ddc1 100644 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -1,4 +1,5 @@ ο»Ώusing AwsLambda.Host.Builder; +using AwsLambda.Host.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -13,8 +14,30 @@ // Build the Lambda application var lambda = builder.Build(); +// throw new Exception("Init failed"); + +// lambda.OnInit(() => +// { +// // throw new Exception("Init failed"); +// // return false; +// }); + // Map your handler - the event is automatically injected -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); +lambda.MapHandler( + async ([Event] string name, ILambdaHostContext context, CancellationToken cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name), "Name is required."); + + return $"Hello {name}!"; + } +); + +lambda.OnShutdown(() => +{ + Console.WriteLine("Shutdown"); +}); // Run the Lambda await lambda.RunAsync(); diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index bcab7c64..81a1e338 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -1,3 +1,4 @@ +using AwesomeAssertions; using AwsLambda.Host.Options; using AwsLambda.Host.Testing; using JetBrains.Annotations; @@ -14,19 +15,63 @@ 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( + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( "Jonas", + "1", TestContext.Current.CancellationToken ); - Assert.True(response.WasSuccess); - Assert.NotNull(response); - Assert.Equal("Hello Jonas!", response.Response); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello Jonas!"); } [Fact] - public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() + public async Task LambdaHost_HandlerReturnsError() + { + await using var factory = new LambdaApplicationFactory(); + + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( + "", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeFalse(); + response.Error.Should().NotBeNull(); + response.Error?.ErrorMessage.Should().Be("Name is required. (Parameter 'name')"); + } + + [Fact] + public async Task LambdaHost_CanBeShutdown() + { + await using var factory = new LambdaApplicationFactory(); + + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello Jonas!"); + + await factory.TestServer.StopAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task LambdaHost_ServerInternalExceptions_AreCaughtAndReturnedAsError() { await using var factory = new LambdaApplicationFactory().WithHostBuilder(builder => { @@ -41,28 +86,30 @@ public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() ); }); - 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); + var act = async () => + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + (await act.Should().ThrowAsync()) + .And.InnerExceptions.Should() + .ContainSingle(ex => + ex is InvalidOperationException + && ex.Message.Contains( + "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" + ) + ); } [Fact] public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); // Launch 5 concurrent invocations var tasks = Enumerable .Range(1, 5) .Select(i => - client.InvokeAsync( + factory.TestServer.InvokeAsync( $"User{i}", TestContext.Current.CancellationToken ) @@ -84,9 +131,9 @@ public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() public async Task InvokeAsync_WithInvalidPayload_ReturnsError() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - var response = await client.InvokeAsync( + var response = await factory.TestServer.InvokeAsync( 123, TestContext.Current.CancellationToken ); @@ -100,38 +147,46 @@ public async Task InvokeAsync_WithInvalidPayload_ReturnsError() public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Assert.ThrowsAsync(() => - client.InvokeAsync("Jonas", cts.Token) + factory.TestServer.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; - } - }); + // [Fact] + // public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => + // await Assert.ThrowsAsync(async () => + // { + // await using var factory = new LambdaApplicationFactory(); + // await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + // + // var options = new LambdaServerOptions(); + // options.InvocationOptions.ClientWaitTimeout = TimeSpan.Zero; + // + // await factory.TestServer.InvokeAsync( + // "Jonas", + // options, + // TestContext.Current.CancellationToken + // ); + // }); + + // [Fact] + // public async Task StartAsync_WithFailingInit_ReturnsInitError() + // { + // // This test verifies that when OnInit returns false (as configured in Program.cs), + // // the runtime posts to /runtime/init/error and StartAsync returns InitResponse with + // //error + // await using var factory = new LambdaApplicationFactory(); + // + // using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + // var initResponse = await factory.TestServer.StartAsync(cts.Token); + // + // Assert.False(initResponse.InitSuccess); + // Assert.NotNull(initResponse.Error); + // Assert.Equal(ServerState.Stopped, factory.TestServer.State); + // } } diff --git a/src/AwsLambda.Host.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs b/src/AwsLambda.Host.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs new file mode 100644 index 00000000..c81de4a4 --- /dev/null +++ b/src/AwsLambda.Host.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Lambda.Serialization.SystemTextJson; +using Amazon.Lambda.Serialization.SystemTextJson.Converters; + +namespace AwsLambda.Host.Options; + +/// Provides the default JSON serializer options used by AWS Lambda. +public static class DefaultLambdaJsonSerializerOptions +{ + /// + /// Creates a instance that matches the defaults used by + /// . + /// + /// + /// Configures null-value ignoring, case-insensitive property names, and the AWS naming policy. + /// Adds the AWS-provided converters for dates, memory streams, constant classes, and byte arrays. + /// + /// Configured JSON serializer options suitable for AWS Lambda payloads. + public static JsonSerializerOptions Create() + { + var options = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new AwsNamingPolicy(), + }; + options.Converters.Add(new DateTimeConverter()); + options.Converters.Add(new MemoryStreamConverter()); + options.Converters.Add(new ConstantClassConverter()); + options.Converters.Add(new ByteArrayConverter()); + + return options; + } +} diff --git a/src/AwsLambda.Host.Abstractions/Options/EnvelopeOptions.cs b/src/AwsLambda.Host.Abstractions/Options/EnvelopeOptions.cs index 8dc0cc2e..56799568 100644 --- a/src/AwsLambda.Host.Abstractions/Options/EnvelopeOptions.cs +++ b/src/AwsLambda.Host.Abstractions/Options/EnvelopeOptions.cs @@ -1,8 +1,6 @@ using System.Text.Json; -using System.Text.Json.Serialization; using System.Xml; using Amazon.Lambda.Serialization.SystemTextJson; -using Amazon.Lambda.Serialization.SystemTextJson.Converters; using AwsLambda.Host.Envelopes; namespace AwsLambda.Host.Options; @@ -57,28 +55,8 @@ public class EnvelopeOptions /// not been explicitly configured. /// /// - public JsonSerializerOptions LambdaDefaultJsonOptions - { - get - { - if (field is null) - { - field = new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = new AwsNamingPolicy(), - }; - field.Converters.Add(new DateTimeConverter()); - field.Converters.Add(new MemoryStreamConverter()); - field.Converters.Add(new ConstantClassConverter()); - field.Converters.Add(new ByteArrayConverter()); - } - - return field; - } - set; - } + public JsonSerializerOptions LambdaDefaultJsonOptions { get; set; } = + DefaultLambdaJsonSerializerOptions.Create(); /// Gets or sets the XML reader settings used when deserializing Lambda event payloads. /// diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 7fe11895..6e3d1fe0 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -25,6 +25,10 @@ internal sealed class DeferredHostBuilder : IHostBuilder TaskCreationOptions.RunContinuationsAsynchronously ); + private readonly TaskCompletionSource _entryPointCompletionTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private Action _configure; private Func? _hostFactory; @@ -39,6 +43,8 @@ public DeferredHostBuilder() => public IDictionary Properties { get; } = new Dictionary(); + public Task EntryPointCompletion => _entryPointCompletionTcs.Task; + public IHost Build() { // Hosting configuration is being provided by args so that @@ -116,10 +122,12 @@ public void EntryPointCompleted(Exception? exception) if (exception is not null) { _hostStartTcs.TrySetException(exception); + _entryPointCompletionTcs.TrySetResult(exception); } else { _hostStartTcs.TrySetResult(); + _entryPointCompletionTcs.TrySetResult(null); } } diff --git a/src/AwsLambda.Host.Testing/DictionaryExtensions.cs b/src/AwsLambda.Host.Testing/DictionaryExtensions.cs new file mode 100644 index 00000000..7a8e5afc --- /dev/null +++ b/src/AwsLambda.Host.Testing/DictionaryExtensions.cs @@ -0,0 +1,19 @@ +namespace AwsLambda.Host.Testing; + +internal static class DictionaryExtensions +{ + extension(IDictionary dictionary) + { + internal void AddRequired(TKey key, TValue value) + { + if (!dictionary.TryAdd(key, value)) + throw new InvalidOperationException($"Key '{key}' already exists."); + } + + internal void GetRequired(TKey? key, out TValue value) + { + if (key is null || !dictionary.TryGetValue(key, out value!)) + throw new InvalidOperationException($"Key '{key}' is null or does not exist."); + } + } +} diff --git a/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs deleted file mode 100644 index 041586b6..00000000 --- a/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace AwsLambda.Host.Testing; - -internal interface ILambdaRuntimeRouteManager -{ - bool TryMatch( - HttpRequestMessage request, - out RequestType? routeType, - out RouteValueDictionary? values - ); -} diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 440371dc..a04f3925 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -73,7 +73,7 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab /// /// Gets the created by this . /// - internal LambdaTestServer Server + public LambdaTestServer TestServer { get { @@ -85,14 +85,7 @@ internal LambdaTestServer Server /// /// Gets the created by the server associated with this . /// - public virtual IServiceProvider Services - { - get - { - EnsureServer(); - return _host!.Services; - } - } + public virtual IServiceProvider Services => TestServer.Services; /// public virtual async ValueTask DisposeAsync() @@ -106,15 +99,10 @@ public virtual async ValueTask DisposeAsync() foreach (var factory in _derivedFactories) await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); + // TestServer handles disposing both processor and host if (_server != null) await _server.DisposeAsync().ConfigureAwait(false); - if (_host != null) - { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); - } - _disposedAsync = true; Dispose(true); @@ -134,12 +122,6 @@ public void Dispose() /// ~LambdaApplicationFactory() => Dispose(false); - public LambdaClient CreateClient() - { - EnsureServer(); - return _server!.CreateLambdaClient(); - } - /// /// Creates a new with a /// that is further customized by . @@ -152,15 +134,12 @@ public LambdaApplicationFactory WithHostBuilder( Action configuration ) => WithHostBuilderCore(configuration); - internal virtual LambdaTestServer CreateServer() => new(); - internal virtual LambdaApplicationFactory WithHostBuilderCore( Action configuration ) { var factory = new DelegatedLambdaApplicationFactory( ClientOptions, - CreateServer, CreateHost, CreateHostBuilder, GetTestAssemblies, @@ -218,7 +197,7 @@ private void EnsureServer() // to IHostBuilder.Build. deferredHostBuilder.SetHostFactory(factory); - ConfigureHostBuilder(deferredHostBuilder); + ConfigureHostBuilder(deferredHostBuilder, deferredHostBuilder.EntryPointCompletion); return; } @@ -226,17 +205,20 @@ private void EnsureServer() } [MemberNotNull(nameof(_server))] - private void ConfigureHostBuilder(IHostBuilder hostBuilder) + private void ConfigureHostBuilder( + IHostBuilder hostBuilder, + Task entryPointCompletion + ) { SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = CreateServer(); + _server = new LambdaTestServer(entryPointCompletion); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => { - services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateHandler())); services.PostConfigure(options => { @@ -245,10 +227,13 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) }); }); + // Build the host but DON'T start it - server will start it _host = CreateHost(hostBuilder); + _server.SetHost(_host); - // Start the server's background processing loop after host is ready - _server.Start(); + // Create the public server with the built (but not started) host + // _server = new LambdaTestServer(_host, processor, entryPointCompletion: + // entryPointCompletion); } private void SetContentRoot(IHostBuilder builder) @@ -440,12 +425,9 @@ private static void EnsureDepsFile() /// /// The used to create the host. /// The with the bootstrapped application. - protected virtual IHost CreateHost(IHostBuilder builder) - { - var host = builder.Build(); - host.Start(); - return host; - } + protected virtual IHost CreateHost(IHostBuilder builder) => + // Build the host but DON'T start it - LambdaTestServer.StartAsync() will start it + builder.Build(); /// /// Gives a fixture an opportunity to configure the application before it gets built. @@ -475,12 +457,10 @@ private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactor { private readonly Func _createHost; private readonly Func _createHostBuilder; - private readonly Func _createServer; private readonly Func> _getTestAssemblies; public DelegatedLambdaApplicationFactory( LambdaApplicationFactoryClientOptions options, - Func createServer, Func createHost, Func createHostBuilder, Func> getTestAssemblies, @@ -488,7 +468,6 @@ Action configureWebHost ) { ClientOptions = options; - _createServer = createServer; _createHost = createHost; _createHostBuilder = createHostBuilder; _getTestAssemblies = getTestAssemblies; @@ -497,8 +476,6 @@ Action configureWebHost protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); - internal override LambdaTestServer CreateServer() => _createServer(); - protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); @@ -510,7 +487,6 @@ Action configuration ) => new DelegatedLambdaApplicationFactory( ClientOptions, - _createServer, _createHost, _createHostBuilder, _getTestAssemblies, diff --git a/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs index bf7cc1a9..ffc9f4df 100644 --- a/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs @@ -41,7 +41,7 @@ CancellationToken cancellationToken } catch (ChannelClosedException) { - // Server is shutting down; propagate cancellation to caller + // TestServer is shutting down; propagate cancellation to caller var canceled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); diff --git a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs index e60d02c3..0f2fd484 100644 --- a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs +++ b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs @@ -4,7 +4,7 @@ namespace AwsLambda.Host.Testing; -internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager +internal class LambdaRuntimeRouteManager { private static readonly RouteTemplate[] Routes = [ @@ -18,6 +18,15 @@ internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager ), }, new() + { + Type = RequestType.PostInitError, + Method = HttpMethod.Post.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("{version}/runtime/init/error"), + new RouteValueDictionary() + ), + }, + new() { Type = RequestType.PostResponse, Method = HttpMethod.Post.Method, diff --git a/src/AwsLambda.Host.Testing/LambdaTestClient.cs b/src/AwsLambda.Host.Testing/LambdaTestClient.cs deleted file mode 100644 index cb370a24..00000000 --- a/src/AwsLambda.Host.Testing/LambdaTestClient.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; - -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 LambdaTestServer _server; - private int _requestCounter; - - internal LambdaClient(LambdaTestServer server, JsonSerializerOptions jsonSerializerOptions) - { - _server = server; - _jsonSerializerOptions = jsonSerializerOptions; - _lambdaClientOptions = new LambdaClientOptions(); - } - - /// - /// Configures client options for invocation headers. - /// - public LambdaClient ConfigureOptions(Action configureOptions) - { - ArgumentNullException.ThrowIfNull(configureOptions); - - configureOptions(_lambdaClientOptions); - - return this; - } - - /// - /// Invokes the Lambda function with the given event and waits for the response. - /// - public async Task> InvokeAsync( - TEvent invokeEvent, - CancellationToken cancellationToken = default - ) - { - // 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 - ); - - // Queue invocation and wait for Bootstrap to process it - var waitTimeout = _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout; - - 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 - { - 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 CreateEventResponse(TEvent invokeEvent, string requestId) - { - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - }; - - // Add standard HTTP headers - response.Headers.Date = new DateTimeOffset( - _lambdaClientOptions.InvocationHeaderOptions.Date - ); - response.Headers.TransferEncodingChunked = _lambdaClientOptions - .InvocationHeaderOptions - .TransferEncodingChunked; - - // Add custom Lambda runtime headers - var deadlineMs = DateTimeOffset - .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.FunctionTimeout) - .ToUnixTimeMilliseconds(); - response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); - response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); - response.Headers.Add( - "Lambda-Runtime-Trace-Id", - _lambdaClientOptions.InvocationHeaderOptions.TraceId - ); - response.Headers.Add( - "Lambda-Runtime-Invoked-Function-Arn", - _lambdaClientOptions.InvocationHeaderOptions.FunctionArn - ); - - // Add any additional custom headers - foreach (var header in _lambdaClientOptions.InvocationHeaderOptions.AdditionalHeaders) - response.Headers.Add(header.Key, header.Value); - - return response; - } - - private string GetRequestId() => - Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); -} diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 773dceb3..357598e8 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,391 +1,420 @@ using System.Collections.Concurrent; using System.Net; +using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Threading.Channels; +using AwsLambda.Host.Options; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace AwsLambda.Host.Testing; -/// -/// Test server that processes HTTP transactions from Lambda Bootstrap. -/// Routes requests, queues invocations, and manages request-response correlation. -/// -internal class LambdaTestServer : IAsyncDisposable +public class LambdaTestServer : IAsyncDisposable { - private const int TransactionChannelCapacity = 1024; - private const int NextRequestChannelCapacity = 1024; - private static readonly OperationCanceledException DisposedException = new( - "LambdaTestServer disposed" - ); - - 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(); + /// + /// Options used to configure how the server interacts with the Lambda. + /// + private readonly LambdaServerOptions _serverOptions; - // Create client that communicates with this server - _client = new LambdaClient(this, _jsonSerializerOptions); - } + /// + /// Task that represents the running Host application that has been captured. + /// + private readonly Task _entryPointCompletion; - public async ValueTask DisposeAsync() - { - await _shutdownCts.CancelAsync(); + /// + /// TCS used to signal the startup has completed + /// + private readonly TaskCompletionSource _initCompletionTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); - _transactionChannel.Writer.TryComplete(); - _queuedNextRequests.Writer.TryComplete(); + /// + /// JSON serializer options used to serialize/deserilize Lambda events and responses. + /// + private readonly JsonSerializerOptions _jsonSerializerOptions; - // Cancel any transactions waiting for work - while (_queuedNextRequests.Reader.TryRead(out var queuedTransaction)) - queuedTransaction.Fail(DisposedException); + /// + /// Channel used to queue pending invocations in a FIFO manner. + /// + private readonly Channel _pendingInvocationIds = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); - // Fail any in-flight transactions that haven't been processed yet - while (_transactionChannel.Reader.TryRead(out var transaction)) - transaction.Fail(DisposedException); + /// + /// Dictionary to track all invocations that have been sent to Lambda. + /// + private readonly ConcurrentDictionary _pendingInvocations = new(); - // Cancel any pending invocations waiting for bootstrap responses - foreach (var pendingInvocation in _pendingInvocations.Values) - pendingInvocation.ResponseTcs.TrySetCanceled(_shutdownCts.Token); + /// + /// Route manager to determine the route of the incoming request from the Lambda. + /// + private readonly LambdaRuntimeRouteManager _routeManager = new(); - if (_processingTask != null) - { - try - { - await _processingTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when task is canceled - } - } + /// + /// CTS used to signal shutdown of the server and cancellation of pending tasks. + /// + private readonly CancellationTokenSource _shutdownCts; - _shutdownCts.Dispose(); - } + /// + /// Channel used to by the Lambda to send events to the server. + /// + private readonly Channel _transactionChannel = + Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); /// - /// Creates the HTTP handler for Lambda Bootstrap to use. + /// Host application lifetime used to signal shutdown to the captioned Host. /// - internal HttpMessageHandler CreateTestingHandler() => - new LambdaTestingHttpHandler(_transactionChannel); + private IHostApplicationLifetime _applicationLifetime; /// - /// Gets the client for test code to invoke Lambda functions. + /// The captured Host instance. /// - internal LambdaClient CreateLambdaClient() => _client; + private IHost? _host; /// - /// Starts the background processing loop. - /// Called automatically by LambdaApplicationFactory after host starts. + /// Task that is running the background processing loop to handle incoming requests from Lambda. /// - internal void Start() - { - if (_processingTask != null) - throw new InvalidOperationException("Server already started"); + private Task? _processingTask; - _processingTask = Task.Run(ProcessTransactionsAsync); - } + /// + /// Counter used to generate unique request IDs. + /// + private int _requestCounter; /// - /// Queues a new invocation to be processed by Lambda Bootstrap. - /// Called by LambdaClient.InvokeAsync(). + /// Current state of the server used to enforce lifecycle rules. /// - internal async Task QueueInvocationAsync( - string requestId, - HttpResponseMessage eventResponse, - DateTimeOffset deadlineUtc, - CancellationToken cancellationToken + private ServerState _state; + + internal LambdaTestServer( + Task? entryPointCompletion, + CancellationToken shutdownToken = default ) { - var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); + ArgumentNullException.ThrowIfNull(entryPointCompletion); - if (!_pendingInvocations.TryAdd(requestId, pending)) - throw new InvalidOperationException($"Duplicate request ID: {requestId}"); + _entryPointCompletion = entryPointCompletion; + _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); + _state = ServerState.Created; - _pendingInvocationIds.Enqueue(requestId); + _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create(); + _serverOptions = new LambdaServerOptions(); + } - // If there's a queued /next request, serve it immediately - if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) - RespondToNextRequest(nextTransaction); + public IServiceProvider Services => _host!.Services; - using var cancellationRegistration = cancellationToken.Register(() => - CancelPendingInvocation(requestId, cancellationToken) - ); + public async ValueTask DisposeAsync() + { + if (_state == ServerState.Running) + await StopAsync(); + + _transactionChannel.Writer.TryComplete(); - // Wait for Bootstrap to process and respond - return await pending.ResponseTcs.Task.WaitAsync(cancellationToken); + await _shutdownCts.CancelAsync(); + + _state = ServerState.Disposed; } - /// - /// Background loop that processes transactions from the handler. - /// - private async Task ProcessTransactionsAsync() + internal void SetHost(IHost host) { - 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); - } + ArgumentNullException.ThrowIfNull(host); + _host = host; } - /// - /// Routes a single transaction based on request type. - /// - private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) + internal HttpMessageHandler CreateHandler() => + new LambdaTestingHttpHandler(_transactionChannel); + + // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + // β”‚ Public API β”‚ + // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + public async Task StartAsync(CancellationToken cancellationToken = default) { - if (!_routeManager.TryMatch(transaction.Request, out var requestType, out var routeValues)) + if (_state != ServerState.Created) throw new InvalidOperationException( - $"Unexpected request: {transaction.Request.Method} {transaction.Request.RequestUri}" + "TestServer has already been started and cannot be restarted." ); - switch (requestType!.Value) + if (_host is null) + throw new InvalidOperationException("Host is not set."); + + using var cts = LinkedCts(cancellationToken); + + _state = ServerState.Starting; + + _applicationLifetime = _host.Services.GetRequiredService(); + + // Start the host + await _host.StartAsync(cts.Token); + + // Start background processing + _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token); + + await TaskHelpers + .WhenAny(_processingTask, _entryPointCompletion, _initCompletionTcs.Task) + .UnwrapAndThrow("Exception(s) encountered while running StartAsync"); + + if (_entryPointCompletion.IsCompleted) + return new InitResponse { InitStatus = InitStatus.HostExited }; + + if (_initCompletionTcs.Task.IsCompleted) { - 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}" - ); + _state = + _initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted + ? ServerState.Running + : ServerState.Stopped; + + return _initCompletionTcs.Task.Result; } + + throw new InvalidOperationException( + "TestServer initialization failed with neither an error nor completion." + ); } - /// - /// Handles GET /invocation/next - Bootstrap polling for work. - /// - private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + public async Task> InvokeAsync( + TEvent invokeEvent, + string? traceId = null, + CancellationToken cancellationToken = default + ) { - // 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); - } + if (_state != ServerState.Running) + throw new InvalidOperationException( + "TestServer is not Running and as such an event cannot be invoked." + ); - return; - } + using var cts = LinkedCtsWithInvocationDeadline(cancellationToken); + + traceId ??= Guid.NewGuid().ToString(); + + // Generate unique request ID + var requestId = GetRequestId(); + + // Create the event response with Lambda headers + var eventResponse = CreateEventResponse(invokeEvent, requestId, traceId); + var deadlineUtc = DateTimeOffset.UtcNow.Add(_serverOptions.FunctionTimeout); + + var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); + + _pendingInvocations.AddRequired(requestId, pending); + + if (!_pendingInvocationIds.Writer.TryWrite(requestId)) + throw new InvalidOperationException("Failed to enqueue pending invocation"); + + var completion = await pending.ResponseTcs.Task.WaitAsync(cts.Token); + + var responseMessage = completion.Request; + var wasSuccess = completion.RequestType == RequestType.PostResponse; + + var response = wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(default) + ) + : default; - RespondToNextRequest(transaction, pending); + var error = !wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(null) + ) + : null; + + return new InvocationResponse + { + WasSuccess = wasSuccess, + Response = response, + Error = error, + }; } - /// - /// Responds to a /next request with a pending invocation. - /// - private void RespondToNextRequest(LambdaHttpTransaction transaction, PendingInvocation pending) + public async Task StopAsync(CancellationToken cancellationToken = default) { - // Respond with the event payload and Lambda headers - if (transaction.Respond(pending.EventResponse)) - return; + if (_state != ServerState.Running) + throw new InvalidOperationException( + "TestServer is not running and as such cannot be stopped." + ); + + _state = ServerState.Stopping; + + await _shutdownCts.CancelAsync(); + + _applicationLifetime.StopApplication(); + + await TaskHelpers + .WhenAll(_entryPointCompletion, _processingTask!) + .UnwrapAndThrow("Exception(s) encountered while running StopAsync") + .WaitAsync(cancellationToken); - // Request was already canceled; re-enqueue invocation to avoid dropping it - _pendingInvocationIds.Enqueue(pending.RequestId); + _state = ServerState.Stopped; } - /// - /// Responds to a /next request with a pending invocation by looking up the next one. - /// - private void RespondToNextRequest(LambdaHttpTransaction transaction) + // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + // β”‚ Internal TestServer Logic β”‚ + // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + private async Task ProcessTransactionsAsync() { - // Try to dequeue next pending invocation (FIFO) - if (!TryDequeuePendingInvocation(out var pending)) + try { - // This shouldn't happen, but if it does, respond with error - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.InternalServerError) + await foreach ( + var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) + ) + { + if ( + !_routeManager.TryMatch( + transaction.Request, + out var requestType, + out var routeValues + ) + ) + throw new InvalidOperationException( + $"Unexpected request received from the Lambda HTTP handler: {transaction.Request.Method} {transaction.Request.RequestUri}" + ); + + switch (requestType!.Value) { - Content = new StringContent("No pending invocations available"), + case RequestType.GetNextInvocation: + await HandleGetNextInvocationAsync(transaction); + break; + + case RequestType.PostResponse: + await HandlePostResponseAsync(transaction, routeValues!); + break; + + case RequestType.PostError: + await HandlePostErrorAsync(transaction, routeValues!); + break; + + case RequestType.PostInitError: + await HandlePostInitErrorAsync(transaction); + break; + + default: + throw new InvalidOperationException( + $"Unexpected request type {requestType} for {transaction.Request.RequestUri}" + ); } - ); - return; + } } + catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested) + { + // Expected when task is canceled + } + } - RespondToNextRequest(transaction, pending); + private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + { + if (_state == ServerState.Starting) + _initCompletionTcs.SetResult( + new InitResponse { InitStatus = InitStatus.InitCompleted } + ); + + if (await _pendingInvocationIds.Reader.WaitToReadAsync(_shutdownCts.Token)) + { + var requestId = await _pendingInvocationIds.Reader.ReadAsync(_shutdownCts.Token); + _pendingInvocations.GetRequired(requestId, out var pendingInvocation); + transaction.ResponseTcs.SetResult(pendingInvocation.EventResponse); + } } - /// - /// Handles POST /invocation/{requestId}/response - successful function execution. - /// private async Task HandlePostResponseAsync( LambdaHttpTransaction transaction, RouteValueDictionary routeValues ) { - var requestId = routeValues["requestId"]?.ToString(); + _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending); - 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; - } + // Acknowledge to Bootstrap + transaction.Respond(CreateSuccessResponse()); - // 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(); + _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending); - 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; - } + // Acknowledge to Bootstrap + transaction.Respond(CreateSuccessResponse()); - // 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"), - } - ); + private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) + { + if (_state == ServerState.Starting) + _initCompletionTcs.SetResult( + new InitResponse + { + Error = await ( + transaction.Request.Content?.ReadFromJsonAsync( + _jsonSerializerOptions + ) ?? Task.FromResult(null) + ), + InitStatus = InitStatus.InitError, + } + ); - return; + throw new InvalidOperationException( + "TestServer is already started and as such an initialization error cannot be reported." + ); } - private bool TryDequeuePendingInvocation(out PendingInvocation pendingInvocation) + private HttpResponseMessage CreateEventResponse( + TEvent invokeEvent, + string requestId, + string traceId + ) { - var now = DateTimeOffset.UtcNow; - - while (_pendingInvocationIds.TryDequeue(out var requestId)) + var response = new HttpResponseMessage(HttpStatusCode.OK) { - if (_pendingInvocations.TryGetValue(requestId, out pendingInvocation)) - { - if (pendingInvocation.DeadlineUtc <= now) - { - if (_pendingInvocations.TryRemove(requestId, out var expiredInvocation)) - expiredInvocation.ResponseTcs.TrySetCanceled(); + Content = new StringContent( + JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }; - continue; - } + // Add standard HTTP headers + response.Headers.Date = new DateTimeOffset(DateTime.UtcNow, TimeSpan.Zero); + response.Headers.TransferEncodingChunked = true; - return true; - } - } + // Add custom Lambda runtime headers + var deadlineMs = DateTimeOffset + .UtcNow.Add(_serverOptions.FunctionTimeout) + .ToUnixTimeMilliseconds(); + response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); + response.Headers.Add("Lambda-Runtime-Trace-Id", traceId); + response.Headers.Add("Lambda-Runtime-Invoked-Function-Arn", _serverOptions.FunctionArn); - pendingInvocation = null!; - return false; - } + // Add any additional custom headers + foreach (var header in _serverOptions.AdditionalHeaders) + response.Headers.Add(header.Key, header.Value); - private void CancelPendingInvocation(string requestId, CancellationToken cancellationToken) - { - if (_pendingInvocations.TryRemove(requestId, out var pendingInvocation)) - pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); + return response; } + private string GetRequestId() => + Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); + private static async Task CreateCompletionAsync( RequestType requestType, HttpRequestMessage sourceRequest @@ -416,4 +445,33 @@ HttpRequestMessage sourceRequest return new InvocationCompletion { Request = clonedRequest, RequestType = requestType }; } + + private static HttpResponseMessage CreateSuccessResponse() => + new(HttpStatusCode.Accepted) + { + Content = new StringContent( + """ + {"status":"success"} + """, + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }; + + private CancellationTokenSource LinkedCtsWithInvocationDeadline( + CancellationToken cancellationTokens + ) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationTokens, + _shutdownCts.Token + ); + cts.CancelAfter(_serverOptions.FunctionTimeout); + + return cts; + } + + private CancellationTokenSource LinkedCts(CancellationToken cancellationTokens) => + CancellationTokenSource.CreateLinkedTokenSource(cancellationTokens, _shutdownCts.Token); } diff --git a/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs b/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs new file mode 100644 index 00000000..43220ecc --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs @@ -0,0 +1,16 @@ +namespace AwsLambda.Host.Testing; + +public static class LambdaTestServerExtensions +{ + extension(LambdaTestServer server) + { + public Task> InvokeAsync( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) => + server.InvokeAsync( + invokeEvent, + cancellationToken: cancellationToken + ); + } +} diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs index 540fd345..02fb088c 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -13,6 +13,9 @@ public class ErrorResponse [JsonPropertyName("cause")] public ErrorCause? Cause { get; set; } + [JsonPropertyName("causes")] + public List? Causes { get; set; } + /// /// The error message describing what went wrong. /// diff --git a/src/AwsLambda.Host.Testing/Models/InitResponse.cs b/src/AwsLambda.Host.Testing/Models/InitResponse.cs new file mode 100644 index 00000000..833a22b2 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InitResponse.cs @@ -0,0 +1,17 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents the result of a Lambda function initialization attempt. +/// +public class InitResponse +{ + /// + /// Gets the error information if initialization failed, or null if initialization succeeded. + /// + public ErrorResponse? Error { get; internal init; } + + /// + /// Gets the status of the initialization attempt. + /// + public InitStatus InitStatus { get; internal init; } +} diff --git a/src/AwsLambda.Host.Testing/Models/InitStatus.cs b/src/AwsLambda.Host.Testing/Models/InitStatus.cs new file mode 100644 index 00000000..eee1c0ff --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InitStatus.cs @@ -0,0 +1,22 @@ +namespace AwsLambda.Host.Testing; + +/// +/// An enumeration of possible statuses for Lambda initialization. +/// +public enum InitStatus +{ + /// + /// Initialization of the Lambda completed successfully. + /// + InitCompleted, + + /// + /// Initialization of the Lambda failed, and the Lambda returned an error. + /// + InitError, + + /// + /// Initialization of the Lambda failed, and the Host process exited. + /// + HostExited, +} diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs deleted file mode 100644 index b4ab21da..00000000 --- a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace AwsLambda.Host.Testing; - -internal class LambdaBootstrapRequest -{ - internal string? RequestId - { - get - { - field ??= - RouteValue.TryGetValue("RequestId", out var requestId) - && requestId is string requestIdString - ? requestIdString - : null; - - 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/RequestType.cs b/src/AwsLambda.Host.Testing/Models/RequestType.cs index 935bd790..49108da9 100644 --- a/src/AwsLambda.Host.Testing/Models/RequestType.cs +++ b/src/AwsLambda.Host.Testing/Models/RequestType.cs @@ -5,4 +5,5 @@ internal enum RequestType GetNextInvocation, PostResponse, PostError, + PostInitError, } diff --git a/src/AwsLambda.Host.Testing/Models/ServerState.cs b/src/AwsLambda.Host.Testing/Models/ServerState.cs new file mode 100644 index 00000000..892b610d --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/ServerState.cs @@ -0,0 +1,37 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Represents the lifecycle state of a LambdaTestServer. +/// +public enum ServerState +{ + /// + /// TestServer created but not started. + /// + Created, + + /// + /// TestServer is starting (building host). + /// + Starting, + + /// + /// TestServer is running and accepting invocations. + /// + Running, + + /// + /// TestServer is stopping. + /// + Stopping, + + /// + /// TestServer has stopped cleanly. + /// + Stopped, + + /// + /// TestServer has been disposed. + /// + Disposed, +} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs deleted file mode 100644 index 025719b1..00000000 --- a/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace AwsLambda.Host.Testing; - -/// -/// Configuration options for the Lambda test client. -/// -public class LambdaClientOptions -{ - /// - /// Gets or sets the headers to include in Lambda invocation responses. - /// - public LambdaInvocationHeaderOptions InvocationHeaderOptions { get; set; } = new(); -} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs deleted file mode 100644 index 45379cc0..00000000 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace AwsLambda.Host.Testing; - -/// -/// Headers returned in Lambda runtime API invocation responses. -/// -public class LambdaInvocationHeaderOptions -{ - /// - /// Gets or sets additional custom headers to include in the Lambda invocation response. - /// Use this to add any additional headers beyond the standard Lambda runtime headers. - /// - public Dictionary AdditionalHeaders { get; set; } = new(); - - /// - /// Gets or sets the response date header. - /// Maps to the Date HTTP header. Defaults to current UTC time. - /// - public DateTime Date { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets the ARN of the Lambda function being invoked. - /// Maps to the Lambda-Runtime-Invoked-Function-Arn header. - /// - public string FunctionArn { get; set; } = - "arn:aws:lambda:us-west-2:123412341234:function:Function"; - - /// - /// Gets or sets the Lambda function timeout duration. - /// Maps to the Lambda-Runtime-Deadline-Ms header as Unix epoch milliseconds. - /// This determines when the Lambda function will timeout, calculated as the current time plus this duration. - /// Defaults to 15 minutes. - /// - 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. - /// - public string TraceId { get; set; } = Guid.NewGuid().ToString(); - - /// - /// Gets or sets whether to use chunked transfer encoding. - /// Maps to the Transfer-Encoding: chunked HTTP header. Defaults to true. - /// - public bool TransferEncodingChunked { get; set; } = true; -} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs new file mode 100644 index 00000000..2b92f9a2 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs @@ -0,0 +1,28 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Configuration options for the Lambda test client. +/// +public class LambdaServerOptions +{ + /// + /// Gets or sets additional custom headers to include in the Lambda invocation response. + /// Use this to add any additional headers beyond the standard Lambda runtime headers. + /// + public Dictionary AdditionalHeaders { get; set; } = new(); + + /// + /// Gets or sets the ARN of the Lambda function being invoked. + /// Maps to the Lambda-Runtime-Invoked-Function-Arn header. + /// + public string FunctionArn { get; set; } = + "arn:aws:lambda:us-west-2:123412341234:function:Function"; + + /// + /// Gets or sets the Lambda function timeout duration. + /// Maps to the Lambda-Runtime-Deadline-Ms header as Unix epoch milliseconds. + /// This determines when the Lambda function will timeout, calculated as the current time plus this duration. + /// Defaults to 15 minutes. + /// + public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); +} diff --git a/src/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md index afb866ba..547ccc1c 100644 --- a/src/AwsLambda.Host.Testing/README.md +++ b/src/AwsLambda.Host.Testing/README.md @@ -178,6 +178,37 @@ Headers: ``` +```json +{ + "errorType": "AggregateException", + "errorMessage": "Encountered errors while running OnInit handlers: (Init failed)", + "stackTrace": [ + "at AwsLambda.Host.Builder.LambdaOnInitBuilder.b__10_1(CancellationToken stoppingToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 65", + "at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.InitializeAsync()" + ], + "cause": { + "errorType": "Exception", + "errorMessage": "Init failed", + "stackTrace": [ + "at Program.<>c.<
$>b__0_1() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs:line 18", + "at AwsLambda.Host.Core.Generated.F0316F908774B11B609C941996D283BB313AD08F671E39F0BE1901FEE2BBA6D4C__GeneratedLambdaOnInitBuilderExtensions.<>c__DisplayClass0_0.g__OnInit|0(IServiceProvider serviceProvider, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/obj/Debug/net10.0/AwsLambda.Host.SourceGenerators/AwsLambda.Host.SourceGenerators.MapHandlerIncrementalGenerator/LambdaHandler.g.cs:line 109", + "at AwsLambda.Host.Builder.LambdaOnInitBuilder.RunInitHandler(LambdaInitDelegate handler, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 81" + ] + }, + "causes": [ + { + "errorType": "Exception", + "errorMessage": "Init failed", + "stackTrace": [ + "at Program.<>c.<
$>b__0_1() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs:line 18", + "at AwsLambda.Host.Core.Generated.F0316F908774B11B609C941996D283BB313AD08F671E39F0BE1901FEE2BBA6D4C__GeneratedLambdaOnInitBuilderExtensions.<>c__DisplayClass0_0.g__OnInit|0(IServiceProvider serviceProvider, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/obj/Debug/net10.0/AwsLambda.Host.SourceGenerators/AwsLambda.Host.SourceGenerators.MapHandlerIncrementalGenerator/LambdaHandler.g.cs:line 109", + "at AwsLambda.Host.Builder.LambdaOnInitBuilder.RunInitHandler(LambdaInitDelegate handler, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 81" + ] + } + ] +} +``` + # In-Memory Lambda Testing Client Implementation Summary ## Overview diff --git a/src/AwsLambda.Host.Testing/TaskHelpers.cs b/src/AwsLambda.Host.Testing/TaskHelpers.cs new file mode 100644 index 00000000..8d23fd5c --- /dev/null +++ b/src/AwsLambda.Host.Testing/TaskHelpers.cs @@ -0,0 +1,36 @@ +namespace AwsLambda.Host.Testing; + +internal static class TaskHelpers +{ + internal static async Task WhenAny(params Task[] tasks) + { + await Task.WhenAny(tasks); + return ExtractExceptions(tasks); + } + + internal static async Task WhenAll(params Task[] tasks) + { + await Task.WhenAll(tasks); + return ExtractExceptions(tasks); + } + + private static Exception[] ExtractExceptions(Task[] tasks) => + tasks + .Where(t => t is { IsFaulted: true, Exception: not null }) + .Select(e => + e.Exception!.InnerExceptions.Count > 1 + ? e.Exception + : e.Exception.InnerExceptions[0] + ) + .ToArray(); + + extension(Task exceptionsTask) + { + internal async Task UnwrapAndThrow(string errorMessage) + { + var exceptions = await exceptionsTask; + if (exceptions.Length > 0) + throw new AggregateException(errorMessage, exceptions); + } + } +} diff --git a/src/AwsLambda.Host/Core/Context/DefaultLambdaHostContext.cs b/src/AwsLambda.Host/Core/Context/DefaultLambdaHostContext.cs index b405ebe9..4c07e181 100644 --- a/src/AwsLambda.Host/Core/Context/DefaultLambdaHostContext.cs +++ b/src/AwsLambda.Host/Core/Context/DefaultLambdaHostContext.cs @@ -69,6 +69,10 @@ public async ValueTask DisposeAsync() public TimeSpan RemainingTime => _lambdaContext.RemainingTime; + public string TenantId => _lambdaContext.TenantId; + + public string TraceId => _lambdaContext.TraceId; + // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” // β”‚ ILambdaHostContext β”‚ // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜