From 22d1469854413089fe883cf1dadc2b66f7adb860 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Feb 2026 16:16:55 -0800 Subject: [PATCH 01/47] Task 1 --- .../Client/IResponseStream.cs | 85 +++++++++++++++++++ .../Client/ResponseStreamContext.cs | 44 ++++++++++ .../Client/StreamingConstants.cs | 48 +++++++++++ 3 files changed, 177 insertions(+) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs new file mode 100644 index 000000000..6107dde16 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs @@ -0,0 +1,85 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// Interface for writing streaming responses in AWS Lambda functions. + /// Obtained by calling ResponseStreamFactory.CreateStream() within a handler. + /// + public interface IResponseStream : IDisposable + { + /// + /// Asynchronously writes a byte array to the response stream. + /// + /// The byte array to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + /// Thrown if writing would exceed the 20 MiB limit. + Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default); + + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + /// Thrown if writing would exceed the 20 MiB limit. + Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); + + /// + /// Asynchronously writes a memory buffer to the response stream. + /// + /// The memory buffer to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + /// Thrown if writing would exceed the 20 MiB limit. + Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); + + /// + /// Reports an error that occurred during streaming. + /// This will send error information via HTTP trailing headers. + /// + /// The exception to report. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has already been reported. + Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default); + + /// + /// Gets the total number of bytes written to the stream so far. + /// + long BytesWritten { get; } + + /// + /// Gets whether the stream has been completed. + /// + bool IsCompleted { get; } + + /// + /// Gets whether an error has been reported. + /// + bool HasError { get; } + } +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs new file mode 100644 index 000000000..fed7352a2 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs @@ -0,0 +1,44 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// Internal context class used by ResponseStreamFactory to track per-invocation streaming state. + /// + internal class ResponseStreamContext + { + /// + /// The AWS request ID for the current invocation. + /// + public string AwsRequestId { get; set; } + + /// + /// Maximum allowed response size in bytes (20 MiB). + /// + public long MaxResponseSize { get; set; } + + /// + /// Whether CreateStream() has been called for this invocation. + /// + public bool StreamCreated { get; set; } + + /// + /// The IResponseStream instance if created. Typed as IResponseStream for now; + /// will be used with the concrete ResponseStream internally. + /// + public IResponseStream Stream { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs new file mode 100644 index 000000000..7eeec86a2 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs @@ -0,0 +1,48 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// Constants used for Lambda response streaming. + /// + internal static class StreamingConstants + { + /// + /// Maximum response size for Lambda streaming responses: 20 MiB. + /// + public const long MaxResponseSize = 20 * 1024 * 1024; + + /// + /// Header name for Lambda response mode. + /// + public const string ResponseModeHeader = "Lambda-Runtime-Function-Response-Mode"; + + /// + /// Value for streaming response mode. + /// + public const string StreamingResponseMode = "streaming"; + + /// + /// Trailer header name for error type. + /// + public const string ErrorTypeTrailer = "Lambda-Runtime-Function-Error-Type"; + + /// + /// Trailer header name for error body. + /// + public const string ErrorBodyTrailer = "Lambda-Runtime-Function-Error-Body"; + } +} From 5e0f8101f8b273ac475707f1737c59242cb940f3 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Feb 2026 16:24:58 -0800 Subject: [PATCH 02/47] Task 2 --- .../Client/ResponseStream.cs | 162 ++++++++++++++ .../Client/ResponseStreamContext.cs | 5 +- .../ResponseStreamTests.cs | 209 ++++++++++++++++++ 3 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs new file mode 100644 index 000000000..1484d1f8d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs @@ -0,0 +1,162 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// Internal implementation of IResponseStream. + /// Buffers written data as chunks for HTTP chunked transfer encoding. + /// + internal class ResponseStream : IResponseStream + { + private readonly long _maxResponseSize; + private readonly List _chunks; + private long _bytesWritten; + private bool _isCompleted; + private bool _hasError; + private Exception _reportedError; + private readonly object _lock = new object(); + + public long BytesWritten => _bytesWritten; + public bool IsCompleted => _isCompleted; + public bool HasError => _hasError; + + internal IReadOnlyList Chunks + { + get + { + lock (_lock) + { + return _chunks.ToList(); + } + } + } + + internal Exception ReportedError => _reportedError; + + public ResponseStream(long maxResponseSize) + { + _maxResponseSize = maxResponseSize; + _chunks = new List(); + _bytesWritten = 0; + _isCompleted = false; + _hasError = false; + } + + public Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + { + if (buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + return WriteAsync(buffer, 0, buffer.Length, cancellationToken); + } + + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + if (buffer == null) + throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || offset > buffer.Length) + throw new ArgumentOutOfRangeException(nameof(offset)); + if (count < 0 || offset + count > buffer.Length) + throw new ArgumentOutOfRangeException(nameof(count)); + + lock (_lock) + { + ThrowIfCompletedOrError(); + + if (_bytesWritten + count > _maxResponseSize) + { + throw new InvalidOperationException( + $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + + $"Current size: {_bytesWritten} bytes."); + } + + var chunk = new byte[count]; + Array.Copy(buffer, offset, chunk, 0, count); + _chunks.Add(chunk); + _bytesWritten += count; + } + + return Task.CompletedTask; + } + + public Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + lock (_lock) + { + ThrowIfCompletedOrError(); + + if (_bytesWritten + buffer.Length > _maxResponseSize) + { + throw new InvalidOperationException( + $"Writing {buffer.Length} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + + $"Current size: {_bytesWritten} bytes."); + } + + var chunk = buffer.ToArray(); + _chunks.Add(chunk); + _bytesWritten += buffer.Length; + } + + return Task.CompletedTask; + } + + public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) + { + if (exception == null) + throw new ArgumentNullException(nameof(exception)); + + lock (_lock) + { + if (_isCompleted) + throw new InvalidOperationException("Cannot report an error after the stream has been completed."); + if (_hasError) + throw new InvalidOperationException("An error has already been reported for this stream."); + + _hasError = true; + _reportedError = exception; + } + + return Task.CompletedTask; + } + + internal void MarkCompleted() + { + lock (_lock) + { + _isCompleted = true; + } + } + + private void ThrowIfCompletedOrError() + { + if (_isCompleted) + throw new InvalidOperationException("Cannot write to a completed stream."); + if (_hasError) + throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); + } + + public void Dispose() + { + // Nothing to dispose - all data is in managed memory + } + } +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs index fed7352a2..07df616e3 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs @@ -36,9 +36,8 @@ internal class ResponseStreamContext public bool StreamCreated { get; set; } /// - /// The IResponseStream instance if created. Typed as IResponseStream for now; - /// will be used with the concrete ResponseStream internally. + /// The ResponseStream instance if created. /// - public IResponseStream Stream { get; set; } + public ResponseStream Stream { get; set; } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs new file mode 100644 index 000000000..7503277ca --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -0,0 +1,209 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + public class ResponseStreamTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; // 20 MiB + + [Fact] + public void Constructor_InitializesStateCorrectly() + { + var stream = new ResponseStream(MaxResponseSize); + + Assert.Equal(0, stream.BytesWritten); + Assert.False(stream.IsCompleted); + Assert.False(stream.HasError); + Assert.Empty(stream.Chunks); + Assert.Null(stream.ReportedError); + } + + [Fact] + public async Task WriteAsync_ByteArray_BuffersDataCorrectly() + { + var stream = new ResponseStream(MaxResponseSize); + var data = new byte[] { 1, 2, 3, 4, 5 }; + + await stream.WriteAsync(data); + + Assert.Equal(5, stream.BytesWritten); + Assert.Single(stream.Chunks); + Assert.Equal(data, stream.Chunks[0]); + } + + [Fact] + public async Task WriteAsync_WithOffset_BuffersCorrectSlice() + { + var stream = new ResponseStream(MaxResponseSize); + var data = new byte[] { 0, 1, 2, 3, 0 }; + + await stream.WriteAsync(data, 1, 3); + + Assert.Equal(3, stream.BytesWritten); + Assert.Equal(new byte[] { 1, 2, 3 }, stream.Chunks[0]); + } + + [Fact] + public async Task WriteAsync_ReadOnlyMemory_BuffersDataCorrectly() + { + var stream = new ResponseStream(MaxResponseSize); + var data = new ReadOnlyMemory(new byte[] { 10, 20, 30 }); + + await stream.WriteAsync(data); + + Assert.Equal(3, stream.BytesWritten); + Assert.Equal(new byte[] { 10, 20, 30 }, stream.Chunks[0]); + } + + [Fact] + public async Task WriteAsync_MultipleWrites_AccumulatesBytesWritten() + { + var stream = new ResponseStream(MaxResponseSize); + + await stream.WriteAsync(new byte[100]); + await stream.WriteAsync(new byte[200]); + await stream.WriteAsync(new byte[300]); + + Assert.Equal(600, stream.BytesWritten); + Assert.Equal(3, stream.Chunks.Count); + } + + [Fact] + public async Task WriteAsync_CopiesData_AvoidingBufferReuseIssues() + { + var stream = new ResponseStream(MaxResponseSize); + var buffer = new byte[] { 1, 2, 3 }; + + await stream.WriteAsync(buffer); + buffer[0] = 99; // mutate original + + Assert.Equal(1, stream.Chunks[0][0]); // chunk should be unaffected + } + + /// + /// Property 6: Size Limit Enforcement - Writing beyond 20 MiB throws InvalidOperationException. + /// Validates: Requirements 3.6, 3.7 + /// + [Theory] + [InlineData(21 * 1024 * 1024)] // Single write exceeding limit + public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) + { + var stream = new ResponseStream(MaxResponseSize); + var data = new byte[writeSize]; + + await Assert.ThrowsAsync(() => stream.WriteAsync(data)); + } + + /// + /// Property 6: Size Limit Enforcement - Multiple writes exceeding 20 MiB throws. + /// Validates: Requirements 3.6, 3.7 + /// + [Fact] + public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() + { + var stream = new ResponseStream(MaxResponseSize); + + await stream.WriteAsync(new byte[10 * 1024 * 1024]); + await Assert.ThrowsAsync( + () => stream.WriteAsync(new byte[11 * 1024 * 1024])); + } + + [Fact] + public async Task SizeLimit_ExactlyAtLimit_Succeeds() + { + var stream = new ResponseStream(MaxResponseSize); + var data = new byte[20 * 1024 * 1024]; + + await stream.WriteAsync(data); + + Assert.Equal(MaxResponseSize, stream.BytesWritten); + } + + /// + /// Property 19: Writes After Completion Rejected - Writes after completion throw InvalidOperationException. + /// Validates: Requirements 8.8 + /// + [Fact] + public async Task WriteAsync_AfterMarkCompleted_Throws() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + + await Assert.ThrowsAsync( + () => stream.WriteAsync(new byte[] { 2 })); + } + + [Fact] + public async Task WriteAsync_AfterReportError_Throws() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("test")); + + await Assert.ThrowsAsync( + () => stream.WriteAsync(new byte[] { 2 })); + } + + // --- Error handling tests (2.6) --- + + [Fact] + public async Task ReportErrorAsync_SetsErrorState() + { + var stream = new ResponseStream(MaxResponseSize); + var exception = new InvalidOperationException("something broke"); + + await stream.ReportErrorAsync(exception); + + Assert.True(stream.HasError); + Assert.Same(exception, stream.ReportedError); + } + + [Fact] + public async Task ReportErrorAsync_AfterCompleted_Throws() + { + var stream = new ResponseStream(MaxResponseSize); + stream.MarkCompleted(); + + await Assert.ThrowsAsync( + () => stream.ReportErrorAsync(new Exception("test"))); + } + + [Fact] + public async Task ReportErrorAsync_CalledTwice_Throws() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.ReportErrorAsync(new Exception("first")); + + await Assert.ThrowsAsync( + () => stream.ReportErrorAsync(new Exception("second"))); + } + + [Fact] + public void MarkCompleted_SetsCompletionState() + { + var stream = new ResponseStream(MaxResponseSize); + + stream.MarkCompleted(); + + Assert.True(stream.IsCompleted); + } + } +} From 20e5ba8c50681295cf44cbcc63f5d96852d3ad98 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Feb 2026 16:31:17 -0800 Subject: [PATCH 03/47] Task 3 --- .../Client/ResponseStreamFactory.cs | 109 ++++++++++ .../ResponseStreamFactoryTests.cs | 188 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs new file mode 100644 index 000000000..9b60eacfd --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs @@ -0,0 +1,109 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Threading; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// Factory for creating streaming responses in AWS Lambda functions. + /// Call CreateStream() within your handler to opt into response streaming for that invocation. + /// + public static class ResponseStreamFactory + { + // For on-demand mode (single invocation at a time) + private static ResponseStreamContext _onDemandContext; + + // For multi-concurrency mode (multiple concurrent invocations) + private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); + + /// + /// Creates a streaming response for the current invocation. + /// Can only be called once per invocation. + /// + /// An IResponseStream for writing response data. + /// Thrown if called outside an invocation context. + /// Thrown if called more than once per invocation. + public static IResponseStream CreateStream() + { + var context = GetCurrentContext(); + + if (context == null) + { + throw new InvalidOperationException( + "ResponseStreamFactory.CreateStream() can only be called within a Lambda handler invocation."); + } + + if (context.StreamCreated) + { + throw new InvalidOperationException( + "ResponseStreamFactory.CreateStream() can only be called once per invocation."); + } + + var stream = new ResponseStream(context.MaxResponseSize); + context.Stream = stream; + context.StreamCreated = true; + + return stream; + } + + // Internal methods for LambdaBootstrap to manage state + + internal static void InitializeInvocation(string awsRequestId, long maxResponseSize, bool isMultiConcurrency) + { + var context = new ResponseStreamContext + { + AwsRequestId = awsRequestId, + MaxResponseSize = maxResponseSize, + StreamCreated = false, + Stream = null + }; + + if (isMultiConcurrency) + { + _asyncLocalContext.Value = context; + } + else + { + _onDemandContext = context; + } + } + + internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) + { + var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; + return context?.Stream; + } + + internal static void CleanupInvocation(bool isMultiConcurrency) + { + if (isMultiConcurrency) + { + _asyncLocalContext.Value = null; + } + else + { + _onDemandContext = null; + } + } + + private static ResponseStreamContext GetCurrentContext() + { + // Check multi-concurrency first (AsyncLocal), then on-demand + return _asyncLocalContext.Value ?? _onDemandContext; + } + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs new file mode 100644 index 000000000..a4b0558af --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -0,0 +1,188 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + public class ResponseStreamFactoryTests : IDisposable + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + public void Dispose() + { + // Clean up both modes to avoid test pollution + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + } + + // --- Task 3.3: CreateStream tests --- + + /// + /// Property 1: CreateStream Returns Valid Stream - on-demand mode. + /// Validates: Requirements 1.3, 2.2, 2.3 + /// + [Fact] + public void CreateStream_OnDemandMode_ReturnsValidStream() + { + ResponseStreamFactory.InitializeInvocation("req-1", MaxResponseSize, isMultiConcurrency: false); + + var stream = ResponseStreamFactory.CreateStream(); + + Assert.NotNull(stream); + Assert.IsAssignableFrom(stream); + } + + /// + /// Property 1: CreateStream Returns Valid Stream - multi-concurrency mode. + /// Validates: Requirements 1.3, 2.2, 2.3 + /// + [Fact] + public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() + { + ResponseStreamFactory.InitializeInvocation("req-2", MaxResponseSize, isMultiConcurrency: true); + + var stream = ResponseStreamFactory.CreateStream(); + + Assert.NotNull(stream); + Assert.IsAssignableFrom(stream); + } + + /// + /// Property 4: Single Stream Per Invocation - calling CreateStream twice throws. + /// Validates: Requirements 2.5, 2.6 + /// + [Fact] + public void CreateStream_CalledTwice_ThrowsInvalidOperationException() + { + ResponseStreamFactory.InitializeInvocation("req-3", MaxResponseSize, isMultiConcurrency: false); + ResponseStreamFactory.CreateStream(); + + Assert.Throws(() => ResponseStreamFactory.CreateStream()); + } + + [Fact] + public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationException() + { + // No InitializeInvocation called + Assert.Throws(() => ResponseStreamFactory.CreateStream()); + } + + // --- Task 3.5: Internal methods tests --- + + [Fact] + public void InitializeInvocation_OnDemand_SetsUpContext() + { + ResponseStreamFactory.InitializeInvocation("req-4", MaxResponseSize, isMultiConcurrency: false); + + // GetStreamIfCreated should return null since CreateStream hasn't been called + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + + // But CreateStream should work (proving context was set up) + var stream = ResponseStreamFactory.CreateStream(); + Assert.NotNull(stream); + } + + [Fact] + public void InitializeInvocation_MultiConcurrency_SetsUpContext() + { + ResponseStreamFactory.InitializeInvocation("req-5", MaxResponseSize, isMultiConcurrency: true); + + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); + + var stream = ResponseStreamFactory.CreateStream(); + Assert.NotNull(stream); + } + + [Fact] + public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() + { + ResponseStreamFactory.InitializeInvocation("req-6", MaxResponseSize, isMultiConcurrency: false); + var created = ResponseStreamFactory.CreateStream(); + + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); + + Assert.NotNull(retrieved); + } + + [Fact] + public void GetStreamIfCreated_NoContext_ReturnsNull() + { + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + } + + [Fact] + public void CleanupInvocation_ClearsState() + { + ResponseStreamFactory.InitializeInvocation("req-7", MaxResponseSize, isMultiConcurrency: false); + ResponseStreamFactory.CreateStream(); + + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Throws(() => ResponseStreamFactory.CreateStream()); + } + + /// + /// Property 16: State Isolation Between Invocations - state from one invocation doesn't leak to the next. + /// Validates: Requirements 6.5, 8.9 + /// + [Fact] + public void StateIsolation_SequentialInvocations_NoLeakage() + { + // First invocation - streaming + ResponseStreamFactory.InitializeInvocation("req-8a", MaxResponseSize, isMultiConcurrency: false); + var stream1 = ResponseStreamFactory.CreateStream(); + Assert.NotNull(stream1); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + + // Second invocation - should start fresh + ResponseStreamFactory.InitializeInvocation("req-8b", MaxResponseSize, isMultiConcurrency: false); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + + // Should be able to create a new stream + var stream2 = ResponseStreamFactory.CreateStream(); + Assert.NotNull(stream2); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + } + + /// + /// Property 16: State Isolation - multi-concurrency mode uses AsyncLocal. + /// Validates: Requirements 2.9, 2.10 + /// + [Fact] + public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() + { + // Initialize in multi-concurrency mode on main thread + ResponseStreamFactory.InitializeInvocation("req-9", MaxResponseSize, isMultiConcurrency: true); + var stream = ResponseStreamFactory.CreateStream(); + Assert.NotNull(stream); + + // A separate task should not see the main thread's context + // (AsyncLocal flows to child tasks, but a fresh Task.Run with new initialization should override) + bool childSawNull = false; + await Task.Run(() => + { + // Clean up the flowed context first + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; + }); + + Assert.True(childSawNull); + } + } +} From 0cdb15916b475b92379c84cab06da4c6383b9a61 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 11:51:17 -0800 Subject: [PATCH 04/47] Task 4 --- .../Client/InvocationResponse.cs | 26 ++++++ .../InvocationResponseTests.cs | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs index 1894b0521..4438c9708 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs @@ -34,6 +34,18 @@ public class InvocationResponse /// public bool DisposeOutputStream { get; private set; } = true; + /// + /// Indicates whether this response uses streaming mode. + /// Set internally by the runtime when ResponseStreamFactory.CreateStream() is called. + /// + internal bool IsStreaming { get; set; } + + /// + /// The ResponseStream instance if streaming mode is used. + /// Set internally by the runtime. + /// + internal ResponseStream ResponseStream { get; set; } + /// /// Construct a InvocationResponse with an output stream that will be disposed by the Lambda Runtime Client. /// @@ -52,6 +64,20 @@ public InvocationResponse(Stream outputStream, bool disposeOutputStream) { OutputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); DisposeOutputStream = disposeOutputStream; + IsStreaming = false; + } + + /// + /// Creates an InvocationResponse for a streaming response. + /// Used internally by the runtime. + /// + internal static InvocationResponse CreateStreamingResponse(ResponseStream responseStream) + { + return new InvocationResponse(Stream.Null, false) + { + IsStreaming = true, + ResponseStream = responseStream + }; } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs new file mode 100644 index 000000000..703ac0cd9 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs @@ -0,0 +1,81 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.IO; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + public class InvocationResponseTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + /// + /// Property 17: InvocationResponse Streaming Flag - Existing constructors set IsStreaming to false. + /// Validates: Requirements 7.3, 8.1 + /// + [Fact] + public void Constructor_WithStream_IsStreamingIsFalse() + { + var response = new InvocationResponse(new MemoryStream()); + + Assert.False(response.IsStreaming); + Assert.Null(response.ResponseStream); + } + + [Fact] + public void Constructor_WithStreamAndDispose_IsStreamingIsFalse() + { + var response = new InvocationResponse(new MemoryStream(), false); + + Assert.False(response.IsStreaming); + Assert.Null(response.ResponseStream); + } + + /// + /// Property 17: InvocationResponse Streaming Flag - CreateStreamingResponse sets IsStreaming to true. + /// Validates: Requirements 7.3, 8.1 + /// + [Fact] + public void CreateStreamingResponse_SetsIsStreamingTrue() + { + var stream = new ResponseStream(MaxResponseSize); + + var response = InvocationResponse.CreateStreamingResponse(stream); + + Assert.True(response.IsStreaming); + } + + [Fact] + public void CreateStreamingResponse_SetsResponseStream() + { + var stream = new ResponseStream(MaxResponseSize); + + var response = InvocationResponse.CreateStreamingResponse(stream); + + Assert.Same(stream, response.ResponseStream); + } + + [Fact] + public void CreateStreamingResponse_DoesNotDisposeOutputStream() + { + var stream = new ResponseStream(MaxResponseSize); + + var response = InvocationResponse.CreateStreamingResponse(stream); + + Assert.False(response.DisposeOutputStream); + } + } +} From 0aa892be011d2f10b774e3ff7cf1eaedbc2669ea Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 11:58:24 -0800 Subject: [PATCH 05/47] Task 5 --- .../Client/StreamingHttpContent.cs | 95 +++++++ .../StreamingHttpContentTests.cs | 264 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs new file mode 100644 index 000000000..c853ed5dd --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -0,0 +1,95 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// HttpContent implementation for streaming responses with chunked transfer encoding. + /// + internal class StreamingHttpContent : HttpContent + { + private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); + private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); + + private readonly ResponseStream _responseStream; + + public StreamingHttpContent(ResponseStream responseStream) + { + _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + foreach (var chunk in _responseStream.Chunks) + { + await WriteChunkAsync(stream, chunk); + } + + await WriteFinalChunkAsync(stream); + + if (_responseStream.HasError) + { + await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); + } + + await stream.FlushAsync(); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + + private async Task WriteChunkAsync(Stream stream, byte[] data) + { + var chunkSizeHex = data.Length.ToString("X"); + var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + + await stream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length); + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); + await stream.WriteAsync(data, 0, data.Length); + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); + } + + private async Task WriteFinalChunkAsync(Stream stream) + { + await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); + } + + private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) + { + var exceptionInfo = ExceptionInfo.GetExceptionInfo(exception); + + var errorTypeHeader = $"{StreamingConstants.ErrorTypeTrailer}: {exceptionInfo.ErrorType}\r\n"; + var errorTypeBytes = Encoding.UTF8.GetBytes(errorTypeHeader); + await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length); + + var errorBodyJson = LambdaJsonExceptionWriter.WriteJson(exceptionInfo); + var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; + var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); + await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length); + + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs new file mode 100644 index 000000000..0682a816e --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -0,0 +1,264 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + public class StreamingHttpContentTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + private async Task SerializeContentAsync(StreamingHttpContent content) + { + using var ms = new MemoryStream(); + await content.CopyToAsync(ms); + return ms.ToArray(); + } + + // --- Task 5.4: Chunked encoding format tests --- + + /// + /// Property 9: Chunked Encoding Format - chunks are formatted as size(hex) + CRLF + data + CRLF. + /// Validates: Requirements 4.3, 10.1, 10.2 + /// + [Theory] + [InlineData(1)] + [InlineData(10)] + [InlineData(255)] + [InlineData(4096)] + public async Task ChunkedEncoding_SingleChunk_CorrectFormat(int chunkSize) + { + var stream = new ResponseStream(MaxResponseSize); + var data = new byte[chunkSize]; + for (int i = 0; i < data.Length; i++) data[i] = (byte)(i % 256); + await stream.WriteAsync(data); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.ASCII.GetString(output); + + var expectedSizeHex = chunkSize.ToString("X"); + Assert.StartsWith(expectedSizeHex + "\r\n", outputStr); + + // Verify chunk data follows the size line + var dataStart = expectedSizeHex.Length + 2; // size + CRLF + for (int i = 0; i < chunkSize; i++) + { + Assert.Equal(data[i], output[dataStart + i]); + } + + // Verify CRLF after data + Assert.Equal((byte)'\r', output[dataStart + chunkSize]); + Assert.Equal((byte)'\n', output[dataStart + chunkSize + 1]); + } + + /// + /// Property 9: Chunked Encoding Format - multiple chunks each formatted correctly. + /// Validates: Requirements 4.3, 10.1 + /// + [Fact] + public async Task ChunkedEncoding_MultipleChunks_EachFormattedCorrectly() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); + await stream.WriteAsync(new byte[] { 0xCC }); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.ASCII.GetString(output); + + // First chunk: "2\r\n" + 2 bytes + "\r\n" + Assert.StartsWith("2\r\n", outputStr); + Assert.Equal(0xAA, output[3]); + Assert.Equal(0xBB, output[4]); + Assert.Equal((byte)'\r', output[5]); + Assert.Equal((byte)'\n', output[6]); + + // Second chunk: "1\r\n" + 1 byte + "\r\n" + Assert.Equal((byte)'1', output[7]); + Assert.Equal((byte)'\r', output[8]); + Assert.Equal((byte)'\n', output[9]); + Assert.Equal(0xCC, output[10]); + Assert.Equal((byte)'\r', output[11]); + Assert.Equal((byte)'\n', output[12]); + } + + /// + /// Property 20: Final Chunk Termination - final chunk "0\r\n" is written. + /// Validates: Requirements 10.2, 10.5 + /// + [Fact] + public async Task FinalChunk_IsWritten() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.ASCII.GetString(output); + + // Output should end with final chunk "0\r\n" + Assert.EndsWith("0\r\n", outputStr); + } + + [Fact] + public async Task FinalChunk_EmptyStream_OnlyFinalChunk() + { + var stream = new ResponseStream(MaxResponseSize); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + + Assert.Equal(Encoding.ASCII.GetBytes("0\r\n"), output); + } + + /// + /// Property 22: CRLF Line Terminators - all line terminators are CRLF, not just LF. + /// Validates: Requirements 10.5 + /// + [Fact] + public async Task CrlfTerminators_NoBareLineFeed() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + + // Check every \n is preceded by \r + for (int i = 0; i < output.Length; i++) + { + if (output[i] == (byte)'\n') + { + Assert.True(i > 0 && output[i - 1] == (byte)'\r', + $"Found bare LF at position {i} without preceding CR"); + } + } + } + + [Fact] + public void TryComputeLength_ReturnsFalse() + { + var stream = new ResponseStream(MaxResponseSize); + var content = new StreamingHttpContent(stream); + + var result = content.Headers.ContentLength; + + Assert.Null(result); + } + + // --- Task 5.6: Error trailer tests --- + + /// + /// Property 11: Midstream Error Type Trailer - error type trailer is included for various exception types. + /// Validates: Requirements 5.1, 5.2 + /// + [Theory] + [InlineData(typeof(InvalidOperationException))] + [InlineData(typeof(ArgumentException))] + [InlineData(typeof(NullReferenceException))] + public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); + await stream.ReportErrorAsync(exception); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); + } + + /// + /// Property 12: Midstream Error Body Trailer - error body trailer includes JSON exception details. + /// Validates: Requirements 5.3 + /// + [Fact] + public async Task ErrorTrailer_IncludesJsonErrorBody() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + Assert.Contains("Lambda-Runtime-Function-Error-Body:", outputStr); + Assert.Contains("something went wrong", outputStr); + Assert.Contains("InvalidOperationException", outputStr); + } + + /// + /// Property 21: Trailer Ordering - trailers appear after final chunk. + /// Validates: Requirements 10.3 + /// + [Fact] + public async Task ErrorTrailers_AppearAfterFinalChunk() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + var finalChunkIndex = outputStr.IndexOf("0\r\n"); + var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); + var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); + + Assert.True(finalChunkIndex >= 0, "Final chunk not found"); + Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); + Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); + } + + [Fact] + public async Task NoError_NoTrailersWritten() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + Assert.DoesNotContain("Lambda-Runtime-Function-Error-Type:", outputStr); + Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); + } + + [Fact] + public async Task ErrorTrailers_EndWithCrlf() + { + var stream = new ResponseStream(MaxResponseSize); + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + + var content = new StreamingHttpContent(stream); + var output = await SerializeContentAsync(content); + var outputStr = Encoding.UTF8.GetString(output); + + // Should end with final CRLF after trailers + Assert.EndsWith("\r\n", outputStr); + } + } +} From 5e29c2141e59813b05ecc93578badd2a13e8227c Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 18:05:11 -0800 Subject: [PATCH 06/47] Task 4 (after redesign) --- .../Client/ResponseStream.cs | 123 +++--- .../Client/ResponseStreamContext.cs | 19 + .../Client/StreamingHttpContent.cs | 34 +- .../ResponseStreamTests.cs | 268 +++++++++++-- .../StreamingHttpContentTests.cs | 353 +++++++++++------- 5 files changed, 546 insertions(+), 251 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs index 1484d1f8d..00f63cf75 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs @@ -14,62 +14,70 @@ */ using System; -using System.Collections.Generic; -using System.Linq; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; namespace Amazon.Lambda.RuntimeSupport { /// - /// Internal implementation of IResponseStream. - /// Buffers written data as chunks for HTTP chunked transfer encoding. + /// Internal implementation of IResponseStream with true streaming. + /// Writes data directly to the HTTP output stream as chunked transfer encoding. /// internal class ResponseStream : IResponseStream { + private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); + private readonly long _maxResponseSize; - private readonly List _chunks; private long _bytesWritten; private bool _isCompleted; private bool _hasError; private Exception _reportedError; private readonly object _lock = new object(); + // The live HTTP output stream, set by StreamingHttpContent when SerializeToStreamAsync is called. + private Stream _httpOutputStream; + private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); + private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + public long BytesWritten => _bytesWritten; public bool IsCompleted => _isCompleted; public bool HasError => _hasError; + internal Exception ReportedError => _reportedError; - internal IReadOnlyList Chunks + public ResponseStream(long maxResponseSize) { - get - { - lock (_lock) - { - return _chunks.ToList(); - } - } + _maxResponseSize = maxResponseSize; } - internal Exception ReportedError => _reportedError; + /// + /// Called by StreamingHttpContent.SerializeToStreamAsync to provide the HTTP output stream. + /// + internal void SetHttpOutputStream(Stream httpOutputStream) + { + _httpOutputStream = httpOutputStream; + _httpStreamReady.Release(); + } - public ResponseStream(long maxResponseSize) + /// + /// Called by StreamingHttpContent.SerializeToStreamAsync to wait until the handler + /// finishes writing (MarkCompleted or ReportErrorAsync). + /// + internal async Task WaitForCompletionAsync() { - _maxResponseSize = maxResponseSize; - _chunks = new List(); - _bytesWritten = 0; - _isCompleted = false; - _hasError = false; + await _completionSignal.WaitAsync(); } - public Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); - return WriteAsync(buffer, 0, buffer.Length, cancellationToken); + await WriteAsync(buffer, 0, buffer.Length, cancellationToken); } - public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); @@ -78,45 +86,45 @@ public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken c if (count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(nameof(count)); - lock (_lock) + // Wait for the HTTP stream to be ready (first write only blocks) + await _httpStreamReady.WaitAsync(cancellationToken); + try { - ThrowIfCompletedOrError(); - - if (_bytesWritten + count > _maxResponseSize) + lock (_lock) { - throw new InvalidOperationException( - $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + - $"Current size: {_bytesWritten} bytes."); - } - - var chunk = new byte[count]; - Array.Copy(buffer, offset, chunk, 0, count); - _chunks.Add(chunk); - _bytesWritten += count; - } + ThrowIfCompletedOrError(); - return Task.CompletedTask; - } - - public Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - lock (_lock) - { - ThrowIfCompletedOrError(); + if (_bytesWritten + count > _maxResponseSize) + { + throw new InvalidOperationException( + $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + + $"Current size: {_bytesWritten} bytes."); + } - if (_bytesWritten + buffer.Length > _maxResponseSize) - { - throw new InvalidOperationException( - $"Writing {buffer.Length} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + - $"Current size: {_bytesWritten} bytes."); + _bytesWritten += count; } - var chunk = buffer.ToArray(); - _chunks.Add(chunk); - _bytesWritten += buffer.Length; + // Write chunk directly to the HTTP stream: size(hex) + CRLF + data + CRLF + var chunkSizeHex = count.ToString("X"); + var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(buffer, offset, count, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.FlushAsync(cancellationToken); + } + finally + { + // Re-release so subsequent writes don't block + _httpStreamReady.Release(); } + } - return Task.CompletedTask; + public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + // Convert to array and delegate — small overhead but keeps the API simple + var array = buffer.ToArray(); + await WriteAsync(array, 0, array.Length, cancellationToken); } public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) @@ -135,6 +143,8 @@ public Task ReportErrorAsync(Exception exception, CancellationToken cancellation _reportedError = exception; } + // Signal completion so StreamingHttpContent can write error trailers and finish + _completionSignal.Release(); return Task.CompletedTask; } @@ -144,6 +154,8 @@ internal void MarkCompleted() { _isCompleted = true; } + // Signal completion so StreamingHttpContent can write the final chunk and finish + _completionSignal.Release(); } private void ThrowIfCompletedOrError() @@ -156,7 +168,8 @@ private void ThrowIfCompletedOrError() public void Dispose() { - // Nothing to dispose - all data is in managed memory + // Ensure completion is signaled if not already + try { _completionSignal.Release(); } catch (SemaphoreFullException) { } } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs index 07df616e3..dc0b4a629 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs @@ -13,6 +13,9 @@ * permissions and limitations under the License. */ +using System.Threading; +using System.Threading.Tasks; + namespace Amazon.Lambda.RuntimeSupport { /// @@ -39,5 +42,21 @@ internal class ResponseStreamContext /// The ResponseStream instance if created. /// public ResponseStream Stream { get; set; } + + /// + /// The RuntimeApiClient used to start the streaming HTTP POST. + /// + public RuntimeApiClient RuntimeApiClient { get; set; } + + /// + /// Cancellation token for the current invocation. + /// + public CancellationToken CancellationToken { get; set; } + + /// + /// The Task representing the in-flight HTTP POST to the Runtime API. + /// Started when CreateStream() is called, completes when the stream is finalized. + /// + public Task SendTask { get; set; } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index c853ed5dd..e563d343b 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -39,18 +39,24 @@ public StreamingHttpContent(ResponseStream responseStream) protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) { - foreach (var chunk in _responseStream.Chunks) - { - await WriteChunkAsync(stream, chunk); - } + // Hand the HTTP output stream to ResponseStream so WriteAsync calls + // can write chunks directly to it. + _responseStream.SetHttpOutputStream(stream); - await WriteFinalChunkAsync(stream); + // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) + await _responseStream.WaitForCompletionAsync(); + // Write final chunk + await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); + + // Write error trailers if present if (_responseStream.HasError) { await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); } + // Write final CRLF to end the chunked message + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); await stream.FlushAsync(); } @@ -60,22 +66,6 @@ protected override bool TryComputeLength(out long length) return false; } - private async Task WriteChunkAsync(Stream stream, byte[] data) - { - var chunkSizeHex = data.Length.ToString("X"); - var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - - await stream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length); - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); - await stream.WriteAsync(data, 0, data.Length); - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); - } - - private async Task WriteFinalChunkAsync(Stream stream) - { - await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); - } - private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) { var exceptionInfo = ExceptionInfo.GetExceptionInfo(exception); @@ -88,8 +78,6 @@ private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length); - - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 7503277ca..a6ef2fe6f 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -14,6 +14,10 @@ */ using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -23,6 +27,20 @@ public class ResponseStreamTests { private const long MaxResponseSize = 20 * 1024 * 1024; // 20 MiB + /// + /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. + /// Returns both so tests can inspect what was written. + /// + private static (ResponseStream stream, MemoryStream httpOutput) CreateWiredStream(long maxSize = MaxResponseSize) + { + var rs = new ResponseStream(maxSize); + var output = new MemoryStream(); + rs.SetHttpOutputStream(output); + return (rs, output); + } + + // ---- Basic state tests ---- + [Fact] public void Constructor_InitializesStateCorrectly() { @@ -31,94 +49,220 @@ public void Constructor_InitializesStateCorrectly() Assert.Equal(0, stream.BytesWritten); Assert.False(stream.IsCompleted); Assert.False(stream.HasError); - Assert.Empty(stream.Chunks); Assert.Null(stream.ReportedError); } - [Fact] - public async Task WriteAsync_ByteArray_BuffersDataCorrectly() + // ---- Chunked encoding format (Property 9, Property 22) ---- + + /// + /// Property 9: Chunked Encoding Format — each chunk is hex-size + CRLF + data + CRLF. + /// Property 22: CRLF Line Terminators — all line terminators are \r\n. + /// Validates: Requirements 3.2, 10.1, 10.5 + /// + [Theory] + [InlineData(new byte[] { 1, 2, 3 }, "3")] // 3 bytes → "3" + [InlineData(new byte[] { 0xFF }, "1")] // 1 byte → "1" + [InlineData(new byte[0], "0")] // 0 bytes → "0" + public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) { - var stream = new ResponseStream(MaxResponseSize); - var data = new byte[] { 1, 2, 3, 4, 5 }; + var (stream, httpOutput) = CreateWiredStream(); await stream.WriteAsync(data); - Assert.Equal(5, stream.BytesWritten); - Assert.Single(stream.Chunks); - Assert.Equal(data, stream.Chunks[0]); + var written = httpOutput.ToArray(); + var expected = Encoding.ASCII.GetBytes(expectedHexSize + "\r\n") + .Concat(data) + .Concat(Encoding.ASCII.GetBytes("\r\n")) + .ToArray(); + + Assert.Equal(expected, written); } + /// + /// Property 9: Chunked Encoding Format — verify with offset/count overload. + /// Validates: Requirements 3.2, 10.1 + /// [Fact] - public async Task WriteAsync_WithOffset_BuffersCorrectSlice() + public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, httpOutput) = CreateWiredStream(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); - Assert.Equal(3, stream.BytesWritten); - Assert.Equal(new byte[] { 1, 2, 3 }, stream.Chunks[0]); + var written = httpOutput.ToArray(); + // 3 bytes → hex "3", data is {1,2,3} + var expected = Encoding.ASCII.GetBytes("3\r\n") + .Concat(new byte[] { 1, 2, 3 }) + .Concat(Encoding.ASCII.GetBytes("\r\n")) + .ToArray(); + + Assert.Equal(expected, written); } + /// + /// Property 9: Chunked Encoding Format — ReadOnlyMemory overload. + /// Validates: Requirements 3.2, 10.1 + /// [Fact] - public async Task WriteAsync_ReadOnlyMemory_BuffersDataCorrectly() + public async Task WriteAsync_ReadOnlyMemory_WritesChunkedFormat() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, httpOutput) = CreateWiredStream(); var data = new ReadOnlyMemory(new byte[] { 10, 20, 30 }); await stream.WriteAsync(data); + var written = httpOutput.ToArray(); + var expected = Encoding.ASCII.GetBytes("3\r\n") + .Concat(new byte[] { 10, 20, 30 }) + .Concat(Encoding.ASCII.GetBytes("\r\n")) + .ToArray(); + + Assert.Equal(expected, written); + } + + // ---- Property 5: Written Data Appears in HTTP Response Immediately ---- + + /// + /// Property 5: Written Data Appears in HTTP Response Immediately — + /// each WriteAsync call writes to the HTTP stream before returning. + /// Validates: Requirements 3.2 + /// + [Fact] + public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() + { + var (stream, httpOutput) = CreateWiredStream(); + + await stream.WriteAsync(new byte[] { 0xAA }); + var afterFirst = httpOutput.ToArray().Length; + Assert.True(afterFirst > 0, "First chunk should be on the HTTP stream immediately after WriteAsync returns"); + + await stream.WriteAsync(new byte[] { 0xBB, 0xCC }); + var afterSecond = httpOutput.ToArray().Length; + Assert.True(afterSecond > afterFirst, "Second chunk should appear on the HTTP stream immediately"); + Assert.Equal(3, stream.BytesWritten); - Assert.Equal(new byte[] { 10, 20, 30 }, stream.Chunks[0]); } + /// + /// Property 5: Written Data Appears in HTTP Response Immediately — + /// verify with a larger payload that hex size is multi-character. + /// Validates: Requirements 3.2 + /// [Fact] - public async Task WriteAsync_MultipleWrites_AccumulatesBytesWritten() + public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, httpOutput) = CreateWiredStream(); + var data = new byte[256]; // 0x100 + + await stream.WriteAsync(data); - await stream.WriteAsync(new byte[100]); - await stream.WriteAsync(new byte[200]); - await stream.WriteAsync(new byte[300]); + var written = Encoding.ASCII.GetString(httpOutput.ToArray()); + Assert.StartsWith("100\r\n", written); + } + + // ---- Semaphore coordination: _httpStreamReady blocks until SetHttpOutputStream ---- - Assert.Equal(600, stream.BytesWritten); - Assert.Equal(3, stream.Chunks.Count); + /// + /// Test that WriteAsync blocks until SetHttpOutputStream is called. + /// Validates: Requirements 3.2, 10.1 + /// + [Fact] + public async Task WriteAsync_BlocksUntilSetHttpOutputStream() + { + var rs = new ResponseStream(MaxResponseSize); + var httpOutput = new MemoryStream(); + var writeStarted = new ManualResetEventSlim(false); + var writeCompleted = new ManualResetEventSlim(false); + + // Start a write on a background thread — it should block + var writeTask = Task.Run(async () => + { + writeStarted.Set(); + await rs.WriteAsync(new byte[] { 1, 2, 3 }); + writeCompleted.Set(); + }); + + // Wait for the write to start, then verify it hasn't completed + writeStarted.Wait(TimeSpan.FromSeconds(2)); + await Task.Delay(100); // give it a moment + Assert.False(writeCompleted.IsSet, "WriteAsync should block until SetHttpOutputStream is called"); + + // Now provide the HTTP stream — the write should complete + rs.SetHttpOutputStream(httpOutput); + await writeTask; + + Assert.True(writeCompleted.IsSet); + Assert.True(httpOutput.ToArray().Length > 0); } + // ---- Completion signaling: MarkCompleted releases _completionSignal ---- + + /// + /// Test that MarkCompleted releases the completion signal (WaitForCompletionAsync unblocks). + /// Validates: Requirements 5.5, 8.3 + /// [Fact] - public async Task WriteAsync_CopiesData_AvoidingBufferReuseIssues() + public async Task MarkCompleted_ReleasesCompletionSignal() { - var stream = new ResponseStream(MaxResponseSize); - var buffer = new byte[] { 1, 2, 3 }; + var (stream, _) = CreateWiredStream(); + + var waitTask = stream.WaitForCompletionAsync(); + Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before MarkCompleted"); - await stream.WriteAsync(buffer); - buffer[0] = 99; // mutate original + stream.MarkCompleted(); - Assert.Equal(1, stream.Chunks[0][0]); // chunk should be unaffected + // Should complete within a reasonable time + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + Assert.True(stream.IsCompleted); + } + + // ---- Completion signaling: ReportErrorAsync releases _completionSignal ---- + + /// + /// Test that ReportErrorAsync releases the completion signal. + /// Validates: Requirements 5.5 + /// + [Fact] + public async Task ReportErrorAsync_ReleasesCompletionSignal() + { + var (stream, _) = CreateWiredStream(); + + var waitTask = stream.WaitForCompletionAsync(); + Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); + + await stream.ReportErrorAsync(new Exception("test error")); + + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + Assert.True(stream.HasError); } + // ---- Property 6: Size Limit Enforcement ---- + /// - /// Property 6: Size Limit Enforcement - Writing beyond 20 MiB throws InvalidOperationException. + /// Property 6: Size Limit Enforcement — single write exceeding limit throws. /// Validates: Requirements 3.6, 3.7 /// [Theory] - [InlineData(21 * 1024 * 1024)] // Single write exceeding limit + [InlineData(21 * 1024 * 1024)] public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); var data = new byte[writeSize]; await Assert.ThrowsAsync(() => stream.WriteAsync(data)); } /// - /// Property 6: Size Limit Enforcement - Multiple writes exceeding 20 MiB throws. + /// Property 6: Size Limit Enforcement — multiple writes exceeding limit throws. /// Validates: Requirements 3.6, 3.7 /// [Fact] public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[10 * 1024 * 1024]); await Assert.ThrowsAsync( @@ -128,7 +272,7 @@ await Assert.ThrowsAsync( [Fact] public async Task SizeLimit_ExactlyAtLimit_Succeeds() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); var data = new byte[20 * 1024 * 1024]; await stream.WriteAsync(data); @@ -136,14 +280,16 @@ public async Task SizeLimit_ExactlyAtLimit_Succeeds() Assert.Equal(MaxResponseSize, stream.BytesWritten); } + // ---- Property 19: Writes After Completion Rejected ---- + /// - /// Property 19: Writes After Completion Rejected - Writes after completion throw InvalidOperationException. + /// Property 19: Writes After Completion Rejected — writes after MarkCompleted throw. /// Validates: Requirements 8.8 /// [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }); stream.MarkCompleted(); @@ -151,10 +297,14 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 })); } + /// + /// Property 19: Writes After Completion Rejected — writes after ReportErrorAsync throw. + /// Validates: Requirements 8.8 + /// [Fact] public async Task WriteAsync_AfterReportError_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }); await stream.ReportErrorAsync(new Exception("test")); @@ -162,7 +312,7 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 })); } - // --- Error handling tests (2.6) --- + // ---- Error handling tests ---- [Fact] public async Task ReportErrorAsync_SetsErrorState() @@ -205,5 +355,47 @@ public void MarkCompleted_SetsCompletionState() Assert.True(stream.IsCompleted); } + + // ---- Argument validation ---- + + [Fact] + public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() + { + var (stream, _) = CreateWiredStream(); + + await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null)); + } + + [Fact] + public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() + { + var (stream, _) = CreateWiredStream(); + + await Assert.ThrowsAsync(() => stream.WriteAsync(null, 0, 0)); + } + + [Fact] + public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() + { + var stream = new ResponseStream(MaxResponseSize); + + await Assert.ThrowsAsync(() => stream.ReportErrorAsync(null)); + } + + // ---- Dispose signals completion ---- + + [Fact] + public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() + { + var stream = new ResponseStream(MaxResponseSize); + + var waitTask = stream.WaitForCompletionAsync(); + Assert.False(waitTask.IsCompleted); + + stream.Dispose(); + + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 0682a816e..53b1e88b7 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -16,6 +16,7 @@ using System; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -25,149 +26,182 @@ public class StreamingHttpContentTests { private const long MaxResponseSize = 20 * 1024 * 1024; - private async Task SerializeContentAsync(StreamingHttpContent content) + /// + /// Helper: runs SerializeToStreamAsync concurrently with handler actions. + /// The handlerAction receives the ResponseStream and should write data then signal completion. + /// Returns the bytes written to the HTTP output stream. + /// + private async Task SerializeWithConcurrentHandler( + ResponseStream responseStream, + Func handlerAction) { - using var ms = new MemoryStream(); - await content.CopyToAsync(ms); - return ms.ToArray(); + var content = new StreamingHttpContent(responseStream); + var outputStream = new MemoryStream(); + + // Start serialization on a background task (it will call SetHttpOutputStream and wait) + var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); + + // Give SerializeToStreamAsync a moment to start and call SetHttpOutputStream + await Task.Delay(50); + + // Run the handler action (writes data, signals completion) + await handlerAction(responseStream); + + // Wait for serialization to complete + await serializeTask; + + return outputStream.ToArray(); } - // --- Task 5.4: Chunked encoding format tests --- + // ---- SerializeToStreamAsync hands off HTTP stream ---- /// - /// Property 9: Chunked Encoding Format - chunks are formatted as size(hex) + CRLF + data + CRLF. - /// Validates: Requirements 4.3, 10.1, 10.2 + /// Test that SerializeToStreamAsync calls SetHttpOutputStream on the ResponseStream, + /// enabling writes to flow through. + /// Validates: Requirements 4.3, 10.1 /// - [Theory] - [InlineData(1)] - [InlineData(10)] - [InlineData(255)] - [InlineData(4096)] - public async Task ChunkedEncoding_SingleChunk_CorrectFormat(int chunkSize) + [Fact] + public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { - var stream = new ResponseStream(MaxResponseSize); - var data = new byte[chunkSize]; - for (int i = 0; i < data.Length; i++) data[i] = (byte)(i % 256); - await stream.WriteAsync(data); - - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.ASCII.GetString(output); + var rs = new ResponseStream(MaxResponseSize); - var expectedSizeHex = chunkSize.ToString("X"); - Assert.StartsWith(expectedSizeHex + "\r\n", outputStr); - - // Verify chunk data follows the size line - var dataStart = expectedSizeHex.Length + 2; // size + CRLF - for (int i = 0; i < chunkSize; i++) + var output = await SerializeWithConcurrentHandler(rs, async stream => { - Assert.Equal(data[i], output[dataStart + i]); - } + await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); + stream.MarkCompleted(); + }); - // Verify CRLF after data - Assert.Equal((byte)'\r', output[dataStart + chunkSize]); - Assert.Equal((byte)'\n', output[dataStart + chunkSize + 1]); + var outputStr = Encoding.ASCII.GetString(output); + // Should contain the chunk data written by the handler + Assert.Contains("2\r\n", outputStr); + Assert.True(output.Length > 0); } /// - /// Property 9: Chunked Encoding Format - multiple chunks each formatted correctly. - /// Validates: Requirements 4.3, 10.1 + /// Test that SerializeToStreamAsync blocks until MarkCompleted is called. + /// Validates: Requirements 4.3 /// [Fact] - public async Task ChunkedEncoding_MultipleChunks_EachFormattedCorrectly() + public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); - await stream.WriteAsync(new byte[] { 0xCC }); + var rs = new ResponseStream(MaxResponseSize); + var content = new StreamingHttpContent(rs); + var outputStream = new MemoryStream(); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.ASCII.GetString(output); + var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); + await Task.Delay(50); + + // Serialization should still be running (waiting for completion) + Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until completion is signaled"); + + // Now signal completion + rs.MarkCompleted(); + await serializeTask; - // First chunk: "2\r\n" + 2 bytes + "\r\n" - Assert.StartsWith("2\r\n", outputStr); - Assert.Equal(0xAA, output[3]); - Assert.Equal(0xBB, output[4]); - Assert.Equal((byte)'\r', output[5]); - Assert.Equal((byte)'\n', output[6]); - - // Second chunk: "1\r\n" + 1 byte + "\r\n" - Assert.Equal((byte)'1', output[7]); - Assert.Equal((byte)'\r', output[8]); - Assert.Equal((byte)'\n', output[9]); - Assert.Equal(0xCC, output[10]); - Assert.Equal((byte)'\r', output[11]); - Assert.Equal((byte)'\n', output[12]); + Assert.True(serializeTask.IsCompleted); } /// - /// Property 20: Final Chunk Termination - final chunk "0\r\n" is written. - /// Validates: Requirements 10.2, 10.5 + /// Test that SerializeToStreamAsync blocks until ReportErrorAsync is called. + /// Validates: Requirements 4.3, 5.1 /// [Fact] - public async Task FinalChunk_IsWritten() + public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); + var rs = new ResponseStream(MaxResponseSize); + var content = new StreamingHttpContent(rs); + var outputStream = new MemoryStream(); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.ASCII.GetString(output); + var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); + await Task.Delay(50); - // Output should end with final chunk "0\r\n" - Assert.EndsWith("0\r\n", outputStr); + Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until error is reported"); + + await rs.ReportErrorAsync(new Exception("test error")); + await serializeTask; + + Assert.True(serializeTask.IsCompleted); } + // ---- Property 20: Final Chunk Termination ---- + + /// + /// Property 20: Final Chunk Termination — final chunk "0\r\n" is written after completion. + /// Validates: Requirements 4.3, 10.2, 10.3 + /// [Fact] - public async Task FinalChunk_EmptyStream_OnlyFinalChunk() + public async Task FinalChunk_WrittenAfterCompletion() { - var stream = new ResponseStream(MaxResponseSize); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + }); - Assert.Equal(Encoding.ASCII.GetBytes("0\r\n"), output); + var outputStr = Encoding.ASCII.GetString(output); + Assert.Contains("0\r\n", outputStr); + + // Final chunk should appear after the data chunk + var dataChunkEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; // "1\r\n" + 1 byte data + "\r\n" + var finalChunkIndex = outputStr.IndexOf("0\r\n", dataChunkEnd); + Assert.True(finalChunkIndex >= 0, "Final chunk 0\\r\\n should appear after data chunks"); } /// - /// Property 22: CRLF Line Terminators - all line terminators are CRLF, not just LF. - /// Validates: Requirements 10.5 + /// Property 20: Final Chunk Termination — empty stream still gets final chunk. + /// Validates: Requirements 10.2 /// [Fact] - public async Task CrlfTerminators_NoBareLineFeed() + public async Task FinalChunk_EmptyStream_StillWritten() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - - // Check every \n is preceded by \r - for (int i = 0; i < output.Length; i++) + var output = await SerializeWithConcurrentHandler(rs, stream => { - if (output[i] == (byte)'\n') - { - Assert.True(i > 0 && output[i - 1] == (byte)'\r', - $"Found bare LF at position {i} without preceding CR"); - } - } + stream.MarkCompleted(); + return Task.CompletedTask; + }); + + var outputStr = Encoding.ASCII.GetString(output); + Assert.StartsWith("0\r\n", outputStr); } + // ---- Property 21: Trailer Ordering ---- + + /// + /// Property 21: Trailer Ordering — trailers appear after final chunk. + /// Validates: Requirements 10.3 + /// [Fact] - public void TryComputeLength_ReturnsFalse() + public async Task ErrorTrailers_AppearAfterFinalChunk() { - var stream = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); + var rs = new ResponseStream(MaxResponseSize); - var result = content.Headers.ContentLength; + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + }); - Assert.Null(result); + var outputStr = Encoding.UTF8.GetString(output); + + // Find the final chunk "0\r\n" that appears after data chunks + var dataEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; + var finalChunkIndex = outputStr.IndexOf("0\r\n", dataEnd); + var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); + var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); + + Assert.True(finalChunkIndex >= 0, "Final chunk not found"); + Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); + Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); } - // --- Task 5.6: Error trailer tests --- + // ---- Property 11: Midstream Error Type Trailer ---- /// - /// Property 11: Midstream Error Type Trailer - error type trailer is included for various exception types. + /// Property 11: Midstream Error Type Trailer — error type trailer is included for various exception types. /// Validates: Requirements 5.1, 5.2 /// [Theory] @@ -176,89 +210,138 @@ public void TryComputeLength_ReturnsFalse() [InlineData(typeof(NullReferenceException))] public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); - await stream.ReportErrorAsync(exception); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); + await stream.ReportErrorAsync(exception); + }); + var outputStr = Encoding.UTF8.GetString(output); Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); } + // ---- Property 12: Midstream Error Body Trailer ---- + /// - /// Property 12: Midstream Error Body Trailer - error body trailer includes JSON exception details. + /// Property 12: Midstream Error Body Trailer — error body trailer includes JSON exception details. /// Validates: Requirements 5.3 /// [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + }); + var outputStr = Encoding.UTF8.GetString(output); Assert.Contains("Lambda-Runtime-Function-Error-Body:", outputStr); Assert.Contains("something went wrong", outputStr); Assert.Contains("InvalidOperationException", outputStr); } + // ---- Final CRLF termination ---- + /// - /// Property 21: Trailer Ordering - trailers appear after final chunk. - /// Validates: Requirements 10.3 + /// Test that the chunked message ends with CRLF after successful completion (no trailers). + /// Validates: Requirements 10.2, 10.5 /// [Fact] - public async Task ErrorTrailers_AppearAfterFinalChunk() + public async Task SuccessfulCompletion_EndsWithCrlf() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + }); - var finalChunkIndex = outputStr.IndexOf("0\r\n"); - var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); - var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); + var outputStr = Encoding.ASCII.GetString(output); + // Should end with "0\r\n" (final chunk) + "\r\n" (end of message) + Assert.EndsWith("0\r\n\r\n", outputStr); + } - Assert.True(finalChunkIndex >= 0, "Final chunk not found"); - Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); - Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); + /// + /// Test that the chunked message ends with CRLF after error trailers. + /// Validates: Requirements 10.3, 10.5 + /// + [Fact] + public async Task ErrorCompletion_EndsWithCrlf() + { + var rs = new ResponseStream(MaxResponseSize); + + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + await stream.ReportErrorAsync(new Exception("fail")); + }); + + var outputStr = Encoding.UTF8.GetString(output); + Assert.EndsWith("\r\n", outputStr); } + // ---- No error, no trailers ---- + [Fact] public async Task NoError_NoTrailersWritten() { - var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); + var rs = new ResponseStream(MaxResponseSize); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 1 }); + stream.MarkCompleted(); + }); + var outputStr = Encoding.UTF8.GetString(output); Assert.DoesNotContain("Lambda-Runtime-Function-Error-Type:", outputStr); Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); } + // ---- TryComputeLength ---- + [Fact] - public async Task ErrorTrailers_EndWithCrlf() + public void TryComputeLength_ReturnsFalse() { var stream = new ResponseStream(MaxResponseSize); - await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); - var content = new StreamingHttpContent(stream); - var output = await SerializeContentAsync(content); - var outputStr = Encoding.UTF8.GetString(output); - // Should end with final CRLF after trailers - Assert.EndsWith("\r\n", outputStr); + var result = content.Headers.ContentLength; + Assert.Null(result); + } + + // ---- CRLF correctness ---- + + /// + /// Property 22: CRLF Line Terminators — all line terminators are CRLF, not just LF. + /// Validates: Requirements 10.5 + /// + [Fact] + public async Task CrlfTerminators_NoBareLineFeed() + { + var rs = new ResponseStream(MaxResponseSize); + + var output = await SerializeWithConcurrentHandler(rs, async stream => + { + await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" + stream.MarkCompleted(); + }); + + for (int i = 0; i < output.Length; i++) + { + if (output[i] == (byte)'\n') + { + Assert.True(i > 0 && output[i - 1] == (byte)'\r', + $"Found bare LF at position {i} without preceding CR"); + } + } } } } From 603612d1635ebc5fcf6091c30afb005605f7abd4 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 18:14:44 -0800 Subject: [PATCH 07/47] Task 5 --- .../Client/ResponseStreamFactory.cs | 25 +++- .../Client/RuntimeApiClient.cs | 41 ++++++ .../ResponseStreamFactoryTests.cs | 135 +++++++++++++++--- .../NoOpInternalRuntimeApiClient.cs | 60 ++++++++ 4 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs index 9b60eacfd..613980fb1 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs @@ -15,6 +15,7 @@ using System; using System.Threading; +using System.Threading.Tasks; namespace Amazon.Lambda.RuntimeSupport { @@ -57,19 +58,29 @@ public static IResponseStream CreateStream() context.Stream = stream; context.StreamCreated = true; + // Start the HTTP POST to the Runtime API. + // This runs concurrently — SerializeToStreamAsync will block + // until the handler finishes writing or reports an error. + context.SendTask = context.RuntimeApiClient.StartStreamingResponseAsync( + context.AwsRequestId, stream, context.CancellationToken); + return stream; } // Internal methods for LambdaBootstrap to manage state - internal static void InitializeInvocation(string awsRequestId, long maxResponseSize, bool isMultiConcurrency) + internal static void InitializeInvocation( + string awsRequestId, long maxResponseSize, bool isMultiConcurrency, + RuntimeApiClient runtimeApiClient, CancellationToken cancellationToken) { var context = new ResponseStreamContext { AwsRequestId = awsRequestId, MaxResponseSize = maxResponseSize, StreamCreated = false, - Stream = null + Stream = null, + RuntimeApiClient = runtimeApiClient, + CancellationToken = cancellationToken }; if (isMultiConcurrency) @@ -88,6 +99,16 @@ internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) return context?.Stream; } + /// + /// Returns the Task for the in-flight HTTP send, or null if streaming wasn't started. + /// LambdaBootstrap awaits this after the handler returns to ensure the HTTP request completes. + /// + internal static Task GetSendTask(bool isMultiConcurrency) + { + var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; + return context?.SendTask; + } + internal static void CleanupInvocation(bool isMultiConcurrency) { if (isMultiConcurrency) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index daa9fff24..13c4e4eac 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -177,6 +177,47 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null #endif + /// + /// Start sending a streaming response to the Runtime API. + /// This initiates the HTTP POST with streaming headers. The actual data + /// is written by the handler via ResponseStream.WriteAsync, which flows + /// through StreamingHttpContent to the HTTP connection. + /// This Task completes when the stream is finalized (MarkCompleted or error). + /// + /// The ID of the function request being responded to. + /// The ResponseStream that will provide the streaming data. + /// The optional cancellation token to use. + /// A Task representing the in-flight HTTP POST. + internal virtual async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); + if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); + + var url = $"http://{LambdaEnvironment.RuntimeServerHostAndPort}/2018-06-01/runtime/invocation/{awsRequestId}/response"; + + using (var request = new HttpRequestMessage(HttpMethod.Post, url)) + { + request.Headers.Add(StreamingConstants.ResponseModeHeader, StreamingConstants.StreamingResponseMode); + request.Headers.TransferEncodingChunked = true; + + // Declare trailers upfront — we always declare them since we don't know + // at request start time whether an error will occur mid-stream. + request.Headers.Add("Trailer", + $"{StreamingConstants.ErrorTypeTrailer}, {StreamingConstants.ErrorBodyTrailer}"); + + request.Content = new StreamingHttpContent(responseStream); + + // SendAsync calls SerializeToStreamAsync, which blocks until the handler + // finishes writing. This is why this method runs concurrently with the handler. + var response = await _httpClient.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + responseStream.MarkCompleted(); + } + /// /// Send a response to a function invocation to the Runtime API as an asynchronous operation. /// diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index a4b0558af..11973ae5f 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -14,6 +14,7 @@ */ using System; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -30,7 +31,40 @@ public void Dispose() ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } - // --- Task 3.3: CreateStream tests --- + /// + /// A minimal RuntimeApiClient subclass for testing that overrides StartStreamingResponseAsync + /// to avoid real HTTP calls while tracking invocations. + /// + private class MockStreamingRuntimeApiClient : RuntimeApiClient + { + public bool StartStreamingCalled { get; private set; } + public string LastAwsRequestId { get; private set; } + public ResponseStream LastResponseStream { get; private set; } + public TaskCompletionSource SendTaskCompletion { get; } = new TaskCompletionSource(); + + public MockStreamingRuntimeApiClient() + : base(new TestEnvironmentVariables(), new TestHelpers.NoOpInternalRuntimeApiClient()) + { + } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + StartStreamingCalled = true; + LastAwsRequestId = awsRequestId; + LastResponseStream = responseStream; + await SendTaskCompletion.Task; + } + } + + private void InitializeWithMock(string requestId, bool isMultiConcurrency, MockStreamingRuntimeApiClient mockClient) + { + ResponseStreamFactory.InitializeInvocation( + requestId, MaxResponseSize, isMultiConcurrency, + mockClient, CancellationToken.None); + } + + // --- Property 1: CreateStream Returns Valid Stream --- /// /// Property 1: CreateStream Returns Valid Stream - on-demand mode. @@ -39,7 +73,8 @@ public void Dispose() [Fact] public void CreateStream_OnDemandMode_ReturnsValidStream() { - ResponseStreamFactory.InitializeInvocation("req-1", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-1", isMultiConcurrency: false, mock); var stream = ResponseStreamFactory.CreateStream(); @@ -54,7 +89,8 @@ public void CreateStream_OnDemandMode_ReturnsValidStream() [Fact] public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() { - ResponseStreamFactory.InitializeInvocation("req-2", MaxResponseSize, isMultiConcurrency: true); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-2", isMultiConcurrency: true, mock); var stream = ResponseStreamFactory.CreateStream(); @@ -62,6 +98,8 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() Assert.IsAssignableFrom(stream); } + // --- Property 4: Single Stream Per Invocation --- + /// /// Property 4: Single Stream Per Invocation - calling CreateStream twice throws. /// Validates: Requirements 2.5, 2.6 @@ -69,7 +107,8 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() [Fact] public void CreateStream_CalledTwice_ThrowsInvalidOperationException() { - ResponseStreamFactory.InitializeInvocation("req-3", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-3", isMultiConcurrency: false, mock); ResponseStreamFactory.CreateStream(); Assert.Throws(() => ResponseStreamFactory.CreateStream()); @@ -82,17 +121,69 @@ public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationExceptio Assert.Throws(() => ResponseStreamFactory.CreateStream()); } - // --- Task 3.5: Internal methods tests --- + // --- CreateStream starts HTTP POST --- + + /// + /// Validates that CreateStream calls StartStreamingResponseAsync on the RuntimeApiClient. + /// Validates: Requirements 1.3, 1.4, 2.2, 2.3, 2.4 + /// + [Fact] + public void CreateStream_CallsStartStreamingResponseAsync() + { + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-start", isMultiConcurrency: false, mock); + + ResponseStreamFactory.CreateStream(); + + Assert.True(mock.StartStreamingCalled); + Assert.Equal("req-start", mock.LastAwsRequestId); + Assert.NotNull(mock.LastResponseStream); + } + + // --- GetSendTask --- + + /// + /// Validates that GetSendTask returns the task from the HTTP POST. + /// Validates: Requirements 5.1, 7.3 + /// + [Fact] + public void GetSendTask_AfterCreateStream_ReturnsNonNullTask() + { + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-send", isMultiConcurrency: false, mock); + + ResponseStreamFactory.CreateStream(); + + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + Assert.NotNull(sendTask); + } + + [Fact] + public void GetSendTask_BeforeCreateStream_ReturnsNull() + { + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-nosend", isMultiConcurrency: false, mock); + + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + Assert.Null(sendTask); + } + + [Fact] + public void GetSendTask_NoContext_ReturnsNull() + { + Assert.Null(ResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); + } + + // --- Internal methods --- [Fact] public void InitializeInvocation_OnDemand_SetsUpContext() { - ResponseStreamFactory.InitializeInvocation("req-4", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-4", isMultiConcurrency: false, mock); - // GetStreamIfCreated should return null since CreateStream hasn't been called Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - // But CreateStream should work (proving context was set up) var stream = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream); } @@ -100,7 +191,8 @@ public void InitializeInvocation_OnDemand_SetsUpContext() [Fact] public void InitializeInvocation_MultiConcurrency_SetsUpContext() { - ResponseStreamFactory.InitializeInvocation("req-5", MaxResponseSize, isMultiConcurrency: true); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-5", isMultiConcurrency: true, mock); Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); @@ -111,11 +203,11 @@ public void InitializeInvocation_MultiConcurrency_SetsUpContext() [Fact] public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() { - ResponseStreamFactory.InitializeInvocation("req-6", MaxResponseSize, isMultiConcurrency: false); - var created = ResponseStreamFactory.CreateStream(); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-6", isMultiConcurrency: false, mock); + ResponseStreamFactory.CreateStream(); var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); - Assert.NotNull(retrieved); } @@ -128,7 +220,8 @@ public void GetStreamIfCreated_NoContext_ReturnsNull() [Fact] public void CleanupInvocation_ClearsState() { - ResponseStreamFactory.InitializeInvocation("req-7", MaxResponseSize, isMultiConcurrency: false); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-7", isMultiConcurrency: false, mock); ResponseStreamFactory.CreateStream(); ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); @@ -137,6 +230,8 @@ public void CleanupInvocation_ClearsState() Assert.Throws(() => ResponseStreamFactory.CreateStream()); } + // --- Property 16: State Isolation Between Invocations --- + /// /// Property 16: State Isolation Between Invocations - state from one invocation doesn't leak to the next. /// Validates: Requirements 6.5, 8.9 @@ -144,17 +239,18 @@ public void CleanupInvocation_ClearsState() [Fact] public void StateIsolation_SequentialInvocations_NoLeakage() { + var mock = new MockStreamingRuntimeApiClient(); + // First invocation - streaming - ResponseStreamFactory.InitializeInvocation("req-8a", MaxResponseSize, isMultiConcurrency: false); + InitializeWithMock("req-8a", isMultiConcurrency: false, mock); var stream1 = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream1); ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); // Second invocation - should start fresh - ResponseStreamFactory.InitializeInvocation("req-8b", MaxResponseSize, isMultiConcurrency: false); + InitializeWithMock("req-8b", isMultiConcurrency: false, mock); Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - // Should be able to create a new stream var stream2 = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream2); ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); @@ -167,17 +263,14 @@ public void StateIsolation_SequentialInvocations_NoLeakage() [Fact] public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() { - // Initialize in multi-concurrency mode on main thread - ResponseStreamFactory.InitializeInvocation("req-9", MaxResponseSize, isMultiConcurrency: true); + var mock = new MockStreamingRuntimeApiClient(); + InitializeWithMock("req-9", isMultiConcurrency: true, mock); var stream = ResponseStreamFactory.CreateStream(); Assert.NotNull(stream); - // A separate task should not see the main thread's context - // (AsyncLocal flows to child tasks, but a fresh Task.Run with new initialization should override) bool childSawNull = false; await Task.Run(() => { - // Clean up the flowed context first ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; }); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs new file mode 100644 index 000000000..9fa0434cd --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/NoOpInternalRuntimeApiClient.cs @@ -0,0 +1,60 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers +{ + /// + /// A no-op implementation of IInternalRuntimeApiClient for unit tests + /// that need to construct a RuntimeApiClient without real HTTP calls. + /// + internal class NoOpInternalRuntimeApiClient : IInternalRuntimeApiClient + { + private static readonly SwaggerResponse EmptyStatusResponse = + new SwaggerResponse(200, new Dictionary>(), new StatusResponse()); + + public Task> ErrorAsync( + string lambda_Runtime_Function_Error_Type, string errorJson, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); + + public Task> NextAsync(CancellationToken cancellationToken) + => Task.FromResult(new SwaggerResponse(200, new Dictionary>(), Stream.Null)); + + public Task> ResponseAsync(string awsRequestId, Stream outputStream) + => Task.FromResult(EmptyStatusResponse); + + public Task> ResponseAsync( + string awsRequestId, Stream outputStream, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); + + public Task> ErrorWithXRayCauseAsync( + string awsRequestId, string lambda_Runtime_Function_Error_Type, + string errorJson, string xrayCause, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); + +#if NET8_0_OR_GREATER + public Task> RestoreNextAsync(CancellationToken cancellationToken) + => Task.FromResult(new SwaggerResponse(200, new Dictionary>(), Stream.Null)); + + public Task> RestoreErrorAsync( + string lambda_Runtime_Function_Error_Type, string errorJson, CancellationToken cancellationToken) + => Task.FromResult(EmptyStatusResponse); +#endif + } +} From 63224bf43b63d1edee3b92576d70c965bbaa60e7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 16 Feb 2026 23:44:57 -0800 Subject: [PATCH 08/47] Task 6 --- .../RuntimeApiClientTests.cs | 223 ++++ .../serverless.template | 659 +--------- .../serverless.template | 22 +- .../TestServerlessApp/serverless.template | 1149 +---------------- 4 files changed, 228 insertions(+), 1825 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs new file mode 100644 index 000000000..75abec101 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -0,0 +1,223 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + /// + /// Tests for RuntimeApiClient streaming and buffered behavior. + /// Validates Properties 7, 8, 10, 13, 18. + /// + public class RuntimeApiClientTests + { + private const long MaxResponseSize = 20 * 1024 * 1024; + + /// + /// Mock HttpMessageHandler that captures the request for header inspection. + /// It completes the ResponseStream and returns immediately without reading + /// the content body, avoiding the SerializeToStreamAsync blocking issue. + /// + private class MockHttpMessageHandler : HttpMessageHandler + { + public HttpRequestMessage CapturedRequest { get; private set; } + private readonly ResponseStream _responseStream; + + public MockHttpMessageHandler(ResponseStream responseStream) + { + _responseStream = responseStream; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedRequest = request; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + private static RuntimeApiClient CreateClientWithMockHandler( + ResponseStream stream, out MockHttpMessageHandler handler) + { + handler = new MockHttpMessageHandler(stream); + var httpClient = new HttpClient(handler); + var envVars = new TestEnvironmentVariables(); + envVars.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", "localhost:9001"); + return new RuntimeApiClient(envVars, httpClient); + } + + // --- Property 7: Streaming Response Mode Header --- + + /// + /// Property 7: Streaming Response Mode Header + /// For any streaming response, the HTTP request should include + /// "Lambda-Runtime-Function-Response-Mode: streaming". + /// **Validates: Requirements 4.1** + /// + [Fact] + public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeader() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out var handler); + + await client.StartStreamingResponseAsync("req-1", stream, CancellationToken.None); + + Assert.NotNull(handler.CapturedRequest); + Assert.True(handler.CapturedRequest.Headers.Contains(StreamingConstants.ResponseModeHeader)); + var values = handler.CapturedRequest.Headers.GetValues(StreamingConstants.ResponseModeHeader).ToList(); + Assert.Single(values); + Assert.Equal(StreamingConstants.StreamingResponseMode, values[0]); + } + + // --- Property 8: Chunked Transfer Encoding Header --- + + /// + /// Property 8: Chunked Transfer Encoding Header + /// For any streaming response, the HTTP request should include + /// "Transfer-Encoding: chunked". + /// **Validates: Requirements 4.2** + /// + [Fact] + public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHeader() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out var handler); + + await client.StartStreamingResponseAsync("req-2", stream, CancellationToken.None); + + Assert.NotNull(handler.CapturedRequest); + Assert.True(handler.CapturedRequest.Headers.TransferEncodingChunked); + } + + // --- Property 13: Trailer Declaration Header --- + + /// + /// Property 13: Trailer Declaration Header + /// For any streaming response, the HTTP request should include a "Trailer" header + /// declaring the error trailer headers upfront (since we cannot know at request + /// start whether an error will occur). + /// **Validates: Requirements 5.4** + /// + [Fact] + public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out var handler); + + await client.StartStreamingResponseAsync("req-3", stream, CancellationToken.None); + + Assert.NotNull(handler.CapturedRequest); + Assert.True(handler.CapturedRequest.Headers.Contains("Trailer")); + var trailerValue = string.Join(", ", handler.CapturedRequest.Headers.GetValues("Trailer")); + Assert.Contains(StreamingConstants.ErrorTypeTrailer, trailerValue); + Assert.Contains(StreamingConstants.ErrorBodyTrailer, trailerValue); + } + + // --- Property 18: Stream Finalization --- + + /// + /// Property 18: Stream Finalization + /// For any streaming response that completes successfully, the ResponseStream + /// should be marked as completed (IsCompleted = true) after the HTTP response succeeds. + /// **Validates: Requirements 8.3** + /// + [Fact] + public async Task StartStreamingResponseAsync_MarksStreamCompletedAfterSuccess() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out _); + + await client.StartStreamingResponseAsync("req-4", stream, CancellationToken.None); + + Assert.True(stream.IsCompleted); + } + + // --- Property 10: Buffered Responses Exclude Streaming Headers --- + + /// + /// Mock HttpMessageHandler that captures the request for buffered response header inspection. + /// Returns an Accepted (202) response since that's what the InternalRuntimeApiClient expects. + /// + private class BufferedMockHttpMessageHandler : HttpMessageHandler + { + public HttpRequestMessage CapturedRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + CapturedRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)); + } + } + + /// + /// Property 10: Buffered Responses Exclude Streaming Headers + /// For any buffered response (where CreateStream was not called), the HTTP request + /// should not include "Lambda-Runtime-Function-Response-Mode" or + /// "Transfer-Encoding: chunked" or "Trailer" headers. + /// **Validates: Requirements 4.6** + /// + [Fact] + public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() + { + var bufferedHandler = new BufferedMockHttpMessageHandler(); + var httpClient = new HttpClient(bufferedHandler); + var envVars = new TestEnvironmentVariables(); + envVars.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", "localhost:9001"); + var client = new RuntimeApiClient(envVars, httpClient); + + var outputStream = new MemoryStream(new byte[] { 1, 2, 3 }); + await client.SendResponseAsync("req-buffered", outputStream, CancellationToken.None); + + Assert.NotNull(bufferedHandler.CapturedRequest); + // Buffered responses must not include streaming-specific headers + Assert.False(bufferedHandler.CapturedRequest.Headers.Contains(StreamingConstants.ResponseModeHeader), + "Buffered response should not include Lambda-Runtime-Function-Response-Mode header"); + Assert.NotEqual(true, bufferedHandler.CapturedRequest.Headers.TransferEncodingChunked); + Assert.False(bufferedHandler.CapturedRequest.Headers.Contains("Trailer"), + "Buffered response should not include Trailer header"); + } + + // --- Argument validation --- + + [Fact] + public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out _); + + await Assert.ThrowsAsync( + () => client.StartStreamingResponseAsync(null, stream, CancellationToken.None)); + } + + [Fact] + public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentNullException() + { + var stream = new ResponseStream(MaxResponseSize); + var client = CreateClientWithMockHandler(stream, out _); + + await Assert.ThrowsAsync( + () => client.StartStreamingResponseAsync("req-5", null, CancellationToken.None)); + } + } +} diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index ac43959b7..229385aba 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -21,662 +21,7 @@ ] } }, - "Resources": { - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "OkResponseWithHeader" - } - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheader/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "OkResponseWithHeaderAsync" - } - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheaderasync/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2Async" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2async/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1Async" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1async/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppDynamicExampleDynamicReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "DynamicReturn" - } - } - } - }, - "TestServerlessAppDynamicExampleDynamicInputGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "DynamicInput" - } - } - } - }, - "GreeterSayHello": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 1024, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "SayHello" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHello", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "GreeterSayHelloAsync": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 50, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "SayHelloAsync" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHelloAsync", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "HasIntrinsic" - } - } - } - }, - "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NullableHeaderHttpApi" - } - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/nullableheaderhttpapi", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppParameterlessMethodsNoParameterGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "provided.al2", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestExecutableServerlessApp", - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NoParameter" - } - } - } - }, - "TestServerlessAppParameterlessMethodWithResponseNoParameterWithResponseGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "provided.al2", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestExecutableServerlessApp", - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "NoParameterWithResponse" - } - } - } - }, - "TestExecutableServerlessAppSourceGenerationSerializationExampleGetPersonGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "GetPerson" - } - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/", - "Method": "GET" - } - } - } - } - }, - "ToUpper": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "ToUpper" - } - } - } - }, - "ToLower": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "provided.al2", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestExecutableServerlessApp", - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "ToLower" - } - } - } - }, - "TestServerlessAppTaskExampleTaskReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "TaskReturn" - } - } - } - }, - "TestServerlessAppVoidExampleVoidReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestExecutableServerlessApp" - ] - }, - "Environment": { - "Variables": { - "ANNOTATIONS_HANDLER": "VoidReturn" - } - } - } - } - }, + "Resources": {}, "Outputs": { "RestApiURL": { "Description": "Rest API endpoint URL for Prod environment", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index c42ff4a47..67ec5dfa4 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,24 +1,6 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", - "Resources": { - "TestServerlessAppNET8FunctionsToUpperGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "Runtime": "dotnet8", - "CodeUri": ".", - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Zip", - "Handler": "TestServerlessApp.NET8::TestServerlessApp.NET8.Functions_ToUpper_Generated::ToUpper" - } - } - } + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", + "Resources": {} } \ No newline at end of file diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 0e3befbe1..e6c1b8bea 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -28,1153 +28,6 @@ "Resources": { "TestQueue": { "Type": "AWS::SQS::Queue" - }, - "TestServerlessAppDynamicExampleDynamicReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicReturn_Generated::DynamicReturn" - ] - } - } - }, - "TestServerlessAppDynamicExampleDynamicInputGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicInput_Generated::DynamicInput" - ] - } - } - }, - "TestServerlessAppFromScratchNoApiGatewayEventsReferenceToUpperGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.FromScratch.NoApiGatewayEventsReference_ToUpper_Generated::ToUpper" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/{text}", - "Method": "GET" - } - } - } - } - }, - "HttpApiAuthorizerTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiExample_HttpApiAuthorizer_Generated::HttpApiAuthorizer" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorAdd": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Add_Generated::Add" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/Add", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorSubtract": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Subtract_Generated::Subtract" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/Subtract", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorMultiply": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Multiply_Generated::Multiply" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/Multiply/{x}/{y}", - "Method": "GET" - } - } - } - } - }, - "SimpleCalculatorDivideAsync": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_DivideAsync_Generated::DivideAsync" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/SimpleCalculator/DivideAsync/{x}/{y}", - "Method": "GET" - } - } - } - } - }, - "PI": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Pi_Generated::Pi" - ] - } - } - }, - "Random": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Random_Generated::Random" - ] - } - } - }, - "Randoms": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SimpleCalculator_Randoms_Generated::Randoms" - ] - } - } - }, - "SQSMessageHandler": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "TestQueueEvent" - ], - "SyncedEventProperties": { - "TestQueueEvent": [ - "Queue.Fn::GetAtt", - "BatchSize", - "FilterCriteria.Filters", - "FunctionResponseTypes", - "MaximumBatchingWindowInSeconds", - "ScalingConfig.MaximumConcurrency" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaSQSQueueExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.SqsMessageProcessing_HandleMessage_Generated::HandleMessage" - ] - }, - "Events": { - "TestQueueEvent": { - "Type": "SQS", - "Properties": { - "BatchSize": 50, - "FilterCriteria": { - "Filters": [ - { - "Pattern": "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }" - } - ] - }, - "FunctionResponseTypes": [ - "ReportBatchItemFailures" - ], - "MaximumBatchingWindowInSeconds": 5, - "ScalingConfig": { - "MaximumConcurrency": 5 - }, - "Queue": { - "Fn::GetAtt": [ - "TestQueue", - "Arn" - ] - } - } - } - } - } - }, - "HttpApiV1AuthorizerTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiV1Example_HttpApiV1Authorizer_Generated::HttpApiV1Authorizer" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer-v1", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.NullableReferenceTypeExample_NullableHeaderHttpApi_Generated::NullableHeaderHttpApi" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/nullableheaderhttpapi", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomAuthorizerWithIHttpResultsExampleAuthorizerWithIHttpResultsGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerWithIHttpResultsExample_AuthorizerWithIHttpResults_Generated::AuthorizerWithIHttpResults" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/authorizerihttpresults", - "Method": "GET" - } - } - } - } - }, - "GreeterSayHello": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 1024, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.Greeter_SayHello_Generated::SayHello" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHello", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "GreeterSayHelloAsync": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 50, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.Greeter_SayHelloAsync_Generated::SayHelloAsync" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/Greeter/SayHelloAsync", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeader_Generated::OkResponseWithHeader" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheader/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeaderAsync_Generated::OkResponseWithHeaderAsync" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/okresponsewithheaderasync/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2_Generated::NotFoundResponseWithHeaderV2" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2Async_Generated::NotFoundResponseWithHeaderV2Async" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv2async/{x}", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1_Generated::NotFoundResponseWithHeaderV1" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1Async_Generated::NotFoundResponseWithHeaderV1Async" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/notfoundwithheaderv1async/{x}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppCustomizeResponseExamplesOkResponseWithCustomSerializerGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method", - "PayloadFormatVersion" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithCustomSerializer_Generated::OkResponseWithCustomSerializer" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/okresponsewithcustomserializerasync/{firstName}/{lastName}", - "Method": "GET", - "PayloadFormatVersion": "1.0" - } - } - } - } - }, - "TestServerlessAppFromScratchNoSerializerAttributeReferenceToUpperGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.FromScratch.NoSerializerAttributeReference_ToUpper_Generated::ToUpper" - ] - } - } - }, - "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.IntrinsicExample_HasIntrinsic_Generated::HasIntrinsic" - ] - } - } - }, - "HttpApiNonString": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerNonStringExample_HttpApiWithNonString_Generated::HttpApiWithNonString" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer-non-string", - "Method": "GET" - } - } - } - } - }, - "AuthNameFallbackTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.AuthNameFallback_GetUserId_Generated::GetUserId" - ] - }, - "Events": { - "RootGet": { - "Type": "HttpApi", - "Properties": { - "Path": "/api/authorizer-fallback", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppVoidExampleVoidReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.VoidExample_VoidReturn_Generated::VoidReturn" - ] - } - } - }, - "RestAuthorizerTest": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootGet" - ], - "SyncedEventProperties": { - "RootGet": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.CustomAuthorizerRestExample_RestAuthorizer_Generated::RestAuthorizer" - ] - }, - "Events": { - "RootGet": { - "Type": "Api", - "Properties": { - "Path": "/rest/authorizer", - "Method": "GET" - } - } - } - } - }, - "TestServerlessAppTaskExampleTaskReturnGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.TaskExample_TaskReturn_Generated::TaskReturn" - ] - } - } - }, - "ToUpper": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations" - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.Sub1.Functions_ToUpper_Generated::ToUpper" - ] - } - } - }, - "TestServerlessAppComplexCalculatorAddGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootPost" - ], - "SyncedEventProperties": { - "RootPost": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.ComplexCalculator_Add_Generated::Add" - ] - }, - "Events": { - "RootPost": { - "Type": "HttpApi", - "Properties": { - "Path": "/ComplexCalculator/Add", - "Method": "POST" - } - } - } - } - }, - "TestServerlessAppComplexCalculatorSubtractGenerated": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "RootPost" - ], - "SyncedEventProperties": { - "RootPost": [ - "Path", - "Method" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.ComplexCalculator_Subtract_Generated::Subtract" - ] - }, - "Events": { - "RootPost": { - "Type": "HttpApi", - "Properties": { - "Path": "/ComplexCalculator/Subtract", - "Method": "POST" - } - } - } - } } }, "Outputs": { From 14c993b08f6c6d44420f085d1d0a5c29ee9d1030 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 17 Feb 2026 18:46:16 -0800 Subject: [PATCH 09/47] Task 8 --- .../Bootstrap/LambdaBootstrap.cs | 45 ++++- .../LambdaBootstrapTests.cs | 156 ++++++++++++++++++ .../TestStreamingRuntimeApiClient.cs | 131 +++++++++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index 0e00f3e7f..68b67c339 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -349,6 +349,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul _logger.LogInformation("Starting InvokeOnceAsync"); var invocation = await Client.GetNextInvocationAsync(cancellationToken); + var isMultiConcurrency = Utils.IsUsingMultiConcurrency(_environmentVariables); Func processingFunc = async () => { @@ -358,6 +359,18 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul SetInvocationTraceId(impl.RuntimeApiHeaders.TraceId); } + // Initialize ResponseStreamFactory — includes RuntimeApiClient reference + var runtimeApiClient = Client as RuntimeApiClient; + if (runtimeApiClient != null) + { + ResponseStreamFactory.InitializeInvocation( + invocation.LambdaContext.AwsRequestId, + StreamingConstants.MaxResponseSize, + isMultiConcurrency, + runtimeApiClient, + cancellationToken); + } + try { InvocationResponse response = null; @@ -372,15 +385,39 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul catch (Exception exception) { WriteUnhandledExceptionToLog(exception); - await Client.ReportInvocationErrorAsync(invocation.LambdaContext.AwsRequestId, exception, cancellationToken); + + var streamIfCreated = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (streamIfCreated != null && streamIfCreated.BytesWritten > 0) + { + // Midstream error — report via trailers on the already-open HTTP connection + await streamIfCreated.ReportErrorAsync(exception); + } + else + { + // Error before streaming started — use standard error reporting + await Client.ReportInvocationErrorAsync(invocation.LambdaContext.AwsRequestId, exception, cancellationToken); + } } finally { _logger.LogInformation("Finished invoking handler"); } - if (invokeSucceeded) + // If streaming was started, await the HTTP send task to ensure it completes + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency); + if (sendTask != null) { + var stream = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (stream != null && !stream.IsCompleted && !stream.HasError) + { + // Handler returned successfully — signal stream completion + stream.MarkCompleted(); + } + await sendTask; // Wait for HTTP request to finish + } + else if (invokeSucceeded) + { + // No streaming — send buffered response _logger.LogInformation("Starting sending response"); try { @@ -415,6 +452,10 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul } finally { + if (runtimeApiClient != null) + { + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); + } invocation.Dispose(); } }; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index e1636ff16..07c2379a0 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -14,9 +14,11 @@ */ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -283,5 +285,159 @@ public void IsCallPreJitTest() environmentVariables.SetEnvironmentVariable(ENVIRONMENT_VARIABLE_AWS_LAMBDA_INITIALIZATION_TYPE, AWS_LAMBDA_INITIALIZATION_TYPE_PC); Assert.True(UserCodeInit.IsCallPreJit(environmentVariables)); } + + // --- Streaming Integration Tests --- + + private TestStreamingRuntimeApiClient CreateStreamingClient() + { + var envVars = new TestEnvironmentVariables(); + var headers = new Dictionary> + { + { RuntimeApiHeaders.HeaderAwsRequestId, new List { "streaming-request-id" } }, + { RuntimeApiHeaders.HeaderInvokedFunctionArn, new List { "invoked_function_arn" } }, + { RuntimeApiHeaders.HeaderAwsTenantId, new List { "tenant_id" } } + }; + return new TestStreamingRuntimeApiClient(envVars, headers); + } + + /// + /// Property 2: CreateStream Enables Streaming Mode + /// When a handler calls ResponseStreamFactory.CreateStream(), the response is transmitted + /// using streaming mode. LambdaBootstrap awaits the send task. + /// **Validates: Requirements 1.4, 6.1, 6.2, 6.3, 6.4** + /// + [Fact] + public async Task StreamingMode_HandlerCallsCreateStream_SendTaskAwaited() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("hello")); + return new InvocationResponse(Stream.Null, false); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + Assert.True(streamingClient.StartStreamingResponseAsyncCalled); + Assert.False(streamingClient.SendResponseAsyncCalled); + } + + /// + /// Property 3: Default Mode Is Buffered + /// When a handler does not call ResponseStreamFactory.CreateStream(), the response + /// is transmitted using buffered mode via SendResponseAsync. + /// **Validates: Requirements 1.5, 7.2** + /// + [Fact] + public async Task BufferedMode_HandlerDoesNotCallCreateStream_UsesSendResponse() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var outputStream = new MemoryStream(Encoding.UTF8.GetBytes("buffered response")); + return new InvocationResponse(outputStream); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + Assert.False(streamingClient.StartStreamingResponseAsyncCalled); + Assert.True(streamingClient.SendResponseAsyncCalled); + } + + /// + /// Property 14: Exception After Writes Uses Trailers + /// When a handler throws an exception after writing data to an IResponseStream, + /// the error is reported via trailers (ReportErrorAsync) rather than standard error reporting. + /// **Validates: Requirements 5.6, 5.7** + /// + [Fact] + public async Task MidstreamError_ExceptionAfterWrites_ReportsViaTrailers() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); + throw new InvalidOperationException("midstream failure"); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + // Error should be reported via trailers on the stream, not via standard error reporting + Assert.True(streamingClient.StartStreamingResponseAsyncCalled); + Assert.NotNull(streamingClient.LastStreamingResponseStream); + Assert.True(streamingClient.LastStreamingResponseStream.HasError); + Assert.False(streamingClient.ReportInvocationErrorAsyncExceptionCalled); + } + + /// + /// Property 15: Exception Before CreateStream Uses Standard Error + /// When a handler throws an exception before calling ResponseStreamFactory.CreateStream(), + /// the error is reported using the standard Lambda error reporting mechanism. + /// **Validates: Requirements 5.7, 7.1** + /// + [Fact] + public async Task PreStreamError_ExceptionBeforeCreateStream_UsesStandardErrorReporting() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + throw new InvalidOperationException("pre-stream failure"); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + Assert.False(streamingClient.StartStreamingResponseAsyncCalled); + Assert.True(streamingClient.ReportInvocationErrorAsyncExceptionCalled); + } + + /// + /// State Isolation: ResponseStreamFactory state is cleared after each invocation. + /// **Validates: Requirements 6.5, 8.9** + /// + [Fact] + public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() + { + var streamingClient = CreateStreamingClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("data")); + return new InvocationResponse(Stream.Null, false); + }; + + using (var bootstrap = new LambdaBootstrap(handler, null)) + { + bootstrap.Client = streamingClient; + await bootstrap.InvokeOnceAsync(); + } + + // After invocation, factory state should be cleaned up + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(false)); + Assert.Null(ResponseStreamFactory.GetSendTask(false)); + } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs new file mode 100644 index 000000000..1128bb075 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -0,0 +1,131 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using Amazon.Lambda.RuntimeSupport.Helpers; +using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + /// + /// A RuntimeApiClient subclass for testing LambdaBootstrap streaming integration. + /// Extends RuntimeApiClient so the (RuntimeApiClient)Client cast in LambdaBootstrap works. + /// Overrides StartStreamingResponseAsync to avoid real HTTP calls. + /// + internal class TestStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApiClient + { + private readonly IEnvironmentVariables _environmentVariables; + private readonly Dictionary> _headers; + + public new IConsoleLoggerWriter ConsoleLogger { get; } = new LogLevelLoggerWriter(new SystemEnvironmentVariables()); + + public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, Dictionary> headers) + : base(environmentVariables, new NoOpInternalRuntimeApiClient()) + { + _environmentVariables = environmentVariables; + _headers = headers; + } + + // Tracking flags + public bool GetNextInvocationAsyncCalled { get; private set; } + public bool ReportInitializationErrorAsyncExceptionCalled { get; private set; } + public bool ReportInvocationErrorAsyncExceptionCalled { get; private set; } + public bool SendResponseAsyncCalled { get; private set; } + public bool StartStreamingResponseAsyncCalled { get; private set; } + + public string LastTraceId { get; private set; } + public byte[] FunctionInput { get; set; } + public Stream LastOutputStream { get; private set; } + public Exception LastRecordedException { get; private set; } + public ResponseStream LastStreamingResponseStream { get; private set; } + + public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) + { + GetNextInvocationAsyncCalled = true; + + LastTraceId = Guid.NewGuid().ToString(); + _headers[RuntimeApiHeaders.HeaderTraceId] = new List() { LastTraceId }; + + var inputStream = new MemoryStream(FunctionInput == null ? new byte[0] : FunctionInput); + inputStream.Position = 0; + + return new InvocationRequest() + { + InputStream = inputStream, + LambdaContext = new LambdaContext( + new RuntimeApiHeaders(_headers), + new LambdaEnvironment(_environmentVariables), + new TestDateTimeHelper(), new SimpleLoggerWriter(_environmentVariables)) + }; + } + + public new Task ReportInitializationErrorAsync(Exception exception, String errorType = null, CancellationToken cancellationToken = default) + { + LastRecordedException = exception; + ReportInitializationErrorAsyncExceptionCalled = true; + return Task.CompletedTask; + } + + public new Task ReportInitializationErrorAsync(string errorType, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public new Task ReportInvocationErrorAsync(string awsRequestId, Exception exception, CancellationToken cancellationToken = default) + { + LastRecordedException = exception; + ReportInvocationErrorAsyncExceptionCalled = true; + return Task.CompletedTask; + } + + public new async Task SendResponseAsync(string awsRequestId, Stream outputStream, CancellationToken cancellationToken = default) + { + if (outputStream != null) + { + LastOutputStream = new MemoryStream((int)outputStream.Length); + outputStream.CopyTo(LastOutputStream); + LastOutputStream.Position = 0; + } + + SendResponseAsyncCalled = true; + } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + StartStreamingResponseAsyncCalled = true; + LastStreamingResponseStream = responseStream; + + // Simulate the HTTP stream being available + responseStream.SetHttpOutputStream(new MemoryStream()); + + // Wait for the handler to finish writing (mirrors real SerializeToStreamAsync behavior) + await responseStream.WaitForCompletionAsync(); + } + +#if NET8_0_OR_GREATER + public new Task RestoreNextInvocationAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public new Task ReportRestoreErrorAsync(Exception exception, String errorType = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; +#endif + } +} From 0b8dd52562168558f4013a5ac868e9afb907387e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 15:53:22 -0800 Subject: [PATCH 10/47] Task 10 --- .../HandlerTests.cs | 2 +- .../LambdaBootstrapTests.cs | 1 + .../ResponseStreamFactoryTests.cs | 1 + .../StreamingIntegrationTests.cs | 652 ++++++++++++++++++ 4 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs index 80f9d13d0..e257b688e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs @@ -31,7 +31,7 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { - [Collection("Bootstrap")] + [Collection("ResponseStreamFactory")] public class HandlerTests { private const string AggregateExceptionTestMarker = "AggregateExceptionTesting"; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index 07c2379a0..ce922d529 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -31,6 +31,7 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests /// Tests to test LambdaBootstrap when it's constructed using its actual constructor. /// Tests of the static GetLambdaBootstrap methods can be found in LambdaBootstrapWrapperTests. /// + [Collection("ResponseStreamFactory")] public class LambdaBootstrapTests { readonly TestHandler _testFunction; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index 11973ae5f..1c714dd97 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -20,6 +20,7 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { + [Collection("ResponseStreamFactory")] public class ResponseStreamFactoryTests : IDisposable { private const long MaxResponseSize = 20 * 1024 * 1024; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs new file mode 100644 index 000000000..c2bd34bdf --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs @@ -0,0 +1,652 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + [CollectionDefinition("ResponseStreamFactory")] + public class ResponseStreamFactoryCollection { } + + /// + /// End-to-end integration tests for the true-streaming architecture. + /// These tests exercise the full pipeline: LambdaBootstrap → ResponseStreamFactory → + /// ResponseStream → StreamingHttpContent → captured HTTP output stream. + /// + [Collection("ResponseStreamFactory")] + public class StreamingIntegrationTests : IDisposable + { + public void Dispose() + { + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static Dictionary> MakeHeaders(string requestId = "test-request-id") + => new Dictionary> + { + { RuntimeApiHeaders.HeaderAwsRequestId, new List { requestId } }, + { RuntimeApiHeaders.HeaderInvokedFunctionArn, new List { "arn:aws:lambda:us-east-1:123456789012:function:test" } }, + { RuntimeApiHeaders.HeaderAwsTenantId, new List { "tenant-id" } }, + { RuntimeApiHeaders.HeaderTraceId, new List { "trace-id" } }, + { RuntimeApiHeaders.HeaderDeadlineMs, new List { "9999999999999" } }, + }; + + /// + /// A capturing RuntimeApiClient that records the raw bytes written to the HTTP output stream + /// by SerializeToStreamAsync, enabling assertions on chunked-encoding format. + /// + private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApiClient + { + private readonly IEnvironmentVariables _envVars; + private readonly Dictionary> _headers; + + public bool StartStreamingCalled { get; private set; } + public bool SendResponseCalled { get; private set; } + public bool ReportInvocationErrorCalled { get; private set; } + public byte[] CapturedHttpBytes { get; private set; } + public ResponseStream LastResponseStream { get; private set; } + public Stream LastBufferedOutputStream { get; private set; } + + public new Amazon.Lambda.RuntimeSupport.Helpers.IConsoleLoggerWriter ConsoleLogger { get; } = new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables()); + + public CapturingStreamingRuntimeApiClient( + IEnvironmentVariables envVars, + Dictionary> headers) + : base(envVars, new NoOpInternalRuntimeApiClient()) + { + _envVars = envVars; + _headers = headers; + } + + public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) + { + _headers[RuntimeApiHeaders.HeaderTraceId] = new List { Guid.NewGuid().ToString() }; + var inputStream = new MemoryStream(new byte[0]); + return new InvocationRequest + { + InputStream = inputStream, + LambdaContext = new LambdaContext( + new RuntimeApiHeaders(_headers), + new LambdaEnvironment(_envVars), + new TestDateTimeHelper(), + new Helpers.SimpleLoggerWriter(_envVars)) + }; + } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + StartStreamingCalled = true; + LastResponseStream = responseStream; + + // Use a real MemoryStream as the HTTP output stream so we capture actual bytes + var captureStream = new MemoryStream(); + var content = new StreamingHttpContent(responseStream); + + // SerializeToStreamAsync hands the stream to ResponseStream and waits for completion + await content.CopyToAsync(captureStream); + CapturedHttpBytes = captureStream.ToArray(); + } + + public new async Task SendResponseAsync(string awsRequestId, Stream outputStream, CancellationToken cancellationToken = default) + { + SendResponseCalled = true; + if (outputStream != null) + { + var ms = new MemoryStream(); + await outputStream.CopyToAsync(ms); + ms.Position = 0; + LastBufferedOutputStream = ms; + } + } + + public new Task ReportInvocationErrorAsync(string awsRequestId, Exception exception, CancellationToken cancellationToken = default) + { + ReportInvocationErrorCalled = true; + return Task.CompletedTask; + } + + public new Task ReportInitializationErrorAsync(Exception exception, string errorType = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public new Task ReportInitializationErrorAsync(string errorType, CancellationToken cancellationToken = default) + => Task.CompletedTask; + +#if NET8_0_OR_GREATER + public new Task RestoreNextInvocationAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public new Task ReportRestoreErrorAsync(Exception exception, string errorType = null, CancellationToken cancellationToken = default) => Task.CompletedTask; +#endif + } + + private static CapturingStreamingRuntimeApiClient CreateClient(string requestId = "test-request-id") + => new CapturingStreamingRuntimeApiClient(new TestEnvironmentVariables(), MakeHeaders(requestId)); + + // ─── 10.1 End-to-end streaming response ───────────────────────────────────── + + /// + /// End-to-end: handler calls CreateStream, writes multiple chunks. + /// Verifies data flows through with correct chunked encoding and stream is finalized. + /// Requirements: 3.2, 4.3, 10.1 + /// + [Fact] + public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() + { + var client = CreateClient(); + var chunks = new[] { "Hello", ", ", "World" }; + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + foreach (var chunk in chunks) + await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.StartStreamingCalled); + Assert.NotNull(client.CapturedHttpBytes); + + var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); + + // Each chunk should appear as: hex-size\r\ndata\r\n + Assert.Contains("5\r\nHello\r\n", output); + Assert.Contains("2\r\n, \r\n", output); + Assert.Contains("5\r\nWorld\r\n", output); + + // Final chunk terminates the stream + Assert.Contains("0\r\n", output); + Assert.EndsWith("0\r\n\r\n", output); + } + + /// + /// End-to-end: all data is transmitted correctly (content round-trip). + /// Requirements: 3.2, 4.3, 10.1 + /// + [Fact] + public async Task Streaming_AllDataTransmitted_ContentRoundTrip() + { + var client = CreateClient(); + var payload = Encoding.UTF8.GetBytes("integration test payload"); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(payload); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + var output = client.CapturedHttpBytes; + Assert.NotNull(output); + + // Decode the single chunk: hex-size\r\ndata\r\n + var outputStr = Encoding.UTF8.GetString(output); + var hexSize = payload.Length.ToString("X"); + Assert.Contains(hexSize + "\r\n", outputStr); + Assert.Contains("integration test payload", outputStr); + } + + /// + /// End-to-end: stream is finalized (final chunk written, BytesWritten matches). + /// Requirements: 3.2, 4.3, 10.1 + /// + [Fact] + public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() + { + var client = CreateClient(); + var data = Encoding.UTF8.GetBytes("finalization check"); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(data); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.NotNull(client.LastResponseStream); + Assert.Equal(data.Length, client.LastResponseStream.BytesWritten); + Assert.True(client.LastResponseStream.IsCompleted); + } + + // ─── 10.2 End-to-end buffered response ────────────────────────────────────── + + /// + /// End-to-end: handler does NOT call CreateStream — response goes via buffered path. + /// Verifies SendResponseAsync is called and streaming headers are absent. + /// Requirements: 1.5, 4.6, 9.4 + /// + [Fact] + public async Task Buffered_HandlerDoesNotCallCreateStream_UsesSendResponsePath() + { + var client = CreateClient(); + var responseBody = Encoding.UTF8.GetBytes("buffered response body"); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(new MemoryStream(responseBody)); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.False(client.StartStreamingCalled, "StartStreamingResponseAsync should NOT be called for buffered mode"); + Assert.True(client.SendResponseCalled, "SendResponseAsync should be called for buffered mode"); + Assert.Null(client.CapturedHttpBytes); + } + + /// + /// End-to-end: buffered response body is transmitted correctly. + /// Requirements: 1.5, 4.6, 9.4 + /// + [Fact] + public async Task Buffered_ResponseBodyTransmittedCorrectly() + { + var client = CreateClient(); + var responseBody = Encoding.UTF8.GetBytes("hello buffered world"); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(new MemoryStream(responseBody)); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.SendResponseCalled); + Assert.NotNull(client.LastBufferedOutputStream); + var received = new MemoryStream(); + await client.LastBufferedOutputStream.CopyToAsync(received); + Assert.Equal(responseBody, received.ToArray()); + } + + // ─── 10.3 Midstream error ──────────────────────────────────────────────────── + + /// + /// End-to-end: handler writes data then throws — error trailers appear after final chunk. + /// Requirements: 5.1, 5.2, 5.3, 5.6 + /// + [Fact] + public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); + throw new InvalidOperationException("midstream failure"); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.StartStreamingCalled); + Assert.NotNull(client.CapturedHttpBytes); + + var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); + + // Data chunk should be present + Assert.Contains("partial data", output); + + // Final chunk must appear + Assert.Contains("0\r\n", output); + + // Error trailers must appear after the final chunk + var finalChunkIdx = output.LastIndexOf("0\r\n"); + var errorTypeIdx = output.IndexOf(StreamingConstants.ErrorTypeTrailer + ":"); + var errorBodyIdx = output.IndexOf(StreamingConstants.ErrorBodyTrailer + ":"); + + Assert.True(errorTypeIdx > finalChunkIdx, "Error-Type trailer should appear after final chunk"); + Assert.True(errorBodyIdx > finalChunkIdx, "Error-Body trailer should appear after final chunk"); + + // Error type should reference the exception type + Assert.Contains("InvalidOperationException", output); + + // Standard error reporting should NOT be used (error went via trailers) + Assert.False(client.ReportInvocationErrorCalled); + } + + /// + /// End-to-end: handler throws before writing any data — standard error reporting is used. + /// Requirements: 5.6, 5.7 + /// + [Fact] + public async Task PreStreamError_ExceptionBeforeAnyWrite_UsesStandardErrorReporting() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + // Throw before writing anything + throw new ArgumentException("pre-write failure"); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + // BytesWritten == 0, so standard error reporting should be used + Assert.True(client.ReportInvocationErrorCalled, + "Standard error reporting should be used when no bytes were written"); + } + + /// + /// End-to-end: error body trailer contains JSON with exception details. + /// Requirements: 5.2, 5.3 + /// + [Fact] + public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() + { + var client = CreateClient(); + const string errorMessage = "something went wrong mid-stream"; + + LambdaBootstrapHandler handler = async (invocation) => + { + var stream = ResponseStreamFactory.CreateStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("some data")); + throw new InvalidOperationException(errorMessage); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); + Assert.Contains(StreamingConstants.ErrorBodyTrailer + ":", output); + Assert.Contains(errorMessage, output); + } + + // ─── 10.4 Multi-concurrency ────────────────────────────────────────────────── + + /// + /// Multi-concurrency: concurrent invocations use AsyncLocal for state isolation. + /// Each invocation independently uses streaming or buffered mode without interference. + /// Requirements: 2.9, 6.5, 8.9 + /// + [Fact] + public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() + { + const int concurrency = 3; + var results = new ConcurrentDictionary(); + var barrier = new SemaphoreSlim(0, concurrency); + var allStarted = new SemaphoreSlim(0, concurrency); + + // Simulate concurrent invocations using AsyncLocal directly + var tasks = new List(); + for (int i = 0; i < concurrency; i++) + { + var requestId = $"req-{i}"; + var payload = $"payload-{i}"; + tasks.Add(Task.Run(async () => + { + var mockClient = new MockMultiConcurrencyStreamingClient(); + ResponseStreamFactory.InitializeInvocation( + requestId, + StreamingConstants.MaxResponseSize, + isMultiConcurrency: true, + mockClient, + CancellationToken.None); + + var stream = ResponseStreamFactory.CreateStream(); + allStarted.Release(); + + // Wait until all tasks have started (to ensure true concurrency) + await barrier.WaitAsync(); + + await stream.WriteAsync(Encoding.UTF8.GetBytes(payload)); + ((ResponseStream)stream).MarkCompleted(); + + // Verify this invocation's stream is still accessible + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + results[requestId] = retrieved != null ? payload : "MISSING"; + + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + })); + } + + // Wait for all tasks to start, then release the barrier + for (int i = 0; i < concurrency; i++) + await allStarted.WaitAsync(); + barrier.Release(concurrency); + + await Task.WhenAll(tasks); + + // Each invocation should have seen its own stream + Assert.Equal(concurrency, results.Count); + for (int i = 0; i < concurrency; i++) + Assert.Equal($"payload-{i}", results[$"req-{i}"]); + } + + /// + /// Multi-concurrency: streaming and buffered invocations can run concurrently without interference. + /// Requirements: 2.9, 6.5, 8.9 + /// + [Fact] + public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInterference() + { + var streamingResults = new ConcurrentBag(); + var bufferedResults = new ConcurrentBag(); + var barrier = new SemaphoreSlim(0, 4); + var allStarted = new SemaphoreSlim(0, 4); + + var tasks = new List(); + + // 2 streaming invocations + for (int i = 0; i < 2; i++) + { + var requestId = $"stream-{i}"; + tasks.Add(Task.Run(async () => + { + var mockClient = new MockMultiConcurrencyStreamingClient(); + ResponseStreamFactory.InitializeInvocation( + requestId, StreamingConstants.MaxResponseSize, + isMultiConcurrency: true, mockClient, CancellationToken.None); + + var stream = ResponseStreamFactory.CreateStream(); + allStarted.Release(); + await barrier.WaitAsync(); + + await stream.WriteAsync(Encoding.UTF8.GetBytes("streaming data")); + ((ResponseStream)stream).MarkCompleted(); + + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + streamingResults.Add(retrieved != null); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + })); + } + + // 2 buffered invocations (no CreateStream) + for (int i = 0; i < 2; i++) + { + var requestId = $"buffered-{i}"; + tasks.Add(Task.Run(async () => + { + var mockClient = new MockMultiConcurrencyStreamingClient(); + ResponseStreamFactory.InitializeInvocation( + requestId, StreamingConstants.MaxResponseSize, + isMultiConcurrency: true, mockClient, CancellationToken.None); + + allStarted.Release(); + await barrier.WaitAsync(); + + // No CreateStream — buffered mode + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + bufferedResults.Add(retrieved == null); // should be null (no stream created) + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + })); + } + + for (int i = 0; i < 4; i++) + await allStarted.WaitAsync(); + barrier.Release(4); + + await Task.WhenAll(tasks); + + Assert.Equal(2, streamingResults.Count); + Assert.All(streamingResults, r => Assert.True(r, "Streaming invocation should have a stream")); + + Assert.Equal(2, bufferedResults.Count); + Assert.All(bufferedResults, r => Assert.True(r, "Buffered invocation should have no stream")); + } + + /// + /// Minimal mock RuntimeApiClient for multi-concurrency tests. + /// Accepts StartStreamingResponseAsync calls without real HTTP. + /// + private class MockMultiConcurrencyStreamingClient : RuntimeApiClient + { + public MockMultiConcurrencyStreamingClient() + : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + // Provide the HTTP output stream so writes don't block + responseStream.SetHttpOutputStream(new MemoryStream()); + await responseStream.WaitForCompletionAsync(); + } + } + + // ─── 10.5 Backward compatibility ──────────────────────────────────────────── + + /// + /// Backward compatibility: existing handler signatures (event + ILambdaContext) work without modification. + /// Requirements: 9.1, 9.2, 9.3 + /// + [Fact] + public async Task BackwardCompat_ExistingHandlerSignature_WorksUnchanged() + { + var client = CreateClient(); + bool handlerCalled = false; + + // Simulate a classic handler that returns a buffered response + LambdaBootstrapHandler handler = async (invocation) => + { + handlerCalled = true; + await Task.Yield(); + return new InvocationResponse(new MemoryStream(Encoding.UTF8.GetBytes("classic response"))); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(handlerCalled); + Assert.True(client.SendResponseCalled); + Assert.False(client.StartStreamingCalled); + } + + /// + /// Backward compatibility: no regression in buffered response behavior — response body is correct. + /// Requirements: 9.4, 9.5 + /// + [Fact] + public async Task BackwardCompat_BufferedResponse_NoRegression() + { + var client = CreateClient(); + var expected = Encoding.UTF8.GetBytes("no regression here"); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(new MemoryStream(expected)); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.SendResponseCalled); + Assert.NotNull(client.LastBufferedOutputStream); + var received = new MemoryStream(); + await client.LastBufferedOutputStream.CopyToAsync(received); + Assert.Equal(expected, received.ToArray()); + } + + /// + /// Backward compatibility: handler that returns null OutputStream still works. + /// Requirements: 9.4 + /// + [Fact] + public async Task BackwardCompat_NullOutputStream_HandledGracefully() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + return new InvocationResponse(Stream.Null, false); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + + // Should not throw + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.SendResponseCalled); + } + + /// + /// Backward compatibility: handler that throws before CreateStream uses standard error path. + /// Requirements: 9.5 + /// + [Fact] + public async Task BackwardCompat_HandlerThrows_StandardErrorReportingUsed() + { + var client = CreateClient(); + + LambdaBootstrapHandler handler = async (invocation) => + { + await Task.Yield(); + throw new Exception("classic handler error"); + }; + + using var bootstrap = new LambdaBootstrap(handler, null); + bootstrap.Client = client; + await bootstrap.InvokeOnceAsync(); + + Assert.True(client.ReportInvocationErrorCalled); + Assert.False(client.StartStreamingCalled); + } + } +} From 414a4495eed950d9cbad9dcf8e24aff19797af3e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 17:47:09 -0800 Subject: [PATCH 11/47] Refactoring --- .../Bootstrap/LambdaBootstrap.cs | 11 +-- ...onseStream.cs => ILambdaResponseStream.cs} | 14 +-- .../Client/InvocationResponse.cs | 26 ----- ...daResponseStream.ILambdaResponseStream.cs} | 78 ++++++++++----- .../Client/LambdaResponseStream.Stream.cs | 97 +++++++++++++++++++ ...text.cs => LambdaResponseStreamContext.cs} | 9 +- ...tory.cs => LambdaResponseStreamFactory.cs} | 30 +++--- .../Client/RuntimeApiClient.cs | 2 +- .../Client/StreamingConstants.cs | 5 - .../Client/StreamingHttpContent.cs | 4 +- .../InvocationResponseTests.cs | 81 ---------------- .../LambdaBootstrapTests.cs | 10 +- .../ResponseStreamFactoryTests.cs | 74 +++++++------- .../ResponseStreamTests.cs | 45 +++------ .../RuntimeApiClientTests.cs | 18 ++-- .../StreamingHttpContentTests.cs | 30 +++--- .../StreamingIntegrationTests.cs | 53 +++++----- .../TestStreamingRuntimeApiClient.cs | 4 +- 18 files changed, 282 insertions(+), 309 deletions(-) rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{IResponseStream.cs => ILambdaResponseStream.cs} (77%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{ResponseStream.cs => LambdaResponseStream.ILambdaResponseStream.cs} (65%) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{ResponseStreamContext.cs => LambdaResponseStreamContext.cs} (88%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/Client/{ResponseStreamFactory.cs => LambdaResponseStreamFactory.cs} (79%) delete mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index 68b67c339..6241fb61f 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -363,9 +363,8 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul var runtimeApiClient = Client as RuntimeApiClient; if (runtimeApiClient != null) { - ResponseStreamFactory.InitializeInvocation( + LambdaResponseStreamFactory.InitializeInvocation( invocation.LambdaContext.AwsRequestId, - StreamingConstants.MaxResponseSize, isMultiConcurrency, runtimeApiClient, cancellationToken); @@ -386,7 +385,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { WriteUnhandledExceptionToLog(exception); - var streamIfCreated = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (streamIfCreated != null && streamIfCreated.BytesWritten > 0) { // Midstream error — report via trailers on the already-open HTTP connection @@ -404,10 +403,10 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul } // If streaming was started, await the HTTP send task to ensure it completes - var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency); + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); if (sendTask != null) { - var stream = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var stream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (stream != null && !stream.IsCompleted && !stream.HasError) { // Handler returned successfully — signal stream completion @@ -454,7 +453,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { if (runtimeApiClient != null) { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency); } invocation.Dispose(); } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs similarity index 77% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs index 6107dde16..36236b28d 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/IResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs @@ -23,7 +23,7 @@ namespace Amazon.Lambda.RuntimeSupport /// Interface for writing streaming responses in AWS Lambda functions. /// Obtained by calling ResponseStreamFactory.CreateStream() within a handler. /// - public interface IResponseStream : IDisposable + public interface ILambdaResponseStream : IDisposable { /// /// Asynchronously writes a byte array to the response stream. @@ -32,7 +32,6 @@ public interface IResponseStream : IDisposable /// Optional cancellation token. /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has been reported. - /// Thrown if writing would exceed the 20 MiB limit. Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default); /// @@ -44,19 +43,8 @@ public interface IResponseStream : IDisposable /// Optional cancellation token. /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has been reported. - /// Thrown if writing would exceed the 20 MiB limit. Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - /// - /// Asynchronously writes a memory buffer to the response stream. - /// - /// The memory buffer to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - /// Thrown if writing would exceed the 20 MiB limit. - Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default); - /// /// Reports an error that occurred during streaming. /// This will send error information via HTTP trailing headers. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs index 4438c9708..1894b0521 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InvocationResponse.cs @@ -34,18 +34,6 @@ public class InvocationResponse /// public bool DisposeOutputStream { get; private set; } = true; - /// - /// Indicates whether this response uses streaming mode. - /// Set internally by the runtime when ResponseStreamFactory.CreateStream() is called. - /// - internal bool IsStreaming { get; set; } - - /// - /// The ResponseStream instance if streaming mode is used. - /// Set internally by the runtime. - /// - internal ResponseStream ResponseStream { get; set; } - /// /// Construct a InvocationResponse with an output stream that will be disposed by the Lambda Runtime Client. /// @@ -64,20 +52,6 @@ public InvocationResponse(Stream outputStream, bool disposeOutputStream) { OutputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); DisposeOutputStream = disposeOutputStream; - IsStreaming = false; - } - - /// - /// Creates an InvocationResponse for a streaming response. - /// Used internally by the runtime. - /// - internal static InvocationResponse CreateStreamingResponse(ResponseStream responseStream) - { - return new InvocationResponse(Stream.Null, false) - { - IsStreaming = true, - ResponseStream = responseStream - }; } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs similarity index 65% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs index 00f63cf75..7830c81b4 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs @@ -22,14 +22,13 @@ namespace Amazon.Lambda.RuntimeSupport { /// - /// Internal implementation of IResponseStream with true streaming. - /// Writes data directly to the HTTP output stream as chunked transfer encoding. + /// A write-only, non-seekable subclass that streams response data + /// to the Lambda Runtime API. Returned by . /// - internal class ResponseStream : IResponseStream + public partial class LambdaResponseStream : Stream, ILambdaResponseStream { private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); - private readonly long _maxResponseSize; private long _bytesWritten; private bool _isCompleted; private bool _hasError; @@ -41,14 +40,26 @@ internal class ResponseStream : IResponseStream private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + /// + /// The number of bytes written to the Lambda response stream so far. + /// public long BytesWritten => _bytesWritten; + + /// + /// Gets a value indicating whether the operation has completed. + /// public bool IsCompleted => _isCompleted; + + /// + /// Gets a value indicating whether an error has occurred. + /// public bool HasError => _hasError; + + internal Exception ReportedError => _reportedError; - public ResponseStream(long maxResponseSize) + internal LambdaResponseStream() { - _maxResponseSize = maxResponseSize; } /// @@ -69,6 +80,13 @@ internal async Task WaitForCompletionAsync() await _completionSignal.WaitAsync(); } + /// + /// Asynchronously writes a byte array to the response stream. + /// + /// The byte array to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) { if (buffer == null) @@ -77,7 +95,16 @@ public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken await WriteAsync(buffer, 0, buffer.Length, cancellationToken); } - public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); @@ -93,14 +120,6 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT lock (_lock) { ThrowIfCompletedOrError(); - - if (_bytesWritten + count > _maxResponseSize) - { - throw new InvalidOperationException( - $"Writing {count} bytes would exceed the maximum response size of {_maxResponseSize} bytes (20 MiB). " + - $"Current size: {_bytesWritten} bytes."); - } - _bytesWritten += count; } @@ -120,13 +139,14 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT } } - public async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - // Convert to array and delegate — small overhead but keeps the API simple - var array = buffer.ToArray(); - await WriteAsync(array, 0, array.Length, cancellationToken); - } - + /// + /// Reports an error that occurred during streaming. + /// This will send error information via HTTP trailing headers. + /// + /// The exception to report. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has already been reported. public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) { if (exception == null) @@ -145,6 +165,7 @@ public Task ReportErrorAsync(Exception exception, CancellationToken cancellation // Signal completion so StreamingHttpContent can write error trailers and finish _completionSignal.Release(); + return Task.CompletedTask; } @@ -166,10 +187,17 @@ private void ThrowIfCompletedOrError() throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); } - public void Dispose() + // ── Dispose ────────────────────────────────────────────────────────── + + /// + protected override void Dispose(bool disposing) { - // Ensure completion is signaled if not already - try { _completionSignal.Release(); } catch (SemaphoreFullException) { } + if (disposing) + { + try { _completionSignal.Release(); } catch (SemaphoreFullException) { } + } + + base.Dispose(disposing); } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs new file mode 100644 index 000000000..5453333e7 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs @@ -0,0 +1,97 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// A write-only, non-seekable subclass that streams response data + /// to the Lambda Runtime API. Returned by . + /// Integrates with standard .NET stream consumers such as . + /// + public partial class LambdaResponseStream : Stream, ILambdaResponseStream + { + // ── System.IO.Stream — capabilities ───────────────────────────────── + + /// Gets a value indicating whether the stream supports reading. Always false. + public override bool CanRead => false; + + /// Gets a value indicating whether the stream supports seeking. Always false. + public override bool CanSeek => false; + + /// Gets a value indicating whether the stream supports writing. Always true. + public override bool CanWrite => true; + + // ── System.IO.Stream — Length / Position ──────────────────────────── + + /// + /// Gets the total number of bytes written to the stream so far. + /// Equivalent to . + /// + public override long Length => BytesWritten; + + /// + /// Getting or setting the position is not supported. + /// + /// Always thrown. + public override long Position + { + get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + } + + // ── System.IO.Stream — seek / read (not supported) ────────────────── + + /// Not supported. + /// Always thrown. + public override long Seek(long offset, SeekOrigin origin) + => throw new NotImplementedException("LambdaResponseStream does not support seeking."); + + /// Not supported. + /// Always thrown. + public override int Read(byte[] buffer, int offset, int count) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + /// Not supported. + /// Always thrown. + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + // ── System.IO.Stream — write ───────────────────────────────────────── + + /// + /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. + /// Prefer to avoid blocking. + /// + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + + // ── System.IO.Stream — flush / set length ──────────────────────────── + + /// + /// Flush is a no-op; data is sent to the Runtime API immediately on each write. + /// + public override void Flush() { } + + /// Not supported. + /// Always thrown. + public override void SetLength(long value) + => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); + } +} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs similarity index 88% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs index dc0b4a629..c6a58c81d 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs @@ -21,18 +21,13 @@ namespace Amazon.Lambda.RuntimeSupport /// /// Internal context class used by ResponseStreamFactory to track per-invocation streaming state. /// - internal class ResponseStreamContext + internal class LambdaResponseStreamContext { /// /// The AWS request ID for the current invocation. /// public string AwsRequestId { get; set; } - /// - /// Maximum allowed response size in bytes (20 MiB). - /// - public long MaxResponseSize { get; set; } - /// /// Whether CreateStream() has been called for this invocation. /// @@ -41,7 +36,7 @@ internal class ResponseStreamContext /// /// The ResponseStream instance if created. /// - public ResponseStream Stream { get; set; } + public LambdaResponseStream Stream { get; set; } /// /// The RuntimeApiClient used to start the streaming HTTP POST. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs similarity index 79% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs index 613980fb1..84d8c0ebd 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs @@ -23,22 +23,25 @@ namespace Amazon.Lambda.RuntimeSupport /// Factory for creating streaming responses in AWS Lambda functions. /// Call CreateStream() within your handler to opt into response streaming for that invocation. /// - public static class ResponseStreamFactory + public static class LambdaResponseStreamFactory { // For on-demand mode (single invocation at a time) - private static ResponseStreamContext _onDemandContext; + private static LambdaResponseStreamContext _onDemandContext; // For multi-concurrency mode (multiple concurrent invocations) - private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); + private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); /// /// Creates a streaming response for the current invocation. /// Can only be called once per invocation. /// - /// An IResponseStream for writing response data. + /// + /// A — a subclass — for writing + /// response data. The returned stream also implements . + /// /// Thrown if called outside an invocation context. /// Thrown if called more than once per invocation. - public static IResponseStream CreateStream() + public static LambdaResponseStream CreateStream() { var context = GetCurrentContext(); @@ -54,29 +57,28 @@ public static IResponseStream CreateStream() "ResponseStreamFactory.CreateStream() can only be called once per invocation."); } - var stream = new ResponseStream(context.MaxResponseSize); - context.Stream = stream; + var lambdaStream = new LambdaResponseStream(); + context.Stream = lambdaStream; context.StreamCreated = true; // Start the HTTP POST to the Runtime API. // This runs concurrently — SerializeToStreamAsync will block // until the handler finishes writing or reports an error. context.SendTask = context.RuntimeApiClient.StartStreamingResponseAsync( - context.AwsRequestId, stream, context.CancellationToken); + context.AwsRequestId, lambdaStream, context.CancellationToken); - return stream; + return lambdaStream; } // Internal methods for LambdaBootstrap to manage state internal static void InitializeInvocation( - string awsRequestId, long maxResponseSize, bool isMultiConcurrency, + string awsRequestId, bool isMultiConcurrency, RuntimeApiClient runtimeApiClient, CancellationToken cancellationToken) { - var context = new ResponseStreamContext + var context = new LambdaResponseStreamContext { AwsRequestId = awsRequestId, - MaxResponseSize = maxResponseSize, StreamCreated = false, Stream = null, RuntimeApiClient = runtimeApiClient, @@ -93,7 +95,7 @@ internal static void InitializeInvocation( } } - internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) + internal static LambdaResponseStream GetStreamIfCreated(bool isMultiConcurrency) { var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; return context?.Stream; @@ -121,7 +123,7 @@ internal static void CleanupInvocation(bool isMultiConcurrency) } } - private static ResponseStreamContext GetCurrentContext() + private static LambdaResponseStreamContext GetCurrentContext() { // Check multi-concurrency first (AsyncLocal), then on-demand return _asyncLocalContext.Value ?? _onDemandContext; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index 13c4e4eac..f594d5e56 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -189,7 +189,7 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null /// The optional cancellation token to use. /// A Task representing the in-flight HTTP POST. internal virtual async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs index 7eeec86a2..c1e99ed17 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs @@ -20,11 +20,6 @@ namespace Amazon.Lambda.RuntimeSupport /// internal static class StreamingConstants { - /// - /// Maximum response size for Lambda streaming responses: 20 MiB. - /// - public const long MaxResponseSize = 20 * 1024 * 1024; - /// /// Header name for Lambda response mode. /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index e563d343b..c642873aa 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -30,9 +30,9 @@ internal class StreamingHttpContent : HttpContent private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); - private readonly ResponseStream _responseStream; + private readonly LambdaResponseStream _responseStream; - public StreamingHttpContent(ResponseStream responseStream) + public StreamingHttpContent(LambdaResponseStream responseStream) { _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs deleted file mode 100644 index 703ac0cd9..000000000 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/InvocationResponseTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System.IO; -using Xunit; - -namespace Amazon.Lambda.RuntimeSupport.UnitTests -{ - public class InvocationResponseTests - { - private const long MaxResponseSize = 20 * 1024 * 1024; - - /// - /// Property 17: InvocationResponse Streaming Flag - Existing constructors set IsStreaming to false. - /// Validates: Requirements 7.3, 8.1 - /// - [Fact] - public void Constructor_WithStream_IsStreamingIsFalse() - { - var response = new InvocationResponse(new MemoryStream()); - - Assert.False(response.IsStreaming); - Assert.Null(response.ResponseStream); - } - - [Fact] - public void Constructor_WithStreamAndDispose_IsStreamingIsFalse() - { - var response = new InvocationResponse(new MemoryStream(), false); - - Assert.False(response.IsStreaming); - Assert.Null(response.ResponseStream); - } - - /// - /// Property 17: InvocationResponse Streaming Flag - CreateStreamingResponse sets IsStreaming to true. - /// Validates: Requirements 7.3, 8.1 - /// - [Fact] - public void CreateStreamingResponse_SetsIsStreamingTrue() - { - var stream = new ResponseStream(MaxResponseSize); - - var response = InvocationResponse.CreateStreamingResponse(stream); - - Assert.True(response.IsStreaming); - } - - [Fact] - public void CreateStreamingResponse_SetsResponseStream() - { - var stream = new ResponseStream(MaxResponseSize); - - var response = InvocationResponse.CreateStreamingResponse(stream); - - Assert.Same(stream, response.ResponseStream); - } - - [Fact] - public void CreateStreamingResponse_DoesNotDisposeOutputStream() - { - var stream = new ResponseStream(MaxResponseSize); - - var response = InvocationResponse.CreateStreamingResponse(stream); - - Assert.False(response.DisposeOutputStream); - } - } -} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index ce922d529..ae40b7e2e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -314,7 +314,7 @@ public async Task StreamingMode_HandlerCallsCreateStream_SendTaskAwaited() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("hello")); return new InvocationResponse(Stream.Null, false); }; @@ -369,7 +369,7 @@ public async Task MidstreamError_ExceptionAfterWrites_ReportsViaTrailers() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -425,7 +425,7 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("data")); return new InvocationResponse(Stream.Null, false); }; @@ -437,8 +437,8 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() } // After invocation, factory state should be cleaned up - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(false)); - Assert.Null(ResponseStreamFactory.GetSendTask(false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(false)); + Assert.Null(LambdaResponseStreamFactory.GetSendTask(false)); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index 1c714dd97..9fce99ad5 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -28,8 +28,8 @@ public class ResponseStreamFactoryTests : IDisposable public void Dispose() { // Clean up both modes to avoid test pollution - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } /// @@ -40,7 +40,7 @@ private class MockStreamingRuntimeApiClient : RuntimeApiClient { public bool StartStreamingCalled { get; private set; } public string LastAwsRequestId { get; private set; } - public ResponseStream LastResponseStream { get; private set; } + public LambdaResponseStream LastResponseStream { get; private set; } public TaskCompletionSource SendTaskCompletion { get; } = new TaskCompletionSource(); public MockStreamingRuntimeApiClient() @@ -49,7 +49,7 @@ public MockStreamingRuntimeApiClient() } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastAwsRequestId = awsRequestId; @@ -60,8 +60,8 @@ internal override async Task StartStreamingResponseAsync( private void InitializeWithMock(string requestId, bool isMultiConcurrency, MockStreamingRuntimeApiClient mockClient) { - ResponseStreamFactory.InitializeInvocation( - requestId, MaxResponseSize, isMultiConcurrency, + LambdaResponseStreamFactory.InitializeInvocation( + requestId, isMultiConcurrency, mockClient, CancellationToken.None); } @@ -77,10 +77,10 @@ public void CreateStream_OnDemandMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-1", isMultiConcurrency: false, mock); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } /// @@ -93,10 +93,10 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-2", isMultiConcurrency: true, mock); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } // --- Property 4: Single Stream Per Invocation --- @@ -110,16 +110,16 @@ public void CreateStream_CalledTwice_ThrowsInvalidOperationException() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-3", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - Assert.Throws(() => ResponseStreamFactory.CreateStream()); + Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); } [Fact] public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationException() { // No InitializeInvocation called - Assert.Throws(() => ResponseStreamFactory.CreateStream()); + Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); } // --- CreateStream starts HTTP POST --- @@ -134,7 +134,7 @@ public void CreateStream_CallsStartStreamingResponseAsync() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-start", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); Assert.True(mock.StartStreamingCalled); Assert.Equal("req-start", mock.LastAwsRequestId); @@ -153,9 +153,9 @@ public void GetSendTask_AfterCreateStream_ReturnsNonNullTask() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-send", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.NotNull(sendTask); } @@ -165,14 +165,14 @@ public void GetSendTask_BeforeCreateStream_ReturnsNull() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-nosend", isMultiConcurrency: false, mock); - var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.Null(sendTask); } [Fact] public void GetSendTask_NoContext_ReturnsNull() { - Assert.Null(ResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); } // --- Internal methods --- @@ -183,9 +183,9 @@ public void InitializeInvocation_OnDemand_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-4", isMultiConcurrency: false, mock); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); } @@ -195,9 +195,9 @@ public void InitializeInvocation_MultiConcurrency_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-5", isMultiConcurrency: true, mock); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); } @@ -206,16 +206,16 @@ public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-6", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); Assert.NotNull(retrieved); } [Fact] public void GetStreamIfCreated_NoContext_ReturnsNull() { - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); } [Fact] @@ -223,12 +223,12 @@ public void CleanupInvocation_ClearsState() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-7", isMultiConcurrency: false, mock); - ResponseStreamFactory.CreateStream(); + LambdaResponseStreamFactory.CreateStream(); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - Assert.Throws(() => ResponseStreamFactory.CreateStream()); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); } // --- Property 16: State Isolation Between Invocations --- @@ -244,17 +244,17 @@ public void StateIsolation_SequentialInvocations_NoLeakage() // First invocation - streaming InitializeWithMock("req-8a", isMultiConcurrency: false, mock); - var stream1 = ResponseStreamFactory.CreateStream(); + var stream1 = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream1); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); // Second invocation - should start fresh InitializeWithMock("req-8b", isMultiConcurrency: false, mock); - Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream2 = ResponseStreamFactory.CreateStream(); + var stream2 = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream2); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); } /// @@ -266,14 +266,14 @@ public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-9", isMultiConcurrency: true, mock); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); Assert.NotNull(stream); bool childSawNull = false; await Task.Run(() => { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); - childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + childSawNull = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; }); Assert.True(childSawNull); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index a6ef2fe6f..735fba482 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -25,15 +25,13 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { public class ResponseStreamTests { - private const long MaxResponseSize = 20 * 1024 * 1024; // 20 MiB - /// /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. /// Returns both so tests can inspect what was written. /// - private static (ResponseStream stream, MemoryStream httpOutput) CreateWiredStream(long maxSize = MaxResponseSize) + private static (LambdaResponseStream stream, MemoryStream httpOutput) CreateWiredStream() { - var rs = new ResponseStream(maxSize); + var rs = new LambdaResponseStream(); var output = new MemoryStream(); rs.SetHttpOutputStream(output); return (rs, output); @@ -44,7 +42,7 @@ private static (ResponseStream stream, MemoryStream httpOutput) CreateWiredStrea [Fact] public void Constructor_InitializesStateCorrectly() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); Assert.Equal(0, stream.BytesWritten); Assert.False(stream.IsCompleted); @@ -100,27 +98,6 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() Assert.Equal(expected, written); } - /// - /// Property 9: Chunked Encoding Format — ReadOnlyMemory overload. - /// Validates: Requirements 3.2, 10.1 - /// - [Fact] - public async Task WriteAsync_ReadOnlyMemory_WritesChunkedFormat() - { - var (stream, httpOutput) = CreateWiredStream(); - var data = new ReadOnlyMemory(new byte[] { 10, 20, 30 }); - - await stream.WriteAsync(data); - - var written = httpOutput.ToArray(); - var expected = Encoding.ASCII.GetBytes("3\r\n") - .Concat(new byte[] { 10, 20, 30 }) - .Concat(Encoding.ASCII.GetBytes("\r\n")) - .ToArray(); - - Assert.Equal(expected, written); - } - // ---- Property 5: Written Data Appears in HTTP Response Immediately ---- /// @@ -170,7 +147,7 @@ public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() [Fact] public async Task WriteAsync_BlocksUntilSetHttpOutputStream() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var httpOutput = new MemoryStream(); var writeStarted = new ManualResetEventSlim(false); var writeCompleted = new ManualResetEventSlim(false); @@ -277,7 +254,7 @@ public async Task SizeLimit_ExactlyAtLimit_Succeeds() await stream.WriteAsync(data); - Assert.Equal(MaxResponseSize, stream.BytesWritten); + Assert.Equal(data.Length, stream.BytesWritten); } // ---- Property 19: Writes After Completion Rejected ---- @@ -317,7 +294,7 @@ await Assert.ThrowsAsync( [Fact] public async Task ReportErrorAsync_SetsErrorState() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var exception = new InvalidOperationException("something broke"); await stream.ReportErrorAsync(exception); @@ -329,7 +306,7 @@ public async Task ReportErrorAsync_SetsErrorState() [Fact] public async Task ReportErrorAsync_AfterCompleted_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); stream.MarkCompleted(); await Assert.ThrowsAsync( @@ -339,7 +316,7 @@ await Assert.ThrowsAsync( [Fact] public async Task ReportErrorAsync_CalledTwice_Throws() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); await stream.ReportErrorAsync(new Exception("first")); await Assert.ThrowsAsync( @@ -349,7 +326,7 @@ await Assert.ThrowsAsync( [Fact] public void MarkCompleted_SetsCompletionState() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); stream.MarkCompleted(); @@ -377,7 +354,7 @@ public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() [Fact] public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); await Assert.ThrowsAsync(() => stream.ReportErrorAsync(null)); } @@ -387,7 +364,7 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() [Fact] public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index 75abec101..fbc4a8ae6 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -40,9 +40,9 @@ public class RuntimeApiClientTests private class MockHttpMessageHandler : HttpMessageHandler { public HttpRequestMessage CapturedRequest { get; private set; } - private readonly ResponseStream _responseStream; + private readonly LambdaResponseStream _responseStream; - public MockHttpMessageHandler(ResponseStream responseStream) + public MockHttpMessageHandler(LambdaResponseStream responseStream) { _responseStream = responseStream; } @@ -57,7 +57,7 @@ protected override Task SendAsync( } private static RuntimeApiClient CreateClientWithMockHandler( - ResponseStream stream, out MockHttpMessageHandler handler) + LambdaResponseStream stream, out MockHttpMessageHandler handler) { handler = new MockHttpMessageHandler(stream); var httpClient = new HttpClient(handler); @@ -77,7 +77,7 @@ private static RuntimeApiClient CreateClientWithMockHandler( [Fact] public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeader() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-1", stream, CancellationToken.None); @@ -100,7 +100,7 @@ public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeade [Fact] public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHeader() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-2", stream, CancellationToken.None); @@ -121,7 +121,7 @@ public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHea [Fact] public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-3", stream, CancellationToken.None); @@ -144,7 +144,7 @@ public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() [Fact] public async Task StartStreamingResponseAsync_MarksStreamCompletedAfterSuccess() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out _); await client.StartStreamingResponseAsync("req-4", stream, CancellationToken.None); @@ -203,7 +203,7 @@ public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() [Fact] public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( @@ -213,7 +213,7 @@ await Assert.ThrowsAsync( [Fact] public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentNullException() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 53b1e88b7..1f85f47a8 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -32,8 +32,8 @@ public class StreamingHttpContentTests /// Returns the bytes written to the HTTP output stream. /// private async Task SerializeWithConcurrentHandler( - ResponseStream responseStream, - Func handlerAction) + LambdaResponseStream responseStream, + Func handlerAction) { var content = new StreamingHttpContent(responseStream); var outputStream = new MemoryStream(); @@ -63,7 +63,7 @@ private async Task SerializeWithConcurrentHandler( [Fact] public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -84,7 +84,7 @@ public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() [Fact] public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -108,7 +108,7 @@ public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() [Fact] public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -132,7 +132,7 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() [Fact] public async Task FinalChunk_WrittenAfterCompletion() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -156,7 +156,7 @@ public async Task FinalChunk_WrittenAfterCompletion() [Fact] public async Task FinalChunk_EmptyStream_StillWritten() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, stream => { @@ -177,7 +177,7 @@ public async Task FinalChunk_EmptyStream_StillWritten() [Fact] public async Task ErrorTrailers_AppearAfterFinalChunk() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -210,7 +210,7 @@ public async Task ErrorTrailers_AppearAfterFinalChunk() [InlineData(typeof(NullReferenceException))] public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -232,7 +232,7 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -255,7 +255,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() [Fact] public async Task SuccessfulCompletion_EndsWithCrlf() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -275,7 +275,7 @@ public async Task SuccessfulCompletion_EndsWithCrlf() [Fact] public async Task ErrorCompletion_EndsWithCrlf() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -292,7 +292,7 @@ public async Task ErrorCompletion_EndsWithCrlf() [Fact] public async Task NoError_NoTrailersWritten() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -310,7 +310,7 @@ public async Task NoError_NoTrailersWritten() [Fact] public void TryComputeLength_ReturnsFalse() { - var stream = new ResponseStream(MaxResponseSize); + var stream = new LambdaResponseStream(); var content = new StreamingHttpContent(stream); var result = content.Headers.ContentLength; @@ -326,7 +326,7 @@ public void TryComputeLength_ReturnsFalse() [Fact] public async Task CrlfTerminators_NoBareLineFeed() { - var rs = new ResponseStream(MaxResponseSize); + var rs = new LambdaResponseStream(); var output = await SerializeWithConcurrentHandler(rs, async stream => { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs index c2bd34bdf..0f15680f4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs @@ -38,8 +38,8 @@ public class StreamingIntegrationTests : IDisposable { public void Dispose() { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -67,7 +67,7 @@ private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApi public bool SendResponseCalled { get; private set; } public bool ReportInvocationErrorCalled { get; private set; } public byte[] CapturedHttpBytes { get; private set; } - public ResponseStream LastResponseStream { get; private set; } + public LambdaResponseStream LastResponseStream { get; private set; } public Stream LastBufferedOutputStream { get; private set; } public new Amazon.Lambda.RuntimeSupport.Helpers.IConsoleLoggerWriter ConsoleLogger { get; } = new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables()); @@ -97,7 +97,7 @@ public CapturingStreamingRuntimeApiClient( } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastResponseStream = responseStream; @@ -159,7 +159,7 @@ public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); foreach (var chunk in chunks) await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); return new InvocationResponse(Stream.Null, false); @@ -196,7 +196,7 @@ public async Task Streaming_AllDataTransmitted_ContentRoundTrip() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(payload); return new InvocationResponse(Stream.Null, false); }; @@ -227,7 +227,7 @@ public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(data); return new InvocationResponse(Stream.Null, false); }; @@ -309,7 +309,7 @@ public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -355,7 +355,7 @@ public async Task PreStreamError_ExceptionBeforeAnyWrite_UsesStandardErrorReport LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); // Throw before writing anything throw new ArgumentException("pre-write failure"); }; @@ -381,7 +381,7 @@ public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() LambdaBootstrapHandler handler = async (invocation) => { - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); await stream.WriteAsync(Encoding.UTF8.GetBytes("some data")); throw new InvalidOperationException(errorMessage); }; @@ -419,27 +419,26 @@ public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - ResponseStreamFactory.InitializeInvocation( + LambdaResponseStreamFactory.InitializeInvocation( requestId, - StreamingConstants.MaxResponseSize, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); allStarted.Release(); // Wait until all tasks have started (to ensure true concurrency) await barrier.WaitAsync(); await stream.WriteAsync(Encoding.UTF8.GetBytes(payload)); - ((ResponseStream)stream).MarkCompleted(); + stream.MarkCompleted(); // Verify this invocation's stream is still accessible - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); results[requestId] = retrieved != null ? payload : "MISSING"; - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -477,20 +476,20 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - ResponseStreamFactory.InitializeInvocation( - requestId, StreamingConstants.MaxResponseSize, + LambdaResponseStreamFactory.InitializeInvocation( + requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = ResponseStreamFactory.CreateStream(); + var stream = LambdaResponseStreamFactory.CreateStream(); allStarted.Release(); await barrier.WaitAsync(); await stream.WriteAsync(Encoding.UTF8.GetBytes("streaming data")); - ((ResponseStream)stream).MarkCompleted(); + stream.MarkCompleted(); - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); streamingResults.Add(retrieved != null); - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -501,17 +500,17 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - ResponseStreamFactory.InitializeInvocation( - requestId, StreamingConstants.MaxResponseSize, + LambdaResponseStreamFactory.InitializeInvocation( + requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); allStarted.Release(); await barrier.WaitAsync(); // No CreateStream — buffered mode - var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); bufferedResults.Add(retrieved == null); // should be null (no stream created) - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -538,7 +537,7 @@ public MockMultiConcurrencyStreamingClient() : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block responseStream.SetHttpOutputStream(new MemoryStream()); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index 1128bb075..da68d2940 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -54,7 +54,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, public byte[] FunctionInput { get; set; } public Stream LastOutputStream { get; private set; } public Exception LastRecordedException { get; private set; } - public ResponseStream LastStreamingResponseStream { get; private set; } + public LambdaResponseStream LastStreamingResponseStream { get; private set; } public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) { @@ -108,7 +108,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, } internal override async Task StartStreamingResponseAsync( - string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingResponseAsyncCalled = true; LastStreamingResponseStream = responseStream; From 21d82d85116fd9f01fbfdc612c3be2cd5ae1b25e Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 17:58:02 -0800 Subject: [PATCH 12/47] Cleanup --- .../Client/ILambdaResponseStream.cs | 2 +- .../serverless.template | 659 +++++++++- .../serverless.template | 22 +- .../TestServerlessApp/serverless.template | 1149 ++++++++++++++++- 4 files changed, 1826 insertions(+), 6 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs index 36236b28d..d3565fdbc 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs @@ -21,7 +21,7 @@ namespace Amazon.Lambda.RuntimeSupport { /// /// Interface for writing streaming responses in AWS Lambda functions. - /// Obtained by calling ResponseStreamFactory.CreateStream() within a handler. + /// Obtained by calling within a handler. /// public interface ILambdaResponseStream : IDisposable { diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index 229385aba..ac43959b7 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -21,7 +21,662 @@ ] } }, - "Resources": {}, + "Resources": { + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "OkResponseWithHeader" + } + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheader/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "OkResponseWithHeaderAsync" + } + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheaderasync/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV2Async" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2async/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NotFoundResponseWithHeaderV1Async" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1async/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppDynamicExampleDynamicReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "DynamicReturn" + } + } + } + }, + "TestServerlessAppDynamicExampleDynamicInputGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "DynamicInput" + } + } + } + }, + "GreeterSayHello": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 1024, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "SayHello" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHello", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "GreeterSayHelloAsync": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 50, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "SayHelloAsync" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHelloAsync", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "HasIntrinsic" + } + } + } + }, + "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NullableHeaderHttpApi" + } + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/nullableheaderhttpapi", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppParameterlessMethodsNoParameterGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "provided.al2", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestExecutableServerlessApp", + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NoParameter" + } + } + } + }, + "TestServerlessAppParameterlessMethodWithResponseNoParameterWithResponseGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "provided.al2", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestExecutableServerlessApp", + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "NoParameterWithResponse" + } + } + } + }, + "TestExecutableServerlessAppSourceGenerationSerializationExampleGetPersonGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "GetPerson" + } + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/", + "Method": "GET" + } + } + } + } + }, + "ToUpper": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "ToUpper" + } + } + } + }, + "ToLower": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "provided.al2", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestExecutableServerlessApp", + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "ToLower" + } + } + } + }, + "TestServerlessAppTaskExampleTaskReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "TaskReturn" + } + } + } + }, + "TestServerlessAppVoidExampleVoidReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestExecutableServerlessApp" + ] + }, + "Environment": { + "Variables": { + "ANNOTATIONS_HANDLER": "VoidReturn" + } + } + } + } + }, "Outputs": { "RestApiURL": { "Description": "Rest API endpoint URL for Prod environment", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index 67ec5dfa4..c42ff4a47 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,6 +1,24 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", - "Resources": {} + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", + "Resources": { + "TestServerlessAppNET8FunctionsToUpperGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet8", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "TestServerlessApp.NET8::TestServerlessApp.NET8.Functions_ToUpper_Generated::ToUpper" + } + } + } } \ No newline at end of file diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index e6c1b8bea..0e3befbe1 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.8.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.9.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", @@ -28,6 +28,1153 @@ "Resources": { "TestQueue": { "Type": "AWS::SQS::Queue" + }, + "TestServerlessAppDynamicExampleDynamicReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicReturn_Generated::DynamicReturn" + ] + } + } + }, + "TestServerlessAppDynamicExampleDynamicInputGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.DynamicExample_DynamicInput_Generated::DynamicInput" + ] + } + } + }, + "TestServerlessAppFromScratchNoApiGatewayEventsReferenceToUpperGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FromScratch.NoApiGatewayEventsReference_ToUpper_Generated::ToUpper" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/{text}", + "Method": "GET" + } + } + } + } + }, + "HttpApiAuthorizerTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiExample_HttpApiAuthorizer_Generated::HttpApiAuthorizer" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorAdd": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Add_Generated::Add" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/Add", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorSubtract": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Subtract_Generated::Subtract" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/Subtract", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorMultiply": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Multiply_Generated::Multiply" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/Multiply/{x}/{y}", + "Method": "GET" + } + } + } + } + }, + "SimpleCalculatorDivideAsync": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_DivideAsync_Generated::DivideAsync" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/SimpleCalculator/DivideAsync/{x}/{y}", + "Method": "GET" + } + } + } + } + }, + "PI": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Pi_Generated::Pi" + ] + } + } + }, + "Random": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Random_Generated::Random" + ] + } + } + }, + "Randoms": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SimpleCalculator_Randoms_Generated::Randoms" + ] + } + } + }, + "SQSMessageHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestQueueEvent" + ], + "SyncedEventProperties": { + "TestQueueEvent": [ + "Queue.Fn::GetAtt", + "BatchSize", + "FilterCriteria.Filters", + "FunctionResponseTypes", + "MaximumBatchingWindowInSeconds", + "ScalingConfig.MaximumConcurrency" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaSQSQueueExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SqsMessageProcessing_HandleMessage_Generated::HandleMessage" + ] + }, + "Events": { + "TestQueueEvent": { + "Type": "SQS", + "Properties": { + "BatchSize": 50, + "FilterCriteria": { + "Filters": [ + { + "Pattern": "{ \"body\" : { \"RequestCode\" : [ \"BBBB\" ] } }" + } + ] + }, + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "MaximumBatchingWindowInSeconds": 5, + "ScalingConfig": { + "MaximumConcurrency": 5 + }, + "Queue": { + "Fn::GetAtt": [ + "TestQueue", + "Arn" + ] + } + } + } + } + } + }, + "HttpApiV1AuthorizerTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerHttpApiV1Example_HttpApiV1Authorizer_Generated::HttpApiV1Authorizer" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer-v1", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppNullableReferenceTypeExampleNullableHeaderHttpApiGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.NullableReferenceTypeExample_NullableHeaderHttpApi_Generated::NullableHeaderHttpApi" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/nullableheaderhttpapi", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomAuthorizerWithIHttpResultsExampleAuthorizerWithIHttpResultsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerWithIHttpResultsExample_AuthorizerWithIHttpResults_Generated::AuthorizerWithIHttpResults" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/authorizerihttpresults", + "Method": "GET" + } + } + } + } + }, + "GreeterSayHello": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 1024, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.Greeter_SayHello_Generated::SayHello" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHello", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "GreeterSayHelloAsync": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 50, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.Greeter_SayHelloAsync_Generated::SayHelloAsync" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/Greeter/SayHelloAsync", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeader_Generated::OkResponseWithHeader" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheader/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithHeaderAsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithHeaderAsync_Generated::OkResponseWithHeaderAsync" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/okresponsewithheaderasync/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2_Generated::NotFoundResponseWithHeaderV2" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV2AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV2Async_Generated::NotFoundResponseWithHeaderV2Async" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv2async/{x}", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1Generated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1_Generated::NotFoundResponseWithHeaderV1" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesNotFoundResponseWithHeaderV1AsyncGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_NotFoundResponseWithHeaderV1Async_Generated::NotFoundResponseWithHeaderV1Async" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/notfoundwithheaderv1async/{x}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppCustomizeResponseExamplesOkResponseWithCustomSerializerGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method", + "PayloadFormatVersion" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomizeResponseExamples_OkResponseWithCustomSerializer_Generated::OkResponseWithCustomSerializer" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/okresponsewithcustomserializerasync/{firstName}/{lastName}", + "Method": "GET", + "PayloadFormatVersion": "1.0" + } + } + } + } + }, + "TestServerlessAppFromScratchNoSerializerAttributeReferenceToUpperGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FromScratch.NoSerializerAttributeReference_ToUpper_Generated::ToUpper" + ] + } + } + }, + "TestServerlessAppIntrinsicExampleHasIntrinsicGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.IntrinsicExample_HasIntrinsic_Generated::HasIntrinsic" + ] + } + } + }, + "HttpApiNonString": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerNonStringExample_HttpApiWithNonString_Generated::HttpApiWithNonString" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer-non-string", + "Method": "GET" + } + } + } + } + }, + "AuthNameFallbackTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.AuthNameFallback_GetUserId_Generated::GetUserId" + ] + }, + "Events": { + "RootGet": { + "Type": "HttpApi", + "Properties": { + "Path": "/api/authorizer-fallback", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppVoidExampleVoidReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.VoidExample_VoidReturn_Generated::VoidReturn" + ] + } + } + }, + "RestAuthorizerTest": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootGet" + ], + "SyncedEventProperties": { + "RootGet": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.CustomAuthorizerRestExample_RestAuthorizer_Generated::RestAuthorizer" + ] + }, + "Events": { + "RootGet": { + "Type": "Api", + "Properties": { + "Path": "/rest/authorizer", + "Method": "GET" + } + } + } + } + }, + "TestServerlessAppTaskExampleTaskReturnGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.TaskExample_TaskReturn_Generated::TaskReturn" + ] + } + } + }, + "ToUpper": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.Sub1.Functions_ToUpper_Generated::ToUpper" + ] + } + } + }, + "TestServerlessAppComplexCalculatorAddGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootPost" + ], + "SyncedEventProperties": { + "RootPost": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.ComplexCalculator_Add_Generated::Add" + ] + }, + "Events": { + "RootPost": { + "Type": "HttpApi", + "Properties": { + "Path": "/ComplexCalculator/Add", + "Method": "POST" + } + } + } + } + }, + "TestServerlessAppComplexCalculatorSubtractGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "RootPost" + ], + "SyncedEventProperties": { + "RootPost": [ + "Path", + "Method" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.ComplexCalculator_Subtract_Generated::Subtract" + ] + }, + "Events": { + "RootPost": { + "Type": "HttpApi", + "Properties": { + "Path": "/ComplexCalculator/Subtract", + "Method": "POST" + } + } + } + } } }, "Outputs": { From 556b7262472ba70f31c693c45ecf9f42200da582 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 18 Feb 2026 23:41:19 -0800 Subject: [PATCH 13/47] Remove tests --- .../c27a62e6-91ca-4a59-9406-394866cdfa62.json | 11 +++++ .../ResponseStreamTests.cs | 41 ------------------- 2 files changed, 11 insertions(+), 41 deletions(-) create mode 100644 .autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json diff --git a/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json new file mode 100644 index 000000000..9ad5afe6e --- /dev/null +++ b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.RuntimeSupport", + "Type": "Minor", + "ChangelogMessages": [ + "Add response streaming support" + ] + } + ] +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 735fba482..a4d265228 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -216,47 +216,6 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() Assert.True(stream.HasError); } - // ---- Property 6: Size Limit Enforcement ---- - - /// - /// Property 6: Size Limit Enforcement — single write exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Theory] - [InlineData(21 * 1024 * 1024)] - public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) - { - var (stream, _) = CreateWiredStream(); - var data = new byte[writeSize]; - - await Assert.ThrowsAsync(() => stream.WriteAsync(data)); - } - - /// - /// Property 6: Size Limit Enforcement — multiple writes exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Fact] - public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() - { - var (stream, _) = CreateWiredStream(); - - await stream.WriteAsync(new byte[10 * 1024 * 1024]); - await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[11 * 1024 * 1024])); - } - - [Fact] - public async Task SizeLimit_ExactlyAtLimit_Succeeds() - { - var (stream, _) = CreateWiredStream(); - var data = new byte[20 * 1024 * 1024]; - - await stream.WriteAsync(data); - - Assert.Equal(data.Length, stream.BytesWritten); - } - // ---- Property 19: Writes After Completion Rejected ---- /// From 4d5dee2fd25fa22cd55c41fa278c682ec381981f Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 4 Mar 2026 16:10:29 -0800 Subject: [PATCH 14/47] Clean up and rework Semaphore locks --- .../Bootstrap/LambdaBootstrap.cs | 27 +++---- .../Client/ILambdaResponseStream.cs | 13 ---- ...bdaResponseStream.ILambdaResponseStream.cs | 55 +++++++------- .../Client/RuntimeApiClient.cs | 2 - .../Client/StreamingHttpContent.cs | 3 + .../ResponseStreamTests.cs | 73 +++---------------- .../RuntimeApiClientTests.cs | 19 ----- ...grationTests.cs => StreamingE2EWithMoq.cs} | 28 +------ .../StreamingHttpContentTests.cs | 10 +-- 9 files changed, 62 insertions(+), 168 deletions(-) rename Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/{StreamingIntegrationTests.cs => StreamingE2EWithMoq.cs} (95%) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index 6241fb61f..ba44c05ed 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -385,15 +385,13 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { WriteUnhandledExceptionToLog(exception); - var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); - if (streamIfCreated != null && streamIfCreated.BytesWritten > 0) + var responseStream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (responseStream != null) { - // Midstream error — report via trailers on the already-open HTTP connection - await streamIfCreated.ReportErrorAsync(exception); + responseStream.ReportError(exception); } else { - // Error before streaming started — use standard error reporting await Client.ReportInvocationErrorAsync(invocation.LambdaContext.AwsRequestId, exception, cancellationToken); } } @@ -402,17 +400,20 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul _logger.LogInformation("Finished invoking handler"); } - // If streaming was started, await the HTTP send task to ensure it completes - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); - if (sendTask != null) + var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + if (streamIfCreated != null) { - var stream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); - if (stream != null && !stream.IsCompleted && !stream.HasError) + streamIfCreated.MarkCompleted(); + + // If streaming was started, await the HTTP send task to ensure it completes + var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); + if (sendTask != null) { - // Handler returned successfully — signal stream completion - stream.MarkCompleted(); + // Wait for the streaming response to finish sending before allowing the next invocation to be processed. This ensures that responses are sent in the order the invocations were received. + await sendTask; } - await sendTask; // Wait for HTTP request to finish + + streamIfCreated.ManualDispose(); } else if (invokeSucceeded) { diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs index d3565fdbc..af7c1a59f 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs @@ -45,25 +45,12 @@ public interface ILambdaResponseStream : IDisposable /// Thrown if the stream is already completed or an error has been reported. Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - /// - /// Reports an error that occurred during streaming. - /// This will send error information via HTTP trailing headers. - /// - /// The exception to report. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has already been reported. - Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default); /// /// Gets the total number of bytes written to the stream so far. /// long BytesWritten { get; } - /// - /// Gets whether the stream has been completed. - /// - bool IsCompleted { get; } /// /// Gets whether an error has been reported. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs index 7830c81b4..9a5e6a651 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs @@ -45,11 +45,6 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream /// public long BytesWritten => _bytesWritten; - /// - /// Gets a value indicating whether the operation has completed. - /// - public bool IsCompleted => _isCompleted; - /// /// Gets a value indicating whether an error has occurred. /// @@ -144,10 +139,8 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc /// This will send error information via HTTP trailing headers. /// /// The exception to report. - /// Optional cancellation token. - /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has already been reported. - public Task ReportErrorAsync(Exception exception, CancellationToken cancellationToken = default) + internal void ReportError(Exception exception) { if (exception == null) throw new ArgumentNullException(nameof(exception)); @@ -161,22 +154,45 @@ public Task ReportErrorAsync(Exception exception, CancellationToken cancellation _hasError = true; _reportedError = exception; - } + + _isCompleted = true; + } // Signal completion so StreamingHttpContent can write error trailers and finish _completionSignal.Release(); - - return Task.CompletedTask; } internal void MarkCompleted() { + bool shouldReleaseLock = false; lock (_lock) { + // Release lock if not already completed, otherwise do nothing (idempotent) + if (!_isCompleted) + { + shouldReleaseLock = true; + } _isCompleted = true; } - // Signal completion so StreamingHttpContent can write the final chunk and finish - _completionSignal.Release(); + + if (shouldReleaseLock) + { + // Signal completion so StreamingHttpContent can write the final chunk and finish + _completionSignal.Release(); + } + } + + /// + /// The resouces like the SemaphoreSlims are manually disposed by LambdaBootstrap after each invocation instead of relying on the + /// Dipose pattern because we don't want the user's Lambda function to trigger Releasing and disposing the semaphores when + /// invocation of the user's code ends. + /// + internal void ManualDispose() + { + try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _httpStreamReady.Dispose(); + try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _completionSignal.Dispose(); } private void ThrowIfCompletedOrError() @@ -186,18 +202,5 @@ private void ThrowIfCompletedOrError() if (_hasError) throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); } - - // ── Dispose ────────────────────────────────────────────────────────── - - /// - protected override void Dispose(bool disposing) - { - if (disposing) - { - try { _completionSignal.Release(); } catch (SemaphoreFullException) { } - } - - base.Dispose(disposing); - } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index f594d5e56..e142b3719 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -214,8 +214,6 @@ internal virtual async Task StartStreamingResponseAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); } - - responseStream.MarkCompleted(); } /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index c642873aa..d29e56470 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -19,6 +19,7 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport { @@ -43,6 +44,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // can write chunks directly to it. _responseStream.SetHttpOutputStream(stream); + InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the undlying Lambda response stream in indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) await _responseStream.WaitForCompletionAsync(); @@ -52,6 +54,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // Write error trailers if present if (_responseStream.HasError) { + InternalLogger.GetDefaultLogger().LogError(_responseStream.ReportedError, "An error occurred during Lambda execution. Writing error trailers to response."); await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 735fba482..e9e2690d4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -45,7 +45,6 @@ public void Constructor_InitializesStateCorrectly() var stream = new LambdaResponseStream(); Assert.Equal(0, stream.BytesWritten); - Assert.False(stream.IsCompleted); Assert.False(stream.HasError); Assert.Null(stream.ReportedError); } @@ -192,7 +191,6 @@ public async Task MarkCompleted_ReleasesCompletionSignal() // Should complete within a reasonable time var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); - Assert.True(stream.IsCompleted); } // ---- Completion signaling: ReportErrorAsync releases _completionSignal ---- @@ -209,54 +207,13 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); - await stream.ReportErrorAsync(new Exception("test error")); + stream.ReportError(new Exception("test error")); var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); Assert.True(stream.HasError); } - // ---- Property 6: Size Limit Enforcement ---- - - /// - /// Property 6: Size Limit Enforcement — single write exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Theory] - [InlineData(21 * 1024 * 1024)] - public async Task SizeLimit_SingleWriteExceedingLimit_Throws(int writeSize) - { - var (stream, _) = CreateWiredStream(); - var data = new byte[writeSize]; - - await Assert.ThrowsAsync(() => stream.WriteAsync(data)); - } - - /// - /// Property 6: Size Limit Enforcement — multiple writes exceeding limit throws. - /// Validates: Requirements 3.6, 3.7 - /// - [Fact] - public async Task SizeLimit_MultipleWritesExceedingLimit_Throws() - { - var (stream, _) = CreateWiredStream(); - - await stream.WriteAsync(new byte[10 * 1024 * 1024]); - await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[11 * 1024 * 1024])); - } - - [Fact] - public async Task SizeLimit_ExactlyAtLimit_Succeeds() - { - var (stream, _) = CreateWiredStream(); - var data = new byte[20 * 1024 * 1024]; - - await stream.WriteAsync(data); - - Assert.Equal(data.Length, stream.BytesWritten); - } - // ---- Property 19: Writes After Completion Rejected ---- /// @@ -283,7 +240,7 @@ public async Task WriteAsync_AfterReportError_Throws() { var (stream, _) = CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("test")); + stream.ReportError(new Exception("test")); await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 })); @@ -297,7 +254,7 @@ public async Task ReportErrorAsync_SetsErrorState() var stream = new LambdaResponseStream(); var exception = new InvalidOperationException("something broke"); - await stream.ReportErrorAsync(exception); + stream.ReportError(exception); Assert.True(stream.HasError); Assert.Same(exception, stream.ReportedError); @@ -309,28 +266,18 @@ public async Task ReportErrorAsync_AfterCompleted_Throws() var stream = new LambdaResponseStream(); stream.MarkCompleted(); - await Assert.ThrowsAsync( - () => stream.ReportErrorAsync(new Exception("test"))); + Assert.Throws( + () => stream.ReportError(new Exception("test"))); } [Fact] public async Task ReportErrorAsync_CalledTwice_Throws() { var stream = new LambdaResponseStream(); - await stream.ReportErrorAsync(new Exception("first")); - - await Assert.ThrowsAsync( - () => stream.ReportErrorAsync(new Exception("second"))); - } - - [Fact] - public void MarkCompleted_SetsCompletionState() - { - var stream = new LambdaResponseStream(); - - stream.MarkCompleted(); + stream.ReportError(new Exception("first")); - Assert.True(stream.IsCompleted); + Assert.Throws( + () => stream.ReportError(new Exception("second"))); } // ---- Argument validation ---- @@ -356,7 +303,7 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() { var stream = new LambdaResponseStream(); - await Assert.ThrowsAsync(() => stream.ReportErrorAsync(null)); + Assert.Throws(() => stream.ReportError(null)); } // ---- Dispose signals completion ---- @@ -369,7 +316,7 @@ public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted); - stream.Dispose(); + stream.ManualDispose(); var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index fbc4a8ae6..3a471ab1e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -133,25 +133,6 @@ public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() Assert.Contains(StreamingConstants.ErrorBodyTrailer, trailerValue); } - // --- Property 18: Stream Finalization --- - - /// - /// Property 18: Stream Finalization - /// For any streaming response that completes successfully, the ResponseStream - /// should be marked as completed (IsCompleted = true) after the HTTP response succeeds. - /// **Validates: Requirements 8.3** - /// - [Fact] - public async Task StartStreamingResponseAsync_MarksStreamCompletedAfterSuccess() - { - var stream = new LambdaResponseStream(); - var client = CreateClientWithMockHandler(stream, out _); - - await client.StartStreamingResponseAsync("req-4", stream, CancellationToken.None); - - Assert.True(stream.IsCompleted); - } - // --- Property 10: Buffered Responses Exclude Streaming Headers --- /// diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs similarity index 95% rename from Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs rename to Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 0f15680f4..377aede2d 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingIntegrationTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -34,7 +34,7 @@ public class ResponseStreamFactoryCollection { } /// ResponseStream → StreamingHttpContent → captured HTTP output stream. /// [Collection("ResponseStreamFactory")] - public class StreamingIntegrationTests : IDisposable + public class StreamingE2EWithMoq : IDisposable { public void Dispose() { @@ -238,7 +238,6 @@ public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() Assert.NotNull(client.LastResponseStream); Assert.Equal(data.Length, client.LastResponseStream.BytesWritten); - Assert.True(client.LastResponseStream.IsCompleted); } // ─── 10.2 End-to-end buffered response ────────────────────────────────────── @@ -344,31 +343,6 @@ public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() Assert.False(client.ReportInvocationErrorCalled); } - /// - /// End-to-end: handler throws before writing any data — standard error reporting is used. - /// Requirements: 5.6, 5.7 - /// - [Fact] - public async Task PreStreamError_ExceptionBeforeAnyWrite_UsesStandardErrorReporting() - { - var client = CreateClient(); - - LambdaBootstrapHandler handler = async (invocation) => - { - var stream = LambdaResponseStreamFactory.CreateStream(); - // Throw before writing anything - throw new ArgumentException("pre-write failure"); - }; - - using var bootstrap = new LambdaBootstrap(handler, null); - bootstrap.Client = client; - await bootstrap.InvokeOnceAsync(); - - // BytesWritten == 0, so standard error reporting should be used - Assert.True(client.ReportInvocationErrorCalled, - "Standard error reporting should be used when no bytes were written"); - } - /// /// End-to-end: error body trailer contains JSON with exception details. /// Requirements: 5.2, 5.3 diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 1f85f47a8..4fed4b810 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -117,7 +117,7 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until error is reported"); - await rs.ReportErrorAsync(new Exception("test error")); + rs.ReportError(new Exception("test error")); await serializeTask; Assert.True(serializeTask.IsCompleted); @@ -182,7 +182,7 @@ public async Task ErrorTrailers_AppearAfterFinalChunk() var output = await SerializeWithConcurrentHandler(rs, async stream => { await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); + stream.ReportError(new Exception("fail")); }); var outputStr = Encoding.UTF8.GetString(output); @@ -216,7 +216,7 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { await stream.WriteAsync(new byte[] { 1 }); var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); - await stream.ReportErrorAsync(exception); + stream.ReportError(exception); }); var outputStr = Encoding.UTF8.GetString(output); @@ -237,7 +237,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() var output = await SerializeWithConcurrentHandler(rs, async stream => { await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new InvalidOperationException("something went wrong")); + stream.ReportError(new InvalidOperationException("something went wrong")); }); var outputStr = Encoding.UTF8.GetString(output); @@ -280,7 +280,7 @@ public async Task ErrorCompletion_EndsWithCrlf() var output = await SerializeWithConcurrentHandler(rs, async stream => { await stream.WriteAsync(new byte[] { 1 }); - await stream.ReportErrorAsync(new Exception("fail")); + stream.ReportError(new Exception("fail")); }); var outputStr = Encoding.UTF8.GetString(output); From 645771ea0d90f4eea8b03825effb81bb12039ef2 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 5 Mar 2026 11:06:34 -0800 Subject: [PATCH 15/47] Start working on supporting having a prelude chuck in the response stream --- ...bdaResponseStream.ILambdaResponseStream.cs | 56 ++++++++++++++++++- .../Client/RuntimeApiClient.cs | 2 +- .../Client/StreamingHttpContent.cs | 19 ++++--- .../ResponseStreamTests.cs | 26 ++++----- .../StreamingE2EWithMoq.cs | 2 +- .../TestStreamingRuntimeApiClient.cs | 2 +- 6 files changed, 80 insertions(+), 27 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs index 9a5e6a651..896e00cd8 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs @@ -18,6 +18,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport { @@ -50,29 +51,78 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream /// public bool HasError => _hasError; + private readonly byte[] _prelude; + internal Exception ReportedError => _reportedError; internal LambdaResponseStream() + : this(Array.Empty()) { } + internal LambdaResponseStream(byte[] prelude) + { + _prelude = prelude; + } + /// /// Called by StreamingHttpContent.SerializeToStreamAsync to provide the HTTP output stream. /// - internal void SetHttpOutputStream(Stream httpOutputStream) + internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, CancellationToken cancellationToken = default) { _httpOutputStream = httpOutputStream; _httpStreamReady.Release(); + + InternalLogger.GetDefaultLogger().LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); + await WritePreludeAsync(cancellationToken); + } + + private async Task WritePreludeAsync(CancellationToken cancellationToken = default) + { + if (_prelude?.Length > 0) + { + await _httpStreamReady.WaitAsync(cancellationToken); + try + { + lock (_lock) + { + ThrowIfCompletedOrError(); + } + + // Write prelude JSON chunk + var chunkSizeHex = _prelude.Length.ToString("X"); + var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(_prelude, 0, _prelude.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + + // Write 8 null bytes delimiter chunk + var delimiterBytes = new byte[8]; + chunkSizeHex = "8"; + chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); + await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(delimiterBytes, 0, delimiterBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + + await _httpOutputStream.FlushAsync(cancellationToken); + } + finally + { + _httpStreamReady.Release(); + } + } } /// /// Called by StreamingHttpContent.SerializeToStreamAsync to wait until the handler /// finishes writing (MarkCompleted or ReportErrorAsync). /// - internal async Task WaitForCompletionAsync() + internal async Task WaitForCompletionAsync(CancellationToken cancellationToken = default) { - await _completionSignal.WaitAsync(); + await _completionSignal.WaitAsync(cancellationToken); } /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index e142b3719..c160fc9f1 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -206,7 +206,7 @@ internal virtual async Task StartStreamingResponseAsync( request.Headers.Add("Trailer", $"{StreamingConstants.ErrorTypeTrailer}, {StreamingConstants.ErrorBodyTrailer}"); - request.Content = new StreamingHttpContent(responseStream); + request.Content = new StreamingHttpContent(responseStream, cancellationToken); // SendAsync calls SerializeToStreamAsync, which blocks until the handler // finishes writing. This is why this method runs concurrently with the handler. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs index d29e56470..25541edb9 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs @@ -18,6 +18,7 @@ using System.Net; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Helpers; @@ -32,24 +33,26 @@ internal class StreamingHttpContent : HttpContent private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); private readonly LambdaResponseStream _responseStream; + private readonly CancellationToken _cancellationToken; - public StreamingHttpContent(LambdaResponseStream responseStream) + public StreamingHttpContent(LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); + _cancellationToken = cancellationToken; } protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) { // Hand the HTTP output stream to ResponseStream so WriteAsync calls // can write chunks directly to it. - _responseStream.SetHttpOutputStream(stream); + await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the undlying Lambda response stream in indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) - await _responseStream.WaitForCompletionAsync(); + await _responseStream.WaitForCompletionAsync(_cancellationToken); // Write final chunk - await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length); + await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length, _cancellationToken); // Write error trailers if present if (_responseStream.HasError) @@ -59,8 +62,8 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon } // Write final CRLF to end the chunked message - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length); - await stream.FlushAsync(); + await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, _cancellationToken); + await stream.FlushAsync(_cancellationToken); } protected override bool TryComputeLength(out long length) @@ -75,12 +78,12 @@ private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) var errorTypeHeader = $"{StreamingConstants.ErrorTypeTrailer}: {exceptionInfo.ErrorType}\r\n"; var errorTypeBytes = Encoding.UTF8.GetBytes(errorTypeHeader); - await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length); + await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length, _cancellationToken); var errorBodyJson = LambdaJsonExceptionWriter.WriteJson(exceptionInfo); var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); - await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length); + await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length, _cancellationToken); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index e9e2690d4..5937e1cf9 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -29,11 +29,11 @@ public class ResponseStreamTests /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. /// Returns both so tests can inspect what was written. /// - private static (LambdaResponseStream stream, MemoryStream httpOutput) CreateWiredStream() + private static async Task<(LambdaResponseStream stream, MemoryStream httpOutput)> CreateWiredStreamAsync() { var rs = new LambdaResponseStream(); var output = new MemoryStream(); - rs.SetHttpOutputStream(output); + await rs.SetHttpOutputStreamAsync(output); return (rs, output); } @@ -62,7 +62,7 @@ public void Constructor_InitializesStateCorrectly() [InlineData(new byte[0], "0")] // 0 bytes → "0" public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); await stream.WriteAsync(data); @@ -82,7 +82,7 @@ public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string exp [Fact] public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); @@ -107,7 +107,7 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() [Fact] public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); await stream.WriteAsync(new byte[] { 0xAA }); var afterFirst = httpOutput.ToArray().Length; @@ -128,7 +128,7 @@ public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() [Fact] public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() { - var (stream, httpOutput) = CreateWiredStream(); + var (stream, httpOutput) = await CreateWiredStreamAsync(); var data = new byte[256]; // 0x100 await stream.WriteAsync(data); @@ -165,7 +165,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() Assert.False(writeCompleted.IsSet, "WriteAsync should block until SetHttpOutputStream is called"); // Now provide the HTTP stream — the write should complete - rs.SetHttpOutputStream(httpOutput); + await rs.SetHttpOutputStreamAsync(httpOutput); await writeTask; Assert.True(writeCompleted.IsSet); @@ -181,7 +181,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() [Fact] public async Task MarkCompleted_ReleasesCompletionSignal() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before MarkCompleted"); @@ -202,7 +202,7 @@ public async Task MarkCompleted_ReleasesCompletionSignal() [Fact] public async Task ReportErrorAsync_ReleasesCompletionSignal() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); @@ -223,7 +223,7 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await stream.WriteAsync(new byte[] { 1 }); stream.MarkCompleted(); @@ -238,7 +238,7 @@ await Assert.ThrowsAsync( [Fact] public async Task WriteAsync_AfterReportError_Throws() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await stream.WriteAsync(new byte[] { 1 }); stream.ReportError(new Exception("test")); @@ -285,7 +285,7 @@ public async Task ReportErrorAsync_CalledTwice_Throws() [Fact] public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null)); } @@ -293,7 +293,7 @@ public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() [Fact] public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() { - var (stream, _) = CreateWiredStream(); + var (stream, _) = await CreateWiredStreamAsync(); await Assert.ThrowsAsync(() => stream.WriteAsync(null, 0, 0)); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 377aede2d..6f6b9aab8 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -514,7 +514,7 @@ internal override async Task StartStreamingResponseAsync( string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block - responseStream.SetHttpOutputStream(new MemoryStream()); + await responseStream.SetHttpOutputStreamAsync(new MemoryStream()); await responseStream.WaitForCompletionAsync(); } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index da68d2940..4d5166fb5 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -114,7 +114,7 @@ internal override async Task StartStreamingResponseAsync( LastStreamingResponseStream = responseStream; // Simulate the HTTP stream being available - responseStream.SetHttpOutputStream(new MemoryStream()); + await responseStream.SetHttpOutputStreamAsync(new MemoryStream(), cancellationToken); // Wait for the handler to finish writing (mirrors real SerializeToStreamAsync behavior) await responseStream.WaitForCompletionAsync(); From 1f17a586fd36e973cd36d6772ce2bb9097812dc7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 5 Mar 2026 14:28:00 -0800 Subject: [PATCH 16/47] Rework to support class library programming model --- .../LambdaResponseStreamFactory.cs | 184 ++++++++++++++++++ .../Bootstrap/LambdaBootstrap.cs | 26 ++- .../ResponseStreaming/ResponseStream.cs} | 70 +++---- .../ResponseStreamContext.cs} | 6 +- .../ResponseStreamFactory.cs} | 20 +- ...onseStreamLambdaCoreInitializerIsolated.cs | 61 ++++++ .../ResponseStreaming}/StreamingConstants.cs | 2 +- .../StreamingHttpContent.cs | 6 +- .../Client/ILambdaResponseStream.cs | 60 ------ .../Client/LambdaResponseStream.Stream.cs | 97 --------- .../Client/RuntimeApiClient.cs | 3 +- .../LambdaBootstrapTests.cs | 12 +- .../ResponseStreamFactoryTests.cs | 73 +++---- .../ResponseStreamTests.cs | 62 +++--- .../RuntimeApiClientTests.cs | 17 +- .../StreamingE2EWithMoq.cs | 43 ++-- .../StreamingHttpContentTests.cs | 32 +-- .../TestStreamingRuntimeApiClient.cs | 5 +- 18 files changed, 445 insertions(+), 334 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client/LambdaResponseStream.ILambdaResponseStream.cs => Bootstrap/ResponseStreaming/ResponseStream.cs} (84%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client/LambdaResponseStreamContext.cs => Bootstrap/ResponseStreaming/ResponseStreamContext.cs} (92%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client/LambdaResponseStreamFactory.cs => Bootstrap/ResponseStreaming/ResponseStreamFactory.cs} (83%) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client => Bootstrap/ResponseStreaming}/StreamingConstants.cs (95%) rename Libraries/src/Amazon.Lambda.RuntimeSupport/{Client => Bootstrap/ResponseStreaming}/StreamingHttpContent.cs (94%) delete mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs delete mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs diff --git a/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs new file mode 100644 index 000000000..46ff77d18 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs @@ -0,0 +1,184 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.Core +{ + /// + /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class LambdaResponseStreamFactory + { + internal const string ParameterizedPreviewMessage = + "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + + "project file to \"true\""; + + private static Func _streamFactory; + + internal static void SetLambdaResponseStream(Func streamFactory) + { + _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); + } + + /// + /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once + /// A Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler + /// return value will be ignored. The stream is write-only and non-seekable. + /// + /// + public static Stream CreateStream() + { + var runtimeResponseStream = _streamFactory(Array.Empty()); + return new LambdaResponseStream(runtimeResponseStream); + } + } + + /// + /// Interface for writing streaming responses in AWS Lambda functions. + /// Obtained by calling within a handler. + /// + internal interface ILambdaResponseStream : IDisposable + { + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); + + + /// + /// Gets the total number of bytes written to the stream so far. + /// + long BytesWritten { get; } + + + /// + /// Gets whether an error has been reported. + /// + bool HasError { get; } + } + + /// + /// A write-only, non-seekable subclass that streams response data + /// to the Lambda Runtime API. Returned by . + /// Integrates with standard .NET stream consumers such as . + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class LambdaResponseStream : Stream + { + private readonly ILambdaResponseStream _responseStream; + + internal LambdaResponseStream(ILambdaResponseStream responseStream) + { + _responseStream = responseStream; + } + + /// + /// The number of bytes written to the Lambda response stream so far. + /// + public long BytesWritten => _responseStream.BytesWritten; + + /// + /// Asynchronously writes a byte array to the response stream. + /// + /// The byte array to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + { + if (buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + await WriteAsync(buffer, 0, buffer.Length, cancellationToken); + } + + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + await _responseStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + #region Noop Overrides + + /// Gets a value indicating whether the stream supports reading. Always false. + public override bool CanRead => false; + + /// Gets a value indicating whether the stream supports seeking. Always false. + public override bool CanSeek => false; + + /// Gets a value indicating whether the stream supports writing. Always true. + public override bool CanWrite => true; + + /// + /// Gets the total number of bytes written to the stream so far. + /// Equivalent to . + /// + public override long Length => BytesWritten; + + /// + /// Getting or setting the position is not supported. + /// + /// Always thrown. + public override long Position + { + get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + } + + /// Not supported. + /// Always thrown. + public override long Seek(long offset, SeekOrigin origin) + => throw new NotImplementedException("LambdaResponseStream does not support seeking."); + + /// Not supported. + /// Always thrown. + public override int Read(byte[] buffer, int offset, int count) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + /// Not supported. + /// Always thrown. + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotImplementedException("LambdaResponseStream does not support reading."); + + /// + /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. + /// Prefer to avoid blocking. + /// + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + + /// + /// Flush is a no-op; data is sent to the Runtime API immediately on each write. + /// + public override void Flush() { } + + /// Not supported. + /// Always thrown. + public override void SetLength(long value) + => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); + #endregion + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index ba44c05ed..a804b0b10 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -20,6 +20,7 @@ using System.Threading; using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Bootstrap; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport @@ -225,6 +226,19 @@ internal LambdaBootstrap(HttpClient httpClient, LambdaBootstrapHandler handler, return; } #if NET8_0_OR_GREATER + + try + { + // Initalize in Amazon.Lambda.Core the factory for creating the response stream and related logic for supporting response streaming. + ResponseStreamLambdaCoreInitializerIsolated.InitializeCore(); + } + catch (TypeLoadException) + { + _logger.LogDebug("Failed to configure Amazon.Lambda.Core with factory to create response stream. This happens when the version of Amazon.Lambda.Core referenced by the Lambda function is out of date."); + } + + + // Check if Initialization type is SnapStart, and invoke the snapshot restore logic. if (_configuration.IsInitTypeSnapstart) { @@ -363,7 +377,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul var runtimeApiClient = Client as RuntimeApiClient; if (runtimeApiClient != null) { - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( invocation.LambdaContext.AwsRequestId, isMultiConcurrency, runtimeApiClient, @@ -385,7 +399,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { WriteUnhandledExceptionToLog(exception); - var responseStream = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var responseStream = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (responseStream != null) { responseStream.ReportError(exception); @@ -400,20 +414,20 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul _logger.LogInformation("Finished invoking handler"); } - var streamIfCreated = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); + var streamIfCreated = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency); if (streamIfCreated != null) { streamIfCreated.MarkCompleted(); // If streaming was started, await the HTTP send task to ensure it completes - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency); + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency); if (sendTask != null) { // Wait for the streaming response to finish sending before allowing the next invocation to be processed. This ensures that responses are sent in the order the invocations were received. await sendTask; } - streamIfCreated.ManualDispose(); + streamIfCreated.Dispose(); } else if (invokeSucceeded) { @@ -454,7 +468,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { if (runtimeApiClient != null) { - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); } invocation.Dispose(); } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs similarity index 84% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index 896e00cd8..37db44c76 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -20,13 +20,12 @@ using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Helpers; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// - /// A write-only, non-seekable subclass that streams response data - /// to the Lambda Runtime API. Returned by . + /// Represents the writable stream used by Lambda handlers to write response data for streaming invocations. /// - public partial class LambdaResponseStream : Stream, ILambdaResponseStream + internal class ResponseStream { private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); @@ -38,6 +37,7 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream // The live HTTP output stream, set by StreamingHttpContent when SerializeToStreamAsync is called. private Stream _httpOutputStream; + private bool _disposedValue; private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); @@ -56,12 +56,7 @@ public partial class LambdaResponseStream : Stream, ILambdaResponseStream internal Exception ReportedError => _reportedError; - internal LambdaResponseStream() - : this(Array.Empty()) - { - } - - internal LambdaResponseStream(byte[] prelude) + internal ResponseStream(byte[] prelude) { _prelude = prelude; } @@ -125,18 +120,10 @@ internal async Task WaitForCompletionAsync(CancellationToken cancellationToken = await _completionSignal.WaitAsync(cancellationToken); } - /// - /// Asynchronously writes a byte array to the response stream. - /// - /// The byte array to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) + internal async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); - await WriteAsync(buffer, 0, buffer.Length, cancellationToken); } @@ -149,7 +136,7 @@ public async Task WriteAsync(byte[] buffer, CancellationToken cancellationToken /// Optional cancellation token. /// A task representing the asynchronous operation. /// Thrown if the stream is already completed or an error has been reported. - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) { if (buffer == null) throw new ArgumentNullException(nameof(buffer)); @@ -232,19 +219,6 @@ internal void MarkCompleted() } } - /// - /// The resouces like the SemaphoreSlims are manually disposed by LambdaBootstrap after each invocation instead of relying on the - /// Dipose pattern because we don't want the user's Lambda function to trigger Releasing and disposing the semaphores when - /// invocation of the user's code ends. - /// - internal void ManualDispose() - { - try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } - _httpStreamReady.Dispose(); - try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } - _completionSignal.Dispose(); - } - private void ThrowIfCompletedOrError() { if (_isCompleted) @@ -252,5 +226,35 @@ private void ThrowIfCompletedOrError() if (_hasError) throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); } + + /// + /// Disposes the stream. After calling Dispose, no further writes or error reports should be made. + /// + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _httpStreamReady.Dispose(); + try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _completionSignal.Dispose(); + } + + _disposedValue = true; + } + } + + /// + /// Dispose of the stream. After calling Dispose, no further writes or error reports should be made. + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs similarity index 92% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs index c6a58c81d..3fb92e51d 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs @@ -16,12 +16,12 @@ using System.Threading; using System.Threading.Tasks; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// Internal context class used by ResponseStreamFactory to track per-invocation streaming state. /// - internal class LambdaResponseStreamContext + internal class ResponseStreamContext { /// /// The AWS request ID for the current invocation. @@ -36,7 +36,7 @@ internal class LambdaResponseStreamContext /// /// The ResponseStream instance if created. /// - public LambdaResponseStream Stream { get; set; } + public ResponseStream Stream { get; set; } /// /// The RuntimeApiClient used to start the streaming HTTP POST. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs similarity index 83% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs index 84d8c0ebd..dcbdf4c92 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs @@ -17,31 +17,29 @@ using System.Threading; using System.Threading.Tasks; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// Factory for creating streaming responses in AWS Lambda functions. /// Call CreateStream() within your handler to opt into response streaming for that invocation. /// - public static class LambdaResponseStreamFactory + internal static class ResponseStreamFactory { // For on-demand mode (single invocation at a time) - private static LambdaResponseStreamContext _onDemandContext; + private static ResponseStreamContext _onDemandContext; // For multi-concurrency mode (multiple concurrent invocations) - private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); + private static readonly AsyncLocal _asyncLocalContext = new AsyncLocal(); /// /// Creates a streaming response for the current invocation. /// Can only be called once per invocation. /// /// - /// A — a subclass — for writing - /// response data. The returned stream also implements . /// /// Thrown if called outside an invocation context. /// Thrown if called more than once per invocation. - public static LambdaResponseStream CreateStream() + public static ResponseStream CreateStream(byte[] prelude) { var context = GetCurrentContext(); @@ -57,7 +55,7 @@ public static LambdaResponseStream CreateStream() "ResponseStreamFactory.CreateStream() can only be called once per invocation."); } - var lambdaStream = new LambdaResponseStream(); + var lambdaStream = new ResponseStream(prelude); context.Stream = lambdaStream; context.StreamCreated = true; @@ -76,7 +74,7 @@ internal static void InitializeInvocation( string awsRequestId, bool isMultiConcurrency, RuntimeApiClient runtimeApiClient, CancellationToken cancellationToken) { - var context = new LambdaResponseStreamContext + var context = new ResponseStreamContext { AwsRequestId = awsRequestId, StreamCreated = false, @@ -95,7 +93,7 @@ internal static void InitializeInvocation( } } - internal static LambdaResponseStream GetStreamIfCreated(bool isMultiConcurrency) + internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) { var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; return context?.Stream; @@ -123,7 +121,7 @@ internal static void CleanupInvocation(bool isMultiConcurrency) } } - private static LambdaResponseStreamContext GetCurrentContext() + private static ResponseStreamContext GetCurrentContext() { // Check multi-concurrency first (AsyncLocal), then on-demand return _asyncLocalContext.Value ?? _onDemandContext; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs new file mode 100644 index 000000000..15791d0b3 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER + +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; +#pragma warning disable CA2252 +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// This class is used to connect the created by to Amazon.Lambda.Core with it's public interfaces. + /// The deployed Lambda function might be referencing an older version of Amazon.Lambda.Core that does not have the public interfaces for response streaming, + /// so this class is used to avoid a direct dependency on Amazon.Lambda.Core in the rest of the response streaming implementation. + /// + /// Any code referencing this class must wrap the code around a try/catch for to allow for the case where the Lambda function + /// is deployed with an older version of Amazon.Lambda.Core that does not have the response streaming interfaces. + /// + /// + internal class ResponseStreamLambdaCoreInitializerIsolated + { + /// + /// Initalize Amazon.Lambda.Core with a factory method for creating that wraps the internal implementation. + /// + internal static void InitializeCore() + { +#if !ANALYZER_UNIT_TESTS // This precompiler directive is used to avoid the unit tests from needing a dependency on Amazon.Lambda.Core. + Func factory = (byte[] prelude) => new ImplLambdaResponseStream(ResponseStreamFactory.CreateStream(prelude)); + LambdaResponseStreamFactory.SetLambdaResponseStream(factory); +#endif + } + + /// + /// Implements the interface by wrapping a . This is used to connect the internal response streaming implementation to the public interfaces in Amazon.Lambda.Core. + /// + internal class ImplLambdaResponseStream : ILambdaResponseStream + { + private readonly ResponseStream _innerStream; + + internal ImplLambdaResponseStream(ResponseStream innerStream) + { + _innerStream = innerStream; + } + + /// + public long BytesWritten => _innerStream.BytesWritten; + + /// + public bool HasError => _innerStream.HasError; + + /// + public void Dispose() => _innerStream.Dispose(); + + /// + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) => _innerStream.WriteAsync(buffer, offset, count); + } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingConstants.cs similarity index 95% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingConstants.cs index c1e99ed17..43ac607b7 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingConstants.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingConstants.cs @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// Constants used for Lambda response streaming. diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs similarity index 94% rename from Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs rename to Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs index 25541edb9..797c92758 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs @@ -22,7 +22,7 @@ using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Helpers; -namespace Amazon.Lambda.RuntimeSupport +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming { /// /// HttpContent implementation for streaming responses with chunked transfer encoding. @@ -32,10 +32,10 @@ internal class StreamingHttpContent : HttpContent private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); - private readonly LambdaResponseStream _responseStream; + private readonly ResponseStream _responseStream; private readonly CancellationToken _cancellationToken; - public StreamingHttpContent(LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + public StreamingHttpContent(ResponseStream responseStream, CancellationToken cancellationToken = default) { _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); _cancellationToken = cancellationToken; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs deleted file mode 100644 index af7c1a59f..000000000 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/ILambdaResponseStream.cs +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Amazon.Lambda.RuntimeSupport -{ - /// - /// Interface for writing streaming responses in AWS Lambda functions. - /// Obtained by calling within a handler. - /// - public interface ILambdaResponseStream : IDisposable - { - /// - /// Asynchronously writes a byte array to the response stream. - /// - /// The byte array to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - Task WriteAsync(byte[] buffer, CancellationToken cancellationToken = default); - - /// - /// Asynchronously writes a portion of a byte array to the response stream. - /// - /// The byte array containing data to write. - /// The zero-based byte offset in buffer at which to begin copying bytes. - /// The number of bytes to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - - - /// - /// Gets the total number of bytes written to the stream so far. - /// - long BytesWritten { get; } - - - /// - /// Gets whether an error has been reported. - /// - bool HasError { get; } - } -} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs deleted file mode 100644 index 5453333e7..000000000 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/LambdaResponseStream.Stream.cs +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Amazon.Lambda.RuntimeSupport -{ - /// - /// A write-only, non-seekable subclass that streams response data - /// to the Lambda Runtime API. Returned by . - /// Integrates with standard .NET stream consumers such as . - /// - public partial class LambdaResponseStream : Stream, ILambdaResponseStream - { - // ── System.IO.Stream — capabilities ───────────────────────────────── - - /// Gets a value indicating whether the stream supports reading. Always false. - public override bool CanRead => false; - - /// Gets a value indicating whether the stream supports seeking. Always false. - public override bool CanSeek => false; - - /// Gets a value indicating whether the stream supports writing. Always true. - public override bool CanWrite => true; - - // ── System.IO.Stream — Length / Position ──────────────────────────── - - /// - /// Gets the total number of bytes written to the stream so far. - /// Equivalent to . - /// - public override long Length => BytesWritten; - - /// - /// Getting or setting the position is not supported. - /// - /// Always thrown. - public override long Position - { - get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); - set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); - } - - // ── System.IO.Stream — seek / read (not supported) ────────────────── - - /// Not supported. - /// Always thrown. - public override long Seek(long offset, SeekOrigin origin) - => throw new NotImplementedException("LambdaResponseStream does not support seeking."); - - /// Not supported. - /// Always thrown. - public override int Read(byte[] buffer, int offset, int count) - => throw new NotImplementedException("LambdaResponseStream does not support reading."); - - /// Not supported. - /// Always thrown. - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => throw new NotImplementedException("LambdaResponseStream does not support reading."); - - // ── System.IO.Stream — write ───────────────────────────────────────── - - /// - /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. - /// Prefer to avoid blocking. - /// - public override void Write(byte[] buffer, int offset, int count) - => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); - - // ── System.IO.Stream — flush / set length ──────────────────────────── - - /// - /// Flush is a no-op; data is sent to the Runtime API immediately on each write. - /// - public override void Flush() { } - - /// Not supported. - /// Always thrown. - public override void SetLength(long value) - => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); - } -} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index c160fc9f1..041097057 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -20,6 +20,7 @@ using System.Threading; using System.Threading.Tasks; using Amazon.Lambda.RuntimeSupport.Bootstrap; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; namespace Amazon.Lambda.RuntimeSupport { @@ -189,7 +190,7 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null /// The optional cancellation token to use. /// A Task representing the in-flight HTTP POST. internal virtual async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index ae40b7e2e..e7f36a377 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -18,10 +18,10 @@ using System.Linq; using System.Net.Http; using System.Text; -using System.Threading; using System.Threading.Tasks; using Xunit; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Bootstrap; using static Amazon.Lambda.RuntimeSupport.Bootstrap.Constants; @@ -314,7 +314,7 @@ public async Task StreamingMode_HandlerCallsCreateStream_SendTaskAwaited() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("hello")); return new InvocationResponse(Stream.Null, false); }; @@ -369,7 +369,7 @@ public async Task MidstreamError_ExceptionAfterWrites_ReportsViaTrailers() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -425,7 +425,7 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("data")); return new InvocationResponse(Stream.Null, false); }; @@ -437,8 +437,8 @@ public async Task Cleanup_ResponseStreamFactoryStateCleared_AfterInvocation() } // After invocation, factory state should be cleaned up - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(false)); - Assert.Null(LambdaResponseStreamFactory.GetSendTask(false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(false)); + Assert.Null(ResponseStreamFactory.GetSendTask(false)); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index 9fce99ad5..b7879e6e3 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -16,6 +16,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -28,8 +29,8 @@ public class ResponseStreamFactoryTests : IDisposable public void Dispose() { // Clean up both modes to avoid test pollution - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } /// @@ -40,7 +41,7 @@ private class MockStreamingRuntimeApiClient : RuntimeApiClient { public bool StartStreamingCalled { get; private set; } public string LastAwsRequestId { get; private set; } - public LambdaResponseStream LastResponseStream { get; private set; } + public ResponseStream LastResponseStream { get; private set; } public TaskCompletionSource SendTaskCompletion { get; } = new TaskCompletionSource(); public MockStreamingRuntimeApiClient() @@ -49,7 +50,7 @@ public MockStreamingRuntimeApiClient() } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastAwsRequestId = awsRequestId; @@ -60,7 +61,7 @@ internal override async Task StartStreamingResponseAsync( private void InitializeWithMock(string requestId, bool isMultiConcurrency, MockStreamingRuntimeApiClient mockClient) { - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency, mockClient, CancellationToken.None); } @@ -77,10 +78,10 @@ public void CreateStream_OnDemandMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-1", isMultiConcurrency: false, mock); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } /// @@ -93,10 +94,10 @@ public void CreateStream_MultiConcurrencyMode_ReturnsValidStream() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-2", isMultiConcurrency: true, mock); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); - Assert.IsAssignableFrom(stream); + Assert.IsAssignableFrom(stream); } // --- Property 4: Single Stream Per Invocation --- @@ -110,16 +111,16 @@ public void CreateStream_CalledTwice_ThrowsInvalidOperationException() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-3", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); + Assert.Throws(() => ResponseStreamFactory.CreateStream(Array.Empty())); } [Fact] public void CreateStream_OutsideInvocationContext_ThrowsInvalidOperationException() { // No InitializeInvocation called - Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); + Assert.Throws(() => ResponseStreamFactory.CreateStream(Array.Empty())); } // --- CreateStream starts HTTP POST --- @@ -134,7 +135,7 @@ public void CreateStream_CallsStartStreamingResponseAsync() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-start", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); Assert.True(mock.StartStreamingCalled); Assert.Equal("req-start", mock.LastAwsRequestId); @@ -153,9 +154,9 @@ public void GetSendTask_AfterCreateStream_ReturnsNonNullTask() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-send", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.NotNull(sendTask); } @@ -165,14 +166,14 @@ public void GetSendTask_BeforeCreateStream_ReturnsNull() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-nosend", isMultiConcurrency: false, mock); - var sendTask = LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false); + var sendTask = ResponseStreamFactory.GetSendTask(isMultiConcurrency: false); Assert.Null(sendTask); } [Fact] public void GetSendTask_NoContext_ReturnsNull() { - Assert.Null(LambdaResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetSendTask(isMultiConcurrency: false)); } // --- Internal methods --- @@ -183,9 +184,9 @@ public void InitializeInvocation_OnDemand_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-4", isMultiConcurrency: false, mock); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); } @@ -195,9 +196,9 @@ public void InitializeInvocation_MultiConcurrency_SetsUpContext() var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-5", isMultiConcurrency: true, mock); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true)); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); } @@ -206,16 +207,16 @@ public void GetStreamIfCreated_AfterCreateStream_ReturnsStream() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-6", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false); Assert.NotNull(retrieved); } [Fact] public void GetStreamIfCreated_NoContext_ReturnsNull() { - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); } [Fact] @@ -223,12 +224,12 @@ public void CleanupInvocation_ClearsState() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-7", isMultiConcurrency: false, mock); - LambdaResponseStreamFactory.CreateStream(); + ResponseStreamFactory.CreateStream(Array.Empty()); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - Assert.Throws(() => LambdaResponseStreamFactory.CreateStream()); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Throws(() => ResponseStreamFactory.CreateStream(Array.Empty())); } // --- Property 16: State Isolation Between Invocations --- @@ -244,17 +245,17 @@ public void StateIsolation_SequentialInvocations_NoLeakage() // First invocation - streaming InitializeWithMock("req-8a", isMultiConcurrency: false, mock); - var stream1 = LambdaResponseStreamFactory.CreateStream(); + var stream1 = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream1); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); // Second invocation - should start fresh InitializeWithMock("req-8b", isMultiConcurrency: false, mock); - Assert.Null(LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); + Assert.Null(ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: false)); - var stream2 = LambdaResponseStreamFactory.CreateStream(); + var stream2 = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream2); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); } /// @@ -266,14 +267,14 @@ public async Task StateIsolation_MultiConcurrency_UsesAsyncLocal() { var mock = new MockStreamingRuntimeApiClient(); InitializeWithMock("req-9", isMultiConcurrency: true, mock); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); Assert.NotNull(stream); bool childSawNull = false; await Task.Run(() => { - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); - childSawNull = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + childSawNull = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true) == null; }); Assert.True(childSawNull); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 5937e1cf9..ac4fc60eb 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -19,6 +19,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -29,9 +30,9 @@ public class ResponseStreamTests /// Helper: creates a ResponseStream and wires up a MemoryStream as the HTTP output stream. /// Returns both so tests can inspect what was written. /// - private static async Task<(LambdaResponseStream stream, MemoryStream httpOutput)> CreateWiredStreamAsync() + private static async Task<(ResponseStream stream, MemoryStream httpOutput)> CreateWiredStream() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = new MemoryStream(); await rs.SetHttpOutputStreamAsync(output); return (rs, output); @@ -42,7 +43,7 @@ public class ResponseStreamTests [Fact] public void Constructor_InitializesStateCorrectly() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); Assert.Equal(0, stream.BytesWritten); Assert.False(stream.HasError); @@ -62,9 +63,9 @@ public void Constructor_InitializesStateCorrectly() [InlineData(new byte[0], "0")] // 0 bytes → "0" public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); - await stream.WriteAsync(data); + await stream.WriteAsync(data, 0, data.Length); var written = httpOutput.ToArray(); var expected = Encoding.ASCII.GetBytes(expectedHexSize + "\r\n") @@ -82,7 +83,7 @@ public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string exp [Fact] public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); @@ -107,13 +108,14 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() [Fact] public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); - await stream.WriteAsync(new byte[] { 0xAA }); + var data = new byte[] { 0xAA }; + await stream.WriteAsync(data, 0, data.Length); var afterFirst = httpOutput.ToArray().Length; Assert.True(afterFirst > 0, "First chunk should be on the HTTP stream immediately after WriteAsync returns"); - await stream.WriteAsync(new byte[] { 0xBB, 0xCC }); + await stream.WriteAsync(new byte[] { 0xBB, 0xCC }, 0, 2); var afterSecond = httpOutput.ToArray().Length; Assert.True(afterSecond > afterFirst, "Second chunk should appear on the HTTP stream immediately"); @@ -128,10 +130,10 @@ public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() [Fact] public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() { - var (stream, httpOutput) = await CreateWiredStreamAsync(); + var (stream, httpOutput) = await CreateWiredStream(); var data = new byte[256]; // 0x100 - await stream.WriteAsync(data); + await stream.WriteAsync(data, 0, data.Length); var written = Encoding.ASCII.GetString(httpOutput.ToArray()); Assert.StartsWith("100\r\n", written); @@ -146,7 +148,7 @@ public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() [Fact] public async Task WriteAsync_BlocksUntilSetHttpOutputStream() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var httpOutput = new MemoryStream(); var writeStarted = new ManualResetEventSlim(false); var writeCompleted = new ManualResetEventSlim(false); @@ -155,7 +157,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() var writeTask = Task.Run(async () => { writeStarted.Set(); - await rs.WriteAsync(new byte[] { 1, 2, 3 }); + await rs.WriteAsync(new byte[] { 1, 2, 3 }, 0, 3); writeCompleted.Set(); }); @@ -181,7 +183,7 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() [Fact] public async Task MarkCompleted_ReleasesCompletionSignal() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before MarkCompleted"); @@ -202,7 +204,7 @@ public async Task MarkCompleted_ReleasesCompletionSignal() [Fact] public async Task ReportErrorAsync_ReleasesCompletionSignal() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted, "WaitForCompletionAsync should block before ReportErrorAsync"); @@ -223,12 +225,12 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { - var (stream, _) = await CreateWiredStreamAsync(); - await stream.WriteAsync(new byte[] { 1 }); + var (stream, _) = await CreateWiredStream(); + await stream.WriteAsync(new byte[] { 1 }, 0, 1); stream.MarkCompleted(); await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[] { 2 })); + () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } /// @@ -238,12 +240,12 @@ await Assert.ThrowsAsync( [Fact] public async Task WriteAsync_AfterReportError_Throws() { - var (stream, _) = await CreateWiredStreamAsync(); - await stream.WriteAsync(new byte[] { 1 }); + var (stream, _) = await CreateWiredStream(); + await stream.WriteAsync(new byte[] { 1 }, 0, 1); stream.ReportError(new Exception("test")); await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[] { 2 })); + () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } // ---- Error handling tests ---- @@ -251,7 +253,7 @@ await Assert.ThrowsAsync( [Fact] public async Task ReportErrorAsync_SetsErrorState() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var exception = new InvalidOperationException("something broke"); stream.ReportError(exception); @@ -263,7 +265,7 @@ public async Task ReportErrorAsync_SetsErrorState() [Fact] public async Task ReportErrorAsync_AfterCompleted_Throws() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); stream.MarkCompleted(); Assert.Throws( @@ -273,7 +275,7 @@ public async Task ReportErrorAsync_AfterCompleted_Throws() [Fact] public async Task ReportErrorAsync_CalledTwice_Throws() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); stream.ReportError(new Exception("first")); Assert.Throws( @@ -285,15 +287,15 @@ public async Task ReportErrorAsync_CalledTwice_Throws() [Fact] public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); - await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null)); + await Assert.ThrowsAsync(() => stream.WriteAsync((byte[])null, 0, 0)); } [Fact] public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() { - var (stream, _) = await CreateWiredStreamAsync(); + var (stream, _) = await CreateWiredStream(); await Assert.ThrowsAsync(() => stream.WriteAsync(null, 0, 0)); } @@ -301,7 +303,7 @@ public async Task WriteAsync_NullBufferWithOffset_ThrowsArgumentNull() [Fact] public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); Assert.Throws(() => stream.ReportError(null)); } @@ -311,12 +313,12 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() [Fact] public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var waitTask = stream.WaitForCompletionAsync(); Assert.False(waitTask.IsCompleted); - stream.ManualDispose(); + stream.Dispose(); var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index 3a471ab1e..08275feb7 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -20,6 +20,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -40,9 +41,9 @@ public class RuntimeApiClientTests private class MockHttpMessageHandler : HttpMessageHandler { public HttpRequestMessage CapturedRequest { get; private set; } - private readonly LambdaResponseStream _responseStream; + private readonly ResponseStream _responseStream; - public MockHttpMessageHandler(LambdaResponseStream responseStream) + public MockHttpMessageHandler(ResponseStream responseStream) { _responseStream = responseStream; } @@ -57,7 +58,7 @@ protected override Task SendAsync( } private static RuntimeApiClient CreateClientWithMockHandler( - LambdaResponseStream stream, out MockHttpMessageHandler handler) + ResponseStream stream, out MockHttpMessageHandler handler) { handler = new MockHttpMessageHandler(stream); var httpClient = new HttpClient(handler); @@ -77,7 +78,7 @@ private static RuntimeApiClient CreateClientWithMockHandler( [Fact] public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeader() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-1", stream, CancellationToken.None); @@ -100,7 +101,7 @@ public async Task StartStreamingResponseAsync_IncludesStreamingResponseModeHeade [Fact] public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHeader() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-2", stream, CancellationToken.None); @@ -121,7 +122,7 @@ public async Task StartStreamingResponseAsync_IncludesChunkedTransferEncodingHea [Fact] public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out var handler); await client.StartStreamingResponseAsync("req-3", stream, CancellationToken.None); @@ -184,7 +185,7 @@ public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() [Fact] public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( @@ -194,7 +195,7 @@ await Assert.ThrowsAsync( [Fact] public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentNullException() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var client = CreateClientWithMockHandler(stream, out _); await Assert.ThrowsAsync( diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 6f6b9aab8..2417b3ccb 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -20,6 +20,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; using Xunit; @@ -38,8 +39,8 @@ public class StreamingE2EWithMoq : IDisposable { public void Dispose() { - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); } // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -67,7 +68,7 @@ private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApi public bool SendResponseCalled { get; private set; } public bool ReportInvocationErrorCalled { get; private set; } public byte[] CapturedHttpBytes { get; private set; } - public LambdaResponseStream LastResponseStream { get; private set; } + public ResponseStream LastResponseStream { get; private set; } public Stream LastBufferedOutputStream { get; private set; } public new Amazon.Lambda.RuntimeSupport.Helpers.IConsoleLoggerWriter ConsoleLogger { get; } = new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables()); @@ -97,7 +98,7 @@ public CapturingStreamingRuntimeApiClient( } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastResponseStream = responseStream; @@ -159,7 +160,7 @@ public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); foreach (var chunk in chunks) await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); return new InvocationResponse(Stream.Null, false); @@ -196,7 +197,7 @@ public async Task Streaming_AllDataTransmitted_ContentRoundTrip() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(payload); return new InvocationResponse(Stream.Null, false); }; @@ -227,7 +228,7 @@ public async Task Streaming_StreamFinalized_BytesWrittenMatchesPayload() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(data); return new InvocationResponse(Stream.Null, false); }; @@ -308,7 +309,7 @@ public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); throw new InvalidOperationException("midstream failure"); }; @@ -355,7 +356,7 @@ public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() LambdaBootstrapHandler handler = async (invocation) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); await stream.WriteAsync(Encoding.UTF8.GetBytes("some data")); throw new InvalidOperationException(errorMessage); }; @@ -393,13 +394,13 @@ public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); allStarted.Release(); // Wait until all tasks have started (to ensure true concurrency) @@ -409,10 +410,10 @@ public async Task MultiConcurrency_ConcurrentInvocations_StateIsolated() stream.MarkCompleted(); // Verify this invocation's stream is still accessible - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); results[requestId] = retrieved != null ? payload : "MISSING"; - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -450,20 +451,20 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); - var stream = LambdaResponseStreamFactory.CreateStream(); + var stream = ResponseStreamFactory.CreateStream(Array.Empty()); allStarted.Release(); await barrier.WaitAsync(); await stream.WriteAsync(Encoding.UTF8.GetBytes("streaming data")); stream.MarkCompleted(); - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); streamingResults.Add(retrieved != null); - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -474,7 +475,7 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter tasks.Add(Task.Run(async () => { var mockClient = new MockMultiConcurrencyStreamingClient(); - LambdaResponseStreamFactory.InitializeInvocation( + ResponseStreamFactory.InitializeInvocation( requestId, isMultiConcurrency: true, mockClient, CancellationToken.None); @@ -482,9 +483,9 @@ public async Task MultiConcurrency_StreamingAndBufferedMixedConcurrently_NoInter await barrier.WaitAsync(); // No CreateStream — buffered mode - var retrieved = LambdaResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); + var retrieved = ResponseStreamFactory.GetStreamIfCreated(isMultiConcurrency: true); bufferedResults.Add(retrieved == null); // should be null (no stream created) - LambdaResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: true); })); } @@ -511,7 +512,7 @@ public MockMultiConcurrencyStreamingClient() : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block await responseStream.SetHttpOutputStreamAsync(new MemoryStream()); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index 4fed4b810..bf87dd31a 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -16,8 +16,8 @@ using System; using System.IO; using System.Text; -using System.Threading; using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Xunit; namespace Amazon.Lambda.RuntimeSupport.UnitTests @@ -32,8 +32,8 @@ public class StreamingHttpContentTests /// Returns the bytes written to the HTTP output stream. /// private async Task SerializeWithConcurrentHandler( - LambdaResponseStream responseStream, - Func handlerAction) + ResponseStream responseStream, + Func handlerAction) { var content = new StreamingHttpContent(responseStream); var outputStream = new MemoryStream(); @@ -63,7 +63,7 @@ private async Task SerializeWithConcurrentHandler( [Fact] public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -84,7 +84,7 @@ public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() [Fact] public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -108,7 +108,7 @@ public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() [Fact] public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var content = new StreamingHttpContent(rs); var outputStream = new MemoryStream(); @@ -132,7 +132,7 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() [Fact] public async Task FinalChunk_WrittenAfterCompletion() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -156,7 +156,7 @@ public async Task FinalChunk_WrittenAfterCompletion() [Fact] public async Task FinalChunk_EmptyStream_StillWritten() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, stream => { @@ -177,7 +177,7 @@ public async Task FinalChunk_EmptyStream_StillWritten() [Fact] public async Task ErrorTrailers_AppearAfterFinalChunk() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -210,7 +210,7 @@ public async Task ErrorTrailers_AppearAfterFinalChunk() [InlineData(typeof(NullReferenceException))] public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -232,7 +232,7 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -255,7 +255,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() [Fact] public async Task SuccessfulCompletion_EndsWithCrlf() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -275,7 +275,7 @@ public async Task SuccessfulCompletion_EndsWithCrlf() [Fact] public async Task ErrorCompletion_EndsWithCrlf() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -292,7 +292,7 @@ public async Task ErrorCompletion_EndsWithCrlf() [Fact] public async Task NoError_NoTrailersWritten() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { @@ -310,7 +310,7 @@ public async Task NoError_NoTrailersWritten() [Fact] public void TryComputeLength_ReturnsFalse() { - var stream = new LambdaResponseStream(); + var stream = new ResponseStream(Array.Empty()); var content = new StreamingHttpContent(stream); var result = content.Headers.ContentLength; @@ -326,7 +326,7 @@ public void TryComputeLength_ReturnsFalse() [Fact] public async Task CrlfTerminators_NoBareLineFeed() { - var rs = new LambdaResponseStream(); + var rs = new ResponseStream(Array.Empty()); var output = await SerializeWithConcurrentHandler(rs, async stream => { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index 4d5166fb5..621f7af6f 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -13,6 +13,7 @@ * permissions and limitations under the License. */ +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Helpers; using Amazon.Lambda.RuntimeSupport.UnitTests.TestHelpers; using System; @@ -54,7 +55,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, public byte[] FunctionInput { get; set; } public Stream LastOutputStream { get; private set; } public Exception LastRecordedException { get; private set; } - public LambdaResponseStream LastStreamingResponseStream { get; private set; } + public ResponseStream LastStreamingResponseStream { get; private set; } public new async Task GetNextInvocationAsync(CancellationToken cancellationToken = default) { @@ -108,7 +109,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, } internal override async Task StartStreamingResponseAsync( - string awsRequestId, LambdaResponseStream responseStream, CancellationToken cancellationToken = default) + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingResponseAsyncCalled = true; LastStreamingResponseStream = responseStream; From 99b83c2a577df905d82712fa506733baf9c27a19 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 6 Mar 2026 11:56:56 -0800 Subject: [PATCH 17/47] Rework encoding and add support for using API Gateway --- .../HttpResponseStreamPrelude.cs | 89 ++++++++++ .../ILambdaResponseStream.cs | 40 +++++ .../LambdaResponseStream.cs} | 65 +------ .../LambdaResponseStreamFactory.cs | 59 +++++++ .../ResponseStreaming/ResponseStream.cs | 34 ++-- ...onseStreamLambdaCoreInitializerIsolated.cs | 2 +- .../ResponseStreaming/StreamingHttpContent.cs | 12 +- .../Client/RuntimeApiClient.cs | 4 + .../ResponseStreamTests.cs | 93 +--------- .../StreamingE2EWithMoq.cs | 93 +--------- .../StreamingHttpContentTests.cs | 165 +----------------- 11 files changed, 209 insertions(+), 447 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs create mode 100644 Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs rename Libraries/src/Amazon.Lambda.Core/{LambdaResponseStreamFactory.cs => ResponseStreaming/LambdaResponseStream.cs} (64%) create mode 100644 Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs new file mode 100644 index 000000000..ebd8a7018 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System.Collections.Generic; +using System.Net; +using System.Runtime.Versioning; +using System.Text.Json; + +namespace Amazon.Lambda.Core.ResponseStreaming +{ + /// + /// The HTTP response prelude to be sent as the first chunk of a streaming response when using . + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class HttpResponseStreamPrelude + { + /// + /// The Http status code to include in the response prelude. + /// + public HttpStatusCode? StatusCode { get; set; } + + /// + /// The response headers to include in the response prelude. This collection supports setting single value for the same headers. + /// + public IDictionary Headers { get; set; } = new Dictionary(); + + /// + /// The response headers to include in the response prelude. This collection supports setting multiple values for the same headers. + /// + public IDictionary> MultiValueHeaders { get; set; } = new Dictionary>(); + + /// + /// The list of cookies to include in the response prelude. This is used for Lambda Function URL responses, which support a separate "cookies" field in the response JSON for setting cookies, rather than requiring cookies to be set via the "Set-Cookie" header. + /// + public IList Cookies { get; set; } = new List(); + + internal byte[] ToByteArray() + { + var bufferWriter = new System.Buffers.ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(bufferWriter)) + { + writer.WriteStartObject(); + + if (StatusCode.HasValue) + writer.WriteNumber("statusCode", (int)StatusCode); + + if (Headers?.Count > 0) + { + writer.WriteStartObject("headers"); + foreach (var header in Headers) + { + writer.WriteString(header.Key, header.Value); + } + writer.WriteEndObject(); + } + + if (MultiValueHeaders?.Count > 0) + { + writer.WriteStartObject("multiValueHeaders"); + foreach (var header in MultiValueHeaders) + { + writer.WriteStartArray(header.Key); + foreach (var value in header.Value) + { + writer.WriteStringValue(value); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + + if (Cookies?.Count > 0) + { + writer.WriteStartArray("cookies"); + foreach (var cookie in Cookies) + { + writer.WriteStringValue(cookie); + } + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + return bufferWriter.WrittenSpan.ToArray(); + } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs new file mode 100644 index 000000000..1385e551e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.Core.ResponseStreaming +{ + /// + /// Interface for writing streaming responses in AWS Lambda functions. + /// Obtained by calling within a handler. + /// + internal interface ILambdaResponseStream : IDisposable + { + /// + /// Asynchronously writes a portion of a byte array to the response stream. + /// + /// The byte array containing data to write. + /// The zero-based byte offset in buffer at which to begin copying bytes. + /// The number of bytes to write. + /// Optional cancellation token. + /// A task representing the asynchronous operation. + /// Thrown if the stream is already completed or an error has been reported. + Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); + + + /// + /// Gets the total number of bytes written to the stream so far. + /// + long BytesWritten { get; } + + + /// + /// Gets whether an error has been reported. + /// + bool HasError { get; } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs similarity index 64% rename from Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs rename to Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs index 46ff77d18..506db46d7 100644 --- a/Libraries/src/Amazon.Lambda.Core/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs @@ -1,76 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 #if NET8_0_OR_GREATER + using System; using System.IO; using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; -namespace Amazon.Lambda.Core +namespace Amazon.Lambda.Core.ResponseStreaming { - /// - /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. - /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] - public class LambdaResponseStreamFactory - { - internal const string ParameterizedPreviewMessage = - "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + - "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + - "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + - "project file to \"true\""; - - private static Func _streamFactory; - - internal static void SetLambdaResponseStream(Func streamFactory) - { - _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); - } - - /// - /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once - /// A Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler - /// return value will be ignored. The stream is write-only and non-seekable. - /// - /// - public static Stream CreateStream() - { - var runtimeResponseStream = _streamFactory(Array.Empty()); - return new LambdaResponseStream(runtimeResponseStream); - } - } - - /// - /// Interface for writing streaming responses in AWS Lambda functions. - /// Obtained by calling within a handler. - /// - internal interface ILambdaResponseStream : IDisposable - { - /// - /// Asynchronously writes a portion of a byte array to the response stream. - /// - /// The byte array containing data to write. - /// The zero-based byte offset in buffer at which to begin copying bytes. - /// The number of bytes to write. - /// Optional cancellation token. - /// A task representing the asynchronous operation. - /// Thrown if the stream is already completed or an error has been reported. - Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - - - /// - /// Gets the total number of bytes written to the stream so far. - /// - long BytesWritten { get; } - - - /// - /// Gets whether an error has been reported. - /// - bool HasError { get; } - } - /// /// A write-only, non-seekable subclass that streams response data /// to the Lambda Runtime API. Returned by . diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs new file mode 100644 index 000000000..c82ce4a3d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +using System; +using System.IO; +using System.Runtime.Versioning; + +namespace Amazon.Lambda.Core.ResponseStreaming +{ + /// + /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. + /// + [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + public class LambdaResponseStreamFactory + { + internal const string ParameterizedPreviewMessage = + "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + + "project file to \"true\""; + + private static Func _streamFactory; + + internal static void SetLambdaResponseStream(Func streamFactory) + { + _streamFactory = streamFactory ?? throw new ArgumentNullException(nameof(streamFactory)); + } + + /// + /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once + /// a Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler + /// return value will be ignored. The stream is write-only and non-seekable. + /// + /// + public static Stream CreateStream() + { + var runtimeResponseStream = _streamFactory(Array.Empty()); + return new LambdaResponseStream(runtimeResponseStream); + } + + /// + /// Create a for writing streaming responses, with an HTTP response prelude containing status code and headers. This should be used for + /// Lambda functions using response streaming that are invoked via the Lambda Function URLs or API Gateway HTTP APIs, where the response format is expected to be an HTTP response. + /// The prelude will be serialized and sent as the first chunk of the response stream, and should contain any necessary HTTP status code and headers. + /// + /// Once a Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler + /// return value will be ignored. The stream is write-only and non-seekable. + /// + /// + /// The HTTP response prelude including status code and headers. + /// + public static Stream CreateHttpStream(HttpResponseStreamPrelude prelude) + { + var runtimeResponseStream = _streamFactory(prelude.ToByteArray()); + return new LambdaResponseStream(runtimeResponseStream); + } + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index 37db44c76..c825c3bb6 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -27,8 +27,6 @@ namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming /// internal class ResponseStream { - private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); - private long _bytesWritten; private bool _isCompleted; private bool _hasError; @@ -41,6 +39,8 @@ internal class ResponseStream private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + private static readonly byte[] PreludeDelimiter = new byte[8]; + /// /// The number of bytes written to the Lambda response stream so far. /// @@ -54,10 +54,14 @@ internal class ResponseStream private readonly byte[] _prelude; + private readonly InternalLogger _logger; + + internal Exception ReportedError => _reportedError; internal ResponseStream(byte[] prelude) { + _logger = InternalLogger.GetDefaultLogger(); _prelude = prelude; } @@ -69,7 +73,6 @@ internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, Cancellati _httpOutputStream = httpOutputStream; _httpStreamReady.Release(); - InternalLogger.GetDefaultLogger().LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); await WritePreludeAsync(cancellationToken); } @@ -77,6 +80,7 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau { if (_prelude?.Length > 0) { + _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); await _httpStreamReady.WaitAsync(cancellationToken); try { @@ -85,22 +89,8 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau ThrowIfCompletedOrError(); } - // Write prelude JSON chunk - var chunkSizeHex = _prelude.Length.ToString("X"); - var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); await _httpOutputStream.WriteAsync(_prelude, 0, _prelude.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); - - // Write 8 null bytes delimiter chunk - var delimiterBytes = new byte[8]; - chunkSizeHex = "8"; - chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(delimiterBytes, 0, delimiterBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); + await _httpOutputStream.WriteAsync(PreludeDelimiter, 0, PreludeDelimiter.Length, cancellationToken); await _httpOutputStream.FlushAsync(cancellationToken); } @@ -149,19 +139,15 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT await _httpStreamReady.WaitAsync(cancellationToken); try { + _logger.LogDebug($"Writing chuck of {count} bytes to HTTP stream."); + lock (_lock) { ThrowIfCompletedOrError(); _bytesWritten += count; } - // Write chunk directly to the HTTP stream: size(hex) + CRLF + data + CRLF - var chunkSizeHex = count.ToString("X"); - var chunkSizeBytes = Encoding.ASCII.GetBytes(chunkSizeHex); - await _httpOutputStream.WriteAsync(chunkSizeBytes, 0, chunkSizeBytes.Length, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); await _httpOutputStream.WriteAsync(buffer, offset, count, cancellationToken); - await _httpOutputStream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, cancellationToken); await _httpOutputStream.FlushAsync(cancellationToken); } finally diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs index 15791d0b3..e9e846723 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs @@ -5,7 +5,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using Amazon.Lambda.Core; +using Amazon.Lambda.Core.ResponseStreaming; using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; #pragma warning disable CA2252 namespace Amazon.Lambda.RuntimeSupport diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs index 797c92758..a0cc0511a 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs @@ -29,9 +29,6 @@ namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming /// internal class StreamingHttpContent : HttpContent { - private static readonly byte[] CrlfBytes = Encoding.ASCII.GetBytes("\r\n"); - private static readonly byte[] FinalChunkBytes = Encoding.ASCII.GetBytes("0\r\n"); - private readonly ResponseStream _responseStream; private readonly CancellationToken _cancellationToken; @@ -47,23 +44,16 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // can write chunks directly to it. await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); - InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the undlying Lambda response stream in indicate it is complete."); + InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream in indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) await _responseStream.WaitForCompletionAsync(_cancellationToken); - // Write final chunk - await stream.WriteAsync(FinalChunkBytes, 0, FinalChunkBytes.Length, _cancellationToken); - // Write error trailers if present if (_responseStream.HasError) { InternalLogger.GetDefaultLogger().LogError(_responseStream.ReportedError, "An error occurred during Lambda execution. Writing error trailers to response."); await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); } - - // Write final CRLF to end the chunked message - await stream.WriteAsync(CrlfBytes, 0, CrlfBytes.Length, _cancellationToken); - await stream.FlushAsync(_cancellationToken); } protected override bool TryComputeLength(out long length) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index 041097057..dcec11ae3 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -201,6 +201,10 @@ internal virtual async Task StartStreamingResponseAsync( { request.Headers.Add(StreamingConstants.ResponseModeHeader, StreamingConstants.StreamingResponseMode); request.Headers.TransferEncodingChunked = true; + request.Headers.TryAddWithoutValidation( + "Content-Type", + "application/vnd.awslambda.http-integration-response" + ); // Declare trailers upfront — we always declare them since we don't know // at request start time whether an error will occur mid-stream. diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index ac4fc60eb..1aa2eb10c 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -50,36 +50,6 @@ public void Constructor_InitializesStateCorrectly() Assert.Null(stream.ReportedError); } - // ---- Chunked encoding format (Property 9, Property 22) ---- - - /// - /// Property 9: Chunked Encoding Format — each chunk is hex-size + CRLF + data + CRLF. - /// Property 22: CRLF Line Terminators — all line terminators are \r\n. - /// Validates: Requirements 3.2, 10.1, 10.5 - /// - [Theory] - [InlineData(new byte[] { 1, 2, 3 }, "3")] // 3 bytes → "3" - [InlineData(new byte[] { 0xFF }, "1")] // 1 byte → "1" - [InlineData(new byte[0], "0")] // 0 bytes → "0" - public async Task WriteAsync_WritesChunkedEncodingFormat(byte[] data, string expectedHexSize) - { - var (stream, httpOutput) = await CreateWiredStream(); - - await stream.WriteAsync(data, 0, data.Length); - - var written = httpOutput.ToArray(); - var expected = Encoding.ASCII.GetBytes(expectedHexSize + "\r\n") - .Concat(data) - .Concat(Encoding.ASCII.GetBytes("\r\n")) - .ToArray(); - - Assert.Equal(expected, written); - } - - /// - /// Property 9: Chunked Encoding Format — verify with offset/count overload. - /// Validates: Requirements 3.2, 10.1 - /// [Fact] public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() { @@ -90,21 +60,11 @@ public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() var written = httpOutput.ToArray(); // 3 bytes → hex "3", data is {1,2,3} - var expected = Encoding.ASCII.GetBytes("3\r\n") - .Concat(new byte[] { 1, 2, 3 }) - .Concat(Encoding.ASCII.GetBytes("\r\n")) - .ToArray(); + var expected = new byte[] { 1, 2, 3 }; Assert.Equal(expected, written); } - // ---- Property 5: Written Data Appears in HTTP Response Immediately ---- - - /// - /// Property 5: Written Data Appears in HTTP Response Immediately — - /// each WriteAsync call writes to the HTTP stream before returning. - /// Validates: Requirements 3.2 - /// [Fact] public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() { @@ -122,29 +82,6 @@ public async Task WriteAsync_MultipleWrites_EachAppearsImmediately() Assert.Equal(3, stream.BytesWritten); } - /// - /// Property 5: Written Data Appears in HTTP Response Immediately — - /// verify with a larger payload that hex size is multi-character. - /// Validates: Requirements 3.2 - /// - [Fact] - public async Task WriteAsync_LargerPayload_HexSizeIsCorrect() - { - var (stream, httpOutput) = await CreateWiredStream(); - var data = new byte[256]; // 0x100 - - await stream.WriteAsync(data, 0, data.Length); - - var written = Encoding.ASCII.GetString(httpOutput.ToArray()); - Assert.StartsWith("100\r\n", written); - } - - // ---- Semaphore coordination: _httpStreamReady blocks until SetHttpOutputStream ---- - - /// - /// Test that WriteAsync blocks until SetHttpOutputStream is called. - /// Validates: Requirements 3.2, 10.1 - /// [Fact] public async Task WriteAsync_BlocksUntilSetHttpOutputStream() { @@ -174,12 +111,6 @@ public async Task WriteAsync_BlocksUntilSetHttpOutputStream() Assert.True(httpOutput.ToArray().Length > 0); } - // ---- Completion signaling: MarkCompleted releases _completionSignal ---- - - /// - /// Test that MarkCompleted releases the completion signal (WaitForCompletionAsync unblocks). - /// Validates: Requirements 5.5, 8.3 - /// [Fact] public async Task MarkCompleted_ReleasesCompletionSignal() { @@ -195,12 +126,6 @@ public async Task MarkCompleted_ReleasesCompletionSignal() Assert.Same(waitTask, completed); } - // ---- Completion signaling: ReportErrorAsync releases _completionSignal ---- - - /// - /// Test that ReportErrorAsync releases the completion signal. - /// Validates: Requirements 5.5 - /// [Fact] public async Task ReportErrorAsync_ReleasesCompletionSignal() { @@ -216,12 +141,6 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() Assert.True(stream.HasError); } - // ---- Property 19: Writes After Completion Rejected ---- - - /// - /// Property 19: Writes After Completion Rejected — writes after MarkCompleted throw. - /// Validates: Requirements 8.8 - /// [Fact] public async Task WriteAsync_AfterMarkCompleted_Throws() { @@ -233,10 +152,6 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } - /// - /// Property 19: Writes After Completion Rejected — writes after ReportErrorAsync throw. - /// Validates: Requirements 8.8 - /// [Fact] public async Task WriteAsync_AfterReportError_Throws() { @@ -248,8 +163,6 @@ await Assert.ThrowsAsync( () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); } - // ---- Error handling tests ---- - [Fact] public async Task ReportErrorAsync_SetsErrorState() { @@ -282,8 +195,6 @@ public async Task ReportErrorAsync_CalledTwice_Throws() () => stream.ReportError(new Exception("second"))); } - // ---- Argument validation ---- - [Fact] public async Task WriteAsync_NullBuffer_ThrowsArgumentNull() { @@ -308,8 +219,6 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() Assert.Throws(() => stream.ReportError(null)); } - // ---- Dispose signals completion ---- - [Fact] public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 2417b3ccb..14018e02b 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -57,7 +57,7 @@ private static Dictionary> MakeHeaders(string reques /// /// A capturing RuntimeApiClient that records the raw bytes written to the HTTP output stream - /// by SerializeToStreamAsync, enabling assertions on chunked-encoding format. + /// by SerializeToStreamAsync. /// private class CapturingStreamingRuntimeApiClient : RuntimeApiClient, IRuntimeApiClient { @@ -145,46 +145,6 @@ internal override async Task StartStreamingResponseAsync( private static CapturingStreamingRuntimeApiClient CreateClient(string requestId = "test-request-id") => new CapturingStreamingRuntimeApiClient(new TestEnvironmentVariables(), MakeHeaders(requestId)); - // ─── 10.1 End-to-end streaming response ───────────────────────────────────── - - /// - /// End-to-end: handler calls CreateStream, writes multiple chunks. - /// Verifies data flows through with correct chunked encoding and stream is finalized. - /// Requirements: 3.2, 4.3, 10.1 - /// - [Fact] - public async Task Streaming_MultipleChunks_FlowThroughWithChunkedEncoding() - { - var client = CreateClient(); - var chunks = new[] { "Hello", ", ", "World" }; - - LambdaBootstrapHandler handler = async (invocation) => - { - var stream = ResponseStreamFactory.CreateStream(Array.Empty()); - foreach (var chunk in chunks) - await stream.WriteAsync(Encoding.UTF8.GetBytes(chunk)); - return new InvocationResponse(Stream.Null, false); - }; - - using var bootstrap = new LambdaBootstrap(handler, null); - bootstrap.Client = client; - await bootstrap.InvokeOnceAsync(); - - Assert.True(client.StartStreamingCalled); - Assert.NotNull(client.CapturedHttpBytes); - - var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); - - // Each chunk should appear as: hex-size\r\ndata\r\n - Assert.Contains("5\r\nHello\r\n", output); - Assert.Contains("2\r\n, \r\n", output); - Assert.Contains("5\r\nWorld\r\n", output); - - // Final chunk terminates the stream - Assert.Contains("0\r\n", output); - Assert.EndsWith("0\r\n\r\n", output); - } - /// /// End-to-end: all data is transmitted correctly (content round-trip). /// Requirements: 3.2, 4.3, 10.1 @@ -209,10 +169,7 @@ public async Task Streaming_AllDataTransmitted_ContentRoundTrip() var output = client.CapturedHttpBytes; Assert.NotNull(output); - // Decode the single chunk: hex-size\r\ndata\r\n var outputStr = Encoding.UTF8.GetString(output); - var hexSize = payload.Length.ToString("X"); - Assert.Contains(hexSize + "\r\n", outputStr); Assert.Contains("integration test payload", outputStr); } @@ -296,54 +253,6 @@ public async Task Buffered_ResponseBodyTransmittedCorrectly() Assert.Equal(responseBody, received.ToArray()); } - // ─── 10.3 Midstream error ──────────────────────────────────────────────────── - - /// - /// End-to-end: handler writes data then throws — error trailers appear after final chunk. - /// Requirements: 5.1, 5.2, 5.3, 5.6 - /// - [Fact] - public async Task MidstreamError_ErrorTrailersIncludedAfterFinalChunk() - { - var client = CreateClient(); - - LambdaBootstrapHandler handler = async (invocation) => - { - var stream = ResponseStreamFactory.CreateStream(Array.Empty()); - await stream.WriteAsync(Encoding.UTF8.GetBytes("partial data")); - throw new InvalidOperationException("midstream failure"); - }; - - using var bootstrap = new LambdaBootstrap(handler, null); - bootstrap.Client = client; - await bootstrap.InvokeOnceAsync(); - - Assert.True(client.StartStreamingCalled); - Assert.NotNull(client.CapturedHttpBytes); - - var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); - - // Data chunk should be present - Assert.Contains("partial data", output); - - // Final chunk must appear - Assert.Contains("0\r\n", output); - - // Error trailers must appear after the final chunk - var finalChunkIdx = output.LastIndexOf("0\r\n"); - var errorTypeIdx = output.IndexOf(StreamingConstants.ErrorTypeTrailer + ":"); - var errorBodyIdx = output.IndexOf(StreamingConstants.ErrorBodyTrailer + ":"); - - Assert.True(errorTypeIdx > finalChunkIdx, "Error-Type trailer should appear after final chunk"); - Assert.True(errorBodyIdx > finalChunkIdx, "Error-Body trailer should appear after final chunk"); - - // Error type should reference the exception type - Assert.Contains("InvalidOperationException", output); - - // Standard error reporting should NOT be used (error went via trailers) - Assert.False(client.ReportInvocationErrorCalled); - } - /// /// End-to-end: error body trailer contains JSON with exception details. /// Requirements: 5.2, 5.3 diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs index bf87dd31a..21fe303b3 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs @@ -24,8 +24,6 @@ namespace Amazon.Lambda.RuntimeSupport.UnitTests { public class StreamingHttpContentTests { - private const long MaxResponseSize = 20 * 1024 * 1024; - /// /// Helper: runs SerializeToStreamAsync concurrently with handler actions. /// The handlerAction receives the ResponseStream and should write data then signal completion. @@ -53,13 +51,6 @@ private async Task SerializeWithConcurrentHandler( return outputStream.ToArray(); } - // ---- SerializeToStreamAsync hands off HTTP stream ---- - - /// - /// Test that SerializeToStreamAsync calls SetHttpOutputStream on the ResponseStream, - /// enabling writes to flow through. - /// Validates: Requirements 4.3, 10.1 - /// [Fact] public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() { @@ -71,16 +62,9 @@ public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() stream.MarkCompleted(); }); - var outputStr = Encoding.ASCII.GetString(output); - // Should contain the chunk data written by the handler - Assert.Contains("2\r\n", outputStr); - Assert.True(output.Length > 0); + Assert.Equal(2, output.Length); } - /// - /// Test that SerializeToStreamAsync blocks until MarkCompleted is called. - /// Validates: Requirements 4.3 - /// [Fact] public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() { @@ -101,10 +85,6 @@ public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() Assert.True(serializeTask.IsCompleted); } - /// - /// Test that SerializeToStreamAsync blocks until ReportErrorAsync is called. - /// Validates: Requirements 4.3, 5.1 - /// [Fact] public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() { @@ -123,87 +103,6 @@ public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() Assert.True(serializeTask.IsCompleted); } - // ---- Property 20: Final Chunk Termination ---- - - /// - /// Property 20: Final Chunk Termination — final chunk "0\r\n" is written after completion. - /// Validates: Requirements 4.3, 10.2, 10.3 - /// - [Fact] - public async Task FinalChunk_WrittenAfterCompletion() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.MarkCompleted(); - }); - - var outputStr = Encoding.ASCII.GetString(output); - Assert.Contains("0\r\n", outputStr); - - // Final chunk should appear after the data chunk - var dataChunkEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; // "1\r\n" + 1 byte data + "\r\n" - var finalChunkIndex = outputStr.IndexOf("0\r\n", dataChunkEnd); - Assert.True(finalChunkIndex >= 0, "Final chunk 0\\r\\n should appear after data chunks"); - } - - /// - /// Property 20: Final Chunk Termination — empty stream still gets final chunk. - /// Validates: Requirements 10.2 - /// - [Fact] - public async Task FinalChunk_EmptyStream_StillWritten() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, stream => - { - stream.MarkCompleted(); - return Task.CompletedTask; - }); - - var outputStr = Encoding.ASCII.GetString(output); - Assert.StartsWith("0\r\n", outputStr); - } - - // ---- Property 21: Trailer Ordering ---- - - /// - /// Property 21: Trailer Ordering — trailers appear after final chunk. - /// Validates: Requirements 10.3 - /// - [Fact] - public async Task ErrorTrailers_AppearAfterFinalChunk() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.ReportError(new Exception("fail")); - }); - - var outputStr = Encoding.UTF8.GetString(output); - - // Find the final chunk "0\r\n" that appears after data chunks - var dataEnd = outputStr.IndexOf("1\r\n") + 3 + 1 + 2; - var finalChunkIndex = outputStr.IndexOf("0\r\n", dataEnd); - var errorTypeIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Type:"); - var errorBodyIndex = outputStr.IndexOf("Lambda-Runtime-Function-Error-Body:"); - - Assert.True(finalChunkIndex >= 0, "Final chunk not found"); - Assert.True(errorTypeIndex > finalChunkIndex, "Error type trailer should appear after final chunk"); - Assert.True(errorBodyIndex > finalChunkIndex, "Error body trailer should appear after final chunk"); - } - - // ---- Property 11: Midstream Error Type Trailer ---- - - /// - /// Property 11: Midstream Error Type Trailer — error type trailer is included for various exception types. - /// Validates: Requirements 5.1, 5.2 - /// [Theory] [InlineData(typeof(InvalidOperationException))] [InlineData(typeof(ArgumentException))] @@ -223,12 +122,6 @@ public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); } - // ---- Property 12: Midstream Error Body Trailer ---- - - /// - /// Property 12: Midstream Error Body Trailer — error body trailer includes JSON exception details. - /// Validates: Requirements 5.3 - /// [Fact] public async Task ErrorTrailer_IncludesJsonErrorBody() { @@ -246,32 +139,7 @@ public async Task ErrorTrailer_IncludesJsonErrorBody() Assert.Contains("InvalidOperationException", outputStr); } - // ---- Final CRLF termination ---- - - /// - /// Test that the chunked message ends with CRLF after successful completion (no trailers). - /// Validates: Requirements 10.2, 10.5 - /// - [Fact] - public async Task SuccessfulCompletion_EndsWithCrlf() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.MarkCompleted(); - }); - - var outputStr = Encoding.ASCII.GetString(output); - // Should end with "0\r\n" (final chunk) + "\r\n" (end of message) - Assert.EndsWith("0\r\n\r\n", outputStr); - } - /// - /// Test that the chunked message ends with CRLF after error trailers. - /// Validates: Requirements 10.3, 10.5 - /// [Fact] public async Task ErrorCompletion_EndsWithCrlf() { @@ -287,8 +155,6 @@ public async Task ErrorCompletion_EndsWithCrlf() Assert.EndsWith("\r\n", outputStr); } - // ---- No error, no trailers ---- - [Fact] public async Task NoError_NoTrailersWritten() { @@ -305,8 +171,6 @@ public async Task NoError_NoTrailersWritten() Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); } - // ---- TryComputeLength ---- - [Fact] public void TryComputeLength_ReturnsFalse() { @@ -316,32 +180,5 @@ public void TryComputeLength_ReturnsFalse() var result = content.Headers.ContentLength; Assert.Null(result); } - - // ---- CRLF correctness ---- - - /// - /// Property 22: CRLF Line Terminators — all line terminators are CRLF, not just LF. - /// Validates: Requirements 10.5 - /// - [Fact] - public async Task CrlfTerminators_NoBareLineFeed() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 65, 66, 67 }); // "ABC" - stream.MarkCompleted(); - }); - - for (int i = 0; i < output.Length; i++) - { - if (output[i] == (byte)'\n') - { - Assert.True(i > 0 && output[i - 1] == (byte)'\r', - $"Found bare LF at position {i} without preceding CR"); - } - } - } } } From ab93ce9513d46658f4e2847f6a4b21eb82e35eac Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 6 Mar 2026 14:04:59 -0800 Subject: [PATCH 18/47] Backfill tests after refactor --- .../LambdaResponseStreamingCoreTests.cs | 557 ++++++++++++++++++ .../ResponseStreamTests.cs | 130 +++- 2 files changed, 682 insertions(+), 5 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs new file mode 100644 index 000000000..6759627db --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs @@ -0,0 +1,557 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER +#pragma warning disable CA2252 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.Core.ResponseStreaming; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + // ───────────────────────────────────────────────────────────────────────────── + // HttpResponseStreamPrelude.ToByteArray() tests + // ───────────────────────────────────────────────────────────────────────────── + + public class HttpResponseStreamPreludeTests + { + private static JsonDocument ParsePrelude(HttpResponseStreamPrelude prelude) + => JsonDocument.Parse(prelude.ToByteArray()); + + [Fact] + public void ToByteArray_EmptyPrelude_ProducesEmptyJsonObject() + { + var prelude = new HttpResponseStreamPrelude(); + var doc = ParsePrelude(prelude); + + Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); + // No properties should be present + Assert.False(doc.RootElement.TryGetProperty("statusCode", out _)); + Assert.False(doc.RootElement.TryGetProperty("headers", out _)); + Assert.False(doc.RootElement.TryGetProperty("multiValueHeaders", out _)); + Assert.False(doc.RootElement.TryGetProperty("cookies", out _)); + } + + [Fact] + public void ToByteArray_WithStatusCode_IncludesStatusCode() + { + var prelude = new HttpResponseStreamPrelude { StatusCode = HttpStatusCode.OK }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("statusCode", out var sc)); + Assert.Equal(200, sc.GetInt32()); + } + + [Fact] + public void ToByteArray_WithHeaders_IncludesHeaders() + { + var prelude = new HttpResponseStreamPrelude + { + Headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["X-Custom"] = "value" + } + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("headers", out var headers)); + Assert.Equal("application/json", headers.GetProperty("Content-Type").GetString()); + Assert.Equal("value", headers.GetProperty("X-Custom").GetString()); + } + + [Fact] + public void ToByteArray_WithMultiValueHeaders_IncludesMultiValueHeaders() + { + var prelude = new HttpResponseStreamPrelude + { + MultiValueHeaders = new Dictionary> + { + ["Set-Cookie"] = new List { "a=1", "b=2" } + } + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("multiValueHeaders", out var mvh)); + var cookies = mvh.GetProperty("Set-Cookie"); + Assert.Equal(JsonValueKind.Array, cookies.ValueKind); + Assert.Equal(2, cookies.GetArrayLength()); + } + + [Fact] + public void ToByteArray_WithCookies_IncludesCookies() + { + var prelude = new HttpResponseStreamPrelude + { + Cookies = new List { "session=abc", "pref=dark" } + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("cookies", out var cookies)); + Assert.Equal(JsonValueKind.Array, cookies.ValueKind); + Assert.Equal(2, cookies.GetArrayLength()); + Assert.Equal("session=abc", cookies[0].GetString()); + } + + [Fact] + public void ToByteArray_AllFieldsPopulated_ProducesCorrectJson() + { + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.Created, + Headers = new Dictionary { ["X-Req"] = "1" }, + MultiValueHeaders = new Dictionary> { ["X-Multi"] = new List { "a", "b" } }, + Cookies = new List { "c=1" } + }; + var doc = ParsePrelude(prelude); + + Assert.Equal(201, doc.RootElement.GetProperty("statusCode").GetInt32()); + Assert.Equal("1", doc.RootElement.GetProperty("headers").GetProperty("X-Req").GetString()); + Assert.Equal(2, doc.RootElement.GetProperty("multiValueHeaders").GetProperty("X-Multi").GetArrayLength()); + Assert.Equal("c=1", doc.RootElement.GetProperty("cookies")[0].GetString()); + } + + [Fact] + public void ToByteArray_EmptyCollections_OmitsThoseFields() + { + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.OK, + Headers = new Dictionary(), // empty — should be omitted + MultiValueHeaders = new Dictionary>(), // empty + Cookies = new List() // empty + }; + var doc = ParsePrelude(prelude); + + Assert.True(doc.RootElement.TryGetProperty("statusCode", out _)); + Assert.False(doc.RootElement.TryGetProperty("headers", out _)); + Assert.False(doc.RootElement.TryGetProperty("multiValueHeaders", out _)); + Assert.False(doc.RootElement.TryGetProperty("cookies", out _)); + } + + [Fact] + public void ToByteArray_ProducesValidUtf8() + { + var prelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.OK, + Headers = new Dictionary { ["Content-Type"] = "text/plain; charset=utf-8" } + }; + var bytes = prelude.ToByteArray(); + + // Should not throw + var text = Encoding.UTF8.GetString(bytes); + Assert.NotEmpty(text); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // LambdaResponseStream (Stream subclass) tests + // ───────────────────────────────────────────────────────────────────────────── + + public class LambdaResponseStreamTests + { + /// + /// Creates a LambdaResponseStream backed by a real ResponseStream wired to a MemoryStream. + /// + private static async Task<(LambdaResponseStream lambdaStream, MemoryStream httpOutput)> CreateWiredLambdaStream() + { + var inner = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + await inner.SetHttpOutputStreamAsync(output); + + var implStream = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var lambdaStream = new LambdaResponseStream(implStream); + return (lambdaStream, output); + } + + [Fact] + public void LambdaResponseStream_IsStreamSubclass() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.IsAssignableFrom(stream); + } + + [Fact] + public void CanWrite_IsTrue() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.True(stream.CanWrite); + } + + [Fact] + public void CanRead_IsFalse() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.False(stream.CanRead); + } + + [Fact] + public void CanSeek_IsFalse() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.False(stream.CanSeek); + } + + [Fact] + public void Read_ThrowsNotImplementedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.Read(new byte[1], 0, 1)); + } + + [Fact] + public void ReadAsync_ThrowsNotImplementedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + // ReadAsync throws synchronously (not async) — capture the thrown task + var ex = Assert.Throws( + () => { var _ = stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None); }); + Assert.NotNull(ex); + } + + [Fact] + public void Seek_ThrowsNotImplementedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void Position_Get_ThrowsNotSupportedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => _ = stream.Position); + } + + [Fact] + public void Position_Set_ThrowsNotSupportedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.Position = 0); + } + + [Fact] + public void SetLength_ThrowsNotSupportedException() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var stream = new LambdaResponseStream(impl); + + Assert.Throws(() => stream.SetLength(100)); + } + + [Fact] + public async Task WriteAsync_WritesRawBytesToHttpStream() + { + var (stream, output) = await CreateWiredLambdaStream(); + var data = Encoding.UTF8.GetBytes("hello streaming"); + + await stream.WriteAsync(data, 0, data.Length); + + Assert.Equal(data, output.ToArray()); + } + + [Fact] + public async Task Write_SyncOverload_WritesRawBytes() + { + var (stream, output) = await CreateWiredLambdaStream(); + var data = new byte[] { 1, 2, 3 }; + + stream.Write(data, 0, data.Length); + + Assert.Equal(data, output.ToArray()); + } + + [Fact] + public async Task Length_ReflectsBytesWritten() + { + var (stream, _) = await CreateWiredLambdaStream(); + var data = new byte[42]; + + await stream.WriteAsync(data, 0, data.Length); + + Assert.Equal(42, stream.Length); + Assert.Equal(42, stream.BytesWritten); + } + + [Fact] + public async Task Flush_IsNoOp() + { + var (stream, _) = await CreateWiredLambdaStream(); + // Should not throw + stream.Flush(); + } + + [Fact] + public async Task WriteAsync_ByteArrayOverload_WritesFullArray() + { + var (stream, output) = await CreateWiredLambdaStream(); + var data = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + + await stream.WriteAsync(data); + + Assert.Equal(data, output.ToArray()); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // ImplLambdaResponseStream (bridge class) tests + // ───────────────────────────────────────────────────────────────────────────── + + public class ImplLambdaResponseStreamTests + { + [Fact] + public async Task WriteAsync_DelegatesToInnerResponseStream() + { + var inner = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + await inner.SetHttpOutputStreamAsync(output); + + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + var data = new byte[] { 1, 2, 3 }; + + await impl.WriteAsync(data, 0, data.Length); + + Assert.Equal(data, output.ToArray()); + } + + [Fact] + public async Task BytesWritten_ReflectsInnerStreamBytesWritten() + { + var inner = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + await inner.SetHttpOutputStreamAsync(output); + + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + await impl.WriteAsync(new byte[7], 0, 7); + + Assert.Equal(7, impl.BytesWritten); + } + + [Fact] + public void HasError_InitiallyFalse() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + + Assert.False(impl.HasError); + } + + [Fact] + public void HasError_TrueAfterReportError() + { + var inner = new ResponseStream(Array.Empty()); + inner.ReportError(new Exception("test")); + + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + + Assert.True(impl.HasError); + } + + [Fact] + public void Dispose_DisposesInnerStream() + { + var inner = new ResponseStream(Array.Empty()); + var impl = new ResponseStreamLambdaCoreInitializerIsolated.ImplLambdaResponseStream(inner); + + // Should not throw + impl.Dispose(); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // LambdaResponseStreamFactory tests + // ───────────────────────────────────────────────────────────────────────────── + + [Collection("ResponseStreamFactory")] + public class LambdaResponseStreamFactoryTests : IDisposable + { + + public LambdaResponseStreamFactoryTests() + { + // Wire up the factory via the initializer (same as production bootstrap does) + ResponseStreamLambdaCoreInitializerIsolated.InitializeCore(); + } + + public void Dispose() + { + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency: false); + } + + private void InitializeInvocation(string requestId = "test-req") + { + var envVars = new TestEnvironmentVariables(); + var client = new NoOpStreamingRuntimeApiClient(envVars); + ResponseStreamFactory.InitializeInvocation(requestId, false, client, CancellationToken.None); + } + + /// + /// Minimal RuntimeApiClient that accepts StartStreamingResponseAsync without real HTTP. + /// + private class NoOpStreamingRuntimeApiClient : RuntimeApiClient + { + public NoOpStreamingRuntimeApiClient(IEnvironmentVariables envVars) + : base(envVars, new TestHelpers.NoOpInternalRuntimeApiClient()) { } + + internal override async Task StartStreamingResponseAsync( + string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) + { + // Provide the HTTP output stream so writes don't block + await responseStream.SetHttpOutputStreamAsync(new MemoryStream(), cancellationToken); + await responseStream.WaitForCompletionAsync(cancellationToken); + } + } + + [Fact] + public void CreateStream_ReturnsLambdaResponseStream() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.NotNull(stream); + Assert.IsType(stream); + } + + [Fact] + public void CreateStream_ReturnsStreamSubclass() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.IsAssignableFrom(stream); + } + + [Fact] + public void CreateStream_ReturnedStream_IsWritable() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.True(stream.CanWrite); + } + + [Fact] + public void CreateStream_ReturnedStream_IsNotSeekable() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.False(stream.CanSeek); + } + + [Fact] + public void CreateStream_ReturnedStream_IsNotReadable() + { + InitializeInvocation(); + + var stream = LambdaResponseStreamFactory.CreateStream(); + + Assert.False(stream.CanRead); + } + + [Fact] + public void CreateHttpStream_WithPrelude_ReturnsLambdaResponseStream() + { + InitializeInvocation(); + + var prelude = new HttpResponseStreamPrelude { StatusCode = HttpStatusCode.OK }; + var stream = LambdaResponseStreamFactory.CreateHttpStream(prelude); + + Assert.NotNull(stream); + Assert.IsType(stream); + } + + [Fact] + public void CreateHttpStream_PassesSerializedPreludeToFactory() + { + // Capture the prelude bytes passed to the inner factory + byte[] capturedPrelude = null; + LambdaResponseStreamFactory.SetLambdaResponseStream(prelude => + { + capturedPrelude = prelude; + // Return a minimal stub that satisfies the interface + return new StubLambdaResponseStream(); + }); + + var httpPrelude = new HttpResponseStreamPrelude + { + StatusCode = HttpStatusCode.Created, + Headers = new Dictionary { ["X-Test"] = "1" } + }; + LambdaResponseStreamFactory.CreateHttpStream(httpPrelude); + + Assert.NotNull(capturedPrelude); + Assert.True(capturedPrelude.Length > 0); + + // Verify the bytes are valid JSON containing the status code + var doc = JsonDocument.Parse(capturedPrelude); + Assert.Equal(201, doc.RootElement.GetProperty("statusCode").GetInt32()); + } + + [Fact] + public void CreateStream_PassesEmptyPreludeToFactory() + { + byte[] capturedPrelude = null; + LambdaResponseStreamFactory.SetLambdaResponseStream(prelude => + { + capturedPrelude = prelude; + return new StubLambdaResponseStream(); + }); + + LambdaResponseStreamFactory.CreateStream(); + + Assert.NotNull(capturedPrelude); + Assert.Empty(capturedPrelude); + } + + private class StubLambdaResponseStream : ILambdaResponseStream + { + public long BytesWritten => 0; + public bool HasError => false; + public void Dispose() { } + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + } +} +#endif diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 1aa2eb10c..517f2b8da 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -51,18 +51,16 @@ public void Constructor_InitializesStateCorrectly() } [Fact] - public async Task WriteAsync_WithOffset_WritesCorrectSliceAsChunk() + public async Task WriteAsync_WithOffset_WritesCorrectSlice() { var (stream, httpOutput) = await CreateWiredStream(); var data = new byte[] { 0, 1, 2, 3, 0 }; await stream.WriteAsync(data, 1, 3); - var written = httpOutput.ToArray(); - // 3 bytes → hex "3", data is {1,2,3} + // Raw bytes {1,2,3} written directly — no chunked encoding var expected = new byte[] { 1, 2, 3 }; - - Assert.Equal(expected, written); + Assert.Equal(expected, httpOutput.ToArray()); } [Fact] @@ -232,5 +230,127 @@ public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); Assert.Same(waitTask, completed); } + + [Fact] + public async Task Dispose_CalledTwice_DoesNotThrow() + { + var stream = new ResponseStream(Array.Empty()); + stream.Dispose(); + // Second dispose should be a no-op + stream.Dispose(); + } + + // ---- Prelude tests ---- + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_WritesPreludeBeforeHandlerData() + { + var prelude = new byte[] { 0x01, 0x02, 0x03 }; + var rs = new ResponseStream(prelude); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + + // Prelude bytes + 8-byte null delimiter should be written before any handler data + var written = output.ToArray(); + Assert.True(written.Length >= prelude.Length + 8, "Prelude + delimiter should be written"); + Assert.Equal(prelude, written[..prelude.Length]); + Assert.Equal(new byte[8], written[prelude.Length..(prelude.Length + 8)]); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_WithEmptyPrelude_WritesNoPreludeBytes() + { + var rs = new ResponseStream(Array.Empty()); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + + // Empty prelude — nothing written yet (handler hasn't written anything) + Assert.Empty(output.ToArray()); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_HandlerDataAppendsAfterDelimiter() + { + var prelude = new byte[] { 0xAA, 0xBB }; + var rs = new ResponseStream(prelude); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + await rs.WriteAsync(new byte[] { 0xFF }, 0, 1); + + var written = output.ToArray(); + // Layout: [prelude][8 null bytes][handler data] + int expectedMinLength = prelude.Length + 8 + 1; + Assert.Equal(expectedMinLength, written.Length); + Assert.Equal(new byte[] { 0xFF }, written[^1..]); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_NullPrelude_WritesNoPreludeBytes() + { + var rs = new ResponseStream(null); + var output = new MemoryStream(); + + await rs.SetHttpOutputStreamAsync(output); + + Assert.Empty(output.ToArray()); + } + + // ---- MarkCompleted idempotency ---- + + [Fact] + public async Task MarkCompleted_CalledTwice_DoesNotThrowOrDoubleRelease() + { + var (stream, _) = await CreateWiredStream(); + + stream.MarkCompleted(); + // Second call should be a no-op — semaphore should not be double-released + stream.MarkCompleted(); + + // WaitForCompletionAsync should complete exactly once without hanging + var waitTask = stream.WaitForCompletionAsync(); + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + } + + [Fact] + public async Task ReportError_ThenMarkCompleted_MarkCompletedIsNoOp() + { + var stream = new ResponseStream(Array.Empty()); + stream.ReportError(new Exception("error")); + + // MarkCompleted after ReportError should not throw and not double-release + stream.MarkCompleted(); + + // WaitForCompletionAsync should complete (released by ReportError) + var waitTask = stream.WaitForCompletionAsync(); + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); + Assert.Same(waitTask, completed); + } + + // ---- BytesWritten tracking ---- + + [Fact] + public async Task BytesWritten_TracksAcrossMultipleWrites() + { + var (stream, _) = await CreateWiredStream(); + + await stream.WriteAsync(new byte[10], 0, 10); + await stream.WriteAsync(new byte[5], 0, 5); + + Assert.Equal(15, stream.BytesWritten); + } + + [Fact] + public async Task BytesWritten_ReflectsOffsetAndCount() + { + var (stream, _) = await CreateWiredStream(); + + await stream.WriteAsync(new byte[10], 2, 6); // only 6 bytes + + Assert.Equal(6, stream.BytesWritten); + } } } From d60bb933005faa00461ccdedeaf53b86afe8ce17 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Mar 2026 00:24:57 -0700 Subject: [PATCH 19/47] Add integ tests --- .gitignore | 2 + Libraries/Libraries.sln | 19 ++- ...bda.RuntimeSupport.IntegrationTests.csproj | 18 +-- .../BaseCustomRuntimeTest.cs | 8 +- .../CustomRuntimeTests.cs | 2 +- .../Helpers/CommandLineWrapper.cs | 6 +- .../Helpers/LambdaToolsHelper.cs | 6 +- .../IntegrationTestCollection.cs | 4 +- .../IntegrationTestFixture.cs | 14 +- .../ResponseStreamingTests.cs | 136 ++++++++++++++++++ .../Function.cs | 56 ++++++++ .../ResponseStreamingFunctionHandlers.csproj | 19 +++ .../aws-lambda-tools-defaults.json | 15 ++ 13 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json diff --git a/.gitignore b/.gitignore index f91715274..1caae6fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.suo *.user +**/.kiro/ + #################### # Build/Test folders #################### diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index f3214606a..23840bdfa 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31717.71 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11512.155 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12}" EndProject @@ -151,6 +151,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp.Int EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp", "test\TestCustomAuthorizerApp\TestCustomAuthorizerApp.csproj", "{3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResponseStreamingFunctionHandlers", "test\Amazon.Lambda.RuntimeSupport.Tests\ResponseStreamingFunctionHandlers\ResponseStreamingFunctionHandlers.csproj", "{E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -941,6 +943,18 @@ Global {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x64.Build.0 = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.ActiveCfg = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.Build.0 = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x64.Build.0 = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Debug|x86.Build.0 = Debug|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|Any CPU.Build.0 = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x64.ActiveCfg = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x64.Build.0 = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x86.ActiveCfg = Release|Any CPU + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1015,6 +1029,7 @@ Global {8D03BDF3-7078-4B46-A3F1-C73BE6D6CE0D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {8EEDD576-7FC4-4FAC-A5A2-F58562753A53} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9} = {B5BD0336-7D08-492C-8489-42C987E29B39} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj index 86a3b5c1e..d206a1f1c 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 @@ -19,19 +19,19 @@ - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers - + - + diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs index c220a671e..fa10cee7d 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs @@ -17,7 +17,7 @@ public class BaseCustomRuntimeTest { public const int FUNCTION_MEMORY_MB = 512; - protected static readonly RegionEndpoint TestRegion = RegionEndpoint.USWest2; + public static readonly RegionEndpoint TestRegion = RegionEndpoint.USWest2; protected static readonly string LAMBDA_ASSUME_ROLE_POLICY = @" { @@ -63,7 +63,7 @@ protected BaseCustomRuntimeTest(IntegrationTestFixture fixture, string functionN /// /// /// - protected async Task CleanUpTestResources(AmazonS3Client s3Client, AmazonLambdaClient lambdaClient, + public async Task CleanUpTestResources(AmazonS3Client s3Client, AmazonLambdaClient lambdaClient, AmazonIdentityManagementServiceClient iamClient, bool roleAlreadyExisted) { await DeleteFunctionIfExistsAsync(lambdaClient); @@ -109,7 +109,7 @@ await iamClient.DetachRolePolicyAsync(new DetachRolePolicyRequest } } - protected async Task PrepareTestResources(IAmazonS3 s3Client, IAmazonLambda lambdaClient, + public async Task PrepareTestResources(IAmazonS3 s3Client, IAmazonLambda lambdaClient, AmazonIdentityManagementServiceClient iamClient) { var roleAlreadyExisted = await ValidateAndSetIamRoleArn(iamClient); @@ -288,7 +288,7 @@ protected async Task CreateFunctionAsync(IAmazonLambda lambdaClient, string buck Handler = Handler, MemorySize = FUNCTION_MEMORY_MB, Timeout = 30, - Runtime = Runtime.Dotnet6, + Runtime = Runtime.Dotnet10, Role = ExecutionRoleArn }; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs index b548d5ba0..8ab008d66 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/CustomRuntimeTests.cs @@ -48,7 +48,7 @@ public async Task TestAllNET8HandlersAsync() public class CustomRuntimeTests : BaseCustomRuntimeTest { - public enum TargetFramework { NET6, NET8} + public enum TargetFramework { NET8 } private TargetFramework _targetFramework; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs index aa8651eae..e18f31833 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Text; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -31,6 +32,7 @@ public static async Task Run(string command, string arguments, string workingDir tcs.TrySetResult(true); }; + var output = new StringBuilder(); try { // Attach event handlers @@ -39,6 +41,7 @@ public static async Task Run(string command, string arguments, string workingDir if (!string.IsNullOrEmpty(args.Data)) { Console.WriteLine(args.Data); + output.Append(args.Data); } }; @@ -47,6 +50,7 @@ public static async Task Run(string command, string arguments, string workingDir if (!string.IsNullOrEmpty(args.Data)) { Console.WriteLine(args.Data); + output.Append(args.Data); } }; @@ -87,4 +91,4 @@ public static async Task Run(string command, string arguments, string workingDir Assert.True(process.ExitCode == 0, $"Command '{command} {arguments}' failed."); } } -} \ No newline at end of file +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs index 42a02aac6..154c84f75 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs @@ -10,6 +10,9 @@ public static class LambdaToolsHelper public static string GetTempTestAppDirectory(string workingDirectory, string testAppPath) { +#if DEBUG + return Path.GetFullPath(Path.Combine(workingDirectory, testAppPath)); +#else var customTestAppPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(customTestAppPath); @@ -17,6 +20,7 @@ public static string GetTempTestAppDirectory(string workingDirectory, string tes CopyDirectory(currentDir, customTestAppPath); return Path.Combine(customTestAppPath, testAppPath); +#endif } public static async Task InstallLambdaTools() @@ -78,4 +82,4 @@ private static void CopyDirectory(DirectoryInfo dir, string destDirName) CopyDirectory(subDir, tempPath); } } -} \ No newline at end of file +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs index c9ce90e35..9b637b547 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs @@ -3,7 +3,7 @@ namespace Amazon.Lambda.RuntimeSupport.IntegrationTests; [CollectionDefinition("Integration Tests")] -public class IntegrationTestCollection : ICollectionFixture +public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture { -} \ No newline at end of file +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs index 89d62d61f..ee63888c0 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs @@ -14,10 +14,11 @@ public class IntegrationTestFixture : IAsyncLifetime public async Task InitializeAsync() { + var toolPath = await LambdaToolsHelper.InstallLambdaTools(); + var testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( "../../../../../../..", "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/CustomRuntimeFunctionTest"); - var toolPath = await LambdaToolsHelper.InstallLambdaTools(); _tempPaths.AddRange([testAppPath, toolPath] ); await LambdaToolsHelper.LambdaPackage(toolPath, "net8.0", testAppPath); TestAppPaths[@"CustomRuntimeFunctionTest\bin\Release\net8.0\CustomRuntimeFunctionTest.zip"] = Path.Combine(testAppPath, @"bin\Release\net8.0\CustomRuntimeFunctionTest.zip"); @@ -25,7 +26,6 @@ public async Task InitializeAsync() testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( "../../../../../../..", "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/CustomRuntimeAspNetCoreMinimalApiTest"); - toolPath = await LambdaToolsHelper.InstallLambdaTools(); _tempPaths.AddRange([testAppPath, toolPath] ); await LambdaToolsHelper.LambdaPackage(toolPath, "net8.0", testAppPath); TestAppPaths[@"CustomRuntimeAspNetCoreMinimalApiTest\bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiTest.zip"] = Path.Combine(testAppPath, @"bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiTest.zip"); @@ -33,19 +33,27 @@ public async Task InitializeAsync() testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( "../../../../../../..", "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest"); - toolPath = await LambdaToolsHelper.InstallLambdaTools(); _tempPaths.AddRange([testAppPath, toolPath] ); await LambdaToolsHelper.LambdaPackage(toolPath, "net8.0", testAppPath); TestAppPaths[@"CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest\bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest.zip"] = Path.Combine(testAppPath, @"bin\Release\net8.0\CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest.zip"); + + testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( + "../../../../../../..", + "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers"); + _tempPaths.AddRange([testAppPath, toolPath]); + await LambdaToolsHelper.LambdaPackage(toolPath, "net10.0", testAppPath); + TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, @"bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"); } public Task DisposeAsync() { +#if !DEBUG foreach (var tempPath in _tempPaths) { LambdaToolsHelper.CleanUp(tempPath); } +#endif return Task.CompletedTask; } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs new file mode 100644 index 000000000..650d968d4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Amazon.IdentityManagement; +using Amazon.Lambda.Model; +using Amazon.Runtime.EventStreams; +using Amazon.S3; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.IntegrationTests +{ + [Collection("Integration Tests")] + public class ResponseStreamingTests : BaseCustomRuntimeTest + { + private readonly static string s_functionName = "IntegTestResponseStreamingFunctionHandlers" + DateTime.Now.Ticks; + + private readonly ResponseStreamingTestsFixture _streamFixture; + + public ResponseStreamingTests(IntegrationTestFixture fixture, ResponseStreamingTestsFixture streamFixture) + : base(fixture, s_functionName, "ResponseStreamingFunctionHandlers.zip", @"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip", "ResponseStreamingFunctionHandlers") + { + _streamFixture = streamFixture; + } + + [Fact] + public async Task SimpleFunctionHandler() + { + await _streamFixture.EnsureResourcesDeployedAsync(this); + + var evnts = await InvokeFunctionAsync(nameof(SimpleFunctionHandler)); + Assert.True(evnts.Any()); + + var content = GetCombinedStreamContent(evnts); + Assert.Equal("Hello, World!", content); + } + + [Fact] + public async Task StreamContentHandler() + { + await _streamFixture.EnsureResourcesDeployedAsync(this); + + var evnts = await InvokeFunctionAsync(nameof(StreamContentHandler)); + Assert.True(evnts.Length > 5); + + var content = GetCombinedStreamContent(evnts); + Assert.Contains("Line 9999", content); + Assert.EndsWith("Finish stream content\n", content); + } + + [Fact] + public async Task UnhandledExceptionHandler() + { + await _streamFixture.EnsureResourcesDeployedAsync(this); + + var evnts = await InvokeFunctionAsync(nameof(UnhandledExceptionHandler)); + Assert.True(evnts.Any()); + + var content = GetCombinedStreamContent(evnts); + Assert.Contains("This method will fail", content); + Assert.Contains("This is an unhandled exception", content); + Assert.Contains("Lambda-Runtime-Function-Error-Type", content); + Assert.Contains("InvalidOperationException", content); + Assert.Contains("This is an unhandled exception", content); + Assert.Contains("stackTrace", content); + } + + private async Task InvokeFunctionAsync(string handlerScenario) + { + using var client = new AmazonLambdaClient(TestRegion); + + var request = new InvokeWithResponseStreamRequest + { + FunctionName = base.FunctionName, + Payload = new MemoryStream(System.Text.Encoding.UTF8.GetBytes($"\"{handlerScenario}\"")), + InvocationType = ResponseStreamingInvocationType.RequestResponse + }; + + var response = await client.InvokeWithResponseStreamAsync(request); + var evnts = response.EventStream.AsEnumerable().ToArray(); + return evnts; + } + + private string GetCombinedStreamContent(IEventStreamEvent[] events) + { + var sb = new StringBuilder(); + foreach (var evnt in events) + { + if (evnt is InvokeResponseStreamUpdate chunk) + { + var text = System.Text.Encoding.UTF8.GetString(chunk.Payload.ToArray()); + sb.Append(text); + } + } + return sb.ToString(); + } + } + + public class ResponseStreamingTestsFixture : IAsyncLifetime + { + private readonly AmazonLambdaClient _lambdaClient = new AmazonLambdaClient(BaseCustomRuntimeTest.TestRegion); + private readonly AmazonS3Client _s3Client = new AmazonS3Client(BaseCustomRuntimeTest.TestRegion); + private readonly AmazonIdentityManagementServiceClient _iamClient = new AmazonIdentityManagementServiceClient(BaseCustomRuntimeTest.TestRegion); + bool _resourcesCreated; + bool _roleAlreadyExisted; + + ResponseStreamingTests _tests; + + public async Task EnsureResourcesDeployedAsync(ResponseStreamingTests tests) + { + if (_resourcesCreated) + return; + + _tests = tests; + _roleAlreadyExisted = await _tests.PrepareTestResources(_s3Client, _lambdaClient, _iamClient); + + _resourcesCreated = true; + } + + public async Task DisposeAsync() + { + await _tests.CleanUpTestResources(_s3Client, _lambdaClient, _iamClient, _roleAlreadyExisted); + + _lambdaClient.Dispose(); + _s3Client.Dispose(); + _iamClient.Dispose(); + } + + public Task InitializeAsync() => Task.CompletedTask; + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs new file mode 100644 index 000000000..29d06941a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs @@ -0,0 +1,56 @@ +#pragma warning disable CA2252 + +using Amazon.Lambda.Core; +using Amazon.Lambda.Core.ResponseStreaming; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +// The function handler that will be called for each Lambda event +var handler = async (string input, ILambdaContext context) => +{ + var stream = LambdaResponseStreamFactory.CreateStream(); + + switch(input) + { + case $"{nameof(SimpleFunctionHandler)}": + await SimpleFunctionHandler(stream, context); + break; + case $"{nameof(StreamContentHandler)}": + await StreamContentHandler(stream, context); + break; + case $"{nameof(UnhandledExceptionHandler)}": + await UnhandledExceptionHandler(stream, context); + break; + default: + throw new ArgumentException($"Unknown handler scenario {input}"); + } +}; + +async Task SimpleFunctionHandler(Stream stream, ILambdaContext context) +{ + using var writer = new StreamWriter(stream); + await writer.WriteAsync("Hello, World!"); +} + +async Task StreamContentHandler(Stream stream, ILambdaContext context) +{ + using var writer = new StreamWriter(stream); + + await writer.WriteLineAsync("Starting stream content..."); + for(var i = 0; i < 10000; i++) + { + await writer.WriteLineAsync($"Line {i}"); + } + await writer.WriteLineAsync("Finish stream content"); +} + +async Task UnhandledExceptionHandler(Stream stream, ILambdaContext context) +{ + using var writer = new StreamWriter(stream); + await writer.WriteAsync("This method will fail"); + throw new InvalidOperationException("This is an unhandled exception"); +} + +await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer()) + .Build() + .RunAsync(); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj new file mode 100644 index 000000000..fa81eaa17 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/ResponseStreamingFunctionHandlers.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0 + enable + enable + true + Lambda + + true + + true + + + + + + + \ No newline at end of file diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json new file mode 100644 index 000000000..3042c3978 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/aws-lambda-tools-defaults.json @@ -0,0 +1,15 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "default", + "region": "us-west-2", + "configuration": "Release", + "function-runtime": "dotnet10", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "ResponseStreamingFunctionHandlers" +} \ No newline at end of file From 9b308ae9f8834ff794b20f90811ef205e75c74a7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Mar 2026 16:30:56 -0700 Subject: [PATCH 20/47] Improve test error message --- .../BaseCustomRuntimeTest.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs index fa10cee7d..b91dfc924 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs @@ -351,7 +351,16 @@ private string GetDeploymentZipPath() if (!File.Exists(deploymentZipFile)) { - throw new NoDeploymentPackageFoundException(); + var message = new StringBuilder(); + message.AppendLine($"Deployment package not found at expected path: {deploymentZipFile}"); + message.AppendLine("Available Test Bundles:"); + foreach (var kvp in _fixture.TestAppPaths) + { + message.AppendLine($"{kvp.Key}: {kvp.Value}"); + } + + + throw new NoDeploymentPackageFoundException(message.ToString()); } return deploymentZipFile; @@ -380,7 +389,9 @@ private static string FindUp(string path, string fileOrDirectoryName, bool combi protected class NoDeploymentPackageFoundException : Exception { + public NoDeploymentPackageFoundException() { } + public NoDeploymentPackageFoundException(string message) : base(message) { } } private ApplicationLogLevel ConvertRuntimeLogLevel(RuntimeLogLevel runtimeLogLevel) From d0861c618c4f43ad3452516294280f49bf117c45 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 11 Mar 2026 19:54:48 -0700 Subject: [PATCH 21/47] Debugging integ tests --- .../BaseCustomRuntimeTest.cs | 2 +- .../IntegrationTestCollection.cs | 2 +- .../IntegrationTestFixture.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs index b91dfc924..314aa45c4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/BaseCustomRuntimeTest.cs @@ -352,7 +352,7 @@ private string GetDeploymentZipPath() if (!File.Exists(deploymentZipFile)) { var message = new StringBuilder(); - message.AppendLine($"Deployment package not found at expected path: {deploymentZipFile}"); + message.AppendLine($"Deployment package for {DeploymentPackageZipRelativePath} not found at expected path: {deploymentZipFile}"); message.AppendLine("Available Test Bundles:"); foreach (var kvp in _fixture.TestAppPaths) { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs index 9b637b547..6e066eb28 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs @@ -2,7 +2,7 @@ namespace Amazon.Lambda.RuntimeSupport.IntegrationTests; -[CollectionDefinition("Integration Tests")] +[CollectionDefinition("Integration Tests", DisableParallelization = true)] public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs index ee63888c0..cc95ac8bc 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs @@ -42,7 +42,7 @@ public async Task InitializeAsync() "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers"); _tempPaths.AddRange([testAppPath, toolPath]); await LambdaToolsHelper.LambdaPackage(toolPath, "net10.0", testAppPath); - TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, @"bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"); + TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, "bin", "Release", "net10.0", "ResponseStreamingFunctionHandlers.zip"); ; } From 3c866296504f30f5aa524cf25417592e251a80f1 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Thu, 12 Mar 2026 11:55:35 -0700 Subject: [PATCH 22/47] Update change file --- .../changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json index 9ad5afe6e..39be8933f 100644 --- a/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json +++ b/.autover/changes/c27a62e6-91ca-4a59-9406-394866cdfa62.json @@ -4,8 +4,15 @@ "Name": "Amazon.Lambda.RuntimeSupport", "Type": "Minor", "ChangelogMessages": [ - "Add response streaming support" + "(Preview) Add response streaming support" ] - } + }, + { + "Name": "Amazon.Lambda.Core", + "Type": "Minor", + "ChangelogMessages": [ + "(Preview) Add response streaming support" + ] + } ] } From c637ef1421e3d36043857acc6886af9c69014fbd Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 17 Mar 2026 16:59:55 -0700 Subject: [PATCH 23/47] Address PR comments --- .../HttpResponseStreamPrelude.cs | 2 +- .../ResponseStreaming/LambdaResponseStream.cs | 2 +- .../LambdaResponseStreamFactory.cs | 17 +++++++++++++++-- .../Bootstrap/LambdaBootstrap.cs | 1 + .../ResponseStreaming/ResponseStream.cs | 12 +++++++++--- .../ResponseStreaming/ResponseStreamContext.cs | 4 +++- .../ResponseStreaming/ResponseStreamFactory.cs | 3 ++- ...sponseStreamLambdaCoreInitializerIsolated.cs | 2 +- .../ResponseStreaming/StreamingHttpContent.cs | 2 +- .../Client/RuntimeApiClient.cs | 5 ++--- .../Helpers/CommandLineWrapper.cs | 1 + .../IntegrationTestFixture.cs | 2 +- .../Function.cs | 2 +- 13 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs index ebd8a7018..d218de397 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs @@ -11,7 +11,7 @@ namespace Amazon.Lambda.Core.ResponseStreaming /// /// The HTTP response prelude to be sent as the first chunk of a streaming response when using . /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class HttpResponseStreamPrelude { /// diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs index 506db46d7..47ce48588 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs @@ -15,7 +15,7 @@ namespace Amazon.Lambda.Core.ResponseStreaming /// to the Lambda Runtime API. Returned by . /// Integrates with standard .NET stream consumers such as . /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class LambdaResponseStream : Stream { private readonly ILambdaResponseStream _responseStream; diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs index c82ce4a3d..7adccc2da 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs @@ -10,15 +10,19 @@ namespace Amazon.Lambda.Core.ResponseStreaming /// /// Factory to create Lambda response streams for writing streaming responses in AWS Lambda functions. The created streams are write-only and non-seekable. /// - [RequiresPreviewFeatures(LambdaResponseStreamFactory.ParameterizedPreviewMessage)] + [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class LambdaResponseStreamFactory { - internal const string ParameterizedPreviewMessage = + internal const string PreviewMessage = "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + "project file to \"true\""; + internal const string UninitializedFactoryMessage = + "LambdaResponseStreamFactory is not initialized. This is caused by mismatch versions of Amazon.Lambda.Core and Amazon.Lambda.RuntimeSupport. " + + "Update both packages to the current version to address the issue."; + private static Func _streamFactory; internal static void SetLambdaResponseStream(Func streamFactory) @@ -34,6 +38,9 @@ internal static void SetLambdaResponseStream(Func /// public static Stream CreateStream() { + if (_streamFactory == null) + throw new InvalidOperationException(UninitializedFactoryMessage); + var runtimeResponseStream = _streamFactory(Array.Empty()); return new LambdaResponseStream(runtimeResponseStream); } @@ -51,6 +58,12 @@ public static Stream CreateStream() /// public static Stream CreateHttpStream(HttpResponseStreamPrelude prelude) { + if (_streamFactory == null) + throw new InvalidOperationException(UninitializedFactoryMessage); + + if (prelude is null) + throw new ArgumentNullException(nameof(prelude)); + var runtimeResponseStream = _streamFactory(prelude.ToByteArray()); return new LambdaResponseStream(runtimeResponseStream); } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index a804b0b10..cd0e9bc4f 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -425,6 +425,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul { // Wait for the streaming response to finish sending before allowing the next invocation to be processed. This ensures that responses are sent in the order the invocations were received. await sendTask; + sendTask.Result.Dispose(); } streamIfCreated.Dispose(); diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index c825c3bb6..c9daa6972 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -37,8 +37,14 @@ internal class ResponseStream private Stream _httpOutputStream; private bool _disposedValue; private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); + + // The wait time is a sanity timeout to avoid waiting indefinitely if SerializeToStreamAsync is not called or takes too long to call. + // Reality is that SerializeToStreamAsync should be called very quickly after CreateStream, so this timeout is generous to avoid false positives but still protects against hanging indefinitely. + private readonly static TimeSpan _httpStreamWaitTimeout = TimeSpan.FromSeconds(30); + private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); + private static readonly byte[] PreludeDelimiter = new byte[8]; /// @@ -81,7 +87,7 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau if (_prelude?.Length > 0) { _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); - await _httpStreamReady.WaitAsync(cancellationToken); + await _httpStreamReady.WaitAsync(_httpStreamWaitTimeout, cancellationToken); try { lock (_lock) @@ -136,10 +142,10 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT throw new ArgumentOutOfRangeException(nameof(count)); // Wait for the HTTP stream to be ready (first write only blocks) - await _httpStreamReady.WaitAsync(cancellationToken); + await _httpStreamReady.WaitAsync(_httpStreamWaitTimeout, cancellationToken); try { - _logger.LogDebug($"Writing chuck of {count} bytes to HTTP stream."); + _logger.LogDebug($"Writing chunk of {count} bytes to HTTP stream."); lock (_lock) { diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs index 3fb92e51d..970c43138 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamContext.cs @@ -13,6 +13,8 @@ * permissions and limitations under the License. */ +using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -52,6 +54,6 @@ internal class ResponseStreamContext /// The Task representing the in-flight HTTP POST to the Runtime API. /// Started when CreateStream() is called, completes when the stream is finalized. /// - public Task SendTask { get; set; } + public Task SendTask { get; set; } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs index dcbdf4c92..f430393c5 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs @@ -14,6 +14,7 @@ */ using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -103,7 +104,7 @@ internal static ResponseStream GetStreamIfCreated(bool isMultiConcurrency) /// Returns the Task for the in-flight HTTP send, or null if streaming wasn't started. /// LambdaBootstrap awaits this after the handler returns to ensure the HTTP request completes. /// - internal static Task GetSendTask(bool isMultiConcurrency) + internal static Task GetSendTask(bool isMultiConcurrency) { var context = isMultiConcurrency ? _asyncLocalContext.Value : _onDemandContext; return context?.SendTask; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs index e9e846723..b86864480 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamLambdaCoreInitializerIsolated.cs @@ -54,7 +54,7 @@ internal ImplLambdaResponseStream(ResponseStream innerStream) public void Dispose() => _innerStream.Dispose(); /// - public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) => _innerStream.WriteAsync(buffer, offset, count); + public Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) => _innerStream.WriteAsync(buffer, offset, count, cancellationToken); } } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs index a0cc0511a..c11c86d49 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs @@ -44,7 +44,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon // can write chunks directly to it. await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); - InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream in indicate it is complete."); + InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream to indicate it is complete."); // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) await _responseStream.WaitForCompletionAsync(_cancellationToken); diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index dcec11ae3..c9acc3832 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -189,7 +189,7 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null /// The ResponseStream that will provide the streaming data. /// The optional cancellation token to use. /// A Task representing the in-flight HTTP POST. - internal virtual async Task StartStreamingResponseAsync( + internal virtual async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); @@ -213,11 +213,10 @@ internal virtual async Task StartStreamingResponseAsync( request.Content = new StreamingHttpContent(responseStream, cancellationToken); - // SendAsync calls SerializeToStreamAsync, which blocks until the handler - // finishes writing. This is why this method runs concurrently with the handler. var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); + return response; } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs index e18f31833..ea6fd059e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs @@ -82,6 +82,7 @@ public static async Task Run(string command, string arguments, string workingDir catch (Exception ex) { Console.WriteLine("Exception: " + ex); + Console.WriteLine(output.ToString()); if (!process.HasExited) { process.Kill(); diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs index cc95ac8bc..b8c71519e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestFixture.cs @@ -42,7 +42,7 @@ public async Task InitializeAsync() "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers"); _tempPaths.AddRange([testAppPath, toolPath]); await LambdaToolsHelper.LambdaPackage(toolPath, "net10.0", testAppPath); - TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, "bin", "Release", "net10.0", "ResponseStreamingFunctionHandlers.zip"); ; + TestAppPaths[@"ResponseStreamingFunctionHandlers\bin\Release\net10.0\ResponseStreamingFunctionHandlers.zip"] = Path.Combine(testAppPath, "bin", "Release", "net10.0", "ResponseStreamingFunctionHandlers.zip"); } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs index 29d06941a..8c645ff5b 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/ResponseStreamingFunctionHandlers/Function.cs @@ -8,7 +8,7 @@ // The function handler that will be called for each Lambda event var handler = async (string input, ILambdaContext context) => { - var stream = LambdaResponseStreamFactory.CreateStream(); + using var stream = LambdaResponseStreamFactory.CreateStream(); switch(input) { From 42d3212e2d8a7221262bf9faba16314c2e6e7f58 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 3 Apr 2026 12:21:51 -0700 Subject: [PATCH 24/47] Rework to have my own HttpClient implementation to handle trailing headers with chunk encoding which wasn't supported in SocketHandler --- .../RawStreamingHttpClient.cs | 291 ++++++++++ .../ResponseStreaming/ResponseStream.cs | 30 +- .../ResponseStreamFactory.cs | 6 +- .../ResponseStreaming/StreamingHttpContent.cs | 79 --- .../Client/RuntimeApiClient.cs | 40 +- .../LambdaResponseStreamingCoreTests.cs | 3 +- .../RawStreamingHttpClientTests.cs | 502 ++++++++++++++++++ .../ResponseStreamFactoryTests.cs | 3 +- .../ResponseStreamTests.cs | 9 +- .../RuntimeApiClientTests.cs | 6 + .../StreamingE2EWithMoq.cs | 30 +- .../StreamingHttpContentTests.cs | 184 ------- .../TestStreamingRuntimeApiClient.cs | 12 +- 13 files changed, 879 insertions(+), 316 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs delete mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs delete mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs new file mode 100644 index 000000000..0226e0660 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs @@ -0,0 +1,291 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Helpers; + +namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming +{ + /// + /// A raw HTTP/1.1 client for sending streaming responses to the Lambda Runtime API + /// with support for HTTP trailing headers (used for error reporting). + /// + /// .NET's HttpClient/SocketsHttpHandler does not support sending HTTP/1.1 trailing headers. + /// The Lambda Runtime API requires error information to be sent as trailing headers + /// (Lambda-Runtime-Function-Error-Type and Lambda-Runtime-Function-Error-Body) after + /// the chunked transfer encoding body. This class gives us full control over the + /// HTTP wire format to properly send those trailers. + /// + internal class RawStreamingHttpClient : IDisposable + { + private readonly string _host; + private readonly int _port; + private TcpClient _tcpClient; + internal Stream _networkStream; + private bool _disposed; + + private readonly InternalLogger _logger = InternalLogger.GetDefaultLogger(); + + public RawStreamingHttpClient(string hostAndPort) + { + var parts = hostAndPort.Split(':'); + _host = parts[0]; + _port = parts.Length > 1 ? int.Parse(parts[1], CultureInfo.InvariantCulture) : 80; + } + + /// + /// Sends a streaming response to the Lambda Runtime API. + /// Connects via TCP, sends HTTP headers, then streams the response body + /// using chunked transfer encoding. When the response stream completes, + /// writes the chunked encoding terminator with optional trailing headers + /// for error reporting. + /// + /// The Lambda request ID. + /// The response stream that provides data and error state. + /// The User-Agent header value. + /// Cancellation token. + public async Task SendStreamingResponseAsync( + string awsRequestId, + ResponseStream responseStream, + string userAgent, + CancellationToken cancellationToken = default) + { + _tcpClient = new TcpClient(); + _tcpClient.NoDelay = true; + await _tcpClient.ConnectAsync(_host, _port, cancellationToken); + _networkStream = _tcpClient.GetStream(); + + // Send HTTP request line and headers + var path = $"/2018-06-01/runtime/invocation/{awsRequestId}/response"; + var headers = new StringBuilder(); + headers.Append($"POST {path} HTTP/1.1\r\n"); + headers.Append($"Host: {_host}:{_port}\r\n"); + headers.Append($"User-Agent: {userAgent}\r\n"); + headers.Append($"Content-Type: application/vnd.awslambda.http-integration-response\r\n"); + headers.Append($"{StreamingConstants.ResponseModeHeader}: {StreamingConstants.StreamingResponseMode}\r\n"); + headers.Append("Transfer-Encoding: chunked\r\n"); + headers.Append($"Trailer: {StreamingConstants.ErrorTypeTrailer}, {StreamingConstants.ErrorBodyTrailer}\r\n"); + headers.Append("\r\n"); + + var headerBytes = Encoding.ASCII.GetBytes(headers.ToString()); + await _networkStream.WriteAsync(headerBytes, cancellationToken); + await _networkStream.FlushAsync(cancellationToken); + + // Hand the network stream (wrapped in a chunked writer) to the ResponseStream + var chunkedWriter = new ChunkedStreamWriter(_networkStream); + await responseStream.SetHttpOutputStreamAsync(chunkedWriter, cancellationToken); + + _logger.LogInformation("In SendStreamingResponseAsync waiting for the underlying Lambda response stream to indicate it is complete."); + + // Wait for the handler to finish writing + await responseStream.WaitForCompletionAsync(cancellationToken); + + // Write the chunked encoding terminator with optional trailers + if (responseStream.HasError) + { + _logger.LogInformation("Adding response stream trailing error headers"); + await WriteTerminatorWithTrailersAsync(responseStream.ReportedError, cancellationToken); + } + else + { + // No error — write simple terminator: 0\r\n\r\n + var terminator = Encoding.ASCII.GetBytes("0\r\n\r\n"); + await _networkStream.WriteAsync(terminator, cancellationToken); + } + + await _networkStream.FlushAsync(cancellationToken); + + // Read and discard the HTTP response (we don't need it, but must consume it) + await ReadAndDiscardResponseAsync(cancellationToken); + } + + /// + /// Writes the chunked encoding terminator with HTTP trailing headers for error reporting. + /// Format: + /// 0\r\n + /// Lambda-Runtime-Function-Error-Type: errorType\r\n + /// Lambda-Runtime-Function-Error-Body: base64EncodedErrorBodyJson\r\n + /// \r\n + /// + /// The error body JSON is Base64-encoded because LambdaJsonExceptionWriter produces + /// pretty-printed multi-line JSON. HTTP trailer values cannot contain raw CR/LF characters + /// as they would break the HTTP framing — the Runtime API would see the first newline + /// inside the JSON as the end of the trailer and treat the rest as malformed data, + /// resulting in Runtime.TruncatedResponse instead of the actual error. + /// + internal async Task WriteTerminatorWithTrailersAsync(Exception exception, CancellationToken cancellationToken) + { + var exceptionInfo = ExceptionInfo.GetExceptionInfo(exception); + var errorBodyJson = LambdaJsonExceptionWriter.WriteJson(exceptionInfo); + var errorBodyBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(errorBodyJson)); + + InternalLogger.GetDefaultLogger().LogInformation($"Writing trailing header {StreamingConstants.ErrorTypeTrailer} with error type {exceptionInfo.ErrorType}."); + var trailers = new StringBuilder(); + trailers.Append("0\r\n"); // zero-length chunk (end of body) + trailers.Append($"{StreamingConstants.ErrorTypeTrailer}: {exceptionInfo.ErrorType}\r\n"); + trailers.Append($"{StreamingConstants.ErrorBodyTrailer}: {errorBodyBase64}\r\n"); + trailers.Append("\r\n"); // end of trailers + + var trailerBytes = Encoding.UTF8.GetBytes(trailers.ToString()); + await _networkStream.WriteAsync(trailerBytes, cancellationToken); + } + + /// + /// Reads and discards the HTTP response from the Runtime API. + /// We need to consume the response to properly close the connection, + /// but we don't need to process it. + /// + internal async Task ReadAndDiscardResponseAsync(CancellationToken cancellationToken) + { + var buffer = new byte[4096]; + try + { + // Read until we get the full response. The Runtime API sends a short response. + var totalRead = 0; + var responseText = new StringBuilder(); + while (true) + { + var bytesRead = await _networkStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + if (bytesRead == 0) + break; + + totalRead += bytesRead; + responseText.Append(Encoding.ASCII.GetString(buffer, 0, bytesRead)); + + // Check if we've received the complete response (ends with \r\n\r\n for headers, + // or we've read the content-length worth of body) + var text = responseText.ToString(); + if (text.Contains("\r\n\r\n")) + { + // Find Content-Length to know if there's a body to read + var headerEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + var headers = text.Substring(0, headerEnd); + + var contentLengthMatch = System.Text.RegularExpressions.Regex.Match( + headers, @"Content-Length:\s*(\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (contentLengthMatch.Success) + { + var contentLength = int.Parse(contentLengthMatch.Groups[1].Value, CultureInfo.InvariantCulture); + var bodyStart = headerEnd + 4; // skip \r\n\r\n + var bodyRead = text.Length - bodyStart; + if (bodyRead >= contentLength) + break; + } + else + { + // No Content-Length, assume response is complete after headers + break; + } + } + + if (totalRead > 16384) + break; // Safety limit + } + } + catch (Exception ex) + { + // Log but don't throw — the streaming response was already sent + _logger.LogDebug($"Error reading Runtime API response: {ex.Message}"); + } + } + + public void Dispose() + { + if (!_disposed) + { + _networkStream?.Dispose(); + _tcpClient?.Dispose(); + _disposed = true; + } + } + } + + /// + /// A write-only Stream wrapper that writes data in HTTP/1.1 chunked transfer encoding format. + /// Each write produces a chunk: {size in hex}\r\n{data}\r\n + /// FlushAsync flushes the underlying network stream to ensure data is sent immediately. + /// The chunked encoding terminator (0\r\n...\r\n) is NOT written by this class — + /// it is handled by RawStreamingHttpClient to support trailing headers. + /// + internal class ChunkedStreamWriter : Stream + { + private readonly Stream _innerStream; + + public ChunkedStreamWriter(Stream innerStream) + { + _innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream)); + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (count == 0) return; + + // Write chunk header: size in hex + \r\n + var chunkHeader = Encoding.ASCII.GetBytes($"{count:X}\r\n"); + await _innerStream.WriteAsync(chunkHeader, 0, chunkHeader.Length, cancellationToken); + + // Write chunk data + await _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + + // Write chunk trailer: \r\n + var crlf = Encoding.ASCII.GetBytes("\r\n"); + await _innerStream.WriteAsync(crlf, 0, crlf.Length, cancellationToken); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (buffer.Length == 0) return; + + var chunkHeader = Encoding.ASCII.GetBytes($"{buffer.Length:X}\r\n"); + await _innerStream.WriteAsync(chunkHeader, cancellationToken); + await _innerStream.WriteAsync(buffer, cancellationToken); + await _innerStream.WriteAsync(Encoding.ASCII.GetBytes("\r\n"), cancellationToken); + } + + public override void Flush() => _innerStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + _innerStream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +} +#endif diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index c9daa6972..277ba426b 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -33,7 +33,7 @@ internal class ResponseStream private Exception _reportedError; private readonly object _lock = new object(); - // The live HTTP output stream, set by StreamingHttpContent when SerializeToStreamAsync is called. + // The live HTTP output stream, set by RawStreamingHttpClient when sending the streaming response. private Stream _httpOutputStream; private bool _disposedValue; private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); @@ -72,7 +72,7 @@ internal ResponseStream(byte[] prelude) } /// - /// Called by StreamingHttpContent.SerializeToStreamAsync to provide the HTTP output stream. + /// Called by RawStreamingHttpClient to provide the HTTP output stream (a ChunkedStreamWriter). /// internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, CancellationToken cancellationToken = default) { @@ -108,8 +108,8 @@ private async Task WritePreludeAsync(CancellationToken cancellationToken = defau } /// - /// Called by StreamingHttpContent.SerializeToStreamAsync to wait until the handler - /// finishes writing (MarkCompleted or ReportErrorAsync). + /// Called by RawStreamingHttpClient to wait until the handler + /// finishes writing (MarkCompleted or ReportError). /// internal async Task WaitForCompletionAsync(CancellationToken cancellationToken = default) { @@ -187,7 +187,7 @@ internal void ReportError(Exception exception) _isCompleted = true; } - // Signal completion so StreamingHttpContent can write error trailers and finish + // Signal completion so RawStreamingHttpClient can write error trailers and finish _completionSignal.Release(); } @@ -206,7 +206,7 @@ internal void MarkCompleted() if (shouldReleaseLock) { - // Signal completion so StreamingHttpContent can write the final chunk and finish + // Signal completion so RawStreamingHttpClient can write the final chunk and finish _completionSignal.Release(); } } @@ -231,8 +231,22 @@ protected virtual void Dispose(bool disposing) { try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } _httpStreamReady.Dispose(); - try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } - _completionSignal.Dispose(); + + // Do NOT release or dispose _completionSignal here. + // + // When the handler uses "using var stream = ...", Dispose() runs during + // stack unwinding BEFORE LambdaBootstrap's catch block can call ReportError(). + // If we release the signal here, RawStreamingHttpClient sees HasError=false + // and writes the chunked terminator without error trailers, causing Lambda + // to report Runtime.TruncatedResponse instead of the actual error. + // + // If we dispose the signal here, subsequent ReportError()/MarkCompleted() + // calls and the WaitForCompletionAsync() in RawStreamingHttpClient will + // throw ObjectDisposedException. + // + // The completion signal lifecycle is managed by MarkCompleted()/ReportError() + // (which release it) and LambdaBootstrap (which awaits the send task after). + // The SemaphoreSlim is a lightweight managed object that the GC will finalize. } _disposedValue = true; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs index f430393c5..27b34e8db 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStreamFactory.cs @@ -12,9 +12,7 @@ * express or implied. See the License for the specific language governing * permissions and limitations under the License. */ - using System; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -42,6 +40,7 @@ internal static class ResponseStreamFactory /// Thrown if called more than once per invocation. public static ResponseStream CreateStream(byte[] prelude) { +#if NET8_0_OR_GREATER var context = GetCurrentContext(); if (context == null) @@ -67,6 +66,9 @@ public static ResponseStream CreateStream(byte[] prelude) context.AwsRequestId, lambdaStream, context.CancellationToken); return lambdaStream; +#else + throw new NotImplementedException(); +#endif } // Internal methods for LambdaBootstrap to manage state diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs deleted file mode 100644 index c11c86d49..000000000 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/StreamingHttpContent.cs +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Amazon.Lambda.RuntimeSupport.Helpers; - -namespace Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming -{ - /// - /// HttpContent implementation for streaming responses with chunked transfer encoding. - /// - internal class StreamingHttpContent : HttpContent - { - private readonly ResponseStream _responseStream; - private readonly CancellationToken _cancellationToken; - - public StreamingHttpContent(ResponseStream responseStream, CancellationToken cancellationToken = default) - { - _responseStream = responseStream ?? throw new ArgumentNullException(nameof(responseStream)); - _cancellationToken = cancellationToken; - } - - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) - { - // Hand the HTTP output stream to ResponseStream so WriteAsync calls - // can write chunks directly to it. - await _responseStream.SetHttpOutputStreamAsync(stream, _cancellationToken); - - InternalLogger.GetDefaultLogger().LogInformation("In SerializeToStreamAsync waiting for the underlying Lambda response stream to indicate it is complete."); - // Wait for the handler to finish writing (MarkCompleted or ReportErrorAsync) - await _responseStream.WaitForCompletionAsync(_cancellationToken); - - // Write error trailers if present - if (_responseStream.HasError) - { - InternalLogger.GetDefaultLogger().LogError(_responseStream.ReportedError, "An error occurred during Lambda execution. Writing error trailers to response."); - await WriteErrorTrailersAsync(stream, _responseStream.ReportedError); - } - } - - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } - - private async Task WriteErrorTrailersAsync(Stream stream, Exception exception) - { - var exceptionInfo = ExceptionInfo.GetExceptionInfo(exception); - - var errorTypeHeader = $"{StreamingConstants.ErrorTypeTrailer}: {exceptionInfo.ErrorType}\r\n"; - var errorTypeBytes = Encoding.UTF8.GetBytes(errorTypeHeader); - await stream.WriteAsync(errorTypeBytes, 0, errorTypeBytes.Length, _cancellationToken); - - var errorBodyJson = LambdaJsonExceptionWriter.WriteJson(exceptionInfo); - var errorBodyHeader = $"{StreamingConstants.ErrorBodyTrailer}: {errorBodyJson}\r\n"; - var errorBodyBytes = Encoding.UTF8.GetBytes(errorBodyHeader); - await stream.WriteAsync(errorBodyBytes, 0, errorBodyBytes.Length, _cancellationToken); - } - } -} diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs index c9acc3832..0cddfcd2a 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/RuntimeApiClient.cs @@ -178,47 +178,33 @@ public Task ReportRestoreErrorAsync(Exception exception, String errorType = null #endif +#if NET8_0_OR_GREATER /// - /// Start sending a streaming response to the Runtime API. - /// This initiates the HTTP POST with streaming headers. The actual data - /// is written by the handler via ResponseStream.WriteAsync, which flows - /// through StreamingHttpContent to the HTTP connection. + /// Start sending a streaming response to the Lambda Runtime API. + /// Uses a raw TCP connection with chunked transfer encoding to support HTTP/1.1 + /// trailing headers for error reporting, which .NET's HttpClient does not support. + /// The actual data is written by the handler via ResponseStream.WriteAsync, which flows + /// through a ChunkedStreamWriter to the TCP connection. /// This Task completes when the stream is finalized (MarkCompleted or error). /// /// The ID of the function request being responded to. /// The ResponseStream that will provide the streaming data. /// The optional cancellation token to use. - /// A Task representing the in-flight HTTP POST. + /// A Task representing the in-flight HTTP POST. The returned IDisposable is the RawStreamingHttpClient that owns the TCP connection. internal virtual async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { if (awsRequestId == null) throw new ArgumentNullException(nameof(awsRequestId)); if (responseStream == null) throw new ArgumentNullException(nameof(responseStream)); - var url = $"http://{LambdaEnvironment.RuntimeServerHostAndPort}/2018-06-01/runtime/invocation/{awsRequestId}/response"; + var userAgent = _httpClient.DefaultRequestHeaders.UserAgent.ToString(); + var rawClient = new RawStreamingHttpClient(LambdaEnvironment.RuntimeServerHostAndPort); - using (var request = new HttpRequestMessage(HttpMethod.Post, url)) - { - request.Headers.Add(StreamingConstants.ResponseModeHeader, StreamingConstants.StreamingResponseMode); - request.Headers.TransferEncodingChunked = true; - request.Headers.TryAddWithoutValidation( - "Content-Type", - "application/vnd.awslambda.http-integration-response" - ); - - // Declare trailers upfront — we always declare them since we don't know - // at request start time whether an error will occur mid-stream. - request.Headers.Add("Trailer", - $"{StreamingConstants.ErrorTypeTrailer}, {StreamingConstants.ErrorBodyTrailer}"); - - request.Content = new StreamingHttpContent(responseStream, cancellationToken); - - var response = await _httpClient.SendAsync( - request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - return response; - } + await rawClient.SendStreamingResponseAsync(awsRequestId, responseStream, userAgent, cancellationToken); + + return rawClient; } +#endif /// /// Send a response to a function invocation to the Runtime API as an asynchronous operation. diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs index 6759627db..0d5c20c86 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaResponseStreamingCoreTests.cs @@ -429,12 +429,13 @@ private class NoOpStreamingRuntimeApiClient : RuntimeApiClient public NoOpStreamingRuntimeApiClient(IEnvironmentVariables envVars) : base(envVars, new TestHelpers.NoOpInternalRuntimeApiClient()) { } - internal override async Task StartStreamingResponseAsync( + internal override async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block await responseStream.SetHttpOutputStreamAsync(new MemoryStream(), cancellationToken); await responseStream.WaitForCompletionAsync(cancellationToken); + return new NoOpDisposable(); } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs new file mode 100644 index 000000000..e203d6968 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs @@ -0,0 +1,502 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +#if NET8_0_OR_GREATER + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + // ───────────────────────────────────────────────────────────────────────────── + // RawStreamingHttpClient tests + // ───────────────────────────────────────────────────────────────────────────── + + public class RawStreamingHttpClientTests + { + // --- Constructor / host parsing --- + + [Fact] + public void Constructor_HostAndPort_ParsedCorrectly() + { + using var client = new RawStreamingHttpClient("localhost:9001"); + // No exception means parsing succeeded. Fields are private but + // we verify indirectly via Dispose not throwing. + } + + [Fact] + public void Constructor_HostOnly_DefaultsToPort80() + { + using var client = new RawStreamingHttpClient("localhost"); + // Should not throw — defaults port to 80 + } + + [Fact] + public void Constructor_HighPort_ParsedCorrectly() + { + using var client = new RawStreamingHttpClient("127.0.0.1:65535"); + } + + // --- Dispose --- + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var client = new RawStreamingHttpClient("localhost:9001"); + client.Dispose(); + client.Dispose(); + } + + [Fact] + public void Dispose_WithoutConnect_DoesNotThrow() + { + var client = new RawStreamingHttpClient("localhost:9001"); + client.Dispose(); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // WriteTerminatorWithTrailersAsync tests + // ───────────────────────────────────────────────────────────────────────────── + + public class WriteTerminatorWithTrailersAsyncTests + { + private static (RawStreamingHttpClient client, MemoryStream output) CreateClientWithMemoryStream() + { + var client = new RawStreamingHttpClient("localhost:9001"); + var output = new MemoryStream(); + client._networkStream = output; + return (client, output); + } + + [Fact] + public async Task WriteTerminator_StartsWithZeroChunk() + { + var (client, output) = CreateClientWithMemoryStream(); + + await client.WriteTerminatorWithTrailersAsync( + new Exception("test"), CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + Assert.StartsWith("0\r\n", written); + } + + [Fact] + public async Task WriteTerminator_ContainsErrorTypeTrailer() + { + var (client, output) = CreateClientWithMemoryStream(); + + await client.WriteTerminatorWithTrailersAsync( + new InvalidOperationException("bad op"), CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + Assert.Contains($"{StreamingConstants.ErrorTypeTrailer}: InvalidOperationException\r\n", written); + } + + [Fact] + public async Task WriteTerminator_ContainsErrorBodyTrailerHeader() + { + var (client, output) = CreateClientWithMemoryStream(); + + await client.WriteTerminatorWithTrailersAsync( + new Exception("some error"), CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + Assert.Contains($"{StreamingConstants.ErrorBodyTrailer}: ", written); + } + + [Fact] + public async Task WriteTerminator_ErrorBodyIsBase64Encoded() + { + var (client, output) = CreateClientWithMemoryStream(); + const string errorMessage = "something broke"; + + await client.WriteTerminatorWithTrailersAsync( + new Exception(errorMessage), CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + + // Extract the Base64 value from the error body trailer + var prefix = $"{StreamingConstants.ErrorBodyTrailer}: "; + var start = written.IndexOf(prefix, StringComparison.Ordinal) + prefix.Length; + var end = written.IndexOf("\r\n", start, StringComparison.Ordinal); + var base64Value = written.Substring(start, end - start); + + // Should be valid Base64 + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(base64Value)); + Assert.Contains(errorMessage, decoded); + } + + [Fact] + public async Task WriteTerminator_ErrorBodyBase64ContainsNoNewlines() + { + var (client, output) = CreateClientWithMemoryStream(); + + // Use an exception with a stack trace that would produce multi-line JSON + Exception caughtException; + try { throw new InvalidOperationException("multi\nline\nerror"); } + catch (Exception ex) { caughtException = ex; } + + await client.WriteTerminatorWithTrailersAsync( + caughtException, CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + + // Extract just the error body trailer line + var prefix = $"{StreamingConstants.ErrorBodyTrailer}: "; + var start = written.IndexOf(prefix, StringComparison.Ordinal) + prefix.Length; + var end = written.IndexOf("\r\n", start, StringComparison.Ordinal); + var base64Value = written.Substring(start, end - start); + + // The Base64 value itself must not contain any newlines + Assert.DoesNotContain("\n", base64Value); + Assert.DoesNotContain("\r", base64Value); + } + + [Fact] + public async Task WriteTerminator_EndsWithEmptyLine() + { + var (client, output) = CreateClientWithMemoryStream(); + + await client.WriteTerminatorWithTrailersAsync( + new Exception("test"), CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + // Must end with \r\n\r\n — the last trailer line's \r\n plus the empty terminator line + Assert.EndsWith("\r\n\r\n", written); + } + + [Fact] + public async Task WriteTerminator_CorrectWireFormat() + { + var (client, output) = CreateClientWithMemoryStream(); + + await client.WriteTerminatorWithTrailersAsync( + new ArgumentException("bad arg"), CancellationToken.None); + + var written = Encoding.UTF8.GetString(output.ToArray()); + var lines = written.Split("\r\n"); + + // Line 0: "0" (zero-length chunk) + Assert.Equal("0", lines[0]); + // Line 1: error type trailer + Assert.StartsWith($"{StreamingConstants.ErrorTypeTrailer}: ", lines[1]); + // Line 2: error body trailer (Base64) + Assert.StartsWith($"{StreamingConstants.ErrorBodyTrailer}: ", lines[2]); + // Line 3: empty (end of trailers) + Assert.Equal("", lines[3]); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // ReadAndDiscardResponseAsync tests + // ───────────────────────────────────────────────────────────────────────────── + + public class ReadAndDiscardResponseAsyncTests + { + private static (RawStreamingHttpClient client, MemoryStream input) CreateClientWithResponse(string httpResponse) + { + var client = new RawStreamingHttpClient("localhost:9001"); + var input = new MemoryStream(Encoding.ASCII.GetBytes(httpResponse)); + client._networkStream = input; + return (client, input); + } + + [Fact] + public async Task ReadAndDiscard_HeadersOnly_CompletesSuccessfully() + { + var (client, _) = CreateClientWithResponse( + "HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n"); + + await client.ReadAndDiscardResponseAsync(CancellationToken.None); + // Should complete without error + } + + [Fact] + public async Task ReadAndDiscard_WithBody_ReadsFullBody() + { + var body = "OK"; + var (client, _) = CreateClientWithResponse( + $"HTTP/1.1 200 OK\r\nContent-Length: {body.Length}\r\n\r\n{body}"); + + await client.ReadAndDiscardResponseAsync(CancellationToken.None); + } + + [Fact] + public async Task ReadAndDiscard_NoContentLength_CompletesAfterHeaders() + { + var (client, _) = CreateClientWithResponse( + "HTTP/1.1 202 Accepted\r\n\r\n"); + + await client.ReadAndDiscardResponseAsync(CancellationToken.None); + } + + [Fact] + public async Task ReadAndDiscard_EmptyStream_CompletesSuccessfully() + { + var client = new RawStreamingHttpClient("localhost:9001"); + client._networkStream = new MemoryStream(Array.Empty()); + + await client.ReadAndDiscardResponseAsync(CancellationToken.None); + } + + [Fact] + public async Task ReadAndDiscard_PartialBody_WaitsForFullBody() + { + // Content-Length says 10 but we provide all 10 bytes + var body = "0123456789"; + var (client, _) = CreateClientWithResponse( + $"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n{body}"); + + await client.ReadAndDiscardResponseAsync(CancellationToken.None); + } + + [Fact] + public async Task ReadAndDiscard_CancellationToken_Respected() + { + // Use a stream that blocks on read to test cancellation + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var client = new RawStreamingHttpClient("localhost:9001"); + client._networkStream = new MemoryStream(Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")); + + // Should not throw — ReadAndDiscardResponseAsync catches exceptions + await client.ReadAndDiscardResponseAsync(cts.Token); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // ChunkedStreamWriter tests + // ───────────────────────────────────────────────────────────────────────────── + + public class ChunkedStreamWriterTests + { + [Fact] + public void CanWrite_IsTrue() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.True(writer.CanWrite); + } + + [Fact] + public void CanRead_IsFalse() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.False(writer.CanRead); + } + + [Fact] + public void CanSeek_IsFalse() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.False(writer.CanSeek); + } + + [Fact] + public void Constructor_NullStream_ThrowsArgumentNullException() + { + Assert.Throws(() => new ChunkedStreamWriter(null)); + } + + [Fact] + public void Length_ThrowsNotSupportedException() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.Throws(() => writer.Length); + } + + [Fact] + public void Position_Get_ThrowsNotSupportedException() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.Throws(() => writer.Position); + } + + [Fact] + public void Position_Set_ThrowsNotSupportedException() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.Throws(() => writer.Position = 0); + } + + [Fact] + public void Read_ThrowsNotSupportedException() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.Throws(() => writer.Read(new byte[1], 0, 1)); + } + + [Fact] + public void Seek_ThrowsNotSupportedException() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.Throws(() => writer.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void SetLength_ThrowsNotSupportedException() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + Assert.Throws(() => writer.SetLength(0)); + } + + [Fact] + public async Task WriteAsync_ByteArray_ProducesCorrectChunkFormat() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + var data = Encoding.UTF8.GetBytes("Hello"); + await writer.WriteAsync(data, 0, data.Length); + + var output = Encoding.ASCII.GetString(inner.ToArray()); + // "Hello" is 5 bytes = 0x5 + Assert.Equal("5\r\nHello\r\n", output); + } + + [Fact] + public async Task WriteAsync_ReadOnlyMemory_ProducesCorrectChunkFormat() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + var data = Encoding.UTF8.GetBytes("Hi"); + await writer.WriteAsync(new ReadOnlyMemory(data)); + + var output = Encoding.ASCII.GetString(inner.ToArray()); + Assert.Equal("2\r\nHi\r\n", output); + } + + [Fact] + public async Task WriteAsync_ZeroBytes_WritesNothing() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + await writer.WriteAsync(Array.Empty(), 0, 0); + + Assert.Equal(0, inner.Length); + } + + [Fact] + public async Task WriteAsync_ReadOnlyMemory_ZeroBytes_WritesNothing() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + await writer.WriteAsync(ReadOnlyMemory.Empty); + + Assert.Equal(0, inner.Length); + } + + [Fact] + public async Task WriteAsync_MultipleChunks_EachCorrectlyFormatted() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + await writer.WriteAsync(Encoding.UTF8.GetBytes("AB"), 0, 2); + await writer.WriteAsync(Encoding.UTF8.GetBytes("CDE"), 0, 3); + + var output = Encoding.ASCII.GetString(inner.ToArray()); + Assert.Equal("2\r\nAB\r\n3\r\nCDE\r\n", output); + } + + [Fact] + public async Task WriteAsync_LargeChunk_HexSizeCorrect() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + var data = new byte[256]; + Array.Fill(data, (byte)'X'); + await writer.WriteAsync(data, 0, data.Length); + + var output = Encoding.ASCII.GetString(inner.ToArray()); + // 256 = 0x100 + Assert.StartsWith("100\r\n", output); + Assert.EndsWith("\r\n", output); + } + + [Fact] + public async Task WriteAsync_WithOffset_WritesCorrectSlice() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + var data = Encoding.UTF8.GetBytes("ABCDE"); + await writer.WriteAsync(data, 1, 3); // "BCD" + + var output = Encoding.ASCII.GetString(inner.ToArray()); + Assert.Equal("3\r\nBCD\r\n", output); + } + + [Fact] + public void Write_Sync_ProducesCorrectChunkFormat() + { + using var inner = new MemoryStream(); + using var writer = new ChunkedStreamWriter(inner); + + var data = Encoding.UTF8.GetBytes("OK"); + writer.Write(data, 0, data.Length); + + var output = Encoding.ASCII.GetString(inner.ToArray()); + Assert.Equal("2\r\nOK\r\n", output); + } + + [Fact] + public async Task FlushAsync_DelegatesToInnerStream() + { + var flushCalled = false; + var inner = new FlushTrackingStream(() => flushCalled = true); + using var writer = new ChunkedStreamWriter(inner); + + await writer.FlushAsync(CancellationToken.None); + + Assert.True(flushCalled); + } + + [Fact] + public void Flush_DelegatesToInnerStream() + { + var flushCalled = false; + var inner = new FlushTrackingStream(() => flushCalled = true); + using var writer = new ChunkedStreamWriter(inner); + + writer.Flush(); + + Assert.True(flushCalled); + } + + /// + /// A minimal writable stream that tracks Flush calls. + /// + private class FlushTrackingStream : MemoryStream + { + private readonly Action _onFlush; + public FlushTrackingStream(Action onFlush) => _onFlush = onFlush; + public override void Flush() { _onFlush(); base.Flush(); } + public override Task FlushAsync(CancellationToken cancellationToken) + { + _onFlush(); + return base.FlushAsync(cancellationToken); + } + } + } +} +#endif diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs index b7879e6e3..cc9a19af2 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamFactoryTests.cs @@ -49,13 +49,14 @@ public MockStreamingRuntimeApiClient() { } - internal override async Task StartStreamingResponseAsync( + internal override async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; LastAwsRequestId = awsRequestId; LastResponseStream = responseStream; await SendTaskCompletion.Task; + return new NoOpDisposable(); } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 517f2b8da..bce503bad 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -218,7 +218,7 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() } [Fact] - public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() + public async Task Dispose_DoesNotReleaseCompletionSignal() { var stream = new ResponseStream(Array.Empty()); @@ -227,8 +227,11 @@ public async Task Dispose_ReleasesCompletionSignalIfNotAlreadyReleased() stream.Dispose(); - var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(2))); - Assert.Same(waitTask, completed); + // Dispose should NOT release the completion signal — only MarkCompleted/ReportError should. + // This prevents a race condition where the handler's "using" block releases the signal + // before LambdaBootstrap can call ReportError, causing trailers to be omitted. + var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMilliseconds(200))); + Assert.NotSame(waitTask, completed); } [Fact] diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs index 08275feb7..71102ddf1 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RuntimeApiClientTests.cs @@ -68,7 +68,10 @@ private static RuntimeApiClient CreateClientWithMockHandler( } // --- Property 7: Streaming Response Mode Header --- + // Note: Properties 7, 8, 13 test the HttpClient-based streaming path which is only used on pre-NET8 targets. + // On NET8+, StartStreamingResponseAsync uses RawStreamingHttpClient (raw TCP) which doesn't go through HttpClient. +#if !NET8_0_OR_GREATER /// /// Property 7: Streaming Response Mode Header /// For any streaming response, the HTTP request should include @@ -133,6 +136,7 @@ public async Task StartStreamingResponseAsync_DeclaresTrailerHeaderUpfront() Assert.Contains(StreamingConstants.ErrorTypeTrailer, trailerValue); Assert.Contains(StreamingConstants.ErrorBodyTrailer, trailerValue); } +#endif // --- Property 10: Buffered Responses Exclude Streaming Headers --- @@ -182,6 +186,7 @@ public async Task SendResponseAsync_BufferedResponse_ExcludesStreamingHeaders() // --- Argument validation --- +#if NET8_0_OR_GREATER [Fact] public async Task StartStreamingResponseAsync_NullRequestId_ThrowsArgumentNullException() { @@ -201,5 +206,6 @@ public async Task StartStreamingResponseAsync_NullResponseStream_ThrowsArgumentN await Assert.ThrowsAsync( () => client.StartStreamingResponseAsync("req-5", null, CancellationToken.None)); } +#endif } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs index 14018e02b..f46c76f13 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingE2EWithMoq.cs @@ -32,7 +32,7 @@ public class ResponseStreamFactoryCollection { } /// /// End-to-end integration tests for the true-streaming architecture. /// These tests exercise the full pipeline: LambdaBootstrap → ResponseStreamFactory → - /// ResponseStream → StreamingHttpContent → captured HTTP output stream. + /// ResponseStream → captured HTTP output stream. /// [Collection("ResponseStreamFactory")] public class StreamingE2EWithMoq : IDisposable @@ -97,7 +97,7 @@ public CapturingStreamingRuntimeApiClient( }; } - internal override async Task StartStreamingResponseAsync( + internal override async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingCalled = true; @@ -105,11 +105,12 @@ internal override async Task StartStreamingResponseAsync( // Use a real MemoryStream as the HTTP output stream so we capture actual bytes var captureStream = new MemoryStream(); - var content = new StreamingHttpContent(responseStream); + await responseStream.SetHttpOutputStreamAsync(captureStream, cancellationToken); - // SerializeToStreamAsync hands the stream to ResponseStream and waits for completion - await content.CopyToAsync(captureStream); + // Wait for the handler to finish writing (mirrors real RawStreamingHttpClient behavior) + await responseStream.WaitForCompletionAsync(cancellationToken); CapturedHttpBytes = captureStream.ToArray(); + return new NoOpDisposable(); } public new async Task SendResponseAsync(string awsRequestId, Stream outputStream, CancellationToken cancellationToken = default) @@ -254,11 +255,12 @@ public async Task Buffered_ResponseBodyTransmittedCorrectly() } /// - /// End-to-end: error body trailer contains JSON with exception details. + /// End-to-end: midstream error sets error state on ResponseStream with exception details. + /// In production, RawStreamingHttpClient reads this state and writes trailing headers. /// Requirements: 5.2, 5.3 /// [Fact] - public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() + public async Task MidstreamError_SetsErrorStateWithExceptionDetails() { var client = CreateClient(); const string errorMessage = "something went wrong mid-stream"; @@ -274,9 +276,16 @@ public async Task MidstreamError_ErrorBodyTrailerContainsJsonDetails() bootstrap.Client = client; await bootstrap.InvokeOnceAsync(); + Assert.True(client.StartStreamingCalled); + Assert.NotNull(client.LastResponseStream); + Assert.True(client.LastResponseStream.HasError); + Assert.NotNull(client.LastResponseStream.ReportedError); + Assert.IsType(client.LastResponseStream.ReportedError); + Assert.Equal(errorMessage, client.LastResponseStream.ReportedError.Message); + + // Verify the handler's data was still captured before the error var output = Encoding.UTF8.GetString(client.CapturedHttpBytes); - Assert.Contains(StreamingConstants.ErrorBodyTrailer + ":", output); - Assert.Contains(errorMessage, output); + Assert.Contains("some data", output); } // ─── 10.4 Multi-concurrency ────────────────────────────────────────────────── @@ -420,12 +429,13 @@ private class MockMultiConcurrencyStreamingClient : RuntimeApiClient public MockMultiConcurrencyStreamingClient() : base(new TestEnvironmentVariables(), new NoOpInternalRuntimeApiClient()) { } - internal override async Task StartStreamingResponseAsync( + internal override async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { // Provide the HTTP output stream so writes don't block await responseStream.SetHttpOutputStreamAsync(new MemoryStream()); await responseStream.WaitForCompletionAsync(); + return new NoOpDisposable(); } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs deleted file mode 100644 index 21fe303b3..000000000 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/StreamingHttpContentTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -using System; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using Amazon.Lambda.RuntimeSupport.Client.ResponseStreaming; -using Xunit; - -namespace Amazon.Lambda.RuntimeSupport.UnitTests -{ - public class StreamingHttpContentTests - { - /// - /// Helper: runs SerializeToStreamAsync concurrently with handler actions. - /// The handlerAction receives the ResponseStream and should write data then signal completion. - /// Returns the bytes written to the HTTP output stream. - /// - private async Task SerializeWithConcurrentHandler( - ResponseStream responseStream, - Func handlerAction) - { - var content = new StreamingHttpContent(responseStream); - var outputStream = new MemoryStream(); - - // Start serialization on a background task (it will call SetHttpOutputStream and wait) - var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); - - // Give SerializeToStreamAsync a moment to start and call SetHttpOutputStream - await Task.Delay(50); - - // Run the handler action (writes data, signals completion) - await handlerAction(responseStream); - - // Wait for serialization to complete - await serializeTask; - - return outputStream.ToArray(); - } - - [Fact] - public async Task SerializeToStreamAsync_HandsOffHttpStream_WritesFlowThrough() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 0xAA, 0xBB }); - stream.MarkCompleted(); - }); - - Assert.Equal(2, output.Length); - } - - [Fact] - public async Task SerializeToStreamAsync_BlocksUntilMarkCompleted() - { - var rs = new ResponseStream(Array.Empty()); - var content = new StreamingHttpContent(rs); - var outputStream = new MemoryStream(); - - var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); - await Task.Delay(50); - - // Serialization should still be running (waiting for completion) - Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until completion is signaled"); - - // Now signal completion - rs.MarkCompleted(); - await serializeTask; - - Assert.True(serializeTask.IsCompleted); - } - - [Fact] - public async Task SerializeToStreamAsync_BlocksUntilReportErrorAsync() - { - var rs = new ResponseStream(Array.Empty()); - var content = new StreamingHttpContent(rs); - var outputStream = new MemoryStream(); - - var serializeTask = Task.Run(() => content.CopyToAsync(outputStream)); - await Task.Delay(50); - - Assert.False(serializeTask.IsCompleted, "SerializeToStreamAsync should block until error is reported"); - - rs.ReportError(new Exception("test error")); - await serializeTask; - - Assert.True(serializeTask.IsCompleted); - } - - [Theory] - [InlineData(typeof(InvalidOperationException))] - [InlineData(typeof(ArgumentException))] - [InlineData(typeof(NullReferenceException))] - public async Task ErrorTrailer_IncludesErrorType(Type exceptionType) - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - var exception = (Exception)Activator.CreateInstance(exceptionType, "test error"); - stream.ReportError(exception); - }); - - var outputStr = Encoding.UTF8.GetString(output); - Assert.Contains($"Lambda-Runtime-Function-Error-Type: {exceptionType.Name}", outputStr); - } - - [Fact] - public async Task ErrorTrailer_IncludesJsonErrorBody() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.ReportError(new InvalidOperationException("something went wrong")); - }); - - var outputStr = Encoding.UTF8.GetString(output); - Assert.Contains("Lambda-Runtime-Function-Error-Body:", outputStr); - Assert.Contains("something went wrong", outputStr); - Assert.Contains("InvalidOperationException", outputStr); - } - - - [Fact] - public async Task ErrorCompletion_EndsWithCrlf() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.ReportError(new Exception("fail")); - }); - - var outputStr = Encoding.UTF8.GetString(output); - Assert.EndsWith("\r\n", outputStr); - } - - [Fact] - public async Task NoError_NoTrailersWritten() - { - var rs = new ResponseStream(Array.Empty()); - - var output = await SerializeWithConcurrentHandler(rs, async stream => - { - await stream.WriteAsync(new byte[] { 1 }); - stream.MarkCompleted(); - }); - - var outputStr = Encoding.UTF8.GetString(output); - Assert.DoesNotContain("Lambda-Runtime-Function-Error-Type:", outputStr); - Assert.DoesNotContain("Lambda-Runtime-Function-Error-Body:", outputStr); - } - - [Fact] - public void TryComputeLength_ReturnsFalse() - { - var stream = new ResponseStream(Array.Empty()); - var content = new StreamingHttpContent(stream); - - var result = content.Headers.ContentLength; - Assert.Null(result); - } - } -} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs index 621f7af6f..1cd6fa09e 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/TestHelpers/TestStreamingRuntimeApiClient.cs @@ -108,7 +108,7 @@ public TestStreamingRuntimeApiClient(IEnvironmentVariables environmentVariables, SendResponseAsyncCalled = true; } - internal override async Task StartStreamingResponseAsync( + internal override async Task StartStreamingResponseAsync( string awsRequestId, ResponseStream responseStream, CancellationToken cancellationToken = default) { StartStreamingResponseAsyncCalled = true; @@ -119,6 +119,8 @@ internal override async Task StartStreamingResponseAsync( // Wait for the handler to finish writing (mirrors real SerializeToStreamAsync behavior) await responseStream.WaitForCompletionAsync(); + + return new NoOpDisposable(); } #if NET8_0_OR_GREATER @@ -129,4 +131,12 @@ internal override async Task StartStreamingResponseAsync( => Task.CompletedTask; #endif } + + /// + /// A no-op IDisposable for test overrides of StartStreamingResponseAsync. + /// + internal class NoOpDisposable : IDisposable + { + public void Dispose() { } + } } From b7b51bdf31019b0c20f3849a32c035b69efe3d1a Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 3 Apr 2026 12:25:36 -0700 Subject: [PATCH 25/47] Change response stream factory to return LambdaResponseStream instead of Stream so that we can potentially expose more methods in the future. --- .../ResponseStreaming/LambdaResponseStreamFactory.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs index 7adccc2da..1b9e6d3b6 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStreamFactory.cs @@ -31,12 +31,12 @@ internal static void SetLambdaResponseStream(Func } /// - /// Creates a that can be used to write streaming responses back to callers of the Lambda function. Once + /// Creates a a subclass of that can be used to write streaming responses back to callers of the Lambda function. Once /// a Lambda function creates a response stream all output must be returned by writing to the stream; the Lambda function's handler /// return value will be ignored. The stream is write-only and non-seekable. /// /// - public static Stream CreateStream() + public static LambdaResponseStream CreateStream() { if (_streamFactory == null) throw new InvalidOperationException(UninitializedFactoryMessage); @@ -46,7 +46,7 @@ public static Stream CreateStream() } /// - /// Create a for writing streaming responses, with an HTTP response prelude containing status code and headers. This should be used for + /// Creates a a subclass of for writing streaming responses, with an HTTP response prelude containing status code and headers. This should be used for /// Lambda functions using response streaming that are invoked via the Lambda Function URLs or API Gateway HTTP APIs, where the response format is expected to be an HTTP response. /// The prelude will be serialized and sent as the first chunk of the response stream, and should contain any necessary HTTP status code and headers. /// @@ -56,7 +56,7 @@ public static Stream CreateStream() /// /// The HTTP response prelude including status code and headers. /// - public static Stream CreateHttpStream(HttpResponseStreamPrelude prelude) + public static LambdaResponseStream CreateHttpStream(HttpResponseStreamPrelude prelude) { if (_streamFactory == null) throw new InvalidOperationException(UninitializedFactoryMessage); From 2eb2eb44bbbcf37ebaee4af2c77a389d526f2c44 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 3 Apr 2026 15:29:29 -0700 Subject: [PATCH 26/47] Clean up --- .../ResponseStreaming/LambdaResponseStream.cs | 12 ++++---- .../Bootstrap/LambdaBootstrap.cs | 5 +--- .../ResponseStreaming/ResponseStream.cs | 28 ++++--------------- .../ResponseStreamingTests.cs | 11 +++----- .../ResponseStreamTests.cs | 17 ----------- 5 files changed, 17 insertions(+), 56 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs index 47ce48588..83ac446a4 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/LambdaResponseStream.cs @@ -82,24 +82,24 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc /// Always thrown. public override long Position { - get => throw new NotSupportedException("LambdaResponseStream does not support seeking."); - set => throw new NotSupportedException("LambdaResponseStream does not support seeking."); + get => throw new NotSupportedException($"{nameof(LambdaResponseStream)} does not support seeking."); + set => throw new NotSupportedException($"{nameof(LambdaResponseStream)} does not support seeking."); } /// Not supported. /// Always thrown. public override long Seek(long offset, SeekOrigin origin) - => throw new NotImplementedException("LambdaResponseStream does not support seeking."); + => throw new NotImplementedException($"{nameof(LambdaResponseStream)} does not support seeking."); /// Not supported. /// Always thrown. public override int Read(byte[] buffer, int offset, int count) - => throw new NotImplementedException("LambdaResponseStream does not support reading."); + => throw new NotImplementedException($"{nameof(LambdaResponseStream)} does not support reading."); /// Not supported. /// Always thrown. public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => throw new NotImplementedException("LambdaResponseStream does not support reading."); + => throw new NotImplementedException($"{nameof(LambdaResponseStream)} does not support reading."); /// /// Writes a sequence of bytes to the stream. Delegates to the async path synchronously. @@ -116,7 +116,7 @@ public override void Flush() { } /// Not supported. /// Always thrown. public override void SetLength(long value) - => throw new NotSupportedException("LambdaResponseStream does not support SetLength."); + => throw new NotSupportedException($"{nameof(LambdaResponseStream)} does not support SetLength."); #endregion } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs index cd0e9bc4f..bb6198d9e 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs @@ -467,10 +467,7 @@ internal async Task InvokeOnceAsync(CancellationToken cancellationToken = defaul } finally { - if (runtimeApiClient != null) - { - ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); - } + ResponseStreamFactory.CleanupInvocation(isMultiConcurrency); invocation.Dispose(); } }; diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index 277ba426b..de983cac0 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -36,15 +36,14 @@ internal class ResponseStream // The live HTTP output stream, set by RawStreamingHttpClient when sending the streaming response. private Stream _httpOutputStream; private bool _disposedValue; - private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); - // The wait time is a sanity timeout to avoid waiting indefinitely if SerializeToStreamAsync is not called or takes too long to call. - // Reality is that SerializeToStreamAsync should be called very quickly after CreateStream, so this timeout is generous to avoid false positives but still protects against hanging indefinitely. + // The wait time is a sanity timeout to avoid waiting indefinitely if SetHttpOutputStreamAsync is not called or takes too long to call. + // Reality is that SetHttpOutputStreamAsync should be called very quickly after CreateStream, so this timeout is generous to avoid false positives but still protects against hanging indefinitely. private readonly static TimeSpan _httpStreamWaitTimeout = TimeSpan.FromSeconds(30); + private readonly SemaphoreSlim _httpStreamReady = new SemaphoreSlim(0, 1); private readonly SemaphoreSlim _completionSignal = new SemaphoreSlim(0, 1); - private static readonly byte[] PreludeDelimiter = new byte[8]; /// @@ -145,7 +144,7 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT await _httpStreamReady.WaitAsync(_httpStreamWaitTimeout, cancellationToken); try { - _logger.LogDebug($"Writing chunk of {count} bytes to HTTP stream."); + _logger.LogDebug("Writing chunk to HTTP response stream."); lock (_lock) { @@ -183,8 +182,6 @@ internal void ReportError(Exception exception) _hasError = true; _reportedError = exception; - - _isCompleted = true; } // Signal completion so RawStreamingHttpClient can write error trailers and finish @@ -232,21 +229,8 @@ protected virtual void Dispose(bool disposing) try { _httpStreamReady.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } _httpStreamReady.Dispose(); - // Do NOT release or dispose _completionSignal here. - // - // When the handler uses "using var stream = ...", Dispose() runs during - // stack unwinding BEFORE LambdaBootstrap's catch block can call ReportError(). - // If we release the signal here, RawStreamingHttpClient sees HasError=false - // and writes the chunked terminator without error trailers, causing Lambda - // to report Runtime.TruncatedResponse instead of the actual error. - // - // If we dispose the signal here, subsequent ReportError()/MarkCompleted() - // calls and the WaitForCompletionAsync() in RawStreamingHttpClient will - // throw ObjectDisposedException. - // - // The completion signal lifecycle is managed by MarkCompleted()/ReportError() - // (which release it) and LambdaBootstrap (which awaits the send task after). - // The SemaphoreSlim is a lightweight managed object that the GC will finalize. + try { _completionSignal.Release(); } catch (SemaphoreFullException) { /* Ignore if already released */ } + _completionSignal.Dispose(); } _disposedValue = true; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs index 650d968d4..006df6d15 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ResponseStreamingTests.cs @@ -61,13 +61,10 @@ public async Task UnhandledExceptionHandler() var evnts = await InvokeFunctionAsync(nameof(UnhandledExceptionHandler)); Assert.True(evnts.Any()); - var content = GetCombinedStreamContent(evnts); - Assert.Contains("This method will fail", content); - Assert.Contains("This is an unhandled exception", content); - Assert.Contains("Lambda-Runtime-Function-Error-Type", content); - Assert.Contains("InvalidOperationException", content); - Assert.Contains("This is an unhandled exception", content); - Assert.Contains("stackTrace", content); + var completeEvent = evnts.Last() as InvokeWithResponseStreamCompleteEvent; + Assert.Equal("InvalidOperationException", completeEvent.ErrorCode); + Assert.Contains("This is an unhandled exception", completeEvent.ErrorDetails); + Assert.Contains("stackTrace", completeEvent.ErrorDetails); } private async Task InvokeFunctionAsync(string handlerScenario) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index bce503bad..d0126e35a 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -217,23 +217,6 @@ public async Task ReportErrorAsync_NullException_ThrowsArgumentNull() Assert.Throws(() => stream.ReportError(null)); } - [Fact] - public async Task Dispose_DoesNotReleaseCompletionSignal() - { - var stream = new ResponseStream(Array.Empty()); - - var waitTask = stream.WaitForCompletionAsync(); - Assert.False(waitTask.IsCompleted); - - stream.Dispose(); - - // Dispose should NOT release the completion signal — only MarkCompleted/ReportError should. - // This prevents a race condition where the handler's "using" block releases the signal - // before LambdaBootstrap can call ReportError, causing trailers to be omitted. - var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMilliseconds(200))); - Assert.NotSame(waitTask, completed); - } - [Fact] public async Task Dispose_CalledTwice_DoesNotThrow() { From 1a86609ecd5f2f7b5435fc169129ce77e381f7c3 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Fri, 3 Apr 2026 16:00:54 -0700 Subject: [PATCH 27/47] Ensure prelude with delimiter are written as a single chunk --- .../ResponseStreaming/ResponseStream.cs | 34 +++--- .../ResponseStreamTests.cs | 101 ++++++++++++++++++ 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index de983cac0..b89ac39e4 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -14,6 +14,7 @@ */ using System; +using System.Buffers; using System.IO; using System.Text; using System.Threading; @@ -76,34 +77,37 @@ internal ResponseStream(byte[] prelude) internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, CancellationToken cancellationToken = default) { _httpOutputStream = httpOutputStream; - _httpStreamReady.Release(); - - await WritePreludeAsync(cancellationToken); - } - private async Task WritePreludeAsync(CancellationToken cancellationToken = default) - { + // Write the prelude BEFORE releasing _httpStreamReady. This prevents a race + // where a handler WriteAsync that is already waiting on the semaphore could + // sneak in and write body data before the prelude, causing intermittent + // "Failed to parse prelude JSON" errors from API Gateway. if (_prelude?.Length > 0) { _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); - await _httpStreamReady.WaitAsync(_httpStreamWaitTimeout, cancellationToken); - try + + lock (_lock) { - lock (_lock) - { - ThrowIfCompletedOrError(); - } + ThrowIfCompletedOrError(); + } - await _httpOutputStream.WriteAsync(_prelude, 0, _prelude.Length, cancellationToken); - await _httpOutputStream.WriteAsync(PreludeDelimiter, 0, PreludeDelimiter.Length, cancellationToken); + var combinedLength = _prelude.Length + PreludeDelimiter.Length; + var combined = ArrayPool.Shared.Rent(combinedLength); + try + { + Buffer.BlockCopy(_prelude, 0, combined, 0, _prelude.Length); + Buffer.BlockCopy(PreludeDelimiter, 0, combined, _prelude.Length, PreludeDelimiter.Length); + await _httpOutputStream.WriteAsync(combined, 0, combinedLength, cancellationToken); await _httpOutputStream.FlushAsync(cancellationToken); } finally { - _httpStreamReady.Release(); + ArrayPool.Shared.Return(combined); } } + + _httpStreamReady.Release(); } /// diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index d0126e35a..6eaad2038 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -284,6 +284,107 @@ public async Task SetHttpOutputStreamAsync_NullPrelude_WritesNoPreludeBytes() Assert.Empty(output.ToArray()); } + // ---- Prelude + delimiter single-chunk tests (via ChunkedStreamWriter) ---- + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_ViaChunkedWriter_ProducesSingleChunk() + { + var preludeJson = Encoding.UTF8.GetBytes("{\"statusCode\":200}"); + var rs = new ResponseStream(preludeJson); + var rawOutput = new MemoryStream(); + var chunkedWriter = new ChunkedStreamWriter(rawOutput); + + await rs.SetHttpOutputStreamAsync(chunkedWriter); + + var wireBytes = Encoding.ASCII.GetString(rawOutput.ToArray()); + + // The prelude (18 bytes) + delimiter (8 bytes) = 26 bytes = 0x1A + // Should be exactly one chunk: "1A\r\n{prelude}{8 null bytes}\r\n" + var expectedDataLength = preludeJson.Length + 8; // 26 + var expectedHex = expectedDataLength.ToString("X"); + Assert.StartsWith($"{expectedHex}\r\n", wireBytes); + + // Verify there is only one chunk header (only one hex size prefix) + var chunkCount = 0; + var remaining = wireBytes; + while (remaining.Length > 0) + { + var crlfIndex = remaining.IndexOf("\r\n", StringComparison.Ordinal); + if (crlfIndex < 0) break; + var sizeStr = remaining.Substring(0, crlfIndex); + if (int.TryParse(sizeStr, System.Globalization.NumberStyles.HexNumber, null, out var chunkSize) && chunkSize >= 0) + { + chunkCount++; + // Skip past: hex\r\n{data}\r\n + remaining = remaining.Substring(crlfIndex + 2 + chunkSize + 2); + } + else + { + break; + } + } + Assert.Equal(1, chunkCount); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_ViaChunkedWriter_DelimiterImmediatelyFollowsPrelude() + { + var preludeJson = Encoding.UTF8.GetBytes("{\"statusCode\":201}"); + var rs = new ResponseStream(preludeJson); + var rawOutput = new MemoryStream(); + var chunkedWriter = new ChunkedStreamWriter(rawOutput); + + await rs.SetHttpOutputStreamAsync(chunkedWriter); + + // Parse the chunk to get the raw data payload + var wireBytes = rawOutput.ToArray(); + var wireStr = Encoding.ASCII.GetString(wireBytes); + var firstCrlf = wireStr.IndexOf("\r\n", StringComparison.Ordinal); + var dataStart = firstCrlf + 2; + var dataLength = preludeJson.Length + 8; + var chunkData = new byte[dataLength]; + Array.Copy(wireBytes, dataStart, chunkData, 0, dataLength); + + // First part should be the prelude JSON + Assert.Equal(preludeJson, chunkData[..preludeJson.Length]); + // Immediately followed by 8 null bytes (delimiter) + Assert.Equal(new byte[8], chunkData[preludeJson.Length..]); + } + + [Fact] + public async Task SetHttpOutputStreamAsync_WithPrelude_ViaChunkedWriter_HandlerDataInSeparateChunk() + { + var preludeJson = Encoding.UTF8.GetBytes("{\"statusCode\":200}"); + var rs = new ResponseStream(preludeJson); + var rawOutput = new MemoryStream(); + var chunkedWriter = new ChunkedStreamWriter(rawOutput); + + await rs.SetHttpOutputStreamAsync(chunkedWriter); + await rs.WriteAsync(Encoding.UTF8.GetBytes("body data"), 0, 9); + + var wireStr = Encoding.ASCII.GetString(rawOutput.ToArray()); + + // Should have exactly 2 chunks: one for prelude+delimiter, one for body + var chunkCount = 0; + var remaining = wireStr; + while (remaining.Length > 0) + { + var crlfIndex = remaining.IndexOf("\r\n", StringComparison.Ordinal); + if (crlfIndex < 0) break; + var sizeStr = remaining.Substring(0, crlfIndex); + if (int.TryParse(sizeStr, System.Globalization.NumberStyles.HexNumber, null, out var chunkSize) && chunkSize >= 0) + { + chunkCount++; + remaining = remaining.Substring(crlfIndex + 2 + chunkSize + 2); + } + else + { + break; + } + } + Assert.Equal(2, chunkCount); + } + // ---- MarkCompleted idempotency ---- [Fact] From 98d2d25690b550c11bc9f1f0b0d7943e2efd4855 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Sat, 4 Apr 2026 13:32:56 -0700 Subject: [PATCH 28/47] Bug fixes --- .../HttpResponseStreamPrelude.cs | 6 ++++++ .../ResponseStreaming/ResponseStream.cs | 19 +++++++++++++------ .../ResponseStreamTests.cs | 12 ++++++++---- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs index d218de397..67eb9d3ae 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 #if NET8_0_OR_GREATER +using System; using System.Collections.Generic; using System.Net; using System.Runtime.Versioning; @@ -82,6 +83,11 @@ internal byte[] ToByteArray() writer.WriteEndObject(); } + if (string.Equals(Environment.GetEnvironmentVariable("LAMBDA_NET_SERIALIZER_DEBUG"), "true", StringComparison.OrdinalIgnoreCase)) + { + LambdaLogger.Log(LogLevel.Information, "HTTP Response Stream Prelude JSON: {Prelude}", System.Text.Encoding.UTF8.GetString(bufferWriter.WrittenSpan)); + } + return bufferWriter.WrittenSpan.ToArray(); } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index b89ac39e4..8271bf4f1 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -82,15 +82,17 @@ internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, Cancellati // where a handler WriteAsync that is already waiting on the semaphore could // sneak in and write body data before the prelude, causing intermittent // "Failed to parse prelude JSON" errors from API Gateway. + // + // Note: we intentionally do NOT check ThrowIfCompletedOrError() here. + // SetHttpOutputStreamAsync is infrastructure setup called by RawStreamingHttpClient, + // not a handler write. For fast-completing responses (e.g. Results.Json), + // LambdaBootstrap may call MarkCompleted() before the TCP connection is established + // and this method is called. The prelude still needs to be written to the wire + // so the response is properly framed. if (_prelude?.Length > 0) { _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); - lock (_lock) - { - ThrowIfCompletedOrError(); - } - var combinedLength = _prelude.Length + PreludeDelimiter.Length; var combined = ArrayPool.Shared.Rent(combinedLength); try @@ -152,7 +154,12 @@ public async Task WriteAsync(byte[] buffer, int offset, int count, CancellationT lock (_lock) { - ThrowIfCompletedOrError(); + // Only throw on error, not on completed. For buffered ASP.NET Core responses + // (e.g. Results.Json), the pipeline completes and LambdaBootstrap calls + // MarkCompleted() before the pre-start buffer has been flushed to the wire. + // The buffered data still needs to be written even after MarkCompleted. + if (_hasError) + throw new InvalidOperationException("Cannot write to a stream after an error has been reported."); _bytesWritten += count; } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs index 6eaad2038..cd2c00fd2 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/ResponseStreamTests.cs @@ -140,14 +140,18 @@ public async Task ReportErrorAsync_ReleasesCompletionSignal() } [Fact] - public async Task WriteAsync_AfterMarkCompleted_Throws() + public async Task WriteAsync_AfterMarkCompleted_StillSucceeds() { - var (stream, _) = await CreateWiredStream(); + var (stream, output) = await CreateWiredStream(); await stream.WriteAsync(new byte[] { 1 }, 0, 1); stream.MarkCompleted(); - await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[] { 2 }, 0, 1)); + // Writes after MarkCompleted are allowed — buffered ASP.NET Core responses + // (e.g. Results.Json) may flush pre-start buffer data after the pipeline + // completes and LambdaBootstrap calls MarkCompleted. + await stream.WriteAsync(new byte[] { 2 }, 0, 1); + + Assert.Equal(new byte[] { 1, 2 }, output.ToArray()); } [Fact] From d86f1af274510d6abbc917853c6ed3179da8842b Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 8 Apr 2026 13:10:19 -0400 Subject: [PATCH 29/47] Application Load Balancer (ALB) (#2318) --- .autover/changes/alb-annotations-support.json | 11 + Libraries/Amazon.Lambda.Annotations.slnf | 5 +- Libraries/Libraries.sln | 30 + .../Diagnostics/AnalyzerReleases.Unshipped.md | 4 + .../Diagnostics/DiagnosticDescriptors.cs | 32 + .../Extensions/ParameterListExtension.cs | 8 +- .../Attributes/ALBApiAttributeBuilder.cs | 68 ++ .../ALBFromHeaderAttributeBuilder.cs | 28 + .../ALBFromQueryAttributeBuilder.cs | 28 + .../Attributes/AttributeModelBuilder.cs | 41 +- .../Models/EventType.cs | 3 +- .../Models/EventTypeBuilder.cs | 4 + .../Models/GeneratedMethodModelBuilder.cs | 27 + .../Models/LambdaMethodModel.cs | 25 + .../SyntaxReceiver.cs | 3 +- .../Templates/ALBInvoke.cs | 420 ++++++++++++ .../Templates/ALBInvoke.tt | 98 +++ .../Templates/ALBInvokeCode.cs | 20 + .../Templates/ALBSetupParameters.cs | 604 ++++++++++++++++++ .../Templates/ALBSetupParameters.tt | 303 +++++++++ .../Templates/ALBSetupParametersCode.cs | 19 + .../Templates/LambdaFunctionTemplate.cs | 6 + .../Templates/LambdaFunctionTemplate.tt | 6 + .../TypeFullNames.cs | 15 +- .../Validation/LambdaFunctionValidator.cs | 100 ++- .../Writers/CloudFormationWriter.cs | 240 ++++++- .../ALB/ALBApiAttribute.cs | 188 ++++++ .../ALB/FromBodyAttribute.cs | 18 + .../ALB/FromHeaderAttribute.cs | 19 + .../ALB/FromQueryAttribute.cs | 19 + .../src/Amazon.Lambda.Annotations/README.md | 227 ++++++- .../ALBApiAttributeTests.cs | 495 ++++++++++++++ .../ALBApiModelTests.cs | 272 ++++++++ ....Annotations.SourceGenerators.Tests.csproj | 1 + .../CSharpSourceGeneratorVerifier.cs | 2 + ...alidALBEvents_HandleRequest_Generated.g.cs | 59 ++ .../ALB/ValidALBEvents_Hello_Generated.g.cs | 59 ++ .../ServerlessTemplates/albEvents.template | 203 ++++++ .../SourceGeneratorTests.cs | 49 ++ .../WriterTests/ALBEventsTests.cs | 430 +++++++++++++ .../ALBIntegrationTestContextFixture.cs | 172 +++++ ...IntegrationTestContextFixtureCollection.cs | 12 + .../ALBTargetTests.cs | 81 +++ .../DeploymentScript.ps1 | 85 +++ ...tServerlessApp.ALB.IntegrationTests.csproj | 20 + .../TestServerlessApp.ALB/ALBFunctions.cs | 109 ++++ .../AssemblyAttributes.cs | 6 + .../TestServerlessApp.ALB.csproj | 15 + .../aws-lambda-tools-defaults.json | 17 + .../TestServerlessApp.ALB/serverless.template | 532 +++++++++++++++ .../ALBEventExamples/ValidALBEvents.cs.txt | 42 ++ 51 files changed, 5267 insertions(+), 13 deletions(-) create mode 100644 .autover/changes/alb-annotations-support.json create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ALB/ValidALBEvents_HandleRequest_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ALB/ValidALBEvents_Hello_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/albEvents.template create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/ALBEventsTests.cs create mode 100644 Libraries/test/TestServerlessApp.ALB.IntegrationTests/ALBIntegrationTestContextFixture.cs create mode 100644 Libraries/test/TestServerlessApp.ALB.IntegrationTests/ALBIntegrationTestContextFixtureCollection.cs create mode 100644 Libraries/test/TestServerlessApp.ALB.IntegrationTests/ALBTargetTests.cs create mode 100644 Libraries/test/TestServerlessApp.ALB.IntegrationTests/DeploymentScript.ps1 create mode 100644 Libraries/test/TestServerlessApp.ALB.IntegrationTests/TestServerlessApp.ALB.IntegrationTests.csproj create mode 100644 Libraries/test/TestServerlessApp.ALB/ALBFunctions.cs create mode 100644 Libraries/test/TestServerlessApp.ALB/AssemblyAttributes.cs create mode 100644 Libraries/test/TestServerlessApp.ALB/TestServerlessApp.ALB.csproj create mode 100644 Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json create mode 100644 Libraries/test/TestServerlessApp.ALB/serverless.template create mode 100644 Libraries/test/TestServerlessApp/ALBEventExamples/ValidALBEvents.cs.txt diff --git a/.autover/changes/alb-annotations-support.json b/.autover/changes/alb-annotations-support.json new file mode 100644 index 000000000..d6b2a21e5 --- /dev/null +++ b/.autover/changes/alb-annotations-support.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [ALBApi] attribute for configuring Lambda functions as targets behind an Application Load Balancer" + ] + } + ] +} diff --git a/Libraries/Amazon.Lambda.Annotations.slnf b/Libraries/Amazon.Lambda.Annotations.slnf index ecb4e01ee..d0bf67584 100644 --- a/Libraries/Amazon.Lambda.Annotations.slnf +++ b/Libraries/Amazon.Lambda.Annotations.slnf @@ -16,7 +16,10 @@ "test\\TestCustomAuthorizerApp.IntegrationTests\\TestCustomAuthorizerApp.IntegrationTests.csproj", "test\\TestServerlessApp.IntegrationTests\\TestServerlessApp.IntegrationTests.csproj", "test\\TestServerlessApp.NET8\\TestServerlessApp.NET8.csproj", - "test\\TestServerlessApp\\TestServerlessApp.csproj" + "src\\Amazon.Lambda.ApplicationLoadBalancerEvents\\Amazon.Lambda.ApplicationLoadBalancerEvents.csproj", + "test\\TestServerlessApp\\TestServerlessApp.csproj", + "test\\TestServerlessApp.ALB\\TestServerlessApp.ALB.csproj", + "test\\TestServerlessApp.ALB.IntegrationTests\\TestServerlessApp.ALB.IntegrationTests.csproj" ] } } diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index f3214606a..17e39c553 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -151,6 +151,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp.Int EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomAuthorizerApp", "test\TestCustomAuthorizerApp\TestCustomAuthorizerApp.csproj", "{3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServerlessApp.ALB", "test\TestServerlessApp.ALB\TestServerlessApp.ALB.csproj", "{8F7C617D-C611-4DC6-A07C-033F13C1835D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServerlessApp.ALB.IntegrationTests", "test\TestServerlessApp.ALB.IntegrationTests\TestServerlessApp.ALB.IntegrationTests.csproj", "{80594C21-C6EB-469E-83CC-68F9F661CA5E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -941,6 +945,30 @@ Global {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x64.Build.0 = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.ActiveCfg = Release|Any CPU {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E}.Release|x86.Build.0 = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x64.Build.0 = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Debug|x86.Build.0 = Debug|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|Any CPU.Build.0 = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x64.ActiveCfg = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x64.Build.0 = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x86.ActiveCfg = Release|Any CPU + {8F7C617D-C611-4DC6-A07C-033F13C1835D}.Release|x86.Build.0 = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x64.ActiveCfg = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x64.Build.0 = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x86.ActiveCfg = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Debug|x86.Build.0 = Debug|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|Any CPU.Build.0 = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x64.ActiveCfg = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x64.Build.0 = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x86.ActiveCfg = Release|Any CPU + {80594C21-C6EB-469E-83CC-68F9F661CA5E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1015,6 +1043,8 @@ Global {8D03BDF3-7078-4B46-A3F1-C73BE6D6CE0D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {8EEDD576-7FC4-4FAC-A5A2-F58562753A53} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {3BFA4B73-BA61-4578-833B-C5B3A16EDA9E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {8F7C617D-C611-4DC6-A07C-033F13C1835D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {80594C21-C6EB-469E-83CC-68F9F661CA5E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index e9b44dd1e..8ff092c83 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -16,3 +16,7 @@ AWSLambda0128 | AWSLambdaCSharpGenerator | Error | Authorizer Payload Version Mi AWSLambda0129 | AWSLambdaCSharpGenerator | Error | Missing LambdaFunction Attribute AWSLambda0130 | AWSLambdaCSharpGenerator | Error | Invalid return type IAuthorizerResult AWSLambda0131 | AWSLambdaCSharpGenerator | Error | FromBody not supported on Authorizer functions +AWSLambda0132 | AWSLambdaCSharpGenerator | Error | Invalid ALBApiAttribute +AWSLambda0133 | AWSLambdaCSharpGenerator | Error | ALB Listener Reference Not Found +AWSLambda0134 | AWSLambdaCSharpGenerator | Error | FromRoute not supported on ALB functions +AWSLambda0135 | AWSLambdaCSharpGenerator | Error | Unmapped parameter on ALB function diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index 69c4f9428..aef6767ce 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -242,5 +242,37 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidAlbApiAttribute = new DiagnosticDescriptor( + id: "AWSLambda0132", + title: "Invalid ALBApiAttribute", + messageFormat: "Invalid ALBApiAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor AlbListenerReferenceNotFound = new DiagnosticDescriptor( + id: "AWSLambda0133", + title: "ALB Listener Reference Not Found", + messageFormat: "The ALBApi ListenerArn references '@{0}', but no resource or parameter named '{0}' was found in the CloudFormation template. Add the listener resource to the template or correct the reference name.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FromRouteNotSupportedOnAlb = new DiagnosticDescriptor( + id: "AWSLambda0134", + title: "FromRoute not supported on ALB functions", + messageFormat: "[FromRoute] is not supported on ALB functions. ALB does not support route path template parameters. Use [FromHeader], [FromQuery], or [FromBody] instead.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor AlbUnmappedParameter = new DiagnosticDescriptor( + id: "AWSLambda0135", + title: "Unmapped parameter on ALB function", + messageFormat: "Parameter '{0}' on ALB function has no binding attribute. Use [FromHeader], [FromQuery], [FromBody], or [FromServices], or use the ApplicationLoadBalancerRequest or ILambdaContext types.", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs index 5465f8323..9310019eb 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Extensions/ParameterListExtension.cs @@ -17,6 +17,12 @@ public static bool HasConvertibleParameter(this IList parameters return false; } + // ALB request types are forwarded to lambda method if specified, there is no parameter conversion required. + if (TypeFullNames.ALBRequests.Contains(p.Type.FullName)) + { + return false; + } + // ILambdaContext is forwarded to lambda method if specified, there is no parameter conversion required. if (p.Type.FullName == TypeFullNames.ILambdaContext) { @@ -24,7 +30,7 @@ public static bool HasConvertibleParameter(this IList parameters } // Body parameter with target type as string doesn't require conversion because body is string by nature. - if (p.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromBodyAttribute) && p.Type.IsString()) + if (p.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromBodyAttribute || att.Type.FullName == TypeFullNames.ALBFromBodyAttribute) && p.Type.IsString()) { return false; } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs new file mode 100644 index 000000000..d64f64048 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBApiAttributeBuilder.cs @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; +using System; +using System.Linq; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class ALBApiAttributeBuilder + { + public static ALBApiAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 3) + { + throw new NotSupportedException($"{TypeFullNames.ALBApiAttribute} must have constructor with 3 arguments."); + } + + var listenerArn = att.ConstructorArguments[0].Value as string; + var pathPattern = att.ConstructorArguments[1].Value as string; + var priority = (int)att.ConstructorArguments[2].Value; + + var data = new ALBApiAttribute(listenerArn, pathPattern, priority); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.MultiValueHeaders) && pair.Value.Value is bool multiValueHeaders) + { + data.MultiValueHeaders = multiValueHeaders; + } + else if (pair.Key == nameof(data.HostHeader) && pair.Value.Value is string hostHeader) + { + data.HostHeader = hostHeader; + } + else if (pair.Key == nameof(data.HttpMethod) && pair.Value.Value is string httpMethod) + { + data.HttpMethod = httpMethod; + } + else if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + { + data.ResourceName = resourceName; + } + else if (pair.Key == nameof(data.HttpHeaderConditionName) && pair.Value.Value is string httpHeaderConditionName) + { + data.HttpHeaderConditionName = httpHeaderConditionName; + } + else if (pair.Key == nameof(data.HttpHeaderConditionValues) && !pair.Value.IsNull) + { + data.HttpHeaderConditionValues = pair.Value.Values.Select(v => v.Value as string).ToArray(); + } + else if (pair.Key == nameof(data.QueryStringConditions) && !pair.Value.IsNull) + { + data.QueryStringConditions = pair.Value.Values.Select(v => v.Value as string).ToArray(); + } + else if (pair.Key == nameof(data.SourceIpConditions) && !pair.Value.IsNull) + { + data.SourceIpConditions = pair.Value.Values.Select(v => v.Value as string).ToArray(); + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs new file mode 100644 index 000000000..a0ca9aced --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromHeaderAttributeBuilder.cs @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class ALBFromHeaderAttributeBuilder + { + public static ALB.FromHeaderAttribute Build(AttributeData att) + { + var data = new ALB.FromHeaderAttribute(); + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.Name) && pair.Value.Value is string value) + { + data.Name = value; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs new file mode 100644 index 000000000..8fb7ce644 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/ALBFromQueryAttributeBuilder.cs @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class ALBFromQueryAttributeBuilder + { + public static ALB.FromQueryAttribute Build(AttributeData att) + { + var data = new ALB.FromQueryAttribute(); + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.Name) && pair.Value.Value is string value) + { + data.Name = value; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index 328a29ac5..add9e6c03 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,4 +1,5 @@ using System; +using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -30,7 +31,7 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FromQueryAttribute), SymbolEqualityComparer.Default)) { var data = FromQueryAttributeBuilder.Build(att); - model = new AttributeModel + model = new AttributeModel { Data = data, Type = TypeModelBuilder.Build(att.AttributeClass, context) @@ -39,7 +40,7 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FromHeaderAttribute), SymbolEqualityComparer.Default)) { var data = FromHeaderAttributeBuilder.Build(att); - model = new AttributeModel + model = new AttributeModel { Data = data, Type = TypeModelBuilder.Build(att.AttributeClass, context) @@ -108,6 +109,42 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBApiAttribute), SymbolEqualityComparer.Default)) + { + var data = ALBApiAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBFromQueryAttribute), SymbolEqualityComparer.Default)) + { + var data = ALBFromQueryAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBFromHeaderAttribute), SymbolEqualityComparer.Default)) + { + var data = ALBFromHeaderAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.ALBFromBodyAttribute), SymbolEqualityComparer.Default)) + { + var data = new ALB.FromBodyAttribute(); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else { model = new AttributeModel diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs index d231967e3..1b392572d 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs @@ -11,6 +11,7 @@ public enum EventType SQS, DynamoDB, Schedule, - Authorizer + Authorizer, + ALB } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3f5775851..06a2a0a1c 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -31,6 +31,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.Authorizer); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.ALBApiAttribute) + { + events.Add(EventType.ALB); + } } return events; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index decb864ee..2dcd58fe0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs @@ -130,6 +130,20 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol, throw new ArgumentOutOfRangeException(); } } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.ALBApiAttribute)) + { + // ALB functions return ApplicationLoadBalancerResponse + // If the user already returns ApplicationLoadBalancerResponse, pass through the return type. + // Otherwise, wrap in ApplicationLoadBalancerResponse. + if (lambdaMethodModel.ReturnsApplicationLoadBalancerResponse) + { + return lambdaMethodModel.ReturnType; + } + var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ? + task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse)): + context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse); + return TypeModelBuilder.Build(symbol, context); + } else { return lambdaMethodModel.ReturnType; @@ -277,6 +291,19 @@ private static IList BuildParameters(IMethodSymbol lambdaMethodS parameters.Add(requestParameter); parameters.Add(contextParameter); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.ALBApiAttribute)) + { + var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerRequest); + var type = TypeModelBuilder.Build(symbol, context); + var requestParameter = new ParameterModel + { + Name = "__request__", + Type = type, + Documentation = "The ALB request object that will be processed by the Lambda function handler." + }; + parameters.Add(requestParameter); + parameters.Add(contextParameter); + } else { // Lambda method with no event attribute are plain lambda functions, therefore, generated method will have diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs index df80c43e5..601e4d86e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/LambdaMethodModel.cs @@ -89,6 +89,31 @@ public bool ReturnsIAuthorizerResult } } + /// + /// Returns true if the Lambda function returns either ApplicationLoadBalancerResponse or Task<ApplicationLoadBalancerResponse> + /// + public bool ReturnsApplicationLoadBalancerResponse + { + get + { + if (ReturnsVoid) + { + return false; + } + + if (ReturnType.FullName == TypeFullNames.ApplicationLoadBalancerResponse) + { + return true; + } + if (ReturnsGenericTask && ReturnType.TypeArguments.Count == 1 && ReturnType.TypeArguments[0].FullName == TypeFullNames.ApplicationLoadBalancerResponse) + { + return true; + } + + return false; + } + } + /// /// Returns true if the Lambda function returns either void, Task, SQSBatchResponse or Task /// diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index a5d7ce9ab..ff6e2ee08 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -21,7 +21,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "RestApiAuthorizerAttribute", "RestApiAuthorizer" }, { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, - { "SQSEventAttribute", "SQSEvent" } + { "SQSEventAttribute", "SQSEvent" }, + { "ALBApiAttribute", "ALBApi" } }; public List LambdaMethods { get; } = new List(); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.cs new file mode 100644 index 000000000..a09bec840 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.cs @@ -0,0 +1,420 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + using System.Linq; + using System.Text; + using System.Collections.Generic; + using Amazon.Lambda.Annotations.SourceGenerator.Extensions; + using Amazon.Lambda.Annotations.SourceGenerator.Models; + using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; + using System; + + /// + /// Class to produce the template output + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public partial class ALBInvoke : ALBInvokeBase + { + /// + /// Create the template output + /// + public virtual string TransformText() + { + + if (_model.GeneratedMethod.ReturnType.FullName == _model.LambdaMethod.ReturnType.FullName) + { + // User already returns ApplicationLoadBalancerResponse (or Task), + // just pass through. + if (_model.LambdaMethod.ReturnsVoid) + { + + this.Write(" "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { + + this.Write(" await "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else + { + + this.Write(" var response = "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnsGenericTask ? "await " : "")); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n return response;\r\n"); + + } + } + else + { + // User returns a non-ALB type, we need to wrap in ApplicationLoadBalancerResponse + if (_model.LambdaMethod.ReturnsVoid) + { + + this.Write(" "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { + + this.Write(" await "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + } + else + { + + this.Write(" var response = "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnsGenericTask ? "await " : "")); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ContainingType.Name.ToCamelCase())); + this.Write("."); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.Name)); + this.Write("("); + this.Write(this.ToStringHelper.ToStringWithCulture(_parameterSignature)); + this.Write(");\r\n"); + + if (_model.LambdaMethod.ReturnType.IsValueType) + { + + this.Write("\r\n var body = response.ToString();\r\n"); + + } + else if (_model.LambdaMethod.ReturnType.IsString()) + { + // no action needed, response is already a string + } + else + { + + this.Write(" var memoryStream = new MemoryStream();\r\n" + + " serializer.Serialize(response, memoryStream);\r\n" + + " memoryStream.Position = 0;\r\n\r\n" + + " // convert stream to string\r\n" + + " StreamReader reader = new StreamReader( memoryStream );\r\n" + + " var body = reader.ReadToEnd();\r\n"); + + } + } + + this.Write("\r\n return new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse\r\n {\r\n"); + + if (!_model.LambdaMethod.ReturnsVoid && !_model.LambdaMethod.ReturnsVoidTask) + { + + this.Write(" Body = "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnType.IsString() ? "response" : "body")); + this.Write(",\r\n Headers = new Dictionary\r\n {\r\n {\"Content-Type\", "); + this.Write(this.ToStringHelper.ToStringWithCulture(_model.LambdaMethod.ReturnType.IsString() ? "\"text/plain\"" : "\"application/json\"")); + this.Write("}\r\n },\r\n"); + + } + + this.Write(" StatusCode = 200\r\n };\r\n"); + + } + + return this.GenerationEnvironment.ToString(); + } + } + + #region Base class + /// + /// Base class for this transformation + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public class ALBInvokeBase + { + #region Fields + private global::System.Text.StringBuilder generationEnvironmentField; + private global::System.CodeDom.Compiler.CompilerErrorCollection errorsField; + private global::System.Collections.Generic.List indentLengthsField; + private string currentIndentField = ""; + private bool endsWithNewline; + private global::System.Collections.Generic.IDictionary sessionField; + #endregion + #region Properties + /// + /// The string builder that generation-time code is using to assemble generated output + /// + public System.Text.StringBuilder GenerationEnvironment + { + get + { + if ((this.generationEnvironmentField == null)) + { + this.generationEnvironmentField = new global::System.Text.StringBuilder(); + } + return this.generationEnvironmentField; + } + set + { + this.generationEnvironmentField = value; + } + } + /// + /// The error collection for the generation process + /// + public System.CodeDom.Compiler.CompilerErrorCollection Errors + { + get + { + if ((this.errorsField == null)) + { + this.errorsField = new global::System.CodeDom.Compiler.CompilerErrorCollection(); + } + return this.errorsField; + } + } + /// + /// A list of the lengths of each indent that was added with PushIndent + /// + private System.Collections.Generic.List indentLengths + { + get + { + if ((this.indentLengthsField == null)) + { + this.indentLengthsField = new global::System.Collections.Generic.List(); + } + return this.indentLengthsField; + } + } + /// + /// Gets the current indent we use when adding lines to the output + /// + public string CurrentIndent + { + get + { + return this.currentIndentField; + } + } + /// + /// Current transformation session + /// + public virtual global::System.Collections.Generic.IDictionary Session + { + get + { + return this.sessionField; + } + set + { + this.sessionField = value; + } + } + #endregion + #region Transform-time helpers + /// + /// Write text directly into the generated output + /// + public void Write(string textToAppend) + { + if (string.IsNullOrEmpty(textToAppend)) + { + return; + } + if (((this.GenerationEnvironment.Length == 0) + || this.endsWithNewline)) + { + this.GenerationEnvironment.Append(this.currentIndentField); + this.endsWithNewline = false; + } + if (textToAppend.EndsWith(global::System.Environment.NewLine, global::System.StringComparison.CurrentCulture)) + { + this.endsWithNewline = true; + } + if ((this.currentIndentField.Length == 0)) + { + this.GenerationEnvironment.Append(textToAppend); + return; + } + textToAppend = textToAppend.Replace(global::System.Environment.NewLine, (global::System.Environment.NewLine + this.currentIndentField)); + if (this.endsWithNewline) + { + this.GenerationEnvironment.Append(textToAppend, 0, (textToAppend.Length - this.currentIndentField.Length)); + } + else + { + this.GenerationEnvironment.Append(textToAppend); + } + } + /// + /// Write text directly into the generated output + /// + public void WriteLine(string textToAppend) + { + this.Write(textToAppend); + this.GenerationEnvironment.AppendLine(); + this.endsWithNewline = true; + } + /// + /// Write formatted text directly into the generated output + /// + public void Write(string format, params object[] args) + { + this.Write(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Write formatted text directly into the generated output + /// + public void WriteLine(string format, params object[] args) + { + this.WriteLine(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Raise an error + /// + public void Error(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + this.Errors.Add(error); + } + /// + /// Raise a warning + /// + public void Warning(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + error.IsWarning = true; + this.Errors.Add(error); + } + /// + /// Increase the indent + /// + public void PushIndent(string indent) + { + if ((indent == null)) + { + throw new global::System.ArgumentNullException("indent"); + } + this.currentIndentField = (this.currentIndentField + indent); + this.indentLengths.Add(indent.Length); + } + /// + /// Remove the last indent that was added with PushIndent + /// + public string PopIndent() + { + string returnValue = ""; + if ((this.indentLengths.Count > 0)) + { + int indentLength = this.indentLengths[(this.indentLengths.Count - 1)]; + this.indentLengths.RemoveAt((this.indentLengths.Count - 1)); + if ((indentLength > 0)) + { + returnValue = this.currentIndentField.Substring((this.currentIndentField.Length - indentLength)); + this.currentIndentField = this.currentIndentField.Remove((this.currentIndentField.Length - indentLength)); + } + } + return returnValue; + } + /// + /// Remove any indentation + /// + public void ClearIndent() + { + this.indentLengths.Clear(); + this.currentIndentField = ""; + } + #endregion + #region ToString Helpers + /// + /// Utility class to produce culture-oriented representation of an object as a string. + /// + public class ToStringInstanceHelper + { + private System.IFormatProvider formatProviderField = global::System.Globalization.CultureInfo.InvariantCulture; + /// + /// Gets or sets format provider to be used by ToStringWithCulture method. + /// + public System.IFormatProvider FormatProvider + { + get + { + return this.formatProviderField ; + } + set + { + if ((value != null)) + { + this.formatProviderField = value; + } + } + } + /// + /// This is called from the compile/run appdomain to convert objects within an expression block to a string + /// + public string ToStringWithCulture(object objectToConvert) + { + if ((objectToConvert == null)) + { + throw new global::System.ArgumentNullException("objectToConvert"); + } + System.Type t = objectToConvert.GetType(); + System.Reflection.MethodInfo method = t.GetMethod("ToString", new System.Type[] { + typeof(System.IFormatProvider)}); + if ((method == null)) + { + return objectToConvert.ToString(); + } + else + { + return ((string)(method.Invoke(objectToConvert, new object[] { + this.formatProviderField }))); + } + } + } + private ToStringInstanceHelper toStringHelperField = new ToStringInstanceHelper(); + /// + /// Helper to produce culture-oriented representation of an object as a string + /// + public ToStringInstanceHelper ToStringHelper + { + get + { + return this.toStringHelperField; + } + } + #endregion + } + #endregion +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt new file mode 100644 index 000000000..e4a8a32fb --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvoke.tt @@ -0,0 +1,98 @@ +<#@ template language="C#" #> +<#@ assembly name="System.Core" #> +<#@ import namespace="System.Linq" #> +<#@ import namespace="System.Text" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Extensions" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes" #> +<# + if (_model.GeneratedMethod.ReturnType.FullName == _model.LambdaMethod.ReturnType.FullName) + { + // User already returns ApplicationLoadBalancerResponse (or Task), + // just pass through. + if (_model.LambdaMethod.ReturnsVoid) + { +#> + <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { +#> + await <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else + { +#> + var response = <#= _model.LambdaMethod.ReturnsGenericTask ? "await " : "" #><#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); + return response; +<# + } + } + else + { + // User returns a non-ALB type, we need to wrap in ApplicationLoadBalancerResponse + if (_model.LambdaMethod.ReturnsVoid) + { +#> + <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else if (_model.LambdaMethod.ReturnsVoidTask) + { +#> + await <#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + } + else + { +#> + var response = <#= _model.LambdaMethod.ReturnsGenericTask ? "await " : "" #><#= _model.LambdaMethod.ContainingType.Name.ToCamelCase() #>.<#= _model.LambdaMethod.Name #>(<#= _parameterSignature #>); +<# + if (_model.LambdaMethod.ReturnType.IsValueType) + { +#> + + var body = response.ToString(); +<# + } + else if (_model.LambdaMethod.ReturnType.IsString()) + { + // no action needed, response is already a string + } + else + { +#> + var memoryStream = new MemoryStream(); + serializer.Serialize(response, memoryStream); + memoryStream.Position = 0; + + // convert stream to string + StreamReader reader = new StreamReader( memoryStream ); + var body = reader.ReadToEnd(); +<# + } + } +#> + + return new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse + { +<# + if (!_model.LambdaMethod.ReturnsVoid && !_model.LambdaMethod.ReturnsVoidTask) + { +#> + Body = <#= _model.LambdaMethod.ReturnType.IsString() ? "response" : "body" #>, + Headers = new Dictionary + { + {"Content-Type", <#= _model.LambdaMethod.ReturnType.IsString() ? "\"text/plain\"" : "\"application/json\"" #>} + }, +<# + } +#> + StatusCode = 200 + }; +<# + } +#> diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs new file mode 100644 index 000000000..04076566c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBInvokeCode.cs @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SourceGenerator.Models; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + public partial class ALBInvoke + { + private readonly LambdaFunctionModel _model; + + public readonly string _parameterSignature; + + public ALBInvoke(LambdaFunctionModel model, string parameterSignature) + { + _model = model; + _parameterSignature = parameterSignature; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs new file mode 100644 index 000000000..a6ce865bc --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.cs @@ -0,0 +1,604 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + using System.Linq; + using System.Text; + using System.Collections.Generic; + using Amazon.Lambda.Annotations.SourceGenerator.Extensions; + using Amazon.Lambda.Annotations.SourceGenerator.Models; + using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; + using System; + + /// + /// Class to produce the template output + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public partial class ALBSetupParameters : ALBSetupParametersBase + { + /// + /// Create the template output + /// + public virtual string TransformText() + { + + ParameterSignature = string.Join(", ", _model.LambdaMethod.Parameters + .Select(p => + { + // Pass the same context parameter for ILambdaContext that comes from the generated method. + if (p.Type.FullName == TypeFullNames.ILambdaContext) + { + return "__context__"; + } + + // Pass the same request parameter for ALB Request Type that comes from the generated method. + if (TypeFullNames.ALBRequests.Contains(p.Type.FullName)) + { + return "__request__"; + } + + return p.Name; + })); + + var albApiAttribute = _model.LambdaMethod.Attributes.FirstOrDefault(att => att.Type.FullName == TypeFullNames.ALBApiAttribute) as AttributeModel; + + // Determine whether multi-value headers are enabled + var useMultiValue = albApiAttribute?.Data?.IsMultiValueHeadersSet == true && albApiAttribute.Data.MultiValueHeaders; + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { + + this.Write(" var validationErrors = new List();\r\n\r\n"); + + } + + foreach (var parameter in _model.LambdaMethod.Parameters) + { + if (parameter.Type.FullName == TypeFullNames.ILambdaContext || TypeFullNames.ALBRequests.Contains(parameter.Type.FullName)) + { + // No action required for ILambdaContext and ALB RequestType, they are passed from the generated method parameter directly to the original method. + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromServiceAttribute)) + { + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = scope.ServiceProvider.GetRequiredService<"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(">();\r\n"); + + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute)) + { + var fromQueryAttribute = parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute) as AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var parameterKey = fromQueryAttribute?.Data?.Name ?? parameter.Name; + + var queryStringParameters = useMultiValue ? "MultiValueQueryStringParameters" : "QueryStringParameters"; + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = default("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(");\r\n"); + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueQueryStringParameters is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("?.ContainsKey(\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\") == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"]\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + else + { + // Single-value mode: QueryStringParameters is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("?.ContainsKey(\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\") == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"].Split(\",\")\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + } + else + { + // Non-generic types are mapped directly to the target parameter. + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("?.ContainsKey(\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\") == true)\r\n {\r\n try\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = ("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(")Convert.ChangeType(__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"], typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(queryStringParameters)); + this.Write("[\""); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\"]} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameterKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n }\r\n }\r\n\r\n"); + + } + + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute)) + { + var fromHeaderAttribute = + parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute) as + AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var headerKey = fromHeaderAttribute?.Data?.Name ?? parameter.Name; + + var headers = useMultiValue ? "MultiValueHeaders" : "Headers"; + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = default("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(");\r\n"); + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueHeaders is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write("?.Any(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)) == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + else + { + // Single-value mode: Headers is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write("?.Any(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)) == true)\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value.Split(\",\")\r\n .Select(q =>\r\n {\r\n try\r\n {\r\n return ("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullName)); + this.Write(")Convert.ChangeType(q, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(typeArgument.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {q} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n return default;\r\n }\r\n })\r\n .ToList();\r\n }\r\n\r\n"); + + } + } + else + { + // Non-generic types are mapped directly to the target parameter. + this.Write(" if (__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write("?.Any(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)) == true)\r\n {\r\n try\r\n {\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = ("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(")Convert.ChangeType(__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value, typeof("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullNameWithoutAnnotations)); + this.Write("));\r\n }\r\n catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException)\r\n {\r\n validationErrors.Add($\"Value {__request__."); + this.Write(this.ToStringHelper.ToStringWithCulture(headers)); + this.Write(".First(x => string.Equals(x.Key, \""); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\", StringComparison.OrdinalIgnoreCase)).Value} at \'"); + this.Write(this.ToStringHelper.ToStringWithCulture(headerKey)); + this.Write("\' failed to satisfy constraint: {e.Message}\");\r\n }\r\n }\r\n\r\n"); + + } + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromBodyAttribute)) + { + // string parameter does not need to be de-serialized + if (parameter.Type.IsString()) + { + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = __request__.Body;\r\n\r\n"); + + } + else + { + + this.Write(" var "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = default("); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(");\r\n try\r\n {\r\n // convert string to stream\r\n var byteArray = Encoding.UTF8.GetBytes(__request__.Body);\r\n var stream = new MemoryStream(byteArray);\r\n "); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Name)); + this.Write(" = serializer.Deserialize<"); + this.Write(this.ToStringHelper.ToStringWithCulture(parameter.Type.FullName)); + this.Write(">(stream);\r\n }\r\n catch (Exception e)\r\n {\r\n validationErrors.Add($\"Value {__request__.Body} at \'body\' failed to satisfy constraint: {e.Message}\");\r\n }\r\n\r\n"); + + } + } + else + { + throw new NotSupportedException($"{parameter.Name} parameter of type {parameter.Type.FullName} passing is not supported for ALB functions. Use [FromHeader], [FromQuery], [FromBody], or [FromServices] attributes."); + } + } + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { + + this.Write(" // return 400 Bad Request if there exists a validation error\r\n" + + " if (validationErrors.Any())\r\n" + + " {\r\n" + + " var errorResult = new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse\r\n" + + " {\r\n" + + " Body = @$\"{{\"\"message\"\": \"\"{validationErrors.Count} validation error(s) detected: {string.Join(\",\", validationErrors)}\"\"}}\",\r\n" + + " Headers = new Dictionary\r\n" + + " {\r\n" + + " {\"Content-Type\", \"application/json\"}\r\n" + + " },\r\n" + + " StatusCode = 400\r\n" + + " };\r\n" + + " return errorResult;\r\n" + + " }\r\n\r\n"); + + } + + return this.GenerationEnvironment.ToString(); + } + } + + #region Base class + /// + /// Base class for this transformation + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "18.0.0.0")] + public class ALBSetupParametersBase + { + #region Fields + private global::System.Text.StringBuilder generationEnvironmentField; + private global::System.CodeDom.Compiler.CompilerErrorCollection errorsField; + private global::System.Collections.Generic.List indentLengthsField; + private string currentIndentField = ""; + private bool endsWithNewline; + private global::System.Collections.Generic.IDictionary sessionField; + #endregion + #region Properties + /// + /// The string builder that generation-time code is using to assemble generated output + /// + public System.Text.StringBuilder GenerationEnvironment + { + get + { + if ((this.generationEnvironmentField == null)) + { + this.generationEnvironmentField = new global::System.Text.StringBuilder(); + } + return this.generationEnvironmentField; + } + set + { + this.generationEnvironmentField = value; + } + } + /// + /// The error collection for the generation process + /// + public System.CodeDom.Compiler.CompilerErrorCollection Errors + { + get + { + if ((this.errorsField == null)) + { + this.errorsField = new global::System.CodeDom.Compiler.CompilerErrorCollection(); + } + return this.errorsField; + } + } + /// + /// A list of the lengths of each indent that was added with PushIndent + /// + private System.Collections.Generic.List indentLengths + { + get + { + if ((this.indentLengthsField == null)) + { + this.indentLengthsField = new global::System.Collections.Generic.List(); + } + return this.indentLengthsField; + } + } + /// + /// Gets the current indent we use when adding lines to the output + /// + public string CurrentIndent + { + get + { + return this.currentIndentField; + } + } + /// + /// Current transformation session + /// + public virtual global::System.Collections.Generic.IDictionary Session + { + get + { + return this.sessionField; + } + set + { + this.sessionField = value; + } + } + #endregion + #region Transform-time helpers + /// + /// Write text directly into the generated output + /// + public void Write(string textToAppend) + { + if (string.IsNullOrEmpty(textToAppend)) + { + return; + } + if (((this.GenerationEnvironment.Length == 0) + || this.endsWithNewline)) + { + this.GenerationEnvironment.Append(this.currentIndentField); + this.endsWithNewline = false; + } + if (textToAppend.EndsWith(global::System.Environment.NewLine, global::System.StringComparison.CurrentCulture)) + { + this.endsWithNewline = true; + } + if ((this.currentIndentField.Length == 0)) + { + this.GenerationEnvironment.Append(textToAppend); + return; + } + textToAppend = textToAppend.Replace(global::System.Environment.NewLine, (global::System.Environment.NewLine + this.currentIndentField)); + if (this.endsWithNewline) + { + this.GenerationEnvironment.Append(textToAppend, 0, (textToAppend.Length - this.currentIndentField.Length)); + } + else + { + this.GenerationEnvironment.Append(textToAppend); + } + } + /// + /// Write text directly into the generated output + /// + public void WriteLine(string textToAppend) + { + this.Write(textToAppend); + this.GenerationEnvironment.AppendLine(); + this.endsWithNewline = true; + } + /// + /// Write formatted text directly into the generated output + /// + public void Write(string format, params object[] args) + { + this.Write(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Write formatted text directly into the generated output + /// + public void WriteLine(string format, params object[] args) + { + this.WriteLine(string.Format(global::System.Globalization.CultureInfo.CurrentCulture, format, args)); + } + /// + /// Raise an error + /// + public void Error(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + this.Errors.Add(error); + } + /// + /// Raise a warning + /// + public void Warning(string message) + { + System.CodeDom.Compiler.CompilerError error = new global::System.CodeDom.Compiler.CompilerError(); + error.ErrorText = message; + error.IsWarning = true; + this.Errors.Add(error); + } + /// + /// Increase the indent + /// + public void PushIndent(string indent) + { + if ((indent == null)) + { + throw new global::System.ArgumentNullException("indent"); + } + this.currentIndentField = (this.currentIndentField + indent); + this.indentLengths.Add(indent.Length); + } + /// + /// Remove the last indent that was added with PushIndent + /// + public string PopIndent() + { + string returnValue = ""; + if ((this.indentLengths.Count > 0)) + { + int indentLength = this.indentLengths[(this.indentLengths.Count - 1)]; + this.indentLengths.RemoveAt((this.indentLengths.Count - 1)); + if ((indentLength > 0)) + { + returnValue = this.currentIndentField.Substring((this.currentIndentField.Length - indentLength)); + this.currentIndentField = this.currentIndentField.Remove((this.currentIndentField.Length - indentLength)); + } + } + return returnValue; + } + /// + /// Remove any indentation + /// + public void ClearIndent() + { + this.indentLengths.Clear(); + this.currentIndentField = ""; + } + #endregion + #region ToString Helpers + /// + /// Utility class to produce culture-oriented representation of an object as a string. + /// + public class ToStringInstanceHelper + { + private System.IFormatProvider formatProviderField = global::System.Globalization.CultureInfo.InvariantCulture; + /// + /// Gets or sets format provider to be used by ToStringWithCulture method. + /// + public System.IFormatProvider FormatProvider + { + get + { + return this.formatProviderField ; + } + set + { + if ((value != null)) + { + this.formatProviderField = value; + } + } + } + /// + /// This is called from the compile/run appdomain to convert objects within an expression block to a string + /// + public string ToStringWithCulture(object objectToConvert) + { + if ((objectToConvert == null)) + { + throw new global::System.ArgumentNullException("objectToConvert"); + } + System.Type t = objectToConvert.GetType(); + System.Reflection.MethodInfo method = t.GetMethod("ToString", new System.Type[] { + typeof(System.IFormatProvider)}); + if ((method == null)) + { + return objectToConvert.ToString(); + } + else + { + return ((string)(method.Invoke(objectToConvert, new object[] { + this.formatProviderField }))); + } + } + } + private ToStringInstanceHelper toStringHelperField = new ToStringInstanceHelper(); + /// + /// Helper to produce culture-oriented representation of an object as a string + /// + public ToStringInstanceHelper ToStringHelper + { + get + { + return this.toStringHelperField; + } + } + #endregion + } + #endregion +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt new file mode 100644 index 000000000..c90cff8b6 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParameters.tt @@ -0,0 +1,303 @@ +<#@ template language="C#" #> +<#@ assembly name="System.Core" #> +<#@ import namespace="System.Linq" #> +<#@ import namespace="System.Text" #> +<#@ import namespace="System.Collections.Generic" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Extensions" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models" #> +<#@ import namespace="Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes" #> +<# + ParameterSignature = string.Join(", ", _model.LambdaMethod.Parameters + .Select(p => + { + // Pass the same context parameter for ILambdaContext that comes from the generated method. + if (p.Type.FullName == TypeFullNames.ILambdaContext) + { + return "__context__"; + } + + // Pass the same request parameter for ALB Request Type that comes from the generated method. + if (TypeFullNames.ALBRequests.Contains(p.Type.FullName)) + { + return "__request__"; + } + + return p.Name; + })); + + var albApiAttribute = _model.LambdaMethod.Attributes.FirstOrDefault(att => att.Type.FullName == TypeFullNames.ALBApiAttribute) as AttributeModel; + + // Determine whether multi-value headers are enabled + var useMultiValue = albApiAttribute?.Data?.IsMultiValueHeadersSet == true && albApiAttribute.Data.MultiValueHeaders; + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { +#> + var validationErrors = new List(); + +<# + } + + foreach (var parameter in _model.LambdaMethod.Parameters) + { + if (parameter.Type.FullName == TypeFullNames.ILambdaContext || TypeFullNames.ALBRequests.Contains(parameter.Type.FullName)) + { + // No action required for ILambdaContext and ALB RequestType, they are passed from the generated method parameter directly to the original method. + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromServiceAttribute)) + { +#> + var <#= parameter.Name #> = scope.ServiceProvider.GetRequiredService<<#= parameter.Type.FullName #>>(); +<# + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute)) + { + var fromQueryAttribute = parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute) as AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var parameterKey = fromQueryAttribute?.Data?.Name ?? parameter.Name; + + var queryStringParameters = useMultiValue ? "MultiValueQueryStringParameters" : "QueryStringParameters"; + +#> + var <#= parameter.Name #> = default(<#= parameter.Type.FullName #>); +<# + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueQueryStringParameters is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= queryStringParameters #>?.ContainsKey("<#= parameterKey #>") == true) + { + <#= parameter.Name #> = __request__.<#= queryStringParameters #>["<#= parameterKey #>"] + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= parameterKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + else + { + // Single-value mode: QueryStringParameters is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= queryStringParameters #>?.ContainsKey("<#= parameterKey #>") == true) + { + <#= parameter.Name #> = __request__.<#= queryStringParameters #>["<#= parameterKey #>"].Split(",") + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= parameterKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + } + else + { + // Non-generic types are mapped directly to the target parameter. +#> + if (__request__.<#= queryStringParameters #>?.ContainsKey("<#= parameterKey #>") == true) + { + try + { + <#= parameter.Name #> = (<#= parameter.Type.FullName #>)Convert.ChangeType(__request__.<#= queryStringParameters #>["<#= parameterKey #>"], typeof(<#= parameter.Type.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.<#= queryStringParameters #>["<#= parameterKey #>"]} at '<#= parameterKey #>' failed to satisfy constraint: {e.Message}"); + } + } + +<# + } + + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute)) + { + var fromHeaderAttribute = + parameter.Attributes.First(att => att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute) as + AttributeModel; + + // Use parameter name as key, if Name has not specified explicitly in the attribute definition. + var headerKey = fromHeaderAttribute?.Data?.Name ?? parameter.Name; + + var headers = useMultiValue ? "MultiValueHeaders" : "Headers"; + +#> + var <#= parameter.Name #> = default(<#= parameter.Type.FullName #>); +<# + + if (parameter.Type.IsEnumerable && parameter.Type.IsGenericType) + { + if (useMultiValue) + { + // Multi-value mode: MultiValueHeaders is IDictionary> + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= headers #>?.Any(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)) == true) + { + <#= parameter.Name #> = __request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= headerKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + else + { + // Single-value mode: Headers is IDictionary + // Split by comma to support multiple values + if (parameter.Type.TypeArguments.Count != 1) + { + throw new NotSupportedException("Only one type argument is supported for generic types."); + } + + var typeArgument = parameter.Type.TypeArguments.First(); +#> + if (__request__.<#= headers #>?.Any(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)) == true) + { + <#= parameter.Name #> = __request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value.Split(",") + .Select(q => + { + try + { + return (<#= typeArgument.FullName #>)Convert.ChangeType(q, typeof(<#= typeArgument.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {q} at '<#= headerKey #>' failed to satisfy constraint: {e.Message}"); + return default; + } + }) + .ToList(); + } + +<# + } + } + else + { + // Non-generic types are mapped directly to the target parameter. +#> + if (__request__.<#= headers #>?.Any(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)) == true) + { + try + { + <#= parameter.Name #> = (<#= parameter.Type.FullName #>)Convert.ChangeType(__request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value, typeof(<#= parameter.Type.FullNameWithoutAnnotations #>)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.<#= headers #>.First(x => string.Equals(x.Key, "<#= headerKey #>", StringComparison.OrdinalIgnoreCase)).Value} at '<#= headerKey #>' failed to satisfy constraint: {e.Message}"); + } + } + +<# + } + } + else if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromBodyAttribute)) + { + // string parameter does not need to be de-serialized + if (parameter.Type.IsString()) + { + #> + var <#= parameter.Name #> = __request__.Body; + +<# + } + else + { + #> + var <#= parameter.Name #> = default(<#= parameter.Type.FullName #>); + try + { + // convert string to stream + var byteArray = Encoding.UTF8.GetBytes(__request__.Body); + var stream = new MemoryStream(byteArray); + <#= parameter.Name #> = serializer.Deserialize<<#= parameter.Type.FullName #>>(stream); + } + catch (Exception e) + { + validationErrors.Add($"Value {__request__.Body} at 'body' failed to satisfy constraint: {e.Message}"); + } + +<# + } + } + else + { + throw new NotSupportedException($"{parameter.Name} parameter of type {parameter.Type.FullName} passing is not supported for ALB functions. Use [FromHeader], [FromQuery], [FromBody], or [FromServices] attributes."); + } + } + + if (_model.LambdaMethod.Parameters.HasConvertibleParameter()) + { +#> + // return 400 Bad Request if there exists a validation error + if (validationErrors.Any()) + { + var errorResult = new Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse + { + Body = @$"{{""message"": ""{validationErrors.Count} validation error(s) detected: {string.Join(",", validationErrors)}""}}", + Headers = new Dictionary + { + {"Content-Type", "application/json"} + }, + StatusCode = 400 + }; + return errorResult; + } + +<# + } +#> diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs new file mode 100644 index 000000000..678f28859 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/ALBSetupParametersCode.cs @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SourceGenerator.Models; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Templates +{ + public partial class ALBSetupParameters + { + private readonly LambdaFunctionModel _model; + + public string ParameterSignature { get; set; } + + public ALBSetupParameters(LambdaFunctionModel model) + { + _model = model; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs index e2c2f957f..6e4a30347 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.cs @@ -188,6 +188,12 @@ public virtual string TransformText() this.Write(apiParameters.TransformText()); this.Write(new APIGatewayInvoke(_model, apiParameters.ParameterSignature).TransformText()); } + else if (_model.LambdaMethod.Events.Contains(EventType.ALB)) + { + var albParameters = new ALBSetupParameters(_model); + this.Write(albParameters.TransformText()); + this.Write(new ALBInvoke(_model, albParameters.ParameterSignature).TransformText()); + } else { this.Write(new NoEventMethodBody(_model).TransformText()); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt index bacf7daf0..aa3c3ab18 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Templates/LambdaFunctionTemplate.tt @@ -66,6 +66,12 @@ this.Write(new FieldsAndConstructor(_model).TransformText()); this.Write(apiParameters.TransformText()); this.Write(new APIGatewayInvoke(_model, apiParameters.ParameterSignature).TransformText()); } + else if (_model.LambdaMethod.Events.Contains(EventType.ALB)) + { + var albParameters = new ALBSetupParameters(_model); + this.Write(albParameters.TransformText()); + this.Write(new ALBInvoke(_model, albParameters.ParameterSignature).TransformText()); + } else { this.Write(new NoEventMethodBody(_model).TransformText()); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 6e15c2175..76871445e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -46,6 +46,13 @@ public static class TypeFullNames public const string SQSBatchResponse = "Amazon.Lambda.SQSEvents.SQSBatchResponse"; public const string SQSEventAttribute = "Amazon.Lambda.Annotations.SQS.SQSEventAttribute"; + public const string ApplicationLoadBalancerRequest = "Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerRequest"; + public const string ApplicationLoadBalancerResponse = "Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse"; + public const string ALBApiAttribute = "Amazon.Lambda.Annotations.ALB.ALBApiAttribute"; + public const string ALBFromQueryAttribute = "Amazon.Lambda.Annotations.ALB.FromQueryAttribute"; + public const string ALBFromHeaderAttribute = "Amazon.Lambda.Annotations.ALB.FromHeaderAttribute"; + public const string ALBFromBodyAttribute = "Amazon.Lambda.Annotations.ALB.FromBodyAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -63,11 +70,17 @@ public static class TypeFullNames APIGatewayCustomAuthorizerRequest }; + public static HashSet ALBRequests = new HashSet + { + ApplicationLoadBalancerRequest + }; + public static HashSet Events = new HashSet { RestApiAttribute, HttpApiAttribute, - SQSEventAttribute + SQSEventAttribute, + ALBApiAttribute }; } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 733124209..c496ac3bf 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -1,4 +1,5 @@ -using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; @@ -59,6 +60,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } @@ -86,6 +88,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.ApplicationLoadBalancerEvents" if the Lambda method is annotated with ALBApi attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.ALBApiAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.ApplicationLoadBalancerEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.ApplicationLoadBalancerEvents")); + return false; + } + } + return true; } @@ -106,10 +118,12 @@ private static void ValidateApiGatewayEvents(LambdaFunctionModel lambdaFunctionM diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.AuthorizerResultOnNonAuthorizerFunction, methodLocation)); } - // If the method does not contain any API or Authorizer events, then it cannot have + // If the method does not contain any API, Authorizer, or ALB events, then it cannot have // parameters that are annotated with HTTP API attributes. // Authorizer functions also support FromHeader, FromQuery, FromRoute attributes. - if (!isApiEvent && !isAuthorizerEvent) + // ALB functions also support FromHeader, FromQuery, FromBody attributes. + var isAlbEvent = lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.ALB); + if (!isApiEvent && !isAuthorizerEvent && !isAlbEvent) { foreach (var parameter in lambdaFunctionModel.LambdaMethod.Parameters) { @@ -268,6 +282,86 @@ private static void ValidateSqsEvents(LambdaFunctionModel lambdaFunctionModel, L } } + private static void ValidateAlbEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + // If the method does not contain any ALB events, then simply return early + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.ALB)) + { + return; + } + + // Validate ALBApiAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.ALBApiAttribute) + continue; + + var albApiAttribute = ((AttributeModel)att).Data; + var validationErrors = albApiAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidAlbApiAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + foreach (var parameter in parameters) + { + // [FromRoute] is not supported on ALB functions + if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.FromRouteAttribute)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.FromRouteNotSupportedOnAlb, methodLocation)); + } + + // Validate [FromQuery] parameter types - only primitive types allowed + if (parameter.Attributes.Any(att => att.Type.FullName == TypeFullNames.ALBFromQueryAttribute)) + { + if (!parameter.Type.IsPrimitiveType() && !parameter.Type.IsPrimitiveEnumerableType()) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.UnsupportedMethodParameterType, methodLocation, parameter.Name, parameter.Type.FullName)); + } + } + + // Validate attribute names for FromQuery and FromHeader + foreach (var att in parameter.Attributes) + { + var parameterAttributeName = string.Empty; + switch (att.Type.FullName) + { + case TypeFullNames.ALBFromQueryAttribute: + if (att is AttributeModel albFromQueryAttribute) + parameterAttributeName = albFromQueryAttribute.Data.Name; + break; + + case TypeFullNames.ALBFromHeaderAttribute: + if (att is AttributeModel albFromHeaderAttribute) + parameterAttributeName = albFromHeaderAttribute.Data.Name; + break; + + default: + break; + } + + if (!string.IsNullOrEmpty(parameterAttributeName) && !_parameterAttributeNameRegex.IsMatch(parameterAttributeName)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidParameterAttributeName, methodLocation, parameterAttributeName, parameter.Name)); + } + } + + // Validate that every parameter has a recognized binding + // Allowed: ILambdaContext, ApplicationLoadBalancerRequest, [FromServices], [FromQuery], [FromHeader], [FromBody] + if (parameter.Type.FullName != TypeFullNames.ILambdaContext && + !TypeFullNames.ALBRequests.Contains(parameter.Type.FullName) && + !parameter.Attributes.Any(att => + att.Type.FullName == TypeFullNames.FromServiceAttribute || + att.Type.FullName == TypeFullNames.ALBFromQueryAttribute || + att.Type.FullName == TypeFullNames.ALBFromHeaderAttribute || + att.Type.FullName == TypeFullNames.ALBFromBodyAttribute || + att.Type.FullName == TypeFullNames.FromRouteAttribute)) // FromRoute already has its own error + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.AlbUnmappedParameter, methodLocation, parameter.Name)); + } + } + } + private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List diagnostics) { var isValid = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a59aaf6d4..c384b7b48 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -1,4 +1,5 @@ -using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; @@ -203,6 +204,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la { var currentSyncedEvents = new List(); var currentSyncedEventProperties = new Dictionary>(); + var currentAlbResources = new List(); foreach (var attributeModel in lambdaFunction.Attributes) { @@ -221,10 +223,15 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessSqsAttribute(lambdaFunction, sqsAttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel albAttributeModel: + var albResourceNames = ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data); + currentAlbResources.AddRange(albResourceNames); + break; } } SynchronizeEventsAndProperties(currentSyncedEvents, currentSyncedEventProperties, lambdaFunction); + SynchronizeAlbResources(currentAlbResources, lambdaFunction); } /// @@ -597,8 +604,237 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S } /// - /// Writes all properties associated with to the serverless template. + /// Generates CloudFormation resources for an Application Load Balancer target. + /// Unlike API Gateway events which map to SAM event types, ALB integration requires + /// generating standalone CloudFormation resources: a TargetGroup, a ListenerRule, and a Lambda Permission. /// + /// List of the three generated CloudFormation resource names for tracking/synchronization. + private List ProcessAlbApiAttribute(ILambdaFunctionSerializable lambdaFunction, ALBApiAttribute att) + { + var baseName = att.IsResourceNameSet ? att.ResourceName : $"{lambdaFunction.ResourceName}ALB"; + var permissionName = $"{baseName}Permission"; + var targetGroupName = $"{baseName}TargetGroup"; + var listenerRuleName = $"{baseName}ListenerRule"; + + // 1. Lambda Permission - allows ELB to invoke the Lambda function + var permPath = $"Resources.{permissionName}"; + if (!_templateWriter.Exists(permPath) || + string.Equals(_templateWriter.GetToken($"{permPath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.SetToken($"{permPath}.Type", "AWS::Lambda::Permission"); + _templateWriter.SetToken($"{permPath}.Metadata.Tool", CREATION_TOOL); + _templateWriter.SetToken($"{permPath}.Properties.FunctionName.{GET_ATTRIBUTE}", new List { lambdaFunction.ResourceName, "Arn" }, TokenType.List); + _templateWriter.SetToken($"{permPath}.Properties.Action", "lambda:InvokeFunction"); + _templateWriter.SetToken($"{permPath}.Properties.Principal", "elasticloadbalancing.amazonaws.com"); + } + + // 2. Target Group - registers the Lambda function as a target + var tgPath = $"Resources.{targetGroupName}"; + if (!_templateWriter.Exists(tgPath) || + string.Equals(_templateWriter.GetToken($"{tgPath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.SetToken($"{tgPath}.Type", "AWS::ElasticLoadBalancingV2::TargetGroup"); + _templateWriter.SetToken($"{tgPath}.Metadata.Tool", CREATION_TOOL); + _templateWriter.SetToken($"{tgPath}.DependsOn", permissionName); + _templateWriter.SetToken($"{tgPath}.Properties.TargetType", "lambda"); + + // MultiValueHeaders must be set via TargetGroupAttributes, not as a top-level property. + // The CFN property "MultiValueHeadersEnabled" does not exist on AWS::ElasticLoadBalancingV2::TargetGroup. + if (att.MultiValueHeaders) + { + _templateWriter.SetToken($"{tgPath}.Properties.TargetGroupAttributes", + new List> + { + new Dictionary + { + { "Key", "lambda.multi_value_headers.enabled" }, + { "Value", "true" } + } + }, TokenType.List); + } + else + { + _templateWriter.RemoveToken($"{tgPath}.Properties.TargetGroupAttributes"); + } + + _templateWriter.SetToken($"{tgPath}.Properties.Targets", new List> + { + new Dictionary + { + { "Id", new Dictionary> { { GET_ATTRIBUTE, new List { lambdaFunction.ResourceName, "Arn" } } } } + } + }, TokenType.List); + } + + // 3. Listener Rule - routes traffic from the ALB listener to the target group + var rulePath = $"Resources.{listenerRuleName}"; + if (!_templateWriter.Exists(rulePath) || + string.Equals(_templateWriter.GetToken($"{rulePath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.SetToken($"{rulePath}.Type", "AWS::ElasticLoadBalancingV2::ListenerRule"); + _templateWriter.SetToken($"{rulePath}.Metadata.Tool", CREATION_TOOL); + + // ListenerArn - handle @reference vs literal ARN + _templateWriter.RemoveToken($"{rulePath}.Properties.ListenerArn"); + if (!string.IsNullOrEmpty(att.ListenerArn) && att.ListenerArn.StartsWith("@")) + { + var refName = att.ListenerArn.Substring(1); + _templateWriter.SetToken($"{rulePath}.Properties.ListenerArn.{REF}", refName); + + // Warn if the referenced resource/parameter doesn't exist in the template + if (!_templateWriter.Exists($"Resources.{refName}") && !_templateWriter.Exists($"{PARAMETERS}.{refName}")) + { + _diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.AlbListenerReferenceNotFound, Location.None, refName)); + } + } + else + { + _templateWriter.SetToken($"{rulePath}.Properties.ListenerArn", att.ListenerArn); + } + + // Priority + _templateWriter.SetToken($"{rulePath}.Properties.Priority", att.Priority); + + // Conditions + var conditions = new List> + { + new Dictionary + { + { "Field", "path-pattern" }, + { "PathPatternConfig", new Dictionary + { + { "Values", new List { att.PathPattern } } + } + } + } + }; + if (!string.IsNullOrEmpty(att.HostHeader)) + { + conditions.Add(new Dictionary + { + { "Field", "host-header" }, + { "HostHeaderConfig", new Dictionary + { + { "Values", new List { att.HostHeader } } + } + } + }); + } + if (!string.IsNullOrEmpty(att.HttpMethod)) + { + conditions.Add(new Dictionary + { + { "Field", "http-request-method" }, + { "HttpRequestMethodConfig", new Dictionary + { + { "Values", new List { att.HttpMethod.ToUpper() } } + } + } + }); + } + if (!string.IsNullOrEmpty(att.HttpHeaderConditionName) && att.HttpHeaderConditionValues != null && att.HttpHeaderConditionValues.Length > 0) + { + conditions.Add(new Dictionary + { + { "Field", "http-header" }, + { "HttpHeaderConfig", new Dictionary + { + { "HttpHeaderName", att.HttpHeaderConditionName }, + { "Values", att.HttpHeaderConditionValues.ToList() } + } + } + }); + } + if (att.QueryStringConditions != null && att.QueryStringConditions.Length > 0) + { + var keyValuePairs = new List>(); + foreach (var entry in att.QueryStringConditions) + { + var separatorIndex = entry.IndexOf('='); + if (separatorIndex >= 0) + { + var key = entry.Substring(0, separatorIndex); + var value = entry.Substring(separatorIndex + 1); + var kvp = new Dictionary(); + if (!string.IsNullOrEmpty(key)) + { + kvp["Key"] = key; + } + kvp["Value"] = value; + keyValuePairs.Add(kvp); + } + } + if (keyValuePairs.Any()) + { + conditions.Add(new Dictionary + { + { "Field", "query-string" }, + { "QueryStringConfig", new Dictionary + { + { "Values", keyValuePairs } + } + } + }); + } + } + if (att.SourceIpConditions != null && att.SourceIpConditions.Length > 0) + { + conditions.Add(new Dictionary + { + { "Field", "source-ip" }, + { "SourceIpConfig", new Dictionary + { + { "Values", att.SourceIpConditions.ToList() } + } + } + }); + } + _templateWriter.SetToken($"{rulePath}.Properties.Conditions", conditions, TokenType.List); + + // Actions - forward to target group + _templateWriter.SetToken($"{rulePath}.Properties.Actions", new List> + { + new Dictionary + { + { "Type", "forward" }, + { "TargetGroupArn", new Dictionary { { REF, targetGroupName } } } + } + }, TokenType.List); + } + + return new List { permissionName, targetGroupName, listenerRuleName }; + } + + /// + /// Synchronizes ALB resources for a given Lambda function. ALB resources (Permission, TargetGroup, ListenerRule) + /// are standalone top-level CloudFormation resources, so they need separate tracking from SAM events. + /// Previously generated ALB resources that are no longer present in the current compilation are removed. + /// + private void SynchronizeAlbResources(List currentAlbResources, ILambdaFunctionSerializable lambdaFunction) + { + var syncedAlbResourcesPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedAlbResources"; + + // Get previously synced ALB resources + var previousAlbResources = _templateWriter.GetToken>(syncedAlbResourcesPath, new List()); + + // Remove orphaned ALB resources + var orphanedAlbResources = previousAlbResources.Except(currentAlbResources).ToList(); + foreach (var resourceName in orphanedAlbResources) + { + var resourcePath = $"Resources.{resourceName}"; + // Only remove if it was created by this tool + if (_templateWriter.Exists(resourcePath) && + string.Equals(_templateWriter.GetToken($"{resourcePath}.Metadata.Tool", string.Empty), CREATION_TOOL, StringComparison.Ordinal)) + { + _templateWriter.RemoveToken(resourcePath); + } + } + + // Update synced ALB resources in the template metadata + _templateWriter.RemoveToken(syncedAlbResourcesPath); + if (currentAlbResources.Any()) + _templateWriter.SetToken(syncedAlbResourcesPath, currentAlbResources, TokenType.List); + } /// /// Writes the default values for the Lambda function's metadata and properties. diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs new file mode 100644 index 000000000..d73f4e365 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/ALBApiAttribute.cs @@ -0,0 +1,188 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Configures the Lambda function to be called from an Application Load Balancer. + /// The source generator will create the necessary CloudFormation resources + /// (TargetGroup, ListenerRule, Lambda Permission) to wire the Lambda function + /// as a target behind the specified ALB listener. + /// + /// + /// The listener ARN (or template reference), path pattern, and priority are required. + /// See ALB Lambda documentation. + /// + [AttributeUsage(AttributeTargets.Method)] + public class ALBApiAttribute : Attribute + { + // Only allow alphanumeric characters for resource names + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The ARN of the existing ALB listener, or a "@ResourceName" reference to a + /// listener resource or parameter defined in the CloudFormation template. + /// To reference a resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string ListenerArn { get; set; } + + /// + /// The path pattern condition for the ALB listener rule (e.g., "/api/orders/*"). + /// ALB supports wildcard path patterns using "*" and "?" characters. + /// + public string PathPattern { get; set; } + + /// + /// The priority of the ALB listener rule. Must be between 1 and 50000. + /// Lower numbers are evaluated first. Each rule on a listener must have a unique priority. + /// + public int Priority { get; set; } + + /// + /// Whether multi-value headers are enabled on the ALB target group. Default: false. + /// When true, the Lambda function should use MultiValueHeaders and + /// MultiValueQueryStringParameters on the request and response objects. + /// When false, use Headers and QueryStringParameters instead. + /// + public bool MultiValueHeaders + { + get => multiValueHeaders.GetValueOrDefault(); + set => multiValueHeaders = value; + } + private bool? multiValueHeaders { get; set; } + internal bool IsMultiValueHeadersSet => multiValueHeaders.HasValue; + + /// + /// Optional host header condition for the listener rule (e.g., "api.example.com"). + /// When specified, the rule will only match requests with this host header value. + /// + public string HostHeader { get; set; } + + /// + /// Optional HTTP method condition for the listener rule (e.g., "GET", "POST"). + /// When specified, the rule will only match requests with this HTTP method. + /// Leave null to match all HTTP methods. + /// + public string HttpMethod { get; set; } + + /// + /// Optional HTTP header name for an http-header listener rule condition (e.g., "X-Environment", "User-Agent"). + /// Must be used together with . + /// The header name is not case-sensitive. + /// + public string HttpHeaderConditionName { get; set; } + + /// + /// Optional HTTP header values for an http-header listener rule condition (e.g., new[] { "dev", "*Chrome*" }). + /// Supports wildcards (* and ?). Must be used together with . + /// Up to 3 match evaluations per condition. + /// + public string[] HttpHeaderConditionValues { get; set; } + + /// + /// Optional query string key/value pairs for a query-string listener rule condition. + /// Format: "key=value" pairs. Use "=value" (empty key) to match any key with that value. + /// Supports wildcards (* and ?). + /// Example: new[] { "version=v1", "=*example*" } + /// + public string[] QueryStringConditions { get; set; } + + /// + /// Optional source IP CIDR blocks for a source-ip listener rule condition. + /// Example: new[] { "192.0.2.0/24", "198.51.100.10/32" } + /// Supports both IPv4 and IPv6 addresses in CIDR format. + /// + public string[] SourceIpConditions { get; set; } + + /// + /// The CloudFormation resource name prefix for the generated ALB resources + /// (TargetGroup, ListenerRule, Permission). Defaults to "{LambdaResourceName}ALB". + /// Must only contain alphanumeric characters. + /// + public string ResourceName + { + get => resourceName; + set => resourceName = value; + } + private string resourceName { get; set; } + internal bool IsResourceNameSet => resourceName != null; + + /// + /// Creates an instance of the class. + /// + /// The ARN of the ALB listener, or a "@ResourceName" reference to a template resource. + /// The path pattern condition (e.g., "/api/orders/*"). + /// The listener rule priority (1-50000). + public ALBApiAttribute(string listenerArn, string pathPattern, int priority) + { + ListenerArn = listenerArn; + PathPattern = pathPattern; + Priority = priority; + } + + /// + /// Validates the attribute properties and returns a list of validation error messages. + /// + internal List Validate() + { + var validationErrors = new List(); + + if (string.IsNullOrEmpty(ListenerArn)) + { + validationErrors.Add($"{nameof(ListenerArn)} is required and cannot be empty."); + } + else if (!ListenerArn.StartsWith("@")) + { + // If it's not a template reference, validate it looks like an ARN + if (!ListenerArn.StartsWith("arn:")) + { + validationErrors.Add($"{nameof(ListenerArn)} = {ListenerArn}. It must be a valid ARN (starting with 'arn:') or a template reference (starting with '@')."); + } + } + + if (string.IsNullOrEmpty(PathPattern)) + { + validationErrors.Add($"{nameof(PathPattern)} is required and cannot be empty."); + } + + if (Priority < 1 || Priority > 50000) + { + validationErrors.Add($"{nameof(Priority)} = {Priority}. It must be between 1 and 50000."); + } + + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string."); + } + + if (!string.IsNullOrEmpty(HttpMethod)) + { + var validMethods = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS" + }; + if (!validMethods.Contains(HttpMethod)) + { + validationErrors.Add($"{nameof(HttpMethod)} = {HttpMethod}. It must be a valid HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)."); + } + } + + // Validate http-header condition: both name and values must be set together + if (!string.IsNullOrEmpty(HttpHeaderConditionName) && (HttpHeaderConditionValues == null || HttpHeaderConditionValues.Length == 0)) + { + validationErrors.Add($"{nameof(HttpHeaderConditionName)} is set to '{HttpHeaderConditionName}' but {nameof(HttpHeaderConditionValues)} is not set. Both must be specified together."); + } + if ((HttpHeaderConditionValues != null && HttpHeaderConditionValues.Length > 0) && string.IsNullOrEmpty(HttpHeaderConditionName)) + { + validationErrors.Add($"{nameof(HttpHeaderConditionValues)} is set but {nameof(HttpHeaderConditionName)} is not set. Both must be specified together."); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs new file mode 100644 index 000000000..73e01adbe --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromBodyAttribute.cs @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Maps this parameter to the HTTP request body from the ALB request + /// + /// + /// If the parameter is a complex type then the request body will be assumed to be JSON and deserialized into the type. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class FromBodyAttribute : Attribute + { + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs new file mode 100644 index 000000000..1e8e07cd9 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromHeaderAttribute.cs @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Maps this parameter to an HTTP header value from the ALB request + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class FromHeaderAttribute : Attribute, INamedAttribute + { + /// + /// Name of the header. If not specified, the parameter name is used. + /// + public string Name { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs new file mode 100644 index 000000000..30d229386 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/ALB/FromQueryAttribute.cs @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; + +namespace Amazon.Lambda.Annotations.ALB +{ + /// + /// Maps this parameter to a query string parameter from the ALB request + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class FromQueryAttribute : Attribute, INamedAttribute + { + /// + /// Name of the query string parameter. If not specified, the parameter name is used. + /// + public string Name { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/README.md b/Libraries/src/Amazon.Lambda.Annotations/README.md index 75dfaac23..d736e06be 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/README.md +++ b/Libraries/src/Amazon.Lambda.Annotations/README.md @@ -19,6 +19,7 @@ Topics: - [Amazon API Gateway example](#amazon-api-gateway-example) - [Amazon S3 example](#amazon-s3-example) - [SQS Event Example](#sqs-event-example) + - [Application Load Balancer (ALB) Example](#application-load-balancer-alb-example) - [Custom Lambda Authorizer Example](#custom-lambda-authorizer-example) - [HTTP API Authorizer](#http-api-authorizer) - [REST API Authorizer](#rest-api-authorizer) @@ -852,6 +853,226 @@ The following SQS event source mapping will be generated for the `SQSMessageHand } ``` +## Application Load Balancer (ALB) Example + +This example shows how to use the `ALBApi` attribute to configure a Lambda function as a target behind an [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html). Unlike API Gateway event attributes that map to SAM event types, the ALB integration generates standalone CloudFormation resources — a `TargetGroup`, a `ListenerRule`, and a `Lambda::Permission` — to wire the Lambda function to an existing ALB listener. + +The `ALBApi` attribute contains the following properties: + +| Property | Type | Required | Default | Description | +|---|---|---|---|---| +| `ListenerArn` | `string` | Yes | — | The ARN of the existing ALB listener, or a `@ResourceName` reference to a listener resource defined in the CloudFormation template. | +| `PathPattern` | `string` | Yes | — | The path pattern condition for the listener rule (e.g., `"/api/orders/*"`). Supports wildcard characters `*` and `?`. | +| `Priority` | `int` | Yes | — | The listener rule priority (1–50000). Lower numbers are evaluated first. Must be unique per listener. | +| `MultiValueHeaders` | `bool` | No | `false` | When `true`, enables multi-value headers on the target group. The function should then use `MultiValueHeaders` and `MultiValueQueryStringParameters` on request/response objects. | +| `HostHeader` | `string` | No | `null` | Optional host header condition (e.g., `"api.example.com"`). | +| `HttpMethod` | `string` | No | `null` | Optional HTTP method condition (e.g., `"GET"`, `"POST"`). Leave null to match all methods. | +| `ResourceName` | `string` | No | `"{LambdaResourceName}ALB"` | Custom CloudFormation resource name prefix for the generated resources. Must be alphanumeric. | + +The `ALBApi` attribute must be applied to a Lambda method along with the `LambdaFunction` attribute. + +The Lambda method must conform to the following rules when tagged with the `ALBApi` attribute: + +1. It must have at least 1 argument and can have at most 2 arguments. + - The first argument is required and must be of type `ApplicationLoadBalancerRequest` defined in the [Amazon.Lambda.ApplicationLoadBalancerEvents](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.ApplicationLoadBalancerEvents) package. + - The second argument is optional and must be of type `ILambdaContext`. +2. The method return type must be `ApplicationLoadBalancerResponse` or `Task`. + +### Prerequisites + +Your CloudFormation template must include an existing ALB and listener. The `ALBApi` attribute references the listener — it does **not** create the ALB or listener for you. You can define them in the same template or reference one that already exists via its ARN. + +### Basic Example + +This example creates a simple hello endpoint behind an ALB listener that is defined elsewhere in the template: + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.ApplicationLoadBalancerEvents; +using Amazon.Lambda.Core; +using System.Collections.Generic; + +public class ALBFunctions +{ + [LambdaFunction(ResourceName = "ALBHello", MemorySize = 256, Timeout = 15)] + [ALBApi("@ALBTestListener", "/hello", 1)] + public ApplicationLoadBalancerResponse Hello(ApplicationLoadBalancerRequest request, ILambdaContext context) + { + context.Logger.LogInformation($"Hello endpoint hit. Path: {request.Path}"); + + return new ApplicationLoadBalancerResponse + { + StatusCode = 200, + StatusDescription = "200 OK", + IsBase64Encoded = false, + Headers = new Dictionary + { + { "Content-Type", "application/json" } + }, + Body = $"{{\"message\": \"Hello from ALB Lambda!\", \"path\": \"{request.Path}\"}}" + }; + } +} +``` + +In the example above, `@ALBTestListener` references a listener resource called `ALBTestListener` defined in the same CloudFormation template. The `@` prefix tells the source generator to use a `Ref` intrinsic function instead of a literal ARN string. + +### Using a Literal Listener ARN + +If you want to reference an ALB listener in a different stack or one that was created outside of CloudFormation, use the full ARN: + +```csharp +[LambdaFunction(ResourceName = "ALBHandler")] +[ALBApi("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc123/def456", "/api/*", 10)] +public ApplicationLoadBalancerResponse HandleRequest(ApplicationLoadBalancerRequest request, ILambdaContext context) +{ + return new ApplicationLoadBalancerResponse + { + StatusCode = 200, + Headers = new Dictionary { { "Content-Type", "application/json" } }, + Body = "{\"status\": \"ok\"}" + }; +} +``` + +### Advanced Example with All Options + +This example shows all optional properties including host header filtering, HTTP method filtering, multi-value headers, and a custom resource name: + +```csharp +[LambdaFunction(ResourceName = "ALBOrders")] +[ALBApi("@MyListener", "/api/orders/*", 5, + MultiValueHeaders = true, + HostHeader = "api.example.com", + HttpMethod = "POST", + ResourceName = "OrdersALB")] +public ApplicationLoadBalancerResponse CreateOrder(ApplicationLoadBalancerRequest request, ILambdaContext context) +{ + // When MultiValueHeaders is true, use MultiValueHeaders and MultiValueQueryStringParameters + var contentTypes = request.MultiValueHeaders?["content-type"]; + + return new ApplicationLoadBalancerResponse + { + StatusCode = 201, + StatusDescription = "201 Created", + MultiValueHeaders = new Dictionary> + { + { "Content-Type", new List { "application/json" } }, + { "X-Custom-Header", new List { "value1", "value2" } } + }, + Body = "{\"orderId\": \"12345\"}" + }; +} +``` + +### Generated CloudFormation Resources + +For each `ALBApi` attribute, the source generator creates three CloudFormation resources. Here is an example of the generated template for the basic hello endpoint: + +```json +"ALBHello": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet8", + "CodeUri": ".", + "MemorySize": 512, + "Timeout": 15, + "Policies": ["AWSLambdaBasicExecutionRole"], + "PackageType": "Zip", + "Handler": "MyProject::MyNamespace.ALBFunctions_Hello_Generated::Hello" + } +}, +"ALBHelloALBPermission": { + "Type": "AWS::Lambda::Permission", + "Metadata": { "Tool": "Amazon.Lambda.Annotations" }, + "Properties": { + "FunctionName": { "Fn::GetAtt": ["ALBHello", "Arn"] }, + "Action": "lambda:InvokeFunction", + "Principal": "elasticloadbalancing.amazonaws.com" + } +}, +"ALBHelloALBTargetGroup": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + "Metadata": { "Tool": "Amazon.Lambda.Annotations" }, + "DependsOn": "ALBHelloALBPermission", + "Properties": { + "TargetType": "lambda", + "Targets": [ + { "Id": { "Fn::GetAtt": ["ALBHello", "Arn"] } } + ] + } +}, +"ALBHelloALBListenerRule": { + "Type": "AWS::ElasticLoadBalancingV2::ListenerRule", + "Metadata": { "Tool": "Amazon.Lambda.Annotations" }, + "Properties": { + "ListenerArn": { "Ref": "ALBTestListener" }, + "Priority": 1, + "Conditions": [ + { "Field": "path-pattern", "Values": ["/hello"] } + ], + "Actions": [ + { "Type": "forward", "TargetGroupArn": { "Ref": "ALBHelloALBTargetGroup" } } + ] + } +} +``` + +When `MultiValueHeaders` is set to `true`, the target group will include a `TargetGroupAttributes` section: + +```json +"TargetGroupAttributes": [ + { "Key": "lambda.multi_value_headers.enabled", "Value": "true" } +] +``` + +When `HostHeader` or `HttpMethod` are specified, additional conditions are added to the listener rule: + +```json +"Conditions": [ + { "Field": "path-pattern", "Values": ["/api/orders/*"] }, + { "Field": "host-header", "Values": ["api.example.com"] }, + { "Field": "http-request-method", "Values": ["POST"] } +] +``` + +### Setting Up the ALB in the Template + +The `ALBApi` attribute requires an existing ALB listener. Here is a minimal example of the infrastructure resources you would add to your `serverless.template`: + +```json +{ + "MyVPC": { "Type": "AWS::EC2::VPC", "Properties": { "CidrBlock": "10.0.0.0/16" } }, + "MySubnet1": { "Type": "AWS::EC2::Subnet", "Properties": { "VpcId": { "Ref": "MyVPC" }, "CidrBlock": "10.0.1.0/24" } }, + "MySubnet2": { "Type": "AWS::EC2::Subnet", "Properties": { "VpcId": { "Ref": "MyVPC" }, "CidrBlock": "10.0.2.0/24" } }, + "MySecurityGroup": { "Type": "AWS::EC2::SecurityGroup", "Properties": { "GroupDescription": "ALB SG", "VpcId": { "Ref": "MyVPC" } } }, + "MyALB": { + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "Properties": { + "Type": "application", + "Scheme": "internet-facing", + "Subnets": [{ "Ref": "MySubnet1" }, { "Ref": "MySubnet2" }], + "SecurityGroups": [{ "Ref": "MySecurityGroup" }] + } + }, + "MyListener": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "LoadBalancerArn": { "Ref": "MyALB" }, + "Port": 80, + "Protocol": "HTTP", + "DefaultActions": [{ "Type": "fixed-response", "FixedResponseConfig": { "StatusCode": "404" } }] + } + } +} +``` + +Then your Lambda function references `@MyListener` in the `ALBApi` attribute. + ## Custom Lambda Authorizer Example Lambda Annotations supports defining custom Lambda authorizers using attributes. Custom authorizers let you control access to your API Gateway endpoints by running a Lambda function that validates tokens or request parameters before the target function is invoked. The source generator automatically wires up the authorizer resources and references in the CloudFormation template. @@ -1198,7 +1419,9 @@ parameter to the `LambdaFunction` must be the event object and the event source * RestApiAuthorizer * Marks a Lambda function as a REST API (API Gateway V1) custom authorizer. The authorizer name is automatically derived from the method name. Other functions reference it via `RestApi.Authorizer` using `nameof()`. Use the `Type` property to choose between `Token` and `Request` authorizer types. * SQSEvent - * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. + * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. +* ALBApi + * Configures the Lambda function to be called from an Application Load Balancer. The listener ARN (or `@ResourceName` template reference), path pattern, and priority are required. The source generator creates standalone CloudFormation resources (TargetGroup, ListenerRule, Lambda Permission) rather than SAM event types. ### Parameter Attributes @@ -1277,3 +1500,5 @@ The content type is determined using the following rules. ## Project References If API Gateway event attributes, such as `RestAPI` or `HttpAPI`, are being used then a package reference to `Amazon.Lambda.APIGatewayEvents` must be added to the project, otherwise the project will not compile. We do not include it by default in order to keep the `Amazon.Lambda.Annotations` library lightweight. + +Similarly, if the `ALBApi` attribute is being used then a package reference to `Amazon.Lambda.ApplicationLoadBalancerEvents` must be added to the project. This provides the `ApplicationLoadBalancerRequest` and `ApplicationLoadBalancerResponse` types used by ALB Lambda functions. diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs new file mode 100644 index 000000000..10cb530b1 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiAttributeTests.cs @@ -0,0 +1,495 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class ALBApiAttributeTests + { + [Fact] + public void Constructor_SetsRequiredProperties() + { + // Arrange & Act + var attr = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/50dc6c495c0c9188/f2f7dc8efc522ab2", + "/api/orders/*", + 10); + + // Assert + Assert.Equal("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/50dc6c495c0c9188/f2f7dc8efc522ab2", attr.ListenerArn); + Assert.Equal("/api/orders/*", attr.PathPattern); + Assert.Equal(10, attr.Priority); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + // Arrange & Act + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/hello", 1); + + // Assert + Assert.False(attr.MultiValueHeaders); + Assert.False(attr.IsMultiValueHeadersSet); + Assert.Null(attr.HostHeader); + Assert.Null(attr.HttpMethod); + Assert.Null(attr.ResourceName); + Assert.False(attr.IsResourceNameSet); + } + + [Fact] + public void MultiValueHeaders_WhenExplicitlySet_IsTracked() + { + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/hello", 1); + + // Before setting + Assert.False(attr.IsMultiValueHeadersSet); + + // After setting to false explicitly + attr.MultiValueHeaders = false; + Assert.True(attr.IsMultiValueHeadersSet); + Assert.False(attr.MultiValueHeaders); + + // After setting to true + attr.MultiValueHeaders = true; + Assert.True(attr.IsMultiValueHeadersSet); + Assert.True(attr.MultiValueHeaders); + } + + [Fact] + public void ResourceName_WhenExplicitlySet_IsTracked() + { + var attr = new ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/hello", 1); + + Assert.False(attr.IsResourceNameSet); + + attr.ResourceName = "MyCustomName"; + Assert.True(attr.IsResourceNameSet); + Assert.Equal("MyCustomName", attr.ResourceName); + } + + [Fact] + public void TemplateReference_IsAccepted() + { + var attr = new ALBApiAttribute("@MyALBListener", "/api/*", 5); + + Assert.Equal("@MyALBListener", attr.ListenerArn); + Assert.StartsWith("@", attr.ListenerArn); + } + + [Fact] + public void OptionalProperties_CanBeSet() + { + var attr = new ALBApiAttribute("@MyALBListener", "/api/*", 5) + { + HostHeader = "api.example.com", + HttpMethod = "GET", + MultiValueHeaders = true, + ResourceName = "MyALBTarget" + }; + + Assert.Equal("api.example.com", attr.HostHeader); + Assert.Equal("GET", attr.HttpMethod); + Assert.True(attr.MultiValueHeaders); + Assert.Equal("MyALBTarget", attr.ResourceName); + } + + // ===== Validation Tests ===== + + [Fact] + public void Validate_ValidArn_ReturnsNoErrors() + { + var attr = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", + "/api/*", + 1); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_ValidTemplateReference_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyALBListener", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_EmptyListenerArn_ReturnsError() + { + var attr = new ALBApiAttribute("", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ListenerArn", errors[0]); + Assert.Contains("required", errors[0]); + } + + [Fact] + public void Validate_NullListenerArn_ReturnsError() + { + var attr = new ALBApiAttribute(null, "/api/*", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ListenerArn", errors[0]); + } + + [Fact] + public void Validate_InvalidListenerArn_NotArnOrReference_ReturnsError() + { + var attr = new ALBApiAttribute("some-random-string", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ListenerArn", errors[0]); + Assert.Contains("arn:", errors[0]); + } + + [Fact] + public void Validate_EmptyPathPattern_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "", 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("PathPattern", errors[0]); + Assert.Contains("required", errors[0]); + } + + [Fact] + public void Validate_NullPathPattern_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", null, 1); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("PathPattern", errors[0]); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(50001)] + [InlineData(100000)] + public void Validate_InvalidPriority_ReturnsError(int priority) + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", priority); + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("Priority", errors[0]); + Assert.Contains("1 and 50000", errors[0]); + } + + [Theory] + [InlineData(1)] + [InlineData(50000)] + [InlineData(100)] + [InlineData(25000)] + public void Validate_ValidPriority_ReturnsNoErrors(int priority) + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", priority); + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidResourceName_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + ResourceName = "invalid-name!" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("ResourceName", errors[0]); + Assert.Contains("alphanumeric", errors[0]); + } + + [Fact] + public void Validate_ValidResourceName_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + ResourceName = "MyValidResource123" + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_UnsetResourceName_ReturnsNoErrors() + { + // ResourceName not set should not produce validation errors + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.False(attr.IsResourceNameSet); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + [InlineData("HEAD")] + [InlineData("OPTIONS")] + [InlineData("get")] + [InlineData("post")] + public void Validate_ValidHttpMethod_ReturnsNoErrors(string method) + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpMethod = method + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_InvalidHttpMethod_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpMethod = "INVALID" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpMethod", errors[0]); + } + + [Fact] + public void Validate_NullHttpMethod_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpMethod = null + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + [Fact] + public void Validate_MultipleErrors_ReturnsAll() + { + var attr = new ALBApiAttribute("", "", 0) + { + ResourceName = "invalid-name!", + HttpMethod = "INVALID" + }; + + var errors = attr.Validate(); + // Should have errors for: ListenerArn, PathPattern, Priority, ResourceName, HttpMethod + Assert.Equal(5, errors.Count); + Assert.Contains(errors, e => e.Contains("ListenerArn")); + Assert.Contains(errors, e => e.Contains("PathPattern")); + Assert.Contains(errors, e => e.Contains("Priority")); + Assert.Contains(errors, e => e.Contains("ResourceName")); + Assert.Contains(errors, e => e.Contains("HttpMethod")); + } + + [Fact] + public void Validate_AllValidWithOptionals_ReturnsNoErrors() + { + var attr = new ALBApiAttribute( + "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", + "/api/v1/products/*", + 42) + { + MultiValueHeaders = true, + HostHeader = "api.example.com", + HttpMethod = "POST", + ResourceName = "ProductsALB" + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== HTTP Header Condition Tests ===== + + [Fact] + public void HttpHeaderCondition_DefaultValues_AreNull() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + + Assert.Null(attr.HttpHeaderConditionName); + Assert.Null(attr.HttpHeaderConditionValues); + } + + [Fact] + public void HttpHeaderCondition_BothSet_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "X-Environment", + HttpHeaderConditionValues = new[] { "dev", "staging" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.Equal("X-Environment", attr.HttpHeaderConditionName); + Assert.Equal(2, attr.HttpHeaderConditionValues.Length); + Assert.Equal("dev", attr.HttpHeaderConditionValues[0]); + Assert.Equal("staging", attr.HttpHeaderConditionValues[1]); + } + + [Fact] + public void HttpHeaderCondition_NameSetWithoutValues_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "X-Environment" + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpHeaderConditionName", errors[0]); + Assert.Contains("HttpHeaderConditionValues", errors[0]); + } + + [Fact] + public void HttpHeaderCondition_ValuesSetWithoutName_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionValues = new[] { "dev" } + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpHeaderConditionValues", errors[0]); + Assert.Contains("HttpHeaderConditionName", errors[0]); + } + + [Fact] + public void HttpHeaderCondition_NameSetWithEmptyValues_ReturnsError() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "User-Agent", + HttpHeaderConditionValues = new string[0] + }; + + var errors = attr.Validate(); + Assert.Single(errors); + Assert.Contains("HttpHeaderConditionName", errors[0]); + } + + [Fact] + public void HttpHeaderCondition_WithWildcards_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HttpHeaderConditionName = "User-Agent", + HttpHeaderConditionValues = new[] { "*Chrome*", "*Safari*" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== Query String Condition Tests ===== + + [Fact] + public void QueryStringConditions_DefaultValue_IsNull() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + Assert.Null(attr.QueryStringConditions); + } + + [Fact] + public void QueryStringConditions_WithKeyValuePairs_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + QueryStringConditions = new[] { "version=v1", "=*example*" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.Equal(2, attr.QueryStringConditions.Length); + Assert.Equal("version=v1", attr.QueryStringConditions[0]); + Assert.Equal("=*example*", attr.QueryStringConditions[1]); + } + + [Fact] + public void QueryStringConditions_WithSingleEntry_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + QueryStringConditions = new[] { "env=prod" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== Source IP Condition Tests ===== + + [Fact] + public void SourceIpConditions_DefaultValue_IsNull() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1); + Assert.Null(attr.SourceIpConditions); + } + + [Fact] + public void SourceIpConditions_WithCidrBlocks_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + SourceIpConditions = new[] { "192.0.2.0/24", "198.51.100.10/32" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + Assert.Equal(2, attr.SourceIpConditions.Length); + } + + [Fact] + public void SourceIpConditions_WithIPv6_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + SourceIpConditions = new[] { "2001:db8::/32" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + + // ===== Combined Condition Tests ===== + + [Fact] + public void AllConditions_CanBeSetTogether_ReturnsNoErrors() + { + var attr = new ALBApiAttribute("@MyListener", "/api/*", 1) + { + HostHeader = "api.example.com", + HttpMethod = "POST", + HttpHeaderConditionName = "X-Environment", + HttpHeaderConditionValues = new[] { "dev" }, + QueryStringConditions = new[] { "version=v1" }, + SourceIpConditions = new[] { "10.0.0.0/8" } + }; + + var errors = attr.Validate(); + Assert.Empty(errors); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs new file mode 100644 index 000000000..f45d4a67a --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/ALBApiModelTests.cs @@ -0,0 +1,272 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; +using Amazon.Lambda.Annotations.SourceGenerator.Extensions; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests +{ + public class ALBApiModelTests + { + [Fact] + public void TypeFullNames_ContainsALBConstants() + { + Assert.Equal("Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerRequest", TypeFullNames.ApplicationLoadBalancerRequest); + Assert.Equal("Amazon.Lambda.ApplicationLoadBalancerEvents.ApplicationLoadBalancerResponse", TypeFullNames.ApplicationLoadBalancerResponse); + Assert.Equal("Amazon.Lambda.Annotations.ALB.ALBApiAttribute", TypeFullNames.ALBApiAttribute); + } + + [Fact] + public void TypeFullNames_Events_ContainsALBApiAttribute() + { + Assert.Contains(TypeFullNames.ALBApiAttribute, TypeFullNames.Events); + } + + [Fact] + public void TypeFullNames_ALBRequests_ContainsLoadBalancerRequest() + { + Assert.Contains(TypeFullNames.ApplicationLoadBalancerRequest, TypeFullNames.ALBRequests); + Assert.Single(TypeFullNames.ALBRequests); + } + + [Fact] + public void EventType_HasALBValue() + { + // Verify the ALB enum value exists + var albEvent = EventType.ALB; + Assert.Equal(EventType.ALB, albEvent); + + // Verify it's distinct from other event types + Assert.NotEqual(EventType.API, albEvent); + Assert.NotEqual(EventType.SQS, albEvent); + } + + [Fact] + public void ALBApiAttributeBuilder_BuildsFromConstructorArgs() + { + // This tests the attribute builder by constructing an ALBApiAttribute directly + // (since we can't easily mock Roslyn AttributeData in unit tests, we test the attribute itself) + var attr = new Annotations.ALB.ALBApiAttribute("@MyListener", "/api/*", 5); + + Assert.Equal("@MyListener", attr.ListenerArn); + Assert.Equal("/api/*", attr.PathPattern); + Assert.Equal(5, attr.Priority); + } + + [Fact] + public void ALBApiAttributeBuilder_BuildsWithAllOptionalProperties() + { + var attr = new Annotations.ALB.ALBApiAttribute("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", "/api/v1/*", 10) + { + MultiValueHeaders = true, + HostHeader = "api.example.com", + HttpMethod = "POST", + ResourceName = "MyCustomALB" + }; + + Assert.Equal("arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-alb/abc/def", attr.ListenerArn); + Assert.Equal("/api/v1/*", attr.PathPattern); + Assert.Equal(10, attr.Priority); + Assert.True(attr.MultiValueHeaders); + Assert.True(attr.IsMultiValueHeadersSet); + Assert.Equal("api.example.com", attr.HostHeader); + Assert.Equal("POST", attr.HttpMethod); + Assert.Equal("MyCustomALB", attr.ResourceName); + Assert.True(attr.IsResourceNameSet); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_WhenDirectReturn() + { + var model = new LambdaMethodModel + { + ReturnsVoid = false, + ReturnsGenericTask = false, + ReturnType = new TypeModel + { + FullName = TypeFullNames.ApplicationLoadBalancerResponse, + TypeArguments = new List() + } + }; + + Assert.True(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_WhenTaskReturn() + { + var model = new LambdaMethodModel + { + ReturnsVoid = false, + ReturnsGenericTask = true, + ReturnType = new TypeModel + { + FullName = "System.Threading.Tasks.Task`1", + TypeArguments = new List + { + new TypeModel { FullName = TypeFullNames.ApplicationLoadBalancerResponse } + } + } + }; + + Assert.True(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_FalseWhenVoid() + { + var model = new LambdaMethodModel + { + ReturnsVoid = true, + ReturnsGenericTask = false, + ReturnType = new TypeModel + { + FullName = "void", + TypeArguments = new List() + } + }; + + Assert.False(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void LambdaMethodModel_ReturnsApplicationLoadBalancerResponse_FalseWhenDifferentType() + { + var model = new LambdaMethodModel + { + ReturnsVoid = false, + ReturnsGenericTask = false, + ReturnType = new TypeModel + { + FullName = "System.String", + TypeArguments = new List() + } + }; + + Assert.False(model.ReturnsApplicationLoadBalancerResponse); + } + + [Fact] + public void ParameterListExtension_ALBRequest_IsNotConvertible() + { + // ApplicationLoadBalancerRequest parameters should be treated as pass-through + var parameters = new List + { + new ParameterModel + { + Name = "request", + Type = new TypeModel { FullName = TypeFullNames.ApplicationLoadBalancerRequest }, + Attributes = new List() + } + }; + + Assert.False(parameters.HasConvertibleParameter()); + } + + [Fact] + public void ParameterListExtension_FromQuery_IsConvertible() + { + // A [FromQuery] string parameter should be convertible + var parameters = new List + { + new ParameterModel + { + Name = "name", + Type = new TypeModel { FullName = "System.String" }, + Attributes = new List + { + new AttributeModel + { + Data = new Annotations.APIGateway.FromQueryAttribute(), + Type = new TypeModel { FullName = TypeFullNames.FromQueryAttribute } + } + } + } + }; + + Assert.True(parameters.HasConvertibleParameter()); + } + + [Fact] + public void ParameterListExtension_ILambdaContext_IsNotConvertible() + { + var parameters = new List + { + new ParameterModel + { + Name = "context", + Type = new TypeModel { FullName = TypeFullNames.ILambdaContext }, + Attributes = new List() + } + }; + + Assert.False(parameters.HasConvertibleParameter()); + } + + [Fact] + public void ParameterListExtension_FromBodyString_IsNotConvertible() + { + // A [FromBody] string parameter should NOT be convertible (string body is pass-through) + var parameters = new List + { + new ParameterModel + { + Name = "body", + Type = new TypeModel { FullName = "string" }, + Attributes = new List + { + new AttributeModel + { + Data = new Annotations.APIGateway.FromBodyAttribute(), + Type = new TypeModel { FullName = TypeFullNames.FromBodyAttribute } + } + } + } + }; + + Assert.False(parameters.HasConvertibleParameter()); + } + + [Fact] + public void DiagnosticDescriptors_FromRouteNotSupportedOnAlb_Exists() + { + Assert.Equal("AWSLambda0134", DiagnosticDescriptors.FromRouteNotSupportedOnAlb.Id); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, DiagnosticDescriptors.FromRouteNotSupportedOnAlb.DefaultSeverity); + } + + [Fact] + public void DiagnosticDescriptors_AlbUnmappedParameter_Exists() + { + Assert.Equal("AWSLambda0135", DiagnosticDescriptors.AlbUnmappedParameter.Id); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, DiagnosticDescriptors.AlbUnmappedParameter.DefaultSeverity); + } + + [Fact] + public void ALBFromQuery_ParameterName_DefaultsToParameterName() + { + // When Name is not set, ALB FromQueryAttribute should default to parameter name + var attr = new Annotations.ALB.FromQueryAttribute(); + Assert.Null(attr.Name); + } + + [Fact] + public void ALBFromQuery_ParameterName_UsesExplicitName() + { + var attr = new Annotations.ALB.FromQueryAttribute { Name = "custom_name" }; + Assert.Equal("custom_name", attr.Name); + } + + [Fact] + public void ALBFromHeader_ParameterName_UsesExplicitName() + { + var attr = new Annotations.ALB.FromHeaderAttribute { Name = "X-Custom-Header" }; + Assert.Equal("X-Custom-Header", attr.Name); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj index c8cc6f306..b9b6a4113 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj @@ -208,6 +208,7 @@ + diff --git a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj index 15da7004d..4e40ae948 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj @@ -11,7 +11,7 @@ ..\..\..\buildtools\public.snk true - 1.10.0 + 1.11.0 diff --git a/Libraries/test/TestCustomAuthorizerApp/serverless.template b/Libraries/test/TestCustomAuthorizerApp/serverless.template index 41e03a2f9..7aad04bcd 100644 --- a/Libraries/test/TestCustomAuthorizerApp/serverless.template +++ b/Libraries/test/TestCustomAuthorizerApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.10.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index 3092da266..93ebd4920 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.10.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", diff --git a/Libraries/test/TestServerlessApp.ALB/serverless.template b/Libraries/test/TestServerlessApp.ALB/serverless.template index d6247bea7..62c1fa590 100644 --- a/Libraries/test/TestServerlessApp.ALB/serverless.template +++ b/Libraries/test/TestServerlessApp.ALB/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.10.0.0).", + "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", "Resources": { "ALBTestVPC": { "Type": "AWS::EC2::VPC", @@ -235,9 +235,11 @@ "Conditions": [ { "Field": "path-pattern", - "Values": [ - "/hello" - ] + "PathPatternConfig": { + "Values": [ + "/hello" + ] + } } ], "Actions": [ @@ -321,9 +323,11 @@ "Conditions": [ { "Field": "path-pattern", - "Values": [ - "/health" - ] + "PathPatternConfig": { + "Values": [ + "/health" + ] + } } ], "Actions": [ @@ -403,16 +407,15 @@ "Tool": "Amazon.Lambda.Annotations" }, "Properties": { - "ListenerArn": { - "Ref": "ALBTestListener" - }, "Priority": 3, "Conditions": [ { "Field": "path-pattern", - "Values": [ - "/greeting" - ] + "PathPatternConfig": { + "Values": [ + "/greeting" + ] + } } ], "Actions": [ @@ -422,7 +425,10 @@ "Ref": "ALBGreetingALBTargetGroup" } } - ] + ], + "ListenerArn": { + "Ref": "ALBTestListener" + } } }, "ALBCreateItem": { @@ -489,22 +495,23 @@ "Tool": "Amazon.Lambda.Annotations" }, "Properties": { - "ListenerArn": { - "Ref": "ALBTestListener" - }, "Priority": 4, "Conditions": [ { "Field": "path-pattern", - "Values": [ - "/items" - ] + "PathPatternConfig": { + "Values": [ + "/items" + ] + } }, { "Field": "http-request-method", - "Values": [ - "POST" - ] + "HttpRequestMethodConfig": { + "Values": [ + "POST" + ] + } } ], "Actions": [ @@ -514,7 +521,10 @@ "Ref": "ALBCreateItemALBTargetGroup" } } - ] + ], + "ListenerArn": { + "Ref": "ALBTestListener" + } } } }, diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index c139ace2b..8e1cc1793 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.10.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", "Resources": { "TestServerlessAppNET8FunctionsToUpperGenerated": { "Type": "AWS::Serverless::Function", diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index a0bf929eb..3b03a56bf 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.10.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", "Resources": { "TestQueue": { "Type": "AWS::SQS::Queue" From 88ba85aaaaa15ad126126f0797183287ea76f9fc Mon Sep 17 00:00:00 2001 From: AlexDaines <55813219+AlexDaines@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:06:42 -0400 Subject: [PATCH 31/47] Add CODEOWNERS file --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..62b77bced --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @aws/aws-sdk-dotnet-team From e06cfdd1868ae84cc73d8447fc25a1045d16005a Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 13 Apr 2026 12:30:22 -0400 Subject: [PATCH 32/47] Add [S3Event] annotation attribute and source generator support (#2321) * Add [S3Event] annotation attribute and source generator support - S3EventAttribute with Bucket (required), ResourceName, Events, FilterPrefix, FilterSuffix, Enabled - S3EventAttributeBuilder for Roslyn AttributeData parsing - TypeFullNames constants and Events hashset registration - SyntaxReceiver secondary attribute registration - EventTypeBuilder S3 event type mapping - AttributeModelBuilder S3 branch - CloudFormationWriter ProcessS3Attribute (SAM S3 event with Ref, Events list, Filter rules) - LambdaFunctionValidator ValidateS3Events (params, return type, dependency check) - DiagnosticDescriptors InvalidS3EventAttribute (AWSLambda0133) Add S3Event annotation tests - ValidS3Events.cs.txt test source with 3 test functions - S3EventsTests.cs CloudFormation writer tests (attribute application + property sync) - S3Events project references in TestServerlessApp.csproj and test project IT test PR comments change file fixes PR comments * add header --- .autover/changes/add-s3event-annotation.json | 11 + .../Diagnostics/DiagnosticDescriptors.cs | 7 + .../Attributes/AttributeModelBuilder.cs | 10 + .../Attributes/S3EventAttributeBuilder.cs | 37 ++ .../Models/EventTypeBuilder.cs | 4 + .../SyntaxReceiver.cs | 3 +- .../TypeFullNames.cs | 6 +- .../Validation/LambdaFunctionValidator.cs | 58 +++ .../Writers/CloudFormationWriter.cs | 53 +++ .../S3/S3EventAttribute.cs | 129 +++++ ....Annotations.SourceGenerators.Tests.csproj | 1 + .../S3EventAttributeTests.cs | 448 ++++++++++++++++++ .../WriterTests/S3EventsTests.cs | 168 +++++++ .../test/IntegrationTests.Helpers/S3Helper.cs | 8 + .../DeploymentScript.ps1 | 9 + .../IntegrationTestContextFixture.cs | 11 +- .../S3EventNotification.cs | 53 +++ .../S3EventExamples/S3EventProcessing.cs | 21 + .../S3EventExamples/ValidS3Events.cs.txt | 38 ++ .../TestServerlessApp.csproj | 1 + .../TestServerlessApp/serverless.template | 57 +++ 21 files changed, 1130 insertions(+), 3 deletions(-) create mode 100644 .autover/changes/add-s3event-annotation.json create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/S3EventAttributeTests.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/S3EventsTests.cs create mode 100644 Libraries/test/TestServerlessApp.IntegrationTests/S3EventNotification.cs create mode 100644 Libraries/test/TestServerlessApp/S3EventExamples/S3EventProcessing.cs create mode 100644 Libraries/test/TestServerlessApp/S3EventExamples/ValidS3Events.cs.txt diff --git a/.autover/changes/add-s3event-annotation.json b/.autover/changes/add-s3event-annotation.json new file mode 100644 index 000000000..90bdc8edf --- /dev/null +++ b/.autover/changes/add-s3event-annotation.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [S3Event] annotation attribute for declaratively configuring S3 event-triggered Lambda functions with support for bucket reference, event types, key prefix/suffix filters, and enabled state." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index aef6767ce..e1a11087f 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -274,5 +274,12 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidS3EventAttribute = new DiagnosticDescriptor(id: "AWSLambda0136", + title: "Invalid S3EventAttribute", + messageFormat: "Invalid S3EventAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index add9e6c03..0d1067bb6 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,6 +1,7 @@ using System; using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; @@ -91,6 +92,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.S3EventAttribute), SymbolEqualityComparer.Default)) + { + var data = S3EventAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs new file mode 100644 index 000000000..66070af74 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/S3EventAttributeBuilder.cs @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.S3; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + public class S3EventAttributeBuilder + { + public static S3EventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + throw new NotSupportedException($"{TypeFullNames.S3EventAttribute} must have constructor with 1 argument."); + + var bucket = att.ConstructorArguments[0].Value as string; + var data = new S3EventAttribute(bucket); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + data.ResourceName = resourceName; + else if (pair.Key == nameof(data.Events) && pair.Value.Value is string events) + data.Events = events; + else if (pair.Key == nameof(data.FilterPrefix) && pair.Value.Value is string filterPrefix) + data.FilterPrefix = filterPrefix; + else if (pair.Key == nameof(data.FilterSuffix) && pair.Value.Value is string filterSuffix) + data.FilterSuffix = filterSuffix; + else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) + data.Enabled = enabled; + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 06a2a0a1c..3dfc51799 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -26,6 +26,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.SQS); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.S3EventAttribute) + { + events.Add(EventType.S3); + } else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAuthorizerAttribute || attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAuthorizerAttribute) { diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index ff6e2ee08..2091d6c94 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -22,7 +22,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, { "SQSEventAttribute", "SQSEvent" }, - { "ALBApiAttribute", "ALBApi" } + { "ALBApiAttribute", "ALBApi" }, + { "S3EventAttribute", "S3Event" } }; public List LambdaMethods { get; } = new List(); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 76871445e..59fa1d830 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -53,6 +53,9 @@ public static class TypeFullNames public const string ALBFromHeaderAttribute = "Amazon.Lambda.Annotations.ALB.FromHeaderAttribute"; public const string ALBFromBodyAttribute = "Amazon.Lambda.Annotations.ALB.FromBodyAttribute"; + public const string S3Event = "Amazon.Lambda.S3Events.S3Event"; + public const string S3EventAttribute = "Amazon.Lambda.Annotations.S3.S3EventAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -80,7 +83,8 @@ public static class TypeFullNames RestApiAttribute, HttpApiAttribute, SQSEventAttribute, - ALBApiAttribute + ALBApiAttribute, + S3EventAttribute }; } } \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index c496ac3bf..7e728660a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -1,4 +1,5 @@ using Amazon.Lambda.Annotations.ALB; +using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; @@ -61,6 +62,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); return ReportDiagnostics(diagnosticReporter, diagnostics); } @@ -98,6 +100,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.S3Events" if the Lambda method is annotated with S3Event attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.S3EventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.S3Events") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.S3Events")); + return false; + } + } + return true; } @@ -362,6 +374,52 @@ private static void ValidateAlbEvents(LambdaFunctionModel lambdaFunctionModel, L } } + private static void ValidateS3Events(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.S3)) + return; + + // Validate S3EventAttributes + var seenResourceNames = new HashSet(); + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.S3EventAttribute) + continue; + + var s3EventAttribute = ((AttributeModel)att).Data; + var validationErrors = s3EventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidS3EventAttribute, methodLocation, errorMessage))); + + // Check for duplicate resource names (only when ResourceName is safe to evaluate) + var derivedResourceName = s3EventAttribute.ResourceName; + if (!string.IsNullOrEmpty(derivedResourceName) && !seenResourceNames.Add(derivedResourceName)) + { + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidS3EventAttribute, methodLocation, + $"Duplicate S3 event resource name '{derivedResourceName}'. Each [S3Event] attribute on the same method must have a unique ResourceName.")); + } + } + + // Validate method parameters - first param must be S3Event, optional second param ILambdaContext + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.S3Event) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.S3Event || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(S3EventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.S3Event}. " + + $"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}."; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + + // Validate method return type - must be void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(S3EventAttribute)}, the Lambda method can return either void or {TypeFullNames.Task}"; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + } + private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List diagnostics) { var isValid = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index c384b7b48..a72e3241b 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -4,6 +4,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System; @@ -227,6 +228,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la var albResourceNames = ProcessAlbApiAttribute(lambdaFunction, albAttributeModel.Data); currentAlbResources.AddRange(albResourceNames); break; + case AttributeModel s3AttributeModel: + eventName = ProcessS3Attribute(lambdaFunction, s3AttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; } } @@ -603,6 +608,54 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S return att.ResourceName; } + /// + /// Writes all properties associated with to the serverless template. + /// + private string ProcessS3Attribute(ILambdaFunctionSerializable lambdaFunction, S3EventAttribute att, Dictionary> syncedEventProperties) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}"; + + _templateWriter.SetToken($"{eventPath}.Type", "S3"); + + // Bucket - always a Ref since S3 events require the bucket resource in the same template (validated to start with "@") + var bucketName = att.Bucket.Substring(1); + _templateWriter.RemoveToken($"{eventPath}.Properties.Bucket"); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Bucket.{REF}", bucketName); + + // Events - list of S3 event types (always written since S3 SAM events require it; uses default "s3:ObjectCreated:*" if not explicitly set) + { + var events = att.Events.Split(';').Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Events", events, TokenType.List); + } + + // Filter - S3 key filter rules + if (att.IsFilterPrefixSet || att.IsFilterSuffixSet) + { + var rules = new List>(); + + if (att.IsFilterPrefixSet) + { + rules.Add(new Dictionary { { "Name", "prefix" }, { "Value", att.FilterPrefix } }); + } + + if (att.IsFilterSuffixSet) + { + rules.Add(new Dictionary { { "Name", "suffix" }, { "Value", att.FilterSuffix } }); + } + + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Filter.S3Key.Rules", rules, TokenType.List); + } + + // Enabled + if (att.IsEnabledSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled); + } + + return att.ResourceName; + } + /// /// Generates CloudFormation resources for an Application Load Balancer target. /// Unlike API Gateway events which map to SAM event types, ALB integration requires diff --git a/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs new file mode 100644 index 000000000..13bc79095 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/S3/S3EventAttribute.cs @@ -0,0 +1,129 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.S3 +{ + /// + /// This attribute defines the S3 event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class S3EventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The S3 bucket that will act as the event trigger for the Lambda function. + /// This must be a reference to an S3 bucket resource defined in the serverless template, prefixed with "@". + /// + public string Bucket { get; set; } + + /// + /// The CloudFormation resource name for the S3 event. By default this is derived from the Bucket reference without the "@" prefix. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + return resourceName; + if (!string.IsNullOrEmpty(Bucket) && Bucket.StartsWith("@")) + return Bucket.Substring(1); + return Bucket; + } + set => resourceName = value; + } + private string resourceName = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// Semicolon-separated list of S3 event types. Default is 's3:ObjectCreated:*'. + /// + public string Events + { + get => events ?? "s3:ObjectCreated:*"; + set => events = value; + } + private string events = null; + internal bool IsEventsSet => events != null; + + /// + /// S3 key prefix filter for the event notification. + /// + public string FilterPrefix + { + get => filterPrefix; + set => filterPrefix = value; + } + private string filterPrefix = null; + internal bool IsFilterPrefixSet => filterPrefix != null; + + /// + /// S3 key suffix filter for the event notification. + /// + public string FilterSuffix + { + get => filterSuffix; + set => filterSuffix = value; + } + private string filterSuffix = null; + internal bool IsFilterSuffixSet => filterSuffix != null; + + /// + /// If set to false, the event source will be disabled. Default value is true. + /// + public bool Enabled + { + get => enabled.GetValueOrDefault(true); + set => enabled = value; + } + private bool? enabled; + internal bool IsEnabledSet => enabled.HasValue; + + /// + /// Creates an instance of the class. + /// + /// property + public S3EventAttribute(string bucket) + { + Bucket = bucket; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (string.IsNullOrEmpty(Bucket)) + { + validationErrors.Add($"{nameof(S3EventAttribute.Bucket)} is required and must not be empty"); + } + else if (!Bucket.StartsWith("@")) + { + validationErrors.Add($"{nameof(S3EventAttribute.Bucket)} = {Bucket}. S3 event sources require a reference to an S3 bucket resource in the serverless template. Prefix the resource name with '@'"); + } + else + { + var bucketResourceName = Bucket.Substring(1); + if (!_resourceNameRegex.IsMatch(bucketResourceName)) + { + validationErrors.Add($"{nameof(S3EventAttribute.Bucket)} = {Bucket}. The referenced S3 bucket resource name must not be empty and must only contain alphanumeric characters after the '@' prefix"); + } + } + + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(S3EventAttribute.ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string"); + } + + if (string.IsNullOrEmpty(Events)) + { + validationErrors.Add($"{nameof(S3EventAttribute.Events)} must not be empty"); + } + + return validationErrors; + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj index b9b6a4113..56da7d597 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj @@ -209,6 +209,7 @@ + diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index 8ff092c83..d1a9a89d0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -20,3 +20,4 @@ AWSLambda0132 | AWSLambdaCSharpGenerator | Error | Invalid ALBApiAttribute AWSLambda0133 | AWSLambdaCSharpGenerator | Error | ALB Listener Reference Not Found AWSLambda0134 | AWSLambdaCSharpGenerator | Error | FromRoute not supported on ALB functions AWSLambda0135 | AWSLambdaCSharpGenerator | Error | Unmapped parameter on ALB function +AWSLambda0136 | AWSLambdaCSharpGenerator | Error | Invalid S3EventAttribute diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/HttpApiAuthorizerAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/HttpApiAuthorizerAttribute.cs index 17b7d0bf7..725c2842a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/HttpApiAuthorizerAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/HttpApiAuthorizerAttribute.cs @@ -8,8 +8,8 @@ namespace Amazon.Lambda.Annotations.APIGateway /// /// /// This attribute must be used in conjunction with the . - /// The authorizer function should return - /// when is true, or + /// The authorizer function should return APIGatewayCustomAuthorizerV2SimpleResponse + /// when is true, or APIGatewayCustomAuthorizerV2IamResponse /// when is false. /// /// @@ -45,8 +45,8 @@ public class HttpApiAuthorizerAttribute : Attribute /// Defaults to true for simpler implementation. /// /// - /// When true, the authorizer should return . - /// When false, the authorizer should return . + /// When true, the authorizer should return APIGatewayCustomAuthorizerV2SimpleResponse. + /// When false, the authorizer should return APIGatewayCustomAuthorizerV2IamResponse. /// public bool EnableSimpleResponses { get; set; } = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/RestApiAuthorizerAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/RestApiAuthorizerAttribute.cs index b578e0d97..d6db9fa04 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/RestApiAuthorizerAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/RestApiAuthorizerAttribute.cs @@ -26,7 +26,7 @@ public enum RestApiAuthorizerType /// /// /// This attribute must be used in conjunction with the . - /// The authorizer function should return . + /// The authorizer function should return APIGatewayCustomAuthorizerResponse. /// /// /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj index 4e40ae948..7a94d47c5 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj @@ -12,6 +12,7 @@ true 1.11.0 + true From 98a1f28efbc46bd1847d54446f53d528a0ed67a5 Mon Sep 17 00:00:00 2001 From: Philippe El Asmar <53088140+philasmar@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:54:57 -0400 Subject: [PATCH 34/47] chore: improve test flakiness (#2325) * chore: improve test flakiness * update changelog --- .../9cf844c3-1a90-46ec-9430-93724dcb4512.json | 11 ++++++++ .../a119819e-ba57-424d-a0be-d941eea599f9.json | 11 ++++++++ .../Helpers/ConsoleLoggerWriter.cs | 5 ++-- .../HandlerTests.cs | 14 +++++++--- .../LambdaBootstrapTests.cs | 6 ++--- .../BaseApiGatewayTest.cs | 1 + .../SQSEventSourceTests.cs | 2 +- .../Helpers/TestHelpers.cs | 17 +++++++++--- .../RuntimeApiTests.cs | 4 +++ .../Amazon.Lambda.TestTool/TestToolStartup.cs | 27 +++++++++---------- 10 files changed, 71 insertions(+), 27 deletions(-) create mode 100644 .autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json create mode 100644 .autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json diff --git a/.autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json b/.autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json new file mode 100644 index 000000000..880709e72 --- /dev/null +++ b/.autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.TestTool.BlazorTester", + "Type": "Patch", + "ChangelogMessages": [ + "Minor fixes to improve the testability of the package" + ] + } + ] +} \ No newline at end of file diff --git a/.autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json b/.autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json new file mode 100644 index 000000000..7b2746bbb --- /dev/null +++ b/.autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.RuntimeSupport", + "Type": "Patch", + "ChangelogMessages": [ + "Minor fixes to improve the testability of the package" + ] + } + ] +} \ No newline at end of file diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Helpers/ConsoleLoggerWriter.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Helpers/ConsoleLoggerWriter.cs index 2caa708e3..a2417cbcc 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Helpers/ConsoleLoggerWriter.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Helpers/ConsoleLoggerWriter.cs @@ -227,6 +227,7 @@ public LogLevelLoggerWriter(IEnvironmentVariables environmentVariables) /// public LogLevelLoggerWriter(TextWriter stdOutWriter, TextWriter stdErrorWriter) { + _environmentVariables = new SystemEnvironmentVariables(); Initialize(stdOutWriter, stdErrorWriter); } @@ -325,7 +326,7 @@ public IRuntimeApiHeaders CurrentRuntimeApiHeaders { get { - if (Utils.IsUsingMultiConcurrency(_environmentVariables)) + if (_currentRuntimeApiHeadersStorage != null && Utils.IsUsingMultiConcurrency(_environmentVariables)) { return _currentRuntimeApiHeadersStorage.Value; } @@ -333,7 +334,7 @@ public IRuntimeApiHeaders CurrentRuntimeApiHeaders } set { - if (Utils.IsUsingMultiConcurrency(_environmentVariables)) + if (_currentRuntimeApiHeadersStorage != null && Utils.IsUsingMultiConcurrency(_environmentVariables)) { _currentRuntimeApiHeadersStorage.Value = value; } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs index 80f9d13d0..20340f561 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/HandlerTests.cs @@ -250,7 +250,7 @@ private async Task TestHandlerFailAsync(string handler, string expect var userCodeLoader = new UserCodeLoader(new SystemEnvironmentVariables(), handler, _internalLogger); var initializer = new UserCodeInitializer(userCodeLoader, _internalLogger); var handlerWrapper = HandlerWrapper.GetHandlerWrapper(userCodeLoader.Invoke); - var bootstrap = new LambdaBootstrap(handlerWrapper, initializer.InitializeAsync) + var bootstrap = new LambdaBootstrap(handlerWrapper.Handler, initializer.InitializeAsync, null, _environmentVariables) { Client = testRuntimeApiClient }; @@ -388,7 +388,9 @@ private async Task ExecHandlerAsync(string handler, string dataIn var userCodeLoader = new UserCodeLoader(new SystemEnvironmentVariables(), handler, _internalLogger); var handlerWrapper = HandlerWrapper.GetHandlerWrapper(userCodeLoader.Invoke); var initializer = new UserCodeInitializer(userCodeLoader, _internalLogger); - var bootstrap = new LambdaBootstrap(handlerWrapper, initializer.InitializeAsync) + // Pass null initializer to bootstrap so RunAsync won't re-invoke Init(), + // which would re-register AssemblyLoad event handlers and re-construct the invoke delegate. + var bootstrap = new LambdaBootstrap(handlerWrapper.Handler, null, null, _environmentVariables) { Client = testRuntimeApiClient }; @@ -403,7 +405,13 @@ private async Task ExecHandlerAsync(string handler, string dataIn Assert.DoesNotContain($"^^[{assertLoggedByInitialize}]^^", actionWriter.ToString()); } - await bootstrap.InitializeAsync(); + await initializer.InitializeAsync(); + + // Re-set logging actions after initialization in case Init's AssemblyLoad event + // handler overwrote them when loading Amazon.Lambda.Core as a handler dependency. + UserCodeLoader.SetCustomerLoggerLogAction(assembly, actionWriter.ToLoggingAction(), _internalLogger); + UserCodeLoader.SetCustomerLoggerLogAction(assembly, actionWriter.ToLoggingWithLevelAction(), _internalLogger); + UserCodeLoader.SetCustomerLoggerLogAction(assembly, actionWriter.ToLoggingWithLevelAndExceptionAction(), _internalLogger); if (assertLoggedByInitialize != null) { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs index e1636ff16..97b40cb60 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaBootstrapTests.cs @@ -165,7 +165,7 @@ public async Task TraceIdEnvironmentVariableIsSet() [Fact] public async Task HandlerThrowsException() { - using (var bootstrap = new LambdaBootstrap(_testFunction.BaseHandlerThrowsAsync, null)) + using (var bootstrap = new LambdaBootstrap(_testFunction.BaseHandlerThrowsAsync, null, null, _environmentVariables)) { bootstrap.Client = _testRuntimeApiClient; Assert.Null(_environmentVariables.GetEnvironmentVariable(LambdaEnvironment.EnvVarTraceId)); @@ -183,7 +183,7 @@ public async Task HandlerInputAndOutputWork() { const string testInput = "a MiXeD cAsE sTrInG"; - using (var bootstrap = new LambdaBootstrap(_testFunction.BaseHandlerToUpperAsync, null)) + using (var bootstrap = new LambdaBootstrap(_testFunction.BaseHandlerToUpperAsync, null, null, _environmentVariables)) { _testRuntimeApiClient.FunctionInput = Encoding.UTF8.GetBytes(testInput); bootstrap.Client = _testRuntimeApiClient; @@ -201,7 +201,7 @@ public async Task HandlerInputAndOutputWork() [Fact] public async Task HandlerReturnsNull() { - using (var bootstrap = new LambdaBootstrap(_testFunction.BaseHandlerReturnsNullAsync, null)) + using (var bootstrap = new LambdaBootstrap(_testFunction.BaseHandlerReturnsNullAsync, null, null, _environmentVariables)) { _testRuntimeApiClient.FunctionInput = new byte[0]; bootstrap.Client = _testRuntimeApiClient; diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/BaseApiGatewayTest.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/BaseApiGatewayTest.cs index f277ffa2a..c9998141c 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/BaseApiGatewayTest.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/BaseApiGatewayTest.cs @@ -48,6 +48,7 @@ protected async Task CleanupAsync() CancellationTokenSource.Dispose(); CancellationTokenSource = new CancellationTokenSource(); } + Environment.SetEnvironmentVariable("APIGATEWAY_EMULATOR_ROUTE_CONFIG", null); } protected async Task StartTestToolProcessAsync(ApiGatewayEmulatorMode apiGatewayMode, string routeName, int lambdaPort, int apiGatewayPort, CancellationTokenSource cancellationTokenSource, string httpMethod = "POST") diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/SQSEventSourceTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/SQSEventSourceTests.cs index fc4aa1882..f02ee3abe 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/SQSEventSourceTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/SQSEventSourceTests.cs @@ -183,7 +183,7 @@ public async Task ProcessMessagesFromMultipleEventSources() await sqsClient.SendMessageAsync(queueUrl2, "MessageFromQueue2"); var startTime = DateTime.UtcNow; - while (listOfProcessedMessages.Count == 0 && DateTime.UtcNow < startTime.AddMinutes(2)) + while (listOfProcessedMessages.Count < 2 && DateTime.UtcNow < startTime.AddMinutes(2)) { Assert.False(lambdaTask.IsFaulted, "Lambda function failed: " + lambdaTask.Exception?.ToString()); await Task.Delay(500); diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.Tests.Common/Helpers/TestHelpers.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.Tests.Common/Helpers/TestHelpers.cs index 79c618d48..9def24fe2 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.Tests.Common/Helpers/TestHelpers.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.Tests.Common/Helpers/TestHelpers.cs @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +using System.Net; +using System.Net.Sockets; + namespace Amazon.Lambda.TestTool.Tests.Common.Helpers; public static class TestHelpers @@ -39,16 +42,22 @@ public static async Task SendRequest(string url) } } - private static int _maxLambdaRuntimePort = 6000; - private static int _maxApiGatewayPort = 9000; + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } public static int GetNextLambdaRuntimePort() { - return Interlocked.Increment(ref _maxLambdaRuntimePort); + return GetFreePort(); } public static int GetNextApiGatewayPort() { - return Interlocked.Increment(ref _maxApiGatewayPort); + return GetFreePort(); } } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs index 998460411..cb2afa01a 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/RuntimeApiTests.cs @@ -39,6 +39,8 @@ public async Task AddEventToDataStore() var testToolProcess = TestToolProcess.Startup(options, cancellationTokenSource.Token); try { + Assert.True(await TestHelpers.WaitForApiToStartAsync($"{testToolProcess.ServiceUrl}/lambda-runtime-api/healthcheck")); + var lambdaClient = ConstructLambdaServiceClient(testToolProcess.ServiceUrl); var invokeFunction = new InvokeRequest { @@ -92,6 +94,8 @@ public async Task InvokeRequestResponse() var testToolProcess = TestToolProcess.Startup(options, cancellationTokenSource.Token); try { + Assert.True(await TestHelpers.WaitForApiToStartAsync($"{testToolProcess.ServiceUrl}/lambda-runtime-api/healthcheck")); + var handler = (string input, ILambdaContext context) => { Thread.Sleep(1000); // Add a sleep to prove the LambdaRuntimeApi waited for the completion. diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/TestToolStartup.cs b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/TestToolStartup.cs index 9693d64e4..87353be34 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/TestToolStartup.cs +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool/TestToolStartup.cs @@ -10,7 +10,6 @@ namespace Amazon.Lambda.TestTool { public class TestToolStartup { - private static bool shouldDisableLogs; public class RunConfiguration { @@ -37,7 +36,7 @@ public static void Startup(string productName, Action try { var commandOptions = CommandLineOptions.Parse(args); - shouldDisableLogs = Utils.ShouldDisableLogs(commandOptions); + var shouldDisableLogs = Utils.ShouldDisableLogs(commandOptions); if (!shouldDisableLogs) Utils.PrintToolTitle(productName); @@ -76,7 +75,7 @@ public static void Startup(string productName, Action if (commandOptions.NoUI) { - ExecuteWithNoUi(localLambdaOptions, commandOptions, lambdaAssemblyDirectory, runConfiguration); + ExecuteWithNoUi(localLambdaOptions, commandOptions, lambdaAssemblyDirectory, runConfiguration, shouldDisableLogs); } else { @@ -118,16 +117,16 @@ public static void Startup(string productName, Action } - public static void ExecuteWithNoUi(LocalLambdaOptions localLambdaOptions, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, RunConfiguration runConfiguration) + public static void ExecuteWithNoUi(LocalLambdaOptions localLambdaOptions, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, RunConfiguration runConfiguration, bool shouldDisableLogs) { if (!shouldDisableLogs) runConfiguration.OutputWriter.WriteLine("Executing Lambda function without web interface"); var lambdaProjectDirectory = Utils.FindLambdaProjectDirectory(lambdaAssemblyDirectory); - string configFile = DetermineConfigFile(commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory); - LambdaConfigInfo configInfo = LoadLambdaConfigInfo(configFile, commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, runConfiguration); - LambdaFunction lambdaFunction = LoadLambdaFunction(configInfo, localLambdaOptions, commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, runConfiguration); + string configFile = DetermineConfigFile(commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, shouldDisableLogs: shouldDisableLogs); + LambdaConfigInfo configInfo = LoadLambdaConfigInfo(configFile, commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, runConfiguration, shouldDisableLogs: shouldDisableLogs); + LambdaFunction lambdaFunction = LoadLambdaFunction(configInfo, localLambdaOptions, commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, runConfiguration, shouldDisableLogs: shouldDisableLogs); - string payload = DeterminePayload(localLambdaOptions, commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, runConfiguration); + string payload = DeterminePayload(localLambdaOptions, commandOptions, lambdaAssemblyDirectory: lambdaAssemblyDirectory, lambdaProjectDirectory: lambdaProjectDirectory, runConfiguration, shouldDisableLogs: shouldDisableLogs); var awsProfile = commandOptions.AWSProfile ?? configInfo.AWSProfile; if (!string.IsNullOrEmpty(awsProfile)) @@ -166,7 +165,7 @@ public static void ExecuteWithNoUi(LocalLambdaOptions localLambdaOptions, Comman Function = lambdaFunction }; - ExecuteRequest(request, localLambdaOptions, runConfiguration); + ExecuteRequest(request, localLambdaOptions, runConfiguration, shouldDisableLogs); if (runConfiguration.Mode == RunConfiguration.RunMode.Normal && commandOptions.PauseExit) @@ -176,7 +175,7 @@ public static void ExecuteWithNoUi(LocalLambdaOptions localLambdaOptions, Comman } } - private static string DetermineConfigFile(CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory) + private static string DetermineConfigFile(CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, bool shouldDisableLogs) { string configFile = null; if (string.IsNullOrEmpty(commandOptions.ConfigFile)) @@ -199,7 +198,7 @@ private static string DetermineConfigFile(CommandLineOptions commandOptions, str return configFile; } - private static LambdaConfigInfo LoadLambdaConfigInfo(string configFile, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, RunConfiguration runConfiguration) + private static LambdaConfigInfo LoadLambdaConfigInfo(string configFile, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, RunConfiguration runConfiguration, bool shouldDisableLogs) { LambdaConfigInfo configInfo; if (configFile != null) @@ -226,7 +225,7 @@ private static LambdaConfigInfo LoadLambdaConfigInfo(string configFile, CommandL return configInfo; } - private static LambdaFunction LoadLambdaFunction(LambdaConfigInfo configInfo, LocalLambdaOptions localLambdaOptions, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, RunConfiguration runConfiguration) + private static LambdaFunction LoadLambdaFunction(LambdaConfigInfo configInfo, LocalLambdaOptions localLambdaOptions, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, RunConfiguration runConfiguration, bool shouldDisableLogs) { // If no function handler was explicitly set and there is only one function defined in the config file then assume the user wants to debug that function. var functionHandler = commandOptions.FunctionHandler; @@ -264,7 +263,7 @@ private static LambdaFunction LoadLambdaFunction(LambdaConfigInfo configInfo, Lo return lambdaFunction; } - private static string DeterminePayload(LocalLambdaOptions localLambdaOptions, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, RunConfiguration runConfiguration) + private static string DeterminePayload(LocalLambdaOptions localLambdaOptions, CommandLineOptions commandOptions, string lambdaAssemblyDirectory, string lambdaProjectDirectory, RunConfiguration runConfiguration, bool shouldDisableLogs) { var payload = commandOptions.Payload; @@ -346,7 +345,7 @@ private static string DeterminePayload(LocalLambdaOptions localLambdaOptions, Co return payload; } - private static void ExecuteRequest(ExecutionRequest request, LocalLambdaOptions localLambdaOptions, RunConfiguration runConfiguration) + private static void ExecuteRequest(ExecutionRequest request, LocalLambdaOptions localLambdaOptions, RunConfiguration runConfiguration, bool shouldDisableLogs) { try { From 49d88391861720afb3f7d5ec8d809c434bbc3415 Mon Sep 17 00:00:00 2001 From: aws-sdk-dotnet-automation <43080735+aws-sdk-dotnet-automation@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:33:22 -0400 Subject: [PATCH 35/47] Release 2026-04-13 #2 (#2330) --- .../changes/7d00dacc-cece-4fcf-903b-f84b894eaf9e.json | 11 ----------- .autover/changes/add-s3event-annotation.json | 11 ----------- CHANGELOG.md | 6 ++++++ .../Amazon.Lambda.Annotations.SourceGenerator.csproj | 2 +- .../Amazon.Lambda.Annotations.csproj | 2 +- .../test/TestCustomAuthorizerApp/serverless.template | 2 +- .../TestExecutableServerlessApp/serverless.template | 2 +- .../test/TestServerlessApp.ALB/serverless.template | 2 +- .../test/TestServerlessApp.NET8/serverless.template | 2 +- Libraries/test/TestServerlessApp/serverless.template | 2 +- 10 files changed, 13 insertions(+), 29 deletions(-) delete mode 100644 .autover/changes/7d00dacc-cece-4fcf-903b-f84b894eaf9e.json delete mode 100644 .autover/changes/add-s3event-annotation.json diff --git a/.autover/changes/7d00dacc-cece-4fcf-903b-f84b894eaf9e.json b/.autover/changes/7d00dacc-cece-4fcf-903b-f84b894eaf9e.json deleted file mode 100644 index 2dae4e61f..000000000 --- a/.autover/changes/7d00dacc-cece-4fcf-903b-f84b894eaf9e.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.Annotations", - "Type": "Patch", - "ChangelogMessages": [ - "treat warnings as errors and fix unshipped.md" - ] - } - ] -} \ No newline at end of file diff --git a/.autover/changes/add-s3event-annotation.json b/.autover/changes/add-s3event-annotation.json deleted file mode 100644 index 90bdc8edf..000000000 --- a/.autover/changes/add-s3event-annotation.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.Annotations", - "Type": "Minor", - "ChangelogMessages": [ - "Added [S3Event] annotation attribute for declaratively configuring S3 event-triggered Lambda functions with support for bucket reference, event types, key prefix/suffix filters, and enabled state." - ] - } - ] -} diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2605741..b541fbd38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Release 2026-04-13 #2 + +### Amazon.Lambda.Annotations (1.12.0) +* treat warnings as errors and fix unshipped.md +* Added [S3Event] annotation attribute for declaratively configuring S3 event-triggered Lambda functions with support for bucket reference, event types, key prefix/suffix filters, and enabled state. + ## Release 2026-04-08 ### Amazon.Lambda.Annotations (1.11.0) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj index 8a2852870..0e90d254a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj @@ -20,7 +20,7 @@ true false - 1.11.0 + 1.12.0 true diff --git a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj index 7a94d47c5..1fcc58ea6 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj @@ -11,7 +11,7 @@ ..\..\..\buildtools\public.snk true - 1.11.0 + 1.12.0 true diff --git a/Libraries/test/TestCustomAuthorizerApp/serverless.template b/Libraries/test/TestCustomAuthorizerApp/serverless.template index 7aad04bcd..a9bd4fc47 100644 --- a/Libraries/test/TestCustomAuthorizerApp/serverless.template +++ b/Libraries/test/TestCustomAuthorizerApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index 93ebd4920..65ce726fb 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", diff --git a/Libraries/test/TestServerlessApp.ALB/serverless.template b/Libraries/test/TestServerlessApp.ALB/serverless.template index 62c1fa590..71b1349ae 100644 --- a/Libraries/test/TestServerlessApp.ALB/serverless.template +++ b/Libraries/test/TestServerlessApp.ALB/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", + "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", "Resources": { "ALBTestVPC": { "Type": "AWS::EC2::VPC", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index 8e1cc1793..b8c707f54 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", "Resources": { "TestServerlessAppNET8FunctionsToUpperGenerated": { "Type": "AWS::Serverless::Function", diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index d5b4b3cb0..82ed9f7d4 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.11.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", "Resources": { "TestQueue": { "Type": "AWS::SQS::Queue" From d984c37eccc8c50262666d833ea728101a5cbf40 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Tue, 14 Apr 2026 13:17:17 -0400 Subject: [PATCH 36/47] Add [FunctionUrl] annotation attribute with CORS support and source generator (#2324) * Phase 1: Add FunctionUrlAttribute with source generator wiring and CloudFormation FunctionUrlConfig generation - New FunctionUrlAttribute class with AuthType property (NONE/AWS_IAM) - New FunctionUrlAuthType enum - Source generator detects FunctionUrlAttribute and maps to EventType.API - Generated wrapper uses HttpApi V2 request/response types (same payload format) - CloudFormationWriter emits FunctionUrlConfig on the function resource - Dependency validation checks for Amazon.Lambda.APIGatewayEvents - SyntaxReceiver detects missing [LambdaFunction] on [FunctionUrl] methods - 6 new unit tests for CloudFormation template generation (JSON + YAML) Phase 2: Add CORS support to FunctionUrlAttribute - AllowOrigins, AllowMethods, AllowHeaders, ExposeHeaders, AllowCredentials, MaxAge properties - FunctionUrlAttributeBuilder parses all CORS properties from AttributeData - CloudFormationWriter emits Cors block under FunctionUrlConfig only when CORS properties are set - 4 new unit tests for CORS generation and no-CORS scenarios Phase 3: FunctionUrlConfig orphan cleanup and attribute switching - Remove FunctionUrlConfig from template when [FunctionUrl] attribute is removed - Clean transition when switching from [FunctionUrl] to [HttpApi] or [RestApi] - 4 new unit tests for orphan cleanup and attribute switching scenarios Phase 4: End-to-end source generator test for FunctionUrl - FunctionUrlExample.cs test source with [FunctionUrl] + [FromQuery] + IHttpResult - Generated wrapper snapshot using HttpApi V2 payload format - Serverless template snapshot with FunctionUrlConfig - Full Roslyn source generator verification test IT tests Update Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> copilot comments change file fix cleanup add header PR comments * fix template * fix test --- .../function-url-annotations-support.json | 11 + .../Attributes/AttributeModelBuilder.cs | 14 +- .../Attributes/FunctionUrlAttributeBuilder.cs | 48 +++ .../Models/EventTypeBuilder.cs | 6 +- .../Models/GeneratedMethodModelBuilder.cs | 25 ++ .../SyntaxReceiver.cs | 8 +- .../TypeFullNames.cs | 9 +- .../Validation/LambdaFunctionValidator.cs | 6 +- .../Writers/CloudFormationWriter.cs | 69 ++- .../APIGateway/FunctionUrlAttribute.cs | 51 +++ .../APIGateway/FunctionUrlAuthType.cs | 21 + .../src/Amazon.Lambda.Annotations/README.md | 107 +++++ ...FunctionUrlExample_GetItems_Generated.g.cs | 98 +++++ .../functionUrlExample.template | 31 ++ .../SourceGeneratorTests.cs | 41 ++ .../WriterTests/FunctionUrlTests.cs | 408 ++++++++++++++++++ .../IntegrationTests.Helpers/LambdaHelper.cs | 11 + .../FunctionUrlExample.cs | 103 +++++ .../IntegrationTestContextFixture.cs | 16 +- .../TestServerlessApp/FunctionUrlExample.cs | 20 + .../aws-lambda-tools-defaults.json | 8 +- .../TestServerlessApp/serverless.template | 150 ++++--- 22 files changed, 1182 insertions(+), 79 deletions(-) create mode 100644 .autover/changes/function-url-annotations-support.json create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs create mode 100644 Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs create mode 100644 Libraries/test/TestServerlessApp/FunctionUrlExample.cs diff --git a/.autover/changes/function-url-annotations-support.json b/.autover/changes/function-url-annotations-support.json new file mode 100644 index 000000000..24ad4e288 --- /dev/null +++ b/.autover/changes/function-url-annotations-support.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [FunctionUrl] attribute for configuring Lambda functions with Function URL endpoints, including optional CORS support" + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index 0d1067bb6..d8715c047 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; @@ -101,6 +104,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.FunctionUrlAttribute), SymbolEqualityComparer.Default)) + { + var data = FunctionUrlAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); @@ -166,4 +178,4 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext return model; } } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs new file mode 100644 index 000000000..48bb69ea8 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/FunctionUrlAttributeBuilder.cs @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Linq; +using Amazon.Lambda.Annotations.APIGateway; +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + public static class FunctionUrlAttributeBuilder + { + public static FunctionUrlAttribute Build(AttributeData att) + { + var authType = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AuthType").Value.Value; + + var data = new FunctionUrlAttribute + { + AuthType = authType == null ? FunctionUrlAuthType.NONE : (FunctionUrlAuthType)authType + }; + + var allowOrigins = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowOrigins").Value; + if (!allowOrigins.IsNull) + data.AllowOrigins = allowOrigins.Values.Select(v => v.Value as string).ToArray(); + + var allowMethods = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowMethods").Value; + if (!allowMethods.IsNull) + data.AllowMethods = allowMethods.Values.Select(v => (LambdaHttpMethod)(int)v.Value).ToArray(); + + var allowHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowHeaders").Value; + if (!allowHeaders.IsNull) + data.AllowHeaders = allowHeaders.Values.Select(v => v.Value as string).ToArray(); + + var exposeHeaders = att.NamedArguments.FirstOrDefault(arg => arg.Key == "ExposeHeaders").Value; + if (!exposeHeaders.IsNull) + data.ExposeHeaders = exposeHeaders.Values.Select(v => v.Value as string).ToArray(); + + var allowCredentials = att.NamedArguments.FirstOrDefault(arg => arg.Key == "AllowCredentials").Value.Value; + if (allowCredentials != null) + data.AllowCredentials = (bool)allowCredentials; + + var maxAge = att.NamedArguments.FirstOrDefault(arg => arg.Key == "MaxAge").Value.Value; + if (maxAge != null) + data.MaxAge = (int)maxAge; + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index 3dfc51799..d3c1f7fd0 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.Linq; @@ -18,7 +21,8 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, foreach (var attribute in lambdaMethodSymbol.GetAttributes()) { if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAttribute - || attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute) + || attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAttribute + || attribute.AttributeClass.ToDisplayString() == TypeFullNames.FunctionUrlAttribute) { events.Add(EventType.API); } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs index 2dcd58fe0..e3c6a020e 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/GeneratedMethodModelBuilder.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.Linq; @@ -144,6 +147,14 @@ private static TypeModel BuildResponseType(IMethodSymbol lambdaMethodSymbol, context.Compilation.GetTypeByMetadataName(TypeFullNames.ApplicationLoadBalancerResponse); return TypeModelBuilder.Build(symbol, context); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)) + { + // Function URLs use the same payload format as HTTP API v2 + var symbol = lambdaMethodModel.ReturnsVoidOrGenericTask ? + task.Construct(context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse)): + context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyResponse); + return TypeModelBuilder.Build(symbol, context); + } else { return lambdaMethodModel.ReturnType; @@ -304,6 +315,20 @@ private static IList BuildParameters(IMethodSymbol lambdaMethodS parameters.Add(requestParameter); parameters.Add(contextParameter); } + else if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute)) + { + // Function URLs use the same payload format as HTTP API v2 + var symbol = context.Compilation.GetTypeByMetadataName(TypeFullNames.APIGatewayHttpApiV2ProxyRequest); + var type = TypeModelBuilder.Build(symbol, context); + var requestParameter = new ParameterModel + { + Name = "__request__", + Type = type, + Documentation = "The Function URL request object that will be processed by the Lambda function handler." + }; + parameters.Add(requestParameter); + parameters.Add(contextParameter); + } else { // Lambda method with no event attribute are plain lambda functions, therefore, generated method will have diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index 2091d6c94..230525edd 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -1,4 +1,7 @@ -using System; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; using System.Collections.Generic; using System.Linq; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; @@ -21,6 +24,7 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "RestApiAuthorizerAttribute", "RestApiAuthorizer" }, { "HttpApiAttribute", "HttpApi" }, { "RestApiAttribute", "RestApi" }, + { "FunctionUrlAttribute", "FunctionUrl" }, { "SQSEventAttribute", "SQSEvent" }, { "ALBApiAttribute", "ALBApi" }, { "S3EventAttribute", "S3Event" } @@ -122,4 +126,4 @@ public void OnVisitSyntaxNode(GeneratorSyntaxContext context) } } } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 59fa1d830..4c66c1875 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System.Collections.Generic; namespace Amazon.Lambda.Annotations.SourceGenerator @@ -34,6 +37,9 @@ public static class TypeFullNames public const string FromRouteAttribute = "Amazon.Lambda.Annotations.APIGateway.FromRouteAttribute"; public const string FromCustomAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.FromCustomAuthorizerAttribute"; + public const string FunctionUrlAttribute = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAttribute"; + public const string FunctionUrlAuthType = "Amazon.Lambda.Annotations.APIGateway.FunctionUrlAuthType"; + public const string HttpApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.HttpApiAuthorizerAttribute"; public const string RestApiAuthorizerAttribute = "Amazon.Lambda.Annotations.APIGateway.RestApiAuthorizerAttribute"; @@ -82,9 +88,10 @@ public static class TypeFullNames { RestApiAttribute, HttpApiAttribute, + FunctionUrlAttribute, SQSEventAttribute, ALBApiAttribute, S3EventAttribute }; } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 7e728660a..4ea09acdf 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.ALB; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.Extensions; @@ -71,6 +74,7 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe { // Check for references to "Amazon.Lambda.APIGatewayEvents" if the Lambda method is annotated with RestApi, HttpApi, or authorizer attributes. if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAttribute) + || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.FunctionUrlAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.HttpApiAuthorizerAttribute) || lambdaMethodSymbol.HasAttribute(context, TypeFullNames.RestApiAuthorizerAttribute)) { if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.APIGatewayEvents") == null) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index a72e3241b..adfa53ae5 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -1,4 +1,7 @@ -using Amazon.Lambda.Annotations.ALB; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.ALB; using Amazon.Lambda.Annotations.APIGateway; using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; @@ -206,6 +209,7 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la var currentSyncedEvents = new List(); var currentSyncedEventProperties = new Dictionary>(); var currentAlbResources = new List(); + var hasFunctionUrl = false; foreach (var attributeModel in lambdaFunction.Attributes) { @@ -232,6 +236,23 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la eventName = ProcessS3Attribute(lambdaFunction, s3AttributeModel.Data, currentSyncedEventProperties); currentSyncedEvents.Add(eventName); break; + case AttributeModel functionUrlAttributeModel: + ProcessFunctionUrlAttribute(lambdaFunction, functionUrlAttributeModel.Data); + _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig", true); + hasFunctionUrl = true; + break; + } + } + + // Remove FunctionUrlConfig only if it was previously created by Annotations (tracked via metadata). + // This preserves any manually-added FunctionUrlConfig that was not created by the source generator. + if (!hasFunctionUrl) + { + var syncedFunctionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig"; + if (_templateWriter.GetToken(syncedFunctionUrlConfigPath, false)) + { + _templateWriter.RemoveToken($"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"); + _templateWriter.RemoveToken(syncedFunctionUrlConfigPath); } } @@ -302,6 +323,50 @@ private string ProcessHttpApiAttribute(ILambdaFunctionSerializable lambdaFunctio return eventName; } + /// + /// Writes the configuration to the serverless template. + /// Unlike HttpApi/RestApi, Function URLs are configured as a property on the function resource + /// rather than as an event source. + /// + private void ProcessFunctionUrlAttribute(ILambdaFunctionSerializable lambdaFunction, FunctionUrlAttribute functionUrlAttribute) + { + var functionUrlConfigPath = $"Resources.{lambdaFunction.ResourceName}.Properties.FunctionUrlConfig"; + _templateWriter.SetToken($"{functionUrlConfigPath}.AuthType", functionUrlAttribute.AuthType.ToString()); + + // Always remove the existing Cors block first to clear any stale properties + // from a previous generation pass, then re-emit only the currently configured values. + var corsPath = $"{functionUrlConfigPath}.Cors"; + _templateWriter.RemoveToken(corsPath); + + var hasCors = functionUrlAttribute.AllowOrigins != null + || functionUrlAttribute.AllowMethods != null + || functionUrlAttribute.AllowHeaders != null + || functionUrlAttribute.ExposeHeaders != null + || functionUrlAttribute.AllowCredentials + || functionUrlAttribute.MaxAge > 0; + + if (hasCors) + { + if (functionUrlAttribute.AllowOrigins != null) + _templateWriter.SetToken($"{corsPath}.AllowOrigins", new List(functionUrlAttribute.AllowOrigins), TokenType.List); + + if (functionUrlAttribute.AllowMethods != null) + _templateWriter.SetToken($"{corsPath}.AllowMethods", functionUrlAttribute.AllowMethods.Select(m => m == LambdaHttpMethod.Any ? "*" : m.ToString().ToUpper()).ToList(), TokenType.List); + + if (functionUrlAttribute.AllowHeaders != null) + _templateWriter.SetToken($"{corsPath}.AllowHeaders", new List(functionUrlAttribute.AllowHeaders), TokenType.List); + + if (functionUrlAttribute.ExposeHeaders != null) + _templateWriter.SetToken($"{corsPath}.ExposeHeaders", new List(functionUrlAttribute.ExposeHeaders), TokenType.List); + + if (functionUrlAttribute.AllowCredentials) + _templateWriter.SetToken($"{corsPath}.AllowCredentials", true); + + if (functionUrlAttribute.MaxAge > 0) + _templateWriter.SetToken($"{corsPath}.MaxAge", functionUrlAttribute.MaxAge); + } + } + /// /// Processes all authorizers and writes them to the serverless template as inline authorizers within the API resources. /// AWS SAM expects authorizers to be defined within the Auth.Authorizers property of AWS::Serverless::HttpApi or AWS::Serverless::Api resources. @@ -1182,4 +1247,4 @@ private void SynchronizeEventsAndProperties(List syncedEvents, Dictionar _templateWriter.SetToken(syncedEventPropertiesPath, syncedEventProperties, TokenType.KeyVal); } } -} \ No newline at end of file +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs new file mode 100644 index 000000000..a92387762 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAttribute.cs @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; + +namespace Amazon.Lambda.Annotations.APIGateway +{ + /// + /// Configures the Lambda function to be invoked via a Lambda Function URL. + /// + /// + /// Function URLs use the same payload format as HTTP API v2 (APIGatewayHttpApiV2ProxyRequest/Response). + /// + [AttributeUsage(AttributeTargets.Method)] + public class FunctionUrlAttribute : Attribute + { + /// + public FunctionUrlAuthType AuthType { get; set; } = FunctionUrlAuthType.NONE; + + /// + /// The allowed origins for CORS requests. Example: new[] { "https://example.com" } + /// + public string[] AllowOrigins { get; set; } + + /// + /// The allowed HTTP methods for CORS requests. Example: new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post } + /// + public LambdaHttpMethod[] AllowMethods { get; set; } + + /// + /// The allowed headers for CORS requests. + /// + public string[] AllowHeaders { get; set; } + + /// + /// Whether credentials are included in the CORS request. + /// + public bool AllowCredentials { get; set; } + + /// + /// The expose headers for CORS responses. + /// + public string[] ExposeHeaders { get; set; } + + /// + /// The maximum time in seconds that a browser can cache the CORS preflight response. + /// A value of 0 means the property is not set. + /// + public int MaxAge { get; set; } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs new file mode 100644 index 000000000..31a1c2397 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/APIGateway/FunctionUrlAuthType.cs @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.Annotations.APIGateway +{ + /// + /// The type of authentication for a Lambda Function URL. + /// + public enum FunctionUrlAuthType + { + /// + /// No authentication. Anyone with the Function URL can invoke the function. + /// + NONE, + + /// + /// IAM authentication. Only authenticated IAM users and roles can invoke the function. + /// + AWS_IAM + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/README.md b/Libraries/src/Amazon.Lambda.Annotations/README.md index d736e06be..45bce6783 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/README.md +++ b/Libraries/src/Amazon.Lambda.Annotations/README.md @@ -20,6 +20,7 @@ Topics: - [Amazon S3 example](#amazon-s3-example) - [SQS Event Example](#sqs-event-example) - [Application Load Balancer (ALB) Example](#application-load-balancer-alb-example) + - [Lambda Function URL Example](#lambda-function-url-example) - [Custom Lambda Authorizer Example](#custom-lambda-authorizer-example) - [HTTP API Authorizer](#http-api-authorizer) - [REST API Authorizer](#rest-api-authorizer) @@ -1073,6 +1074,110 @@ The `ALBApi` attribute requires an existing ALB listener. Here is a minimal exam Then your Lambda function references `@MyListener` in the `ALBApi` attribute. +## Lambda Function URL Example + +[Lambda Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) provide a dedicated HTTPS endpoint for your Lambda function without needing API Gateway or an Application Load Balancer. The `FunctionUrl` attribute configures the function to be invoked via a Function URL. Function URLs use the same payload format as HTTP API v2 (`APIGatewayHttpApiV2ProxyRequest`/`APIGatewayHttpApiV2ProxyResponse`). + +The `FunctionUrl` attribute contains the following properties: + +| Property | Type | Required | Default | Description | +|---|---|---|---|---| +| `AuthType` | `FunctionUrlAuthType` | No | `NONE` | The authentication type: `NONE` (public) or `AWS_IAM` (IAM-authenticated). | +| `AllowOrigins` | `string[]` | No | `null` | Allowed origins for CORS requests (e.g., `new[] { "https://example.com" }`). | +| `AllowMethods` | `LambdaHttpMethod[]` | No | `null` | Allowed HTTP methods for CORS requests, using the `LambdaHttpMethod` enum (e.g., `new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }`). | +| `AllowHeaders` | `string[]` | No | `null` | Allowed headers for CORS requests. | +| `ExposeHeaders` | `string[]` | No | `null` | Headers to expose in CORS responses. | +| `AllowCredentials` | `bool` | No | `false` | Whether credentials are included in the CORS request. | +| `MaxAge` | `int` | No | `0` | Maximum time in seconds that a browser can cache the CORS preflight response. `0` means not set. | + +### Basic Example + +A simple function with a public Function URL (no authentication): + +```csharp +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Core; + +public class Functions +{ + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [FunctionUrl(AuthType = FunctionUrlAuthType.NONE)] + public IHttpResult GetItems([FromQuery] string category, ILambdaContext context) + { + context.Logger.LogLine($"Getting items for category: {category}"); + return HttpResults.Ok(new { items = new[] { "item1", "item2" }, category }); + } +} +``` + +### With IAM Authentication + +Use `FunctionUrlAuthType.AWS_IAM` to require IAM authentication for the Function URL: + +```csharp +[LambdaFunction(PackageType = LambdaPackageType.Image)] +[FunctionUrl(AuthType = FunctionUrlAuthType.AWS_IAM)] +public IHttpResult SecureEndpoint(ILambdaContext context) +{ + return HttpResults.Ok(new { message = "This endpoint requires IAM auth" }); +} +``` + +### With CORS Configuration + +Configure CORS settings directly on the attribute. The `AllowMethods` property uses the type-safe `LambdaHttpMethod` enum, consistent with the `HttpApi` and `RestApi` attributes: + +```csharp +[LambdaFunction(PackageType = LambdaPackageType.Image)] +[FunctionUrl( + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com", "https://app.example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowHeaders = new[] { "Content-Type", "Authorization" }, + AllowCredentials = true, + MaxAge = 3600)] +public IHttpResult GetData([FromQuery] string id, ILambdaContext context) +{ + return HttpResults.Ok(new { id, data = "some data" }); +} +``` + +### Generated CloudFormation + +The source generator creates a `FunctionUrlConfig` property on the Lambda function resource (not a SAM event source). Here is an example with CORS: + +```json +"GetDataFunction": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedFunctionUrlConfig": true + }, + "Properties": { + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": ["MyAssembly::MyNamespace.Functions_GetData_Generated::GetData"] + }, + "MemorySize": 512, + "Timeout": 30, + "FunctionUrlConfig": { + "AuthType": "NONE", + "Cors": { + "AllowOrigins": ["https://example.com", "https://app.example.com"], + "AllowMethods": ["GET", "POST"], + "AllowHeaders": ["Content-Type", "Authorization"], + "AllowCredentials": true, + "MaxAge": 3600 + } + } + } +} +``` + +> **Note:** Unlike `HttpApi` and `RestApi` which create SAM event sources, `FunctionUrl` configures the `FunctionUrlConfig` property directly on the function resource. If the `FunctionUrl` attribute is removed from the code, the source generator will automatically clean up the `FunctionUrlConfig` from the CloudFormation template. + ## Custom Lambda Authorizer Example Lambda Annotations supports defining custom Lambda authorizers using attributes. Custom authorizers let you control access to your API Gateway endpoints by running a Lambda function that validates tokens or request parameters before the target function is invoked. The source generator automatically wires up the authorizer resources and references in the CloudFormation template. @@ -1422,6 +1527,8 @@ parameter to the `LambdaFunction` must be the event object and the event source * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. * ALBApi * Configures the Lambda function to be called from an Application Load Balancer. The listener ARN (or `@ResourceName` template reference), path pattern, and priority are required. The source generator creates standalone CloudFormation resources (TargetGroup, ListenerRule, Lambda Permission) rather than SAM event types. +* FunctionUrl + * Configures the Lambda function to be invoked via a Lambda Function URL. Supports `AuthType` (`NONE` or `AWS_IAM`) and CORS configuration including `AllowMethods` (using the `LambdaHttpMethod` enum), `AllowOrigins`, `AllowHeaders`, `AllowCredentials`, and `MaxAge`. The source generator writes a `FunctionUrlConfig` property on the function resource rather than a SAM event source. ### Parameter Attributes diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs new file mode 100644 index 000000000..002cbfe60 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/FunctionUrlExample_GetItems_Generated.g.cs @@ -0,0 +1,98 @@ +// + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Amazon.Lambda.Core; +using Amazon.Lambda.Annotations.APIGateway; + +namespace TestServerlessApp +{ + public class FunctionUrlExample_GetItems_Generated + { + private readonly FunctionUrlExample functionUrlExample; + private readonly Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer serializer; + + /// + /// Default constructor. This constructor is used by Lambda to construct the instance. When invoked in a Lambda environment + /// the AWS credentials will come from the IAM role associated with the function and the AWS region will be set to the + /// region the Lambda function is executed in. + /// + public FunctionUrlExample_GetItems_Generated() + { + SetExecutionEnvironment(); + functionUrlExample = new FunctionUrlExample(); + serializer = new Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer(); + } + + /// + /// The generated Lambda function handler for + /// + /// The Function URL request object that will be processed by the Lambda function handler. + /// The ILambdaContext that provides methods for logging and describing the Lambda environment. + /// Result of the Lambda function execution + public System.IO.Stream GetItems(Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest __request__, Amazon.Lambda.Core.ILambdaContext __context__) + { + var validationErrors = new List(); + + var category = default(string); + if (__request__.QueryStringParameters?.ContainsKey("category") == true) + { + try + { + category = (string)Convert.ChangeType(__request__.QueryStringParameters["category"], typeof(string)); + } + catch (Exception e) when (e is InvalidCastException || e is FormatException || e is OverflowException || e is ArgumentException) + { + validationErrors.Add($"Value {__request__.QueryStringParameters["category"]} at 'category' failed to satisfy constraint: {e.Message}"); + } + } + + // return 400 Bad Request if there exists a validation error + if (validationErrors.Any()) + { + var errorResult = new Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyResponse + { + Body = @$"{{""message"": ""{validationErrors.Count} validation error(s) detected: {string.Join(",", validationErrors)}""}}", + Headers = new Dictionary + { + {"Content-Type", "application/json"}, + {"x-amzn-ErrorType", "ValidationException"} + }, + StatusCode = 400 + }; + var errorStream = new System.IO.MemoryStream(); + serializer.Serialize(errorResult, errorStream); + errorStream.Position = 0; + return errorStream; + } + + var httpResults = functionUrlExample.GetItems(category, __context__); + HttpResultSerializationOptions.ProtocolFormat serializationFormat = HttpResultSerializationOptions.ProtocolFormat.HttpApi; + HttpResultSerializationOptions.ProtocolVersion serializationVersion = HttpResultSerializationOptions.ProtocolVersion.V2; + var serializationOptions = new HttpResultSerializationOptions { Format = serializationFormat, Version = serializationVersion, Serializer = serializer }; + var response = httpResults.Serialize(serializationOptions); + return response; + } + + private static void SetExecutionEnvironment() + { + const string envName = "AWS_EXECUTION_ENV"; + + var envValue = new StringBuilder(); + + // If there is an existing execution environment variable add the annotations package as a suffix. + if(!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envName))) + { + envValue.Append($"{Environment.GetEnvironmentVariable(envName)}_"); + } + + envValue.Append("lib/amazon-lambda-annotations#{ANNOTATIONS_ASSEMBLY_VERSION}"); + + Environment.SetEnvironmentVariable(envName, envValue.ToString()); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template new file mode 100644 index 000000000..7187fc6ad --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/functionUrlExample.template @@ -0,0 +1,31 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v{ANNOTATIONS_ASSEMBLY_VERSION}).", + "Resources": { + "TestServerlessAppFunctionUrlExampleGetItemsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedFunctionUrlConfig": true + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestProject::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems" + ] + }, + "FunctionUrlConfig": { + "AuthType": "NONE" + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs index d14bb893f..ae19b36b0 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SourceGeneratorTests.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.IO; using System.Text; @@ -1917,6 +1920,44 @@ public async Task IAuthorizerResultHttpApiTest() Assert.Equal(expectedTemplateContent, actualTemplateContent); } + [Fact] + public async Task FunctionUrlExample() + { + var expectedTemplateContent = await ReadSnapshotContent(Path.Combine("Snapshots", "ServerlessTemplates", "functionUrlExample.template")); + var expectedGetItemsGenerated = await ReadSnapshotContent(Path.Combine("Snapshots", "FunctionUrlExample_GetItems_Generated.g.cs")); + + await new VerifyCS.Test + { + TestState = + { + Sources = + { + (Path.Combine("TestServerlessApp", "FunctionUrlExample.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "FunctionUrlExample.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaFunctionAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "LambdaStartupAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "LambdaStartupAttribute.cs"))), + (Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "FunctionUrlAttribute.cs"), await File.ReadAllTextAsync(Path.Combine("Amazon.Lambda.Annotations", "APIGateway", "FunctionUrlAttribute.cs"))), + (Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"), await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "AssemblyAttributes.cs"))), + }, + GeneratedSources = + { + ( + typeof(SourceGenerator.Generator), + "FunctionUrlExample_GetItems_Generated.g.cs", + SourceText.From(expectedGetItemsGenerated, Encoding.UTF8, SourceHashAlgorithm.Sha256) + ) + }, + ExpectedDiagnostics = + { + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info).WithArguments("FunctionUrlExample_GetItems_Generated.g.cs", expectedGetItemsGenerated), + new DiagnosticResult("AWSLambda0103", DiagnosticSeverity.Info).WithArguments($"TestServerlessApp{Path.DirectorySeparatorChar}serverless.template", expectedTemplateContent) + } + } + }.RunAsync(); + + var actualTemplateContent = await File.ReadAllTextAsync(Path.Combine("TestServerlessApp", "serverless.template")); + Assert.Equal(expectedTemplateContent, actualTemplateContent); + } + public void Dispose() { File.Delete(Path.Combine("TestServerlessApp", "serverless.template")); diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs new file mode 100644 index 000000000..18d5ce3a3 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/FunctionUrlTests.cs @@ -0,0 +1,408 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Annotations.SourceGenerator; +using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SourceGenerator.Writers; +using Xunit; + +namespace Amazon.Lambda.Annotations.SourceGenerators.Tests.WriterTests +{ + public partial class CloudFormationWriterTests + { + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithDefaultAuthType(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithIamAuth(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.AWS_IAM } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("AWS_IAM", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlDoesNotCreateEventEntry(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.Events")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithCors(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowHeaders = new[] { "Content-Type", "Authorization" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.Equal(new List { "GET", "POST" }, templateWriter.GetToken>($"{corsPath}.AllowMethods")); + Assert.Equal(new List { "Content-Type", "Authorization" }, templateWriter.GetToken>($"{corsPath}.AllowHeaders")); + Assert.True(templateWriter.GetToken($"{corsPath}.AllowCredentials")); + Assert.Equal(3600, templateWriter.GetToken($"{corsPath}.MaxAge")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlWithoutCorsDoesNotEmitCorsBlock(CloudFormationTemplateFormat templateFormat) + { + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.AWS_IAM } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig.Cors")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlCorsRemovedWhenCorsCleared(CloudFormationTemplateFormat templateFormat) + { + // First pass: emit full CORS config + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowHeaders = new[] { "Content-Type" }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.True(templateWriter.Exists(corsPath)); + Assert.Equal(new List { "https://example.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.True(templateWriter.GetToken($"{corsPath}.AllowCredentials")); + Assert.Equal(3600, templateWriter.GetToken($"{corsPath}.MaxAge")); + + // Second pass: clear all CORS properties (AllowOrigins=null, AllowCredentials=false, MaxAge=0) + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AuthType = FunctionUrlAuthType.NONE } + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal("NONE", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + Assert.False(templateWriter.Exists(corsPath)); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlCorsUpdatedBetweenPasses(CloudFormationTemplateFormat templateFormat) + { + // First pass: emit CORS with AllowOrigins and AllowMethods + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://example.com" }, + AllowMethods = new[] { LambdaHttpMethod.Get, LambdaHttpMethod.Post }, + AllowCredentials = true, + MaxAge = 3600 + } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + var corsPath = "Resources.TestMethod.Properties.FunctionUrlConfig.Cors"; + Assert.True(templateWriter.Exists($"{corsPath}.AllowOrigins")); + Assert.True(templateWriter.Exists($"{corsPath}.AllowMethods")); + Assert.True(templateWriter.Exists($"{corsPath}.AllowCredentials")); + Assert.True(templateWriter.Exists($"{corsPath}.MaxAge")); + + // Second pass: change to only AllowOrigins with a different value, remove everything else + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute + { + AuthType = FunctionUrlAuthType.NONE, + AllowOrigins = new[] { "https://other.com" } + } + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.Equal(new List { "https://other.com" }, templateWriter.GetToken>($"{corsPath}.AllowOrigins")); + Assert.False(templateWriter.Exists($"{corsPath}.AllowMethods")); + Assert.False(templateWriter.Exists($"{corsPath}.AllowCredentials")); + Assert.False(templateWriter.Exists($"{corsPath}.MaxAge")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlConfigRemovedWhenAttributeRemoved(CloudFormationTemplateFormat templateFormat) + { + // First pass: create FunctionUrlConfig + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new FunctionUrlAttribute { AllowOrigins = new[] { "*" } } + } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + + // Second pass: remove the attribute, FunctionUrlConfig should be cleaned up + lambdaFunctionModel.Attributes = new List(); + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void ManualFunctionUrlConfigPreservedWhenNoAttribute(CloudFormationTemplateFormat templateFormat) + { + // Simulate a template where FunctionUrlConfig was manually added (no SyncedFunctionUrlConfig metadata) + var content = templateFormat == CloudFormationTemplateFormat.Json + ? @"{ + 'AWSTemplateFormatVersion': '2010-09-09', + 'Transform': 'AWS::Serverless-2016-10-31', + 'Resources': { + 'TestMethod': { + 'Type': 'AWS::Serverless::Function', + 'Metadata': { + 'Tool': 'Amazon.Lambda.Annotations' + }, + 'Properties': { + 'Runtime': 'dotnet8', + 'CodeUri': '', + 'MemorySize': 512, + 'Timeout': 30, + 'Policies': ['AWSLambdaBasicExecutionRole'], + 'PackageType': 'Image', + 'ImageUri': '.', + 'ImageConfig': { 'Command': ['MyAssembly::MyNamespace.MyType::Handler'] }, + 'FunctionUrlConfig': { + 'AuthType': 'AWS_IAM' + } + } + } + } + }" + : "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31\nResources:\n TestMethod:\n Type: AWS::Serverless::Function\n Metadata:\n Tool: Amazon.Lambda.Annotations\n Properties:\n Runtime: dotnet8\n CodeUri: ''\n MemorySize: 512\n Timeout: 30\n Policies:\n - AWSLambdaBasicExecutionRole\n PackageType: Image\n ImageUri: .\n ImageConfig:\n Command:\n - 'MyAssembly::MyNamespace.MyType::Handler'\n FunctionUrlConfig:\n AuthType: AWS_IAM"; + + var mockFileManager = GetMockFileManager(content); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + // No FunctionUrl attribute + lambdaFunctionModel.Attributes = new List(); + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + var report = GetAnnotationReport(new List { lambdaFunctionModel }); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(report); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + // The manually-added FunctionUrlConfig should be preserved + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.Equal("AWS_IAM", templateWriter.GetToken("Resources.TestMethod.Properties.FunctionUrlConfig.AuthType")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void FunctionUrlMetadataTrackedAndCleanedUp(CloudFormationTemplateFormat templateFormat) + { + // First pass: create FunctionUrlConfig via attribute + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + // Verify metadata is set + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.True(templateWriter.GetToken("Resources.TestMethod.Metadata.SyncedFunctionUrlConfig")); + + // Second pass: remove the attribute + lambdaFunctionModel.Attributes = new List(); + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + + // Verify both FunctionUrlConfig and metadata are cleaned up + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.False(templateWriter.Exists("Resources.TestMethod.Metadata.SyncedFunctionUrlConfig")); + } + + [Theory] + [InlineData(CloudFormationTemplateFormat.Json)] + [InlineData(CloudFormationTemplateFormat.Yaml)] + public void SwitchFromFunctionUrlToHttpApi(CloudFormationTemplateFormat templateFormat) + { + // First pass: FunctionUrl + var mockFileManager = GetMockFileManager(string.Empty); + var lambdaFunctionModel = GetLambdaFunctionModel("MyAssembly::MyNamespace.MyType::Handler", + "TestMethod", 30, 512, null, null); + lambdaFunctionModel.Attributes = new List + { + new AttributeModel { Data = new FunctionUrlAttribute() } + }; + var cloudFormationWriter = GetCloudFormationWriter(mockFileManager, _directoryManager, templateFormat, _diagnosticReporter); + ITemplateWriter templateWriter = templateFormat == CloudFormationTemplateFormat.Json ? new JsonWriter() : new YamlWriter(); + + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + + // Second pass: switch to HttpApi + lambdaFunctionModel.Attributes = new List + { + new AttributeModel + { + Data = new HttpApiAttribute(LambdaHttpMethod.Get, "/items") + } + }; + cloudFormationWriter.ApplyReport(GetAnnotationReport(new List { lambdaFunctionModel })); + + templateWriter.Parse(mockFileManager.ReadAllText(ServerlessTemplateFilePath)); + Assert.False(templateWriter.Exists("Resources.TestMethod.Properties.FunctionUrlConfig")); + Assert.True(templateWriter.Exists("Resources.TestMethod.Properties.Events.RootGet")); + Assert.Equal("HttpApi", templateWriter.GetToken("Resources.TestMethod.Properties.Events.RootGet.Type")); + } + } +} diff --git a/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs b/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs index 6436c7c7b..591eb5e06 100644 --- a/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs +++ b/Libraries/test/IntegrationTests.Helpers/LambdaHelper.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System.Collections.Generic; using System.Threading.Tasks; using Amazon.Lambda; @@ -55,6 +58,14 @@ public async Task ListEventSourceMappingsAsync( }); } + public async Task GetFunctionUrlConfigAsync(string functionName) + { + return await _lambdaClient.GetFunctionUrlConfigAsync(new GetFunctionUrlConfigRequest + { + FunctionName = functionName + }); + } + public async Task WaitTillNotPending(List functions) { foreach (var function in functions) diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs new file mode 100644 index 000000000..b3f97929b --- /dev/null +++ b/Libraries/test/TestServerlessApp.IntegrationTests/FunctionUrlExample.cs @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace TestServerlessApp.IntegrationTests +{ + [Collection("Integration Tests")] + public class FunctionUrlExample + { + private readonly IntegrationTestContextFixture _fixture; + + public FunctionUrlExample(IntegrationTestContextFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task GetItems_WithCategory_ReturnsOkWithItems() + { + Assert.False(string.IsNullOrEmpty(_fixture.FunctionUrlPrefix), "FunctionUrlPrefix should not be empty. The Function URL was not discovered during setup."); + + var response = await GetWithRetryAsync($"{_fixture.FunctionUrlPrefix}?category=electronics"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(content); + + Assert.Equal("electronics", json["category"]?.ToString()); + Assert.NotNull(json["items"]); + var items = json["items"].ToObject(); + Assert.Equal(2, items.Length); + Assert.Contains("item1", items); + Assert.Contains("item2", items); + } + + [Fact] + public async Task GetItems_LogsToCloudWatch() + { + Assert.False(string.IsNullOrEmpty(_fixture.FunctionUrlPrefix), "FunctionUrlPrefix should not be empty. The Function URL was not discovered during setup."); + + var response = await GetWithRetryAsync($"{_fixture.FunctionUrlPrefix}?category=books"); + response.EnsureSuccessStatusCode(); + + var lambdaFunctionName = _fixture.LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + Assert.False(string.IsNullOrEmpty(lambdaFunctionName)); + + var logGroupName = _fixture.CloudWatchHelper.GetLogGroupName(lambdaFunctionName); + Assert.True( + await _fixture.CloudWatchHelper.MessageExistsInRecentLogEventsAsync("Getting items for category: books", logGroupName, logGroupName), + "Expected log message not found in CloudWatch logs"); + } + + [Fact] + public async Task VerifyFunctionUrlConfig_HasNoneAuthType() + { + var lambdaFunctionName = _fixture.LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + Assert.False(string.IsNullOrEmpty(lambdaFunctionName)); + + var functionUrlConfig = await _fixture.LambdaHelper.GetFunctionUrlConfigAsync(lambdaFunctionName); + Assert.NotNull(functionUrlConfig); + Assert.Equal("NONE", functionUrlConfig.AuthType.Value); + Assert.False(string.IsNullOrEmpty(functionUrlConfig.FunctionUrl), "Function URL should not be empty"); + Assert.Contains(".lambda-url.", functionUrlConfig.FunctionUrl); + } + + private async Task GetWithRetryAsync(string url) + { + const int maxAttempts = 10; + HttpResponseMessage response = null; + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + await Task.Delay(attempt * 1000); + try + { + response = await _fixture.HttpClient.GetAsync(url); + + // If we get a 403 Forbidden, it may be an eventual consistency issue + // with the Function URL permissions propagating. + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + continue; + + break; + } + catch + { + if (attempt + 1 == maxAttempts) + throw; + } + } + + return response; + } + } +} diff --git a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs index 0102424a8..c4b139417 100644 --- a/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs +++ b/Libraries/test/TestServerlessApp.IntegrationTests/IntegrationTestContextFixture.cs @@ -1,3 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + using System; using System.Collections.Generic; using System.IO; @@ -30,6 +33,7 @@ public class IntegrationTestContextFixture : IAsyncLifetime public string RestApiUrlPrefix; public string HttpApiUrlPrefix; + public string FunctionUrlPrefix; public string TestQueueARN; public string TestS3BucketName; public List LambdaFunctions; @@ -90,12 +94,22 @@ public async Task InitializeAsync() Console.WriteLine($"[IntegrationTest] Found {LambdaFunctions.Count} Lambda functions: {string.Join(", ", LambdaFunctions.Select(f => f.Name ?? "(null)"))}"); Assert.True(await _s3Helper.BucketExistsAsync(_bucketName), $"S3 bucket {_bucketName} should exist"); - Assert.Equal(37, LambdaFunctions.Count); + Assert.Equal(38, LambdaFunctions.Count); Assert.False(string.IsNullOrEmpty(RestApiUrlPrefix), "RestApiUrlPrefix should not be empty"); Assert.False(string.IsNullOrEmpty(HttpApiUrlPrefix), "HttpApiUrlPrefix should not be empty"); await LambdaHelper.WaitTillNotPending(LambdaFunctions.Where(x => x.Name != null).Select(x => x.Name).ToList()); + // Discover the Function URL for the FunctionUrlExample function + var functionUrlLambdaName = LambdaFunctions + .FirstOrDefault(x => string.Equals(x.LogicalId, "TestServerlessAppFunctionUrlExampleGetItemsGenerated"))?.Name; + if (!string.IsNullOrEmpty(functionUrlLambdaName)) + { + var functionUrlConfig = await LambdaHelper.GetFunctionUrlConfigAsync(functionUrlLambdaName); + FunctionUrlPrefix = functionUrlConfig.FunctionUrl.TrimEnd('/'); + Console.WriteLine($"[IntegrationTest] FunctionUrlPrefix: {FunctionUrlPrefix}"); + } + // Wait an additional 10 seconds for any other eventually consistency state to finish up. await Task.Delay(10000); } diff --git a/Libraries/test/TestServerlessApp/FunctionUrlExample.cs b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs new file mode 100644 index 000000000..4909c768e --- /dev/null +++ b/Libraries/test/TestServerlessApp/FunctionUrlExample.cs @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.APIGateway; +using Amazon.Lambda.Core; + +namespace TestServerlessApp +{ + public class FunctionUrlExample + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [FunctionUrl(AuthType = FunctionUrlAuthType.NONE)] + public IHttpResult GetItems([FromQuery] string category, ILambdaContext context) + { + context.Logger.LogLine($"Getting items for category: {category}"); + return HttpResults.Ok(new { items = new[] { "item1", "item2" }, category }); + } + } +} diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 0b96350ff..71f6d708b 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", - "s3-bucket": "test-serverless-app", - "stack-name": "test-serverless-app", - "function-architecture": "x86_64" -} \ No newline at end of file +"s3-bucket" : "test-serverless-app-535afbc5", +"stack-name" : "test-serverless-app-535afbc5", +"function-architecture" : "x86_64" +} diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 82ed9f7d4..3f79bc177 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -3,9 +3,6 @@ "Transform": "AWS::Serverless-2016-10-31", "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", "Resources": { - "TestQueue": { - "Type": "AWS::SQS::Queue" - }, "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", "Metadata": { @@ -801,6 +798,30 @@ } } }, + "TestServerlessAppFunctionUrlExampleGetItemsGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedFunctionUrlConfig": true + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.FunctionUrlExample_GetItems_Generated::GetItems" + ] + }, + "FunctionUrlConfig": { + "AuthType": "NONE" + } + } + }, "GreeterSayHello": { "Type": "AWS::Serverless::Function", "Metadata": { @@ -991,6 +1012,60 @@ } } }, + "S3EventHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestS3Bucket" + ], + "SyncedEventProperties": { + "TestS3Bucket": [ + "Bucket.Ref", + "Events", + "Filter.S3Key.Rules" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole", + "AmazonS3ReadOnlyAccess" + ], + "PackageType": "Image", + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.S3EventExamples.S3EventProcessing_ProcessS3Event_Generated::ProcessS3Event" + ] + }, + "Events": { + "TestS3Bucket": { + "Type": "S3", + "Properties": { + "Bucket": { + "Ref": "TestS3Bucket" + }, + "Events": [ + "s3:ObjectCreated:*" + ], + "Filter": { + "S3Key": { + "Rules": [ + { + "Name": "suffix", + "Value": ".json" + } + ] + } + } + } + } + } + } + }, "SimpleCalculatorAdd": { "Type": "AWS::Serverless::Function", "Metadata": { @@ -1254,6 +1329,12 @@ "TestQueueEvent": { "Type": "SQS", "Properties": { + "Queue": { + "Fn::GetAtt": [ + "TestQueue", + "Arn" + ] + }, "BatchSize": 50, "FilterCriteria": { "Filters": [ @@ -1268,12 +1349,6 @@ "MaximumBatchingWindowInSeconds": 5, "ScalingConfig": { "MaximumConcurrency": 5 - }, - "Queue": { - "Fn::GetAtt": [ - "TestQueue", - "Arn" - ] } } } @@ -1339,63 +1414,6 @@ ] } } - }, - "S3EventHandler": { - "Type": "AWS::Serverless::Function", - "Metadata": { - "Tool": "Amazon.Lambda.Annotations", - "SyncedEvents": [ - "TestS3Bucket" - ], - "SyncedEventProperties": { - "TestS3Bucket": [ - "Bucket.Ref", - "Events", - "Filter.S3Key.Rules" - ] - } - }, - "Properties": { - "MemorySize": 512, - "Timeout": 30, - "Policies": [ - "AWSLambdaBasicExecutionRole", - "AmazonS3ReadOnlyAccess" - ], - "PackageType": "Image", - "ImageUri": ".", - "ImageConfig": { - "Command": [ - "TestServerlessApp::TestServerlessApp.S3EventExamples.S3EventProcessing_ProcessS3Event_Generated::ProcessS3Event" - ] - }, - "Events": { - "TestS3Bucket": { - "Type": "S3", - "Properties": { - "Events": [ - "s3:ObjectCreated:*" - ], - "Filter": { - "S3Key": { - "Rules": [ - { - "Name": "suffix", - "Value": ".json" - } - ] - } - }, - "Bucket": { - "Ref": "TestS3Bucket" - } - } - } - } - } - }, - "TestS3Bucket": { - "Type": "AWS::S3::Bucket" } } } \ No newline at end of file From ee1a65e6a437faca4ad76636e336f8e15ad70a58 Mon Sep 17 00:00:00 2001 From: aws-sdk-dotnet-automation <43080735+aws-sdk-dotnet-automation@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:41:39 -0400 Subject: [PATCH 37/47] Release 2026-04-14 (#2332) * release_2026-04-14 * Update test app CloudFormation templates * Updated changelog --- .../9cf844c3-1a90-46ec-9430-93724dcb4512.json | 11 ---------- .../a119819e-ba57-424d-a0be-d941eea599f9.json | 11 ---------- .../function-url-annotations-support.json | 11 ---------- CHANGELOG.md | 9 +++++++++ ....Lambda.Annotations.SourceGenerator.csproj | 2 +- .../Amazon.Lambda.Annotations.csproj | 2 +- .../Amazon.Lambda.RuntimeSupport.csproj | 2 +- .../serverless.template | 2 +- .../serverless.template | 2 +- .../TestServerlessApp.ALB/serverless.template | 2 +- .../serverless.template | 2 +- .../TestServerlessApp/serverless.template | 20 +++++++++---------- ...Amazon.Lambda.TestTool.BlazorTester.csproj | 2 +- ...mbda.TestTool.BlazorTester10_0-pack.csproj | 2 +- ...Lambda.TestTool.BlazorTester80-pack.csproj | 2 +- ...Lambda.TestTool.BlazorTester90-pack.csproj | 2 +- 16 files changed, 30 insertions(+), 54 deletions(-) delete mode 100644 .autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json delete mode 100644 .autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json delete mode 100644 .autover/changes/function-url-annotations-support.json diff --git a/.autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json b/.autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json deleted file mode 100644 index 880709e72..000000000 --- a/.autover/changes/9cf844c3-1a90-46ec-9430-93724dcb4512.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.TestTool.BlazorTester", - "Type": "Patch", - "ChangelogMessages": [ - "Minor fixes to improve the testability of the package" - ] - } - ] -} \ No newline at end of file diff --git a/.autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json b/.autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json deleted file mode 100644 index 7b2746bbb..000000000 --- a/.autover/changes/a119819e-ba57-424d-a0be-d941eea599f9.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.RuntimeSupport", - "Type": "Patch", - "ChangelogMessages": [ - "Minor fixes to improve the testability of the package" - ] - } - ] -} \ No newline at end of file diff --git a/.autover/changes/function-url-annotations-support.json b/.autover/changes/function-url-annotations-support.json deleted file mode 100644 index 24ad4e288..000000000 --- a/.autover/changes/function-url-annotations-support.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.Annotations", - "Type": "Minor", - "ChangelogMessages": [ - "Added [FunctionUrl] attribute for configuring Lambda functions with Function URL endpoints, including optional CORS support" - ] - } - ] -} diff --git a/CHANGELOG.md b/CHANGELOG.md index b541fbd38..54300c8e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## Release 2026-04-14 + +### Amazon.Lambda.TestTool.BlazorTester (0.17.1) +* Minor fixes to improve the testability of the package +### Amazon.Lambda.RuntimeSupport (1.14.3) +* Minor fixes to improve the testability of the package +### Amazon.Lambda.Annotations (1.13.0) +* Added [FunctionUrl] attribute for configuring Lambda functions with Function URL endpoints, including optional CORS support + ## Release 2026-04-13 #2 ### Amazon.Lambda.Annotations (1.12.0) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj index 0e90d254a..79a18d2b8 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj @@ -20,7 +20,7 @@ true false - 1.12.0 + 1.13.0 true diff --git a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj index 1fcc58ea6..fb365ff5d 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj @@ -11,7 +11,7 @@ ..\..\..\buildtools\public.snk true - 1.12.0 + 1.13.0 true diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj index b3bfb0488..6f8dabfa2 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj @@ -4,7 +4,7 @@ netstandard2.0;net6.0;net8.0;net9.0;net10.0;net11.0 - 1.14.2 + 1.14.3 Provides a bootstrap and Lambda Runtime API Client to help you to develop custom .NET Core Lambda Runtimes. Amazon.Lambda.RuntimeSupport Amazon.Lambda.RuntimeSupport diff --git a/Libraries/test/TestCustomAuthorizerApp/serverless.template b/Libraries/test/TestCustomAuthorizerApp/serverless.template index a9bd4fc47..d50b5f6ef 100644 --- a/Libraries/test/TestCustomAuthorizerApp/serverless.template +++ b/Libraries/test/TestCustomAuthorizerApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index 65ce726fb..a4112a9eb 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", diff --git a/Libraries/test/TestServerlessApp.ALB/serverless.template b/Libraries/test/TestServerlessApp.ALB/serverless.template index 71b1349ae..e07e2e226 100644 --- a/Libraries/test/TestServerlessApp.ALB/serverless.template +++ b/Libraries/test/TestServerlessApp.ALB/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", + "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", "Resources": { "ALBTestVPC": { "Type": "AWS::EC2::VPC", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index b8c707f54..03b6cb0d5 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", "Resources": { "TestServerlessAppNET8FunctionsToUpperGenerated": { "Type": "AWS::Serverless::Function", diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 3f79bc177..b5753ecfd 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.12.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", @@ -1045,9 +1045,6 @@ "TestS3Bucket": { "Type": "S3", "Properties": { - "Bucket": { - "Ref": "TestS3Bucket" - }, "Events": [ "s3:ObjectCreated:*" ], @@ -1060,6 +1057,9 @@ } ] } + }, + "Bucket": { + "Ref": "TestS3Bucket" } } } @@ -1329,12 +1329,6 @@ "TestQueueEvent": { "Type": "SQS", "Properties": { - "Queue": { - "Fn::GetAtt": [ - "TestQueue", - "Arn" - ] - }, "BatchSize": 50, "FilterCriteria": { "Filters": [ @@ -1349,6 +1343,12 @@ "MaximumBatchingWindowInSeconds": 5, "ScalingConfig": { "MaximumConcurrency": 5 + }, + "Queue": { + "Fn::GetAtt": [ + "TestQueue", + "Arn" + ] } } } diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester.csproj b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester.csproj index 878c49507..345380f68 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester.csproj +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester.csproj @@ -6,7 +6,7 @@ Exe A tool to help debug and test your .NET Core AWS Lambda functions locally. Latest - 0.17.0 + 0.17.1 AWS .NET Lambda Test Tool Apache 2 AWS;Amazon;Lambda diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester10_0-pack.csproj b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester10_0-pack.csproj index f18756bce..55fbe0b02 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester10_0-pack.csproj +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester10_0-pack.csproj @@ -5,7 +5,7 @@ Exe A tool to help debug and test your .NET 10.0 AWS Lambda functions locally. - 0.17.0 + 0.17.1 AWS .NET Lambda Test Tool Apache 2 AWS;Amazon;Lambda diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester80-pack.csproj b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester80-pack.csproj index 45afdfeb1..422742f07 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester80-pack.csproj +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester80-pack.csproj @@ -5,7 +5,7 @@ Exe A tool to help debug and test your .NET 8.0 AWS Lambda functions locally. - 0.17.0 + 0.17.1 AWS .NET Lambda Test Tool Apache 2 AWS;Amazon;Lambda diff --git a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester90-pack.csproj b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester90-pack.csproj index e4a7cc450..d656e40be 100644 --- a/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester90-pack.csproj +++ b/Tools/LambdaTestTool/src/Amazon.Lambda.TestTool.BlazorTester/Amazon.Lambda.TestTool.BlazorTester90-pack.csproj @@ -5,7 +5,7 @@ Exe A tool to help debug and test your .NET 9.0 AWS Lambda functions locally. - 0.17.0 + 0.17.1 AWS .NET Lambda Test Tool Apache 2 AWS;Amazon;Lambda From 2e70f8d102f2e47e736e05f1962157e11720123d Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 14 Apr 2026 16:31:51 -0700 Subject: [PATCH 38/47] Address PR comments --- .../ResponseStreaming/HttpResponseStreamPrelude.cs | 14 ++++++++++---- .../ResponseStreaming/ILambdaResponseStream.cs | 2 -- .../ResponseStreaming/RawStreamingHttpClient.cs | 11 ++++++++--- .../IntegrationTestCollection.cs | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs index 67eb9d3ae..1a10aa2dc 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/HttpResponseStreamPrelude.cs @@ -11,27 +11,33 @@ namespace Amazon.Lambda.Core.ResponseStreaming { /// /// The HTTP response prelude to be sent as the first chunk of a streaming response when using . + /// When using Lambda response streaming with a Lambda Function URL or API Gateway, the response prelude is used to set the HTTP status code, + /// headers, and cookies for the response. The prelude must be sent as the first chunk of the response stream, followed by the response body chunks. + /// This allows you to set the status code and headers for the response before sending any of the response body. /// [RequiresPreviewFeatures(LambdaResponseStreamFactory.PreviewMessage)] public class HttpResponseStreamPrelude { /// - /// The Http status code to include in the response prelude. + /// The Http status code. /// public HttpStatusCode? StatusCode { get; set; } /// - /// The response headers to include in the response prelude. This collection supports setting single value for the same headers. + /// The response headers. This collection supports setting single value for the same headers. When using + /// Lambda Function URLs as this event source this collection should be used. /// public IDictionary Headers { get; set; } = new Dictionary(); /// - /// The response headers to include in the response prelude. This collection supports setting multiple values for the same headers. + /// The response headers. This collection supports setting multiple values for the same headers. When using + /// API Gateway REST APIs as this event source this collection should be used. /// public IDictionary> MultiValueHeaders { get; set; } = new Dictionary>(); /// - /// The list of cookies to include in the response prelude. This is used for Lambda Function URL responses, which support a separate "cookies" field in the response JSON for setting cookies, rather than requiring cookies to be set via the "Set-Cookie" header. + /// The list of cookies. This is used for Lambda Function URL responses, which support a separate "cookies" field in + /// the response JSON for setting cookies, rather than requiring cookies to be set via the "Set-Cookie" header. /// public IList Cookies { get; set; } = new List(); diff --git a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs index 1385e551e..4b604cc58 100644 --- a/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.Core/ResponseStreaming/ILambdaResponseStream.cs @@ -24,13 +24,11 @@ internal interface ILambdaResponseStream : IDisposable /// Thrown if the stream is already completed or an error has been reported. Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default); - /// /// Gets the total number of bytes written to the stream so far. /// long BytesWritten { get; } - /// /// Gets whether an error has been reported. /// diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs index 0226e0660..c944d104a 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/RawStreamingHttpClient.cs @@ -49,8 +49,11 @@ internal class RawStreamingHttpClient : IDisposable public RawStreamingHttpClient(string hostAndPort) { var parts = hostAndPort.Split(':'); + if (parts.Length != 2) + throw new ArgumentException($"Invalid host and port format: {hostAndPort}. Expected format is 'host:port'"); + _host = parts[0]; - _port = parts.Length > 1 ? int.Parse(parts[1], CultureInfo.InvariantCulture) : 80; + _port = int.Parse(parts[1], CultureInfo.InvariantCulture); } /// @@ -157,6 +160,7 @@ internal async Task WriteTerminatorWithTrailersAsync(Exception exception, Cancel /// internal async Task ReadAndDiscardResponseAsync(CancellationToken cancellationToken) { + const string headerDelimiter = "\r\n\r\n"; var buffer = new byte[4096]; try { @@ -175,10 +179,10 @@ internal async Task ReadAndDiscardResponseAsync(CancellationToken cancellationTo // Check if we've received the complete response (ends with \r\n\r\n for headers, // or we've read the content-length worth of body) var text = responseText.ToString(); - if (text.Contains("\r\n\r\n")) + if (text.Contains(headerDelimiter)) { // Find Content-Length to know if there's a body to read - var headerEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + var headerEnd = text.IndexOf(headerDelimiter, StringComparison.Ordinal); var headers = text.Substring(0, headerEnd); var contentLengthMatch = System.Text.RegularExpressions.Regex.Match( @@ -199,6 +203,7 @@ internal async Task ReadAndDiscardResponseAsync(CancellationToken cancellationTo } } + // 16KB is more than enough for the Runtime API response, so we can break here to avoid an infinite loop in case of malformed response if (totalRead > 16384) break; // Safety limit } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs index 6e066eb28..9b637b547 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/IntegrationTestCollection.cs @@ -2,7 +2,7 @@ namespace Amazon.Lambda.RuntimeSupport.IntegrationTests; -[CollectionDefinition("Integration Tests", DisableParallelization = true)] +[CollectionDefinition("Integration Tests")] public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture { From 62a9ad73920f7a5b0729031c1893b531b2c50a7d Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 14 Apr 2026 16:45:55 -0700 Subject: [PATCH 39/47] Remove obsolete unit test --- .../RawStreamingHttpClientTests.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs index e203d6968..57b94ce22 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/RawStreamingHttpClientTests.cs @@ -28,13 +28,6 @@ public void Constructor_HostAndPort_ParsedCorrectly() // we verify indirectly via Dispose not throwing. } - [Fact] - public void Constructor_HostOnly_DefaultsToPort80() - { - using var client = new RawStreamingHttpClient("localhost"); - // Should not throw — defaults port to 80 - } - [Fact] public void Constructor_HighPort_ParsedCorrectly() { From d7b8cb5701ae23f9140e3c017d46e8bbba19b8d1 Mon Sep 17 00:00:00 2001 From: aws-sdk-dotnet-automation Date: Wed, 15 Apr 2026 00:51:23 +0000 Subject: [PATCH 40/47] chore: Daily ASP.NET Core version update in Dockerfiles --- LambdaRuntimeDockerfiles/Images/net10/amd64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net10/arm64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net11/amd64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net11/arm64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net9/amd64/Dockerfile | 4 ++-- LambdaRuntimeDockerfiles/Images/net9/arm64/Dockerfile | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/LambdaRuntimeDockerfiles/Images/net10/amd64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net10/amd64/Dockerfile index 6b08ef4cb..a1d477249 100644 --- a/LambdaRuntimeDockerfiles/Images/net10/amd64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net10/amd64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=10.0.5 -ARG ASPNET_SHA512=7108ecdda8e2607fa80e2b45f1209d7af5301d53438b65d2269605b8415aebd49db23455d8dcd77d8fdccc904c9202b4834f9ca2e00e27a501d2006174d76cc4 +ARG ASPNET_VERSION=10.0.6 +ARG ASPNET_SHA512=89eeb16d1971dc0a854754a3bc4cebb637a959c889f56216af292580b76fbff01c329dd0933896b74ef6a2d4bc56b9c4bb605196d5c0520ca43027421d155365 ARG LAMBDA_RUNTIME_NAME=dotnet10 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net10/arm64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net10/arm64/Dockerfile index 773f877f6..9d0134268 100644 --- a/LambdaRuntimeDockerfiles/Images/net10/arm64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net10/arm64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=10.0.5 -ARG ASPNET_SHA512=6cab3b81910ba3e6e118595a45948331f5d1506b42af0942f79ea3db6623e820557a1757973becb9afd3d6f8ead9e9a641667860f2a7fbbd598bcafa38f4739c +ARG ASPNET_VERSION=10.0.6 +ARG ASPNET_SHA512=8dee4e65a4a76e833867616df508a39c2f8a710269a2361e47acb73d09080e50405c1d84206327ff5df127a9f206451b0cedf1451d43144e440d9c4e350e9497 ARG LAMBDA_RUNTIME_NAME=dotnet10 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net11/amd64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net11/amd64/Dockerfile index df5a89084..0630f9077 100644 --- a/LambdaRuntimeDockerfiles/Images/net11/amd64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net11/amd64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=11.0.0-preview.2.26159.112 -ARG ASPNET_SHA512=4c02fbb66bc4b7389e0f43c0ea96a3046954eb3c7ad06bf0f8d90997c2e603dd0a3929e2b29b9e8933cc76341fb5e62ebe5bcb83d9a31419bdd1195904ff5af6 +ARG ASPNET_VERSION=11.0.0-preview.3.26207.106 +ARG ASPNET_SHA512=61694de0e8f7ac4c5daa03a6a3edbf99241a9dae63d9c02153d91df9df45b3fdb2d5412030d6e52f52c27582d636ad916b54ff89a7578de7246b773bfd764c06 ARG LAMBDA_RUNTIME_NAME=dotnet11 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net11/arm64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net11/arm64/Dockerfile index 9f150d25b..593a9db9a 100644 --- a/LambdaRuntimeDockerfiles/Images/net11/arm64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net11/arm64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=11.0.0-preview.2.26159.112 -ARG ASPNET_SHA512=711914b72530c8b6ba49e5077942893a52bf5508cc082b09ae04f5c72ae4a09dd78699ffcad16dd25a2fe43d586533f897dcdb5c82ab2982ff6b4ad6fdfe5a58 +ARG ASPNET_VERSION=11.0.0-preview.3.26207.106 +ARG ASPNET_SHA512=ef06c925082be5bc6ad0fec0ab33cfe4795f96d5ae253df930e7ccffa017643edc625c27fc2d2619e694f9bbf678e431ab9d614362aa616e8fc18c8e44d6c31c ARG LAMBDA_RUNTIME_NAME=dotnet11 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile index 3fa341885..f11a52f0b 100644 --- a/LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=8.0.25 -ARG ASPNET_SHA512=ddb66ac366252ab382271241b3e53a75201d2c848c9ec870a27fb178a6db18e4d949b9896a3d8530d03d255f4fd51d635367bedda3d9f3c677cb596784dbcb9c +ARG ASPNET_VERSION=8.0.26 +ARG ASPNET_SHA512=2a5d39bfdb2d734fd765f806bf4be6138bc8d9b44f2f8aee3250ced56eaad4c0e4bf06141dec6ef2c6a46a8faf3d8ffd60b7361b687ff7d8b45179df35bb0149 ARG LAMBDA_RUNTIME_NAME=dotnet8 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile index 7180c4277..a21fb97fe 100644 --- a/LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=8.0.25 -ARG ASPNET_SHA512=65d8b16bbef90c44daac906aaf92818f43a8482191dbf3f20bddcdd1ad6077d17b6178364bd08249501ddd3021a4c8f5f98a1c0360e126870db14cf06cd12727 +ARG ASPNET_VERSION=8.0.26 +ARG ASPNET_SHA512=93969f80c4bffa276a5538a5133e20ea8afd59b7f83f932cd0749ff37332f85ca3e3e9e90fb45ec8e2fd1a814df373b617565808823113e2911f5b55d018d19c ARG LAMBDA_RUNTIME_NAME=dotnet8 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net9/amd64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net9/amd64/Dockerfile index 6fcff677b..0dbd59f2b 100644 --- a/LambdaRuntimeDockerfiles/Images/net9/amd64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net9/amd64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=9.0.14 -ARG ASPNET_SHA512=6d0947390f9ec316297f21a9ec022528a27205a44328f9417fb8675783edc56022e5e15ea65650c5e73cb73917c09c52305d3e85e4c12acd5e11074871bb0679 +ARG ASPNET_VERSION=9.0.15 +ARG ASPNET_SHA512=85d474b303fb8f1867125d9aa1ec39db5afff179b165287e45fd78b7ec51d414e5937871bda5948b8e750afe9c220afe6138eba7108090bfba1e5dad93d39c4e ARG LAMBDA_RUNTIME_NAME=dotnet9 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 diff --git a/LambdaRuntimeDockerfiles/Images/net9/arm64/Dockerfile b/LambdaRuntimeDockerfiles/Images/net9/arm64/Dockerfile index 2294235c1..da4fd7fd0 100644 --- a/LambdaRuntimeDockerfiles/Images/net9/arm64/Dockerfile +++ b/LambdaRuntimeDockerfiles/Images/net9/arm64/Dockerfile @@ -1,7 +1,7 @@ # Based on Docker image from: https://github.com/dotnet/dotnet-docker/ -ARG ASPNET_VERSION=9.0.14 -ARG ASPNET_SHA512=c5cd05971c9cba0c211ceb55e226cbfb62f172db8345fa5d4e476a4ccb3a4f61d6e51dc2b2d178eb55f3673ad2d61cf72ae649fb84ccf2c3dbbbd4acdbb78132 +ARG ASPNET_VERSION=9.0.15 +ARG ASPNET_SHA512=72b9f4b0649e5a3b874597c1b50dbbb246831e933455bbe34c3c53ffdd3016eca7d42090c6d785bb9dae78b9f34d37757df2b430f6309d811c08df6839ce00cf ARG LAMBDA_RUNTIME_NAME=dotnet9 ARG AMAZON_LINUX=public.ecr.aws/lambda/provided:al2023 From 6d5d887d8b251e090ea21e495a26998c577e6908 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 15 Apr 2026 15:29:33 -0700 Subject: [PATCH 41/47] Add support for Lambda Response Streaming in ASP.NET Core bridge packages (#2293) --- .../f0d5a912-bcfa-4244-96cb-ac3c847f877c.json | 27 + Libraries/Amazon.Lambda.RuntimeSupport.slnf | 5 +- Libraries/Libraries.sln | 17 +- ...zon.Lambda.AspNetCoreServer.Hosting.csproj | 2 +- .../HostingOptions.cs | 15 + .../GetBeforeSnapshotRequestsCollector.cs | 2 - .../Internal/LambdaRuntimeSupportServer.cs | 45 +- .../ServiceCollectionExtensions.cs | 2 - .../APIGatewayHttpApiV2ProxyFunction.cs | 40 + .../APIGatewayProxyFunction.cs | 45 +- .../AbstractAspNetCoreFunction.cs | 218 +++++- .../Amazon.Lambda.AspNetCoreServer.csproj | 8 +- .../ApplicationLoadBalancerFunction.cs | 7 +- .../Internal/HttpRequestMessageConverter.cs | 8 - .../Internal/InvokeFeatures.cs | 11 +- .../Internal/StreamingResponseBodyFeature.cs | 250 +++++++ .../Amazon.Lambda.Logging.AspNetCore.csproj | 2 +- .../ResponseStreaming/ResponseStream.cs | 2 +- .../AddAWSLambdaBeforeSnapshotRequestTests.cs | 2 - .../ResponseStreamingHostingTests.cs | 254 +++++++ .../ResponseStreamingPropertyTests.cs | 129 ++++ ...Amazon.Lambda.AspNetCoreServer.Test.csproj | 5 +- .../BuildStreamingPreludeTests.cs | 267 +++++++ .../ResponseStreamingPropertyTests.cs | 478 ++++++++++++ .../StreamingFunctionHandlerAsyncTests.cs | 703 ++++++++++++++++++ .../StreamingResponseBodyFeatureTests.cs | 286 +++++++ .../TestApiGatewayHttpApiV2Calls.cs | 5 - ...zon.Lambda.Logging.AspNetCore.Tests.csproj | 2 +- ...bda.RuntimeSupport.IntegrationTests.csproj | 1 + .../ApiGatewayStreamingTests.cs | 437 +++++++++++ .../Helpers/CommandLineWrapper.cs | 4 +- .../AspNetCoreStreamingApiGatewayTest.csproj | 15 + .../Program.cs | 133 ++++ .../aws-lambda-tools-defaults.json | 17 + .../serverless-functionurl.template | 38 + .../serverless-restapi.template | 100 +++ .../TestMinimalAPIApp.csproj | 2 +- Libraries/test/TestWebApp/TestWebApp.csproj | 2 +- 38 files changed, 3476 insertions(+), 110 deletions(-) create mode 100644 .autover/changes/f0d5a912-bcfa-4244-96cb-ac3c847f877c.json create mode 100644 Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/StreamingResponseBodyFeature.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingHostingTests.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingPropertyTests.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/BuildStreamingPreludeTests.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/ResponseStreamingPropertyTests.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingFunctionHandlerAsyncTests.cs create mode 100644 Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingResponseBodyFeatureTests.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/AspNetCoreStreamingApiGatewayTest.csproj create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/Program.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/aws-lambda-tools-defaults.json create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-functionurl.template create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template diff --git a/.autover/changes/f0d5a912-bcfa-4244-96cb-ac3c847f877c.json b/.autover/changes/f0d5a912-bcfa-4244-96cb-ac3c847f877c.json new file mode 100644 index 000000000..f8dad2d13 --- /dev/null +++ b/.autover/changes/f0d5a912-bcfa-4244-96cb-ac3c847f877c.json @@ -0,0 +1,27 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.AspNetCoreServer", + "Type": "Major", + "ChangelogMessages": [ + "[Breaking] Update build targets from .NET 6 and 8 to .NET 8 and 10", + "[Preview] Add support for Lambda Response Streaming enabled by setting the EnableResponseStreaming property from the base class AbstractAspNetCoreFunction" + ] + }, + { + "Name": "Amazon.Lambda.AspNetCoreServer.Hosting", + "Type": "Major", + "ChangelogMessages": [ + "[Breaking] Update build targets from .NET 6 and 8 to .NET 8 and 10", + "[Preview] Add support for Lambda Response Streaming enabled by setting the EnableResponseStreaming property on the HostingOptions object passed into the AddAWSLambdaHosting method" + ] + }, + { + "Name": "Amazon.Lambda.Logging.AspNetCore", + "Type": "Major", + "ChangelogMessages": [ + "[Breaking] Update build targets from .NET 6 and 8 to .NET 8 and 10" + ] + } + ] +} \ No newline at end of file diff --git a/Libraries/Amazon.Lambda.RuntimeSupport.slnf b/Libraries/Amazon.Lambda.RuntimeSupport.slnf index fb03ebc05..cd6d74977 100644 --- a/Libraries/Amazon.Lambda.RuntimeSupport.slnf +++ b/Libraries/Amazon.Lambda.RuntimeSupport.slnf @@ -14,12 +14,13 @@ "src\\SnapshotRestore.Registry\\SnapshotRestore.Registry.csproj", "test\\Amazon.Lambda.RuntimeSupport.Tests\\Amazon.Lambda.RuntimeSupport.IntegrationTests\\Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj", "test\\Amazon.Lambda.RuntimeSupport.Tests\\Amazon.Lambda.RuntimeSupport.UnitTests\\Amazon.Lambda.RuntimeSupport.UnitTests.csproj", + "test\\Amazon.Lambda.RuntimeSupport.Tests\\AspNetCoreStreamingApiGatewayTest\\AspNetCoreStreamingApiGatewayTest.csproj", "test\\Amazon.Lambda.RuntimeSupport.Tests\\CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest\\CustomRuntimeAspNetCoreMinimalApiCustomSerializerTest.csproj", "test\\Amazon.Lambda.RuntimeSupport.Tests\\CustomRuntimeAspNetCoreMinimalApiTest\\CustomRuntimeAspNetCoreMinimalApiTest.csproj", "test\\Amazon.Lambda.RuntimeSupport.Tests\\CustomRuntimeFunctionTest\\CustomRuntimeFunctionTest.csproj", - "test\\SnapshotRestore.Registry.Tests\\SnapshotRestore.Registry.Tests.csproj", "test\\HandlerTestNoSerializer\\HandlerTestNoSerializer.csproj", - "test\\HandlerTest\\HandlerTest.csproj" + "test\\HandlerTest\\HandlerTest.csproj", + "test\\SnapshotRestore.Registry.Tests\\SnapshotRestore.Registry.Tests.csproj" ] } } \ No newline at end of file diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index aa4c33d06..3fac89872 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.3.11512.155 d18.3 +VisualStudioVersion = 18.3.11512.155 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12}" EndProject @@ -157,6 +157,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServerlessApp.ALB.Integ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResponseStreamingFunctionHandlers", "test\Amazon.Lambda.RuntimeSupport.Tests\ResponseStreamingFunctionHandlers\ResponseStreamingFunctionHandlers.csproj", "{E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreStreamingApiGatewayTest", "test\Amazon.Lambda.RuntimeSupport.Tests\AspNetCoreStreamingApiGatewayTest\AspNetCoreStreamingApiGatewayTest.csproj", "{0768FA72-CF49-2B59-BC4C-E4CE579E5D93}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -983,6 +985,18 @@ Global {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x64.Build.0 = Release|Any CPU {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x86.ActiveCfg = Release|Any CPU {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9}.Release|x86.Build.0 = Release|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Debug|x64.ActiveCfg = Debug|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Debug|x64.Build.0 = Debug|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Debug|x86.ActiveCfg = Debug|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Debug|x86.Build.0 = Debug|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|Any CPU.Build.0 = Release|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x64.ActiveCfg = Release|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x64.Build.0 = Release|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x86.ActiveCfg = Release|Any CPU + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1060,6 +1074,7 @@ Global {8F7C617D-C611-4DC6-A07C-033F13C1835D} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {80594C21-C6EB-469E-83CC-68F9F661CA5E} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {E404A7AC-812B-BC03-CA76-02C0BC2BA7F9} = {B5BD0336-7D08-492C-8489-42C987E29B39} + {0768FA72-CF49-2B59-BC4C-E4CE579E5D93} = {B5BD0336-7D08-492C-8489-42C987E29B39} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Amazon.Lambda.AspNetCoreServer.Hosting.csproj b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Amazon.Lambda.AspNetCoreServer.Hosting.csproj index 2ed732314..a22fd248c 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Amazon.Lambda.AspNetCoreServer.Hosting.csproj +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Amazon.Lambda.AspNetCoreServer.Hosting.csproj @@ -4,7 +4,7 @@ Package for running ASP.NET Core applications using the Minimal API style as a AWS Lambda function. - net6.0;net8.0 + net8.0;net10.0 enable enable 1.10.0 diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/HostingOptions.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/HostingOptions.cs index d4fd7937c..d5d435e89 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/HostingOptions.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/HostingOptions.cs @@ -9,6 +9,12 @@ namespace Amazon.Lambda.AspNetCoreServer.Hosting; /// public class HostingOptions { + internal const string ParameterizedPreviewMessage = + "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + + "project file to \"true\""; + /// /// The ILambdaSerializer used by Lambda to convert the incoming event JSON into the .NET event type and serialize the .NET response type /// back to JSON to return to Lambda. @@ -27,6 +33,15 @@ public class HostingOptions /// public bool IncludeUnhandledExceptionDetailInResponse { get; set; } = false; + /// + /// When true, the Lambda hosting server enables Lambda response streaming behavior + /// when invoking FunctionHandlerAsync. In streaming mode, + /// FunctionHandlerAsync writes directly to the Lambda response stream and + /// returns null. Requires net8.0 or later. + /// + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + public bool EnableResponseStreaming { get; set; } = false; + /// /// Callback invoked after request marshalling to customize the HTTP request feature. /// Receives the IHttpRequestFeature, Lambda request object, and ILambdaContext. diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/GetBeforeSnapshotRequestsCollector.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/GetBeforeSnapshotRequestsCollector.cs index 8cbb12d8f..1d9ee854f 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/GetBeforeSnapshotRequestsCollector.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/GetBeforeSnapshotRequestsCollector.cs @@ -5,7 +5,6 @@ namespace Amazon.Lambda.AspNetCoreServer.Hosting.Internal; -#if NET8_0_OR_GREATER /// /// Helper class for storing Requests for /// @@ -14,4 +13,3 @@ internal class GetBeforeSnapshotRequestsCollector { public HttpRequestMessage? Request { get; set; } } -#endif diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs index f50a37f7b..4218d463e 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/Internal/LambdaRuntimeSupportServer.cs @@ -78,8 +78,13 @@ public APIGatewayHttpApiV2LambdaRuntimeSupportServer(IServiceProvider servicePro /// protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceProvider) { - var handler = new APIGatewayHttpApiV2MinimalApi(serviceProvider).FunctionHandlerAsync; - return HandlerWrapper.GetHandlerWrapper(handler, this.Serializer); + var handler = new APIGatewayHttpApiV2MinimalApi(serviceProvider); +#pragma warning disable CA2252 + var hostingOptions = serviceProvider.GetService(); + handler.EnableResponseStreaming = hostingOptions?.EnableResponseStreaming ?? false; +#pragma warning restore CA2252 + Func> bufferedHandler = handler.FunctionHandlerAsync; + return HandlerWrapper.GetHandlerWrapper(bufferedHandler, this.Serializer); } /// @@ -87,9 +92,7 @@ protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceP /// public class APIGatewayHttpApiV2MinimalApi : APIGatewayHttpApiV2ProxyFunction { - #if NET8_0_OR_GREATER private readonly IEnumerable _beforeSnapshotRequestsCollectors; - #endif private readonly HostingOptions? _hostingOptions; /// @@ -99,9 +102,7 @@ public class APIGatewayHttpApiV2MinimalApi : APIGatewayHttpApiV2ProxyFunction public APIGatewayHttpApiV2MinimalApi(IServiceProvider serviceProvider) : base(serviceProvider) { - #if NET8_0_OR_GREATER _beforeSnapshotRequestsCollectors = serviceProvider.GetServices(); - #endif // Retrieve HostingOptions from service provider (may be null for backward compatibility) _hostingOptions = serviceProvider.GetService(); @@ -127,14 +128,12 @@ public APIGatewayHttpApiV2MinimalApi(IServiceProvider serviceProvider) } } - #if NET8_0_OR_GREATER protected override IEnumerable GetBeforeSnapshotRequests() { foreach (var collector in _beforeSnapshotRequestsCollectors) if (collector.Request != null) yield return collector.Request; } - #endif protected override void PostMarshallRequestFeature(IHttpRequestFeature aspNetCoreRequestFeature, APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest lambdaRequest, ILambdaContext lambdaContext) { @@ -208,8 +207,13 @@ public APIGatewayRestApiLambdaRuntimeSupportServer(IServiceProvider serviceProvi /// protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceProvider) { - var handler = new APIGatewayRestApiMinimalApi(serviceProvider).FunctionHandlerAsync; - return HandlerWrapper.GetHandlerWrapper(handler, this.Serializer); + var handler = new APIGatewayRestApiMinimalApi(serviceProvider); +#pragma warning disable CA2252 + var hostingOptions = serviceProvider.GetService(); + handler.EnableResponseStreaming = hostingOptions?.EnableResponseStreaming ?? false; +#pragma warning restore CA2252 + Func> bufferedHandler = handler.FunctionHandlerAsync; + return HandlerWrapper.GetHandlerWrapper(bufferedHandler, this.Serializer); } /// @@ -217,9 +221,7 @@ protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceP /// public class APIGatewayRestApiMinimalApi : APIGatewayProxyFunction { - #if NET8_0_OR_GREATER private readonly IEnumerable _beforeSnapshotRequestsCollectors; - #endif private readonly HostingOptions? _hostingOptions; /// @@ -229,9 +231,7 @@ public class APIGatewayRestApiMinimalApi : APIGatewayProxyFunction public APIGatewayRestApiMinimalApi(IServiceProvider serviceProvider) : base(serviceProvider) { - #if NET8_0_OR_GREATER _beforeSnapshotRequestsCollectors = serviceProvider.GetServices(); - #endif // Retrieve HostingOptions from service provider (may be null for backward compatibility) _hostingOptions = serviceProvider.GetService(); @@ -257,14 +257,12 @@ public APIGatewayRestApiMinimalApi(IServiceProvider serviceProvider) } } - #if NET8_0_OR_GREATER protected override IEnumerable GetBeforeSnapshotRequests() { foreach (var collector in _beforeSnapshotRequestsCollectors) if (collector.Request != null) yield return collector.Request; } - #endif protected override void PostMarshallRequestFeature(IHttpRequestFeature aspNetCoreRequestFeature, APIGatewayEvents.APIGatewayProxyRequest lambdaRequest, ILambdaContext lambdaContext) { @@ -338,8 +336,13 @@ public ApplicationLoadBalancerLambdaRuntimeSupportServer(IServiceProvider servic /// protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceProvider) { - var handler = new ApplicationLoadBalancerMinimalApi(serviceProvider).FunctionHandlerAsync; - return HandlerWrapper.GetHandlerWrapper(handler, this.Serializer); + var handler = new ApplicationLoadBalancerMinimalApi(serviceProvider); +#pragma warning disable CA2252 + var hostingOptions = serviceProvider.GetService(); + handler.EnableResponseStreaming = hostingOptions?.EnableResponseStreaming ?? false; +#pragma warning restore CA2252 + Func> bufferedHandler = handler.FunctionHandlerAsync; + return HandlerWrapper.GetHandlerWrapper(bufferedHandler, this.Serializer); } /// @@ -347,9 +350,7 @@ protected override HandlerWrapper CreateHandlerWrapper(IServiceProvider serviceP /// public class ApplicationLoadBalancerMinimalApi : ApplicationLoadBalancerFunction { - #if NET8_0_OR_GREATER private readonly IEnumerable _beforeSnapshotRequestsCollectors; - #endif private readonly HostingOptions? _hostingOptions; /// @@ -359,9 +360,7 @@ public class ApplicationLoadBalancerMinimalApi : ApplicationLoadBalancerFunction public ApplicationLoadBalancerMinimalApi(IServiceProvider serviceProvider) : base(serviceProvider) { - #if NET8_0_OR_GREATER _beforeSnapshotRequestsCollectors = serviceProvider.GetServices(); - #endif // Retrieve HostingOptions from service provider (may be null for backward compatibility) _hostingOptions = serviceProvider.GetService(); @@ -387,14 +386,12 @@ public ApplicationLoadBalancerMinimalApi(IServiceProvider serviceProvider) } } - #if NET8_0_OR_GREATER protected override IEnumerable GetBeforeSnapshotRequests() { foreach (var collector in _beforeSnapshotRequestsCollectors) if (collector.Request != null) yield return collector.Request; } - #endif protected override void PostMarshallRequestFeature(IHttpRequestFeature aspNetCoreRequestFeature, ApplicationLoadBalancerEvents.ApplicationLoadBalancerRequest lambdaRequest, ILambdaContext lambdaContext) { diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs index aa952bc54..bd4089df0 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer.Hosting/ServiceCollectionExtensions.cs @@ -88,7 +88,6 @@ public static IServiceCollection AddAWSLambdaHosting(this IServiceCollection ser return services; } - #if NET8_0_OR_GREATER /// /// Adds a > that will be used to invoke /// Routes in your lambda function in order to initialize the ASP.NET Core and Lambda pipelines @@ -142,7 +141,6 @@ public static IServiceCollection AddAWSLambdaBeforeSnapshotRequest(this IService return services; } - #endif private static bool TryLambdaSetup(IServiceCollection services, LambdaEventSource eventSource, Action? configure, out HostingOptions? hostingOptions) { diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayHttpApiV2ProxyFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayHttpApiV2ProxyFunction.cs index a7bcd519d..3026a1f67 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayHttpApiV2ProxyFunction.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayHttpApiV2ProxyFunction.cs @@ -54,6 +54,46 @@ private protected override void InternalCustomResponseExceptionHandling(APIGatew apiGatewayResponse.SetHeaderValues("ErrorType", ex.GetType().Name, false); } +#if NET8_0_OR_GREATER + /// + /// Override for HTTP API v2 to use single-value headers in the streaming prelude + /// instead of multiValueHeaders. API Gateway HTTP API v2 expects the headers + /// format; using multiValueHeaders causes a 500 Internal Server Error. + /// + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + protected override Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude BuildStreamingPrelude(IHttpResponseFeature responseFeature) + { + var prelude = new Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude + { + StatusCode = (System.Net.HttpStatusCode)(responseFeature.StatusCode != 0 ? responseFeature.StatusCode : 200) + }; + + foreach (var kvp in responseFeature.Headers) + { + if (string.Equals(kvp.Key, "Content-Length", StringComparison.OrdinalIgnoreCase) || + string.Equals(kvp.Key, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(kvp.Key, "Set-Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var value in kvp.Value) + { + prelude.Cookies.Add(value); + } + } + else + { + // HTTP API v2 uses single-value headers. Join multiple values with ", ". + prelude.Headers[kvp.Key] = string.Join(", ", kvp.Value); + } + } + + return prelude; + } +#endif + /// /// Convert the JSON document received from API Gateway into the InvokeFeatures object. /// InvokeFeatures is then passed into IHttpApplication to create the ASP.NET Core request objects. diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs index 841b3b1d5..9cbca5c39 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/APIGatewayProxyFunction.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -104,6 +104,49 @@ private protected override void InternalCustomResponseExceptionHandling(APIGatew apiGatewayResponse.MultiValueHeaders["ErrorType"] = new List { ex.GetType().Name }; } + /// + /// Builds an from the current + /// ASP.NET Core response feature. The status code defaults to 200 when + /// is 0. Set-Cookie header values are moved to ; + /// all other headers are placed in . + /// + /// The ASP.NET Core response feature for the current invocation. + /// A populated . + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + protected override Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude BuildStreamingPrelude(IHttpResponseFeature responseFeature) + { + var prelude = new Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude + { + StatusCode = (System.Net.HttpStatusCode)(responseFeature.StatusCode != 0 ? responseFeature.StatusCode : 200) + }; + + foreach (var kvp in responseFeature.Headers) + { + // Skip hop-by-hop and framing headers that are meaningless for streaming + // responses. Content-Length conflicts with chunked transfer encoding and + // can cause API Gateway to reject the response with a 502. + if (string.Equals(kvp.Key, "Content-Length", StringComparison.OrdinalIgnoreCase) || + string.Equals(kvp.Key, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(kvp.Key, "Set-Cookie", StringComparison.OrdinalIgnoreCase)) + { + foreach (var value in kvp.Value) + { + prelude.Cookies.Add(value); + } + } + else + { + prelude.MultiValueHeaders[kvp.Key] = kvp.Value.ToArray(); + } + } + + return prelude; + } + /// /// Convert the JSON document received from API Gateway into the InvokeFeatures object. diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/AbstractAspNetCoreFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/AbstractAspNetCoreFunction.cs index b24a9fd61..1b1e20092 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/AbstractAspNetCoreFunction.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/AbstractAspNetCoreFunction.cs @@ -24,6 +24,12 @@ namespace Amazon.Lambda.AspNetCoreServer /// public abstract class AbstractAspNetCoreFunction { + internal const string ParameterizedPreviewMessage = + "Response streaming is in preview till a new version of .NET Lambda runtime client that supports response streaming " + + "has been deployed to the .NET Lambda managed runtime. Till deployment has been made the feature can be used by deploying as an " + + "executable including the latest version of Amazon.Lambda.RuntimeSupport and setting the \"EnablePreviewFeatures\" in the Lambda " + + "project file to \"true\""; + /// /// Key to access the ILambdaContext object from the HttpContext.Items collection. /// @@ -194,6 +200,15 @@ public void RegisterResponseContentEncodingForContentEncoding(string contentEnco /// public bool IncludeUnhandledExceptionDetailInResponse { get; set; } + /// + /// When true, writes the response directly to a + /// instead of + /// buffering it and returning a typed response object (which will be null). + /// Requires net8.0 or later. + /// + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + public virtual bool EnableResponseStreaming { get; set; } = false; + /// /// Method to initialize the web builder before starting the web host. In a typical Web API this is similar to the main function. @@ -255,7 +270,6 @@ protected virtual IHostBuilder CreateHostBuilder() return builder; } - #if NET8_0_OR_GREATER /// /// Return one or more s that will be used to invoke /// Routes in your lambda function in order to initialize the ASP.NET Core and Lambda pipelines @@ -294,7 +308,6 @@ protected virtual IHostBuilder CreateHostBuilder() /// protected virtual IEnumerable GetBeforeSnapshotRequests() => Enumerable.Empty(); - #endif private protected bool IsStarted { @@ -306,8 +319,6 @@ private protected bool IsStarted private void AddRegisterBeforeSnapshot() { - #if NET8_0_OR_GREATER - Amazon.Lambda.Core.SnapshotRestore.RegisterBeforeSnapshot(async () => { var beforeSnapstartRequests = GetBeforeSnapshotRequests(); @@ -339,8 +350,6 @@ private void AddRegisterBeforeSnapshot() } } }); - - #endif } /// @@ -475,6 +484,14 @@ public virtual async Task FunctionHandlerAsync(TREQUEST request, ILam PostMarshallItemsFeatureFeature(itemFeatures, request, lambdaContext); } +#pragma warning disable CA2252 + if (EnableResponseStreaming) + { + await ExecuteStreamingRequestAsync(features, request, lambdaContext); + return default; + } +#pragma warning restore CA2252 + var scope = this._hostServices.CreateScope(); try { @@ -509,41 +526,7 @@ protected async Task ProcessRequest(ILambdaContext lambdaContext, obj { try { - await this._server.Application.ProcessRequestAsync(context); - } - catch (AggregateException agex) - { - ex = agex; - _logger.LogError(agex, $"Caught AggregateException: '{agex}'"); - var sb = new StringBuilder(); - foreach (var newEx in agex.InnerExceptions) - { - sb.AppendLine(this.ErrorReport(newEx)); - } - - _logger.LogError(sb.ToString()); - ((IHttpResponseFeature)features).StatusCode = 500; - } - catch (ReflectionTypeLoadException rex) - { - ex = rex; - _logger.LogError(rex, $"Caught ReflectionTypeLoadException: '{rex}'"); - var sb = new StringBuilder(); - foreach (var loaderException in rex.LoaderExceptions) - { - var fileNotFoundException = loaderException as FileNotFoundException; - if (fileNotFoundException != null && !string.IsNullOrEmpty(fileNotFoundException.FileName)) - { - sb.AppendLine($"Missing file: {fileNotFoundException.FileName}"); - } - else - { - sb.AppendLine(this.ErrorReport(loaderException)); - } - } - - _logger.LogError(sb.ToString()); - ((IHttpResponseFeature)features).StatusCode = 500; + await RunPipelineAsync(context, features); } catch (Exception e) { @@ -697,5 +680,158 @@ protected virtual void PostMarshallResponseFeature(IHttpResponseFeature aspNetCo /// /// protected abstract TRESPONSE MarshallResponse(IHttpResponseFeature responseFeatures, ILambdaContext lambdaContext, int statusCodeIfNotSet = 200); + + /// + /// Builds an from the current + /// ASP.NET Core response feature. + /// + /// The ASP.NET Core response feature for the current invocation. + /// A populated . + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + protected abstract Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude BuildStreamingPrelude(IHttpResponseFeature responseFeature); + + /// + /// Creates a for writing the streaming Lambda response. + /// The default implementation calls . + /// Subclasses may override this method to substitute a different stream (e.g. a + /// in unit tests). + /// + /// The HTTP response prelude containing status code and headers. + /// A writable for the response body. + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + protected virtual System.IO.Stream CreateLambdaResponseStream( + Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude prelude) + { + return Amazon.Lambda.Core.ResponseStreaming.LambdaResponseStreamFactory.CreateHttpStream(prelude); + } + + /// + /// Executes the streaming response path. Called by when + /// is true. Writes the response directly to a + /// . + /// + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + private async Task ExecuteStreamingRequestAsync(InvokeFeatures features, TREQUEST request, ILambdaContext lambdaContext) + { + var responseFeature = (IHttpResponseFeature)features; + System.IO.Stream lambdaStream = null; + bool streamOpened = false; + + async Task OpenStream() + { + var prelude = BuildStreamingPrelude(responseFeature); + _logger.LogDebug("Opening Lambda response stream with Status code {StatusCode}", prelude.StatusCode); + var stream = CreateLambdaResponseStream(prelude); + lambdaStream = stream; + streamOpened = true; + return stream; + } + + var streamingBodyFeature = new Internal.StreamingResponseBodyFeature(_logger, responseFeature, OpenStream); + features[typeof(IHttpResponseBodyFeature)] = streamingBodyFeature; + + var scope = this._hostServices.CreateScope(); + Exception pipelineException = null; + try + { + ((IServiceProvidersFeature)features).RequestServices = scope.ServiceProvider; + + var context = this.CreateContext(features); + try + { + try + { + await RunPipelineAsync(context, features); + await streamingBodyFeature.CompleteAsync(); + } + catch (Exception e) + { + pipelineException = e; + _logger.LogError(e, "Error in streaming request pipeline"); + + if (!streamOpened) + { + var errorPrelude = new Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude + { + StatusCode = System.Net.HttpStatusCode.InternalServerError + }; + var errorStream = CreateLambdaResponseStream(errorPrelude); + lambdaStream = errorStream; + streamOpened = true; + if (IncludeUnhandledExceptionDetailInResponse) + { + var errorBytes = System.Text.Encoding.UTF8.GetBytes(ErrorReport(e)); + await errorStream.WriteAsync(errorBytes, 0, errorBytes.Length); + } + } + else if (streamOpened) + { + _logger.LogError(e, $"Unhandled exception after response stream was opened: {ErrorReport(e)}"); + } + else + { + _logger.LogError(e, $"Unknown error responding to request: {ErrorReport(e)}"); + } + } + } + finally + { + if (lambdaStream != null) + { + lambdaStream.Dispose(); + } + + if (features.ResponseCompletedEvents != null) + { + await features.ResponseCompletedEvents.ExecuteAsync(); + } + + this._server.Application.DisposeContext(context, pipelineException); + } + } + finally + { + scope.Dispose(); + } + } + + /// + /// Invokes the ASP.NET Core pipeline for the given context, handling + /// and with + /// detailed logging. Any other exception is rethrown to the caller. + /// + private async Task RunPipelineAsync(object context, InvokeFeatures features) + { + try + { + await this._server.Application.ProcessRequestAsync(context); + } + catch (AggregateException agex) + { + _logger.LogError(agex, $"Caught AggregateException: '{agex}'"); + var sb = new StringBuilder(); + foreach (var newEx in agex.InnerExceptions) + sb.AppendLine(this.ErrorReport(newEx)); + _logger.LogError(sb.ToString()); + ((IHttpResponseFeature)features).StatusCode = 500; + throw; + } + catch (ReflectionTypeLoadException rex) + { + _logger.LogError(rex, $"Caught ReflectionTypeLoadException: '{rex}'"); + var sb = new StringBuilder(); + foreach (var loaderException in rex.LoaderExceptions) + { + var fileNotFoundException = loaderException as FileNotFoundException; + if (fileNotFoundException != null && !string.IsNullOrEmpty(fileNotFoundException.FileName)) + sb.AppendLine($"Missing file: {fileNotFoundException.FileName}"); + else + sb.AppendLine(this.ErrorReport(loaderException)); + } + _logger.LogError(sb.ToString()); + ((IHttpResponseFeature)features).StatusCode = 500; + throw; + } + } } } diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Amazon.Lambda.AspNetCoreServer.csproj b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Amazon.Lambda.AspNetCoreServer.csproj index 561616cd6..ea382d609 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Amazon.Lambda.AspNetCoreServer.csproj +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Amazon.Lambda.AspNetCoreServer.csproj @@ -4,7 +4,7 @@ Amazon.Lambda.AspNetCoreServer makes it easy to run ASP.NET Core Web API applications as AWS Lambda functions. - net6.0;net8.0 + net8.0;net10.0 Amazon.Lambda.AspNetCoreServer 9.2.1 Amazon.Lambda.AspNetCoreServer @@ -27,7 +27,11 @@ - + + + + + diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/ApplicationLoadBalancerFunction.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/ApplicationLoadBalancerFunction.cs index 3048284b2..f6f8c638e 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/ApplicationLoadBalancerFunction.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/ApplicationLoadBalancerFunction.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using System.Text; @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.Extensions.Primitives; using System.Globalization; +using Amazon.Lambda.Core.ResponseStreaming; namespace Amazon.Lambda.AspNetCoreServer { @@ -220,6 +221,10 @@ private protected override void InternalCustomResponseExceptionHandling(Applicat } } + /// + [System.Runtime.Versioning.RequiresPreviewFeatures(ParameterizedPreviewMessage)] + protected override HttpResponseStreamPrelude BuildStreamingPrelude(IHttpResponseFeature responseFeature) => throw new NotImplementedException(); + private string GetSingleHeaderValue(ApplicationLoadBalancerRequest request, string headerName) { if (this._multiHeaderValuesEnabled) diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/HttpRequestMessageConverter.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/HttpRequestMessageConverter.cs index 285fb3898..016f3dabb 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/HttpRequestMessageConverter.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/HttpRequestMessageConverter.cs @@ -1,17 +1,10 @@ -#if NET8_0_OR_GREATER using System; -using System.Collections.Generic; using System.Linq; using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.ApplicationLoadBalancerEvents; -using Microsoft.AspNetCore.Identity.Data; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Primitives; namespace Amazon.Lambda.AspNetCoreServer.Internal { @@ -118,4 +111,3 @@ private static async Task ReadContent(HttpRequestMessage r) } } } -#endif diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/InvokeFeatures.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/InvokeFeatures.cs index 398817af2..987878311 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/InvokeFeatures.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/InvokeFeatures.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; @@ -28,13 +28,9 @@ public class InvokeFeatures : IFeatureCollection, IServiceProvidersFeature, ITlsConnectionFeature, IHttpRequestIdentifierFeature, - IHttpResponseBodyFeature - -#if NET6_0_OR_GREATER ,IHttpRequestBodyDetectionFeature ,IHttpActivityFeature -#endif /* , IHttpUpgradeFeature, @@ -54,11 +50,8 @@ public InvokeFeatures() this[typeof(ITlsConnectionFeature)] = this; this[typeof(IHttpResponseBodyFeature)] = this; this[typeof(IHttpRequestIdentifierFeature)] = this; - -#if NET6_0_OR_GREATER this[typeof(IHttpRequestBodyDetectionFeature)] = this; this[typeof(IHttpActivityFeature)] = this; -#endif } #region IFeatureCollection @@ -385,7 +378,6 @@ string IHttpRequestIdentifierFeature.TraceIdentifier #endregion -#if NET6_0_OR_GREATER bool IHttpRequestBodyDetectionFeature.CanHaveBody { get @@ -396,6 +388,5 @@ bool IHttpRequestBodyDetectionFeature.CanHaveBody } Activity IHttpActivityFeature.Activity { get; set; } -#endif } } diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/StreamingResponseBodyFeature.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/StreamingResponseBodyFeature.cs new file mode 100644 index 000000000..03ec929a1 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/StreamingResponseBodyFeature.cs @@ -0,0 +1,250 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +using System; +using System.IO; +using System.IO.Pipelines; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http.Features; + +using Amazon.Lambda.Core.ResponseStreaming; +using Microsoft.Extensions.Logging; + +namespace Amazon.Lambda.AspNetCoreServer.Internal +{ + /// + /// An implementation that supports Lambda response streaming. + /// Uses a two-phase approach: bytes written before are buffered in a + /// ; after all writes go directly to the + /// obtained from the stream opener delegate. + /// + [RequiresPreviewFeatures(AbstractAspNetCoreFunction.ParameterizedPreviewMessage)] + internal class StreamingResponseBodyFeature : IHttpResponseBodyFeature + { + private readonly ILogger _logger; + private readonly IHttpResponseFeature _responseFeature; + private readonly Func> _streamOpener; + + private Stream _lambdaStream; // null until StartAsync completes + private MemoryStream _preStartBuffer; // accumulates bytes written before StartAsync + private bool _started; + private PipeWriter _pipeWriter; // lazily created; always wraps the live Stream + + /// + /// Initializes a new instance of . + /// + /// + /// + /// The for the current invocation. Used to fire + /// OnStarting callbacks when is called. + /// + /// + /// A delegate that, when invoked, builds the from + /// the response headers and calls + /// to obtain the . + /// + public StreamingResponseBodyFeature( + ILogger logger, + IHttpResponseFeature responseFeature, + Func> streamOpener) + { + _logger = logger; + _responseFeature = responseFeature ?? throw new ArgumentNullException(nameof(responseFeature)); + _streamOpener = streamOpener ?? throw new ArgumentNullException(nameof(streamOpener)); + } + + /// + /// Initializes a new instance without a logger (for use in tests). + /// + internal StreamingResponseBodyFeature( + IHttpResponseFeature responseFeature, + Func> streamOpener) + : this(null, responseFeature, streamOpener) { } + + /// + /// + /// Returns the once has been + /// called; otherwise returns a lazy-initialized that buffers + /// bytes until the stream is opened. + /// + public Stream Stream => _lambdaStream ?? (_preStartBuffer ??= new MemoryStream()); + + /// + /// + /// Returns a that calls on first + /// flush/write so that the Lambda stream is opened (and the HTTP prelude is sent) + /// as soon as the application first flushes, rather than waiting until the end. + /// + public PipeWriter Writer => _pipeWriter ??= new StartOnFlushPipeWriter(this); + + /// + /// + /// Fires all registered OnStarting callbacks, then calls the stream opener delegate + /// to obtain the , and finally flushes any bytes that + /// were buffered before this method was called. + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + _logger?.LogInformation("Starting response streaming"); + + if (_started) + return; + + // Fire OnStarting callbacks registered on the response feature. + // InvokeFeatures (which implements IHttpResponseFeature) stores these in + // ResponseStartingEvents, which is internal to this assembly. + if (_responseFeature is InvokeFeatures invokeFeatures && + invokeFeatures.ResponseStartingEvents != null) + { + await invokeFeatures.ResponseStartingEvents.ExecuteAsync(); + } + + // Open the Lambda response stream (this writes the HTTP prelude). + _lambdaStream = await _streamOpener(); + + // Flush any bytes that were written before StartAsync was called. + if (_preStartBuffer != null && _preStartBuffer.Length > 0) + { + _preStartBuffer.Position = 0; + await _preStartBuffer.CopyToAsync(_lambdaStream, cancellationToken); + } + + _started = true; + } + + /// + public async Task CompleteAsync() + { + await StartAsync(); + + if (_pipeWriter != null) + { + await _pipeWriter.FlushAsync(); + } + } + + /// + /// No-op: the stream is already unbuffered once opened. + public void DisableBuffering() + { + // Intentional no-op per design: the Lambda response stream is already unbuffered. + } + + /// + /// + /// Calls to ensure the stream is open, then reads the specified + /// byte range from the file and writes it to the . + /// + public async Task SendFileAsync( + string path, + long offset, + long? count, + CancellationToken cancellationToken = default) + { + await StartAsync(cancellationToken); + + var fileInfo = new FileInfo(path); + if (offset < 0 || offset > fileInfo.Length) + throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty); + if (count.HasValue && (count.Value < 0 || count.Value > fileInfo.Length - offset)) + throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty); + + cancellationToken.ThrowIfCancellationRequested(); + + const int bufferSize = 1024 * 16; + var fileStream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + bufferSize: bufferSize, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + + using (fileStream) + { + fileStream.Seek(offset, SeekOrigin.Begin); + await Utilities.CopyToAsync(fileStream, _lambdaStream, count, bufferSize, cancellationToken); + } + } + + // ----------------------------------------------------------------------- + // StartOnFlushPipeWriter + // + // A PipeWriter wrapper that ensures StartAsync is called (opening the Lambda + // stream and sending the HTTP prelude) the first time the application flushes + // or completes the writer — not just at the very end of the request. + // + // The inner PipeWriter is created lazily against the *live* Stream property + // so it always targets the correct underlying stream (Lambda stream after + // StartAsync, pre-start buffer before). + // ----------------------------------------------------------------------- + private sealed class StartOnFlushPipeWriter : PipeWriter + { + private readonly StreamingResponseBodyFeature _feature; + private PipeWriter _inner; + + // The inner writer must be recreated after StartAsync because Stream + // switches from _preStartBuffer to _lambdaStream at that point. + private PipeWriter Inner => _inner ??= PipeWriter.Create(_feature.Stream); + + public StartOnFlushPipeWriter(StreamingResponseBodyFeature feature) + { + _feature = feature; + } + + public override void Advance(int bytes) => Inner.Advance(bytes); + + public override bool CanGetUnflushedBytes => true; + + public override long UnflushedBytes => Inner.UnflushedBytes; + + public override Memory GetMemory(int sizeHint = 0) => Inner.GetMemory(sizeHint); + + public override Span GetSpan(int sizeHint = 0) => Inner.GetSpan(sizeHint); + + public override async ValueTask FlushAsync(CancellationToken cancellationToken = default) + { + if (!_feature._started) + { + // Flush buffered bytes into the pre-start buffer first, then open the stream. + var innerFlushResult = await Inner.FlushAsync(cancellationToken); + // Recreate inner writer against the Lambda stream after StartAsync. + _inner = null; + await _feature.StartAsync(cancellationToken); + + return innerFlushResult; + } + + return await Inner.FlushAsync(cancellationToken); + } + + public override async ValueTask CompleteAsync(Exception exception = null) + { + if (!_feature._started) + { + await Inner.FlushAsync(); + _inner = null; + await _feature.StartAsync(); + } + await Inner.CompleteAsync(exception); + } + + // Complete (sync) — mirror CompleteAsync behavior to ensure the response is started. + public override void Complete(Exception exception = null) + { + if (!_feature._started) + { + // Flush buffered bytes into the pre-start buffer, then open the stream. + Inner.FlushAsync().GetAwaiter().GetResult(); + _inner = null; + _feature.StartAsync().GetAwaiter().GetResult(); + } + + Inner.Complete(exception); + } + public override void CancelPendingFlush() => Inner.CancelPendingFlush(); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Logging.AspNetCore/Amazon.Lambda.Logging.AspNetCore.csproj b/Libraries/src/Amazon.Lambda.Logging.AspNetCore/Amazon.Lambda.Logging.AspNetCore.csproj index 673a9ca30..7db23986c 100644 --- a/Libraries/src/Amazon.Lambda.Logging.AspNetCore/Amazon.Lambda.Logging.AspNetCore.csproj +++ b/Libraries/src/Amazon.Lambda.Logging.AspNetCore/Amazon.Lambda.Logging.AspNetCore.csproj @@ -4,7 +4,7 @@ Amazon Lambda .NET Core support - Logging ASP.NET Core package. - net6.0;net8.0 + net8.0;net10.0 Amazon.Lambda.Logging.AspNetCore 4.1.1 Amazon.Lambda.Logging.AspNetCore diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs index 8271bf4f1..2df051b72 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/ResponseStreaming/ResponseStream.cs @@ -91,7 +91,7 @@ internal async Task SetHttpOutputStreamAsync(Stream httpOutputStream, Cancellati // so the response is properly framed. if (_prelude?.Length > 0) { - _logger.LogDebug($"Writing prelude of {_prelude.Length} bytes to HTTP stream."); + _logger.LogDebug("Writing prelude to HTTP stream."); var combinedLength = _prelude.Length + PreludeDelimiter.Length; var combined = ArrayPool.Shared.Rent(combinedLength); diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/AddAWSLambdaBeforeSnapshotRequestTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/AddAWSLambdaBeforeSnapshotRequestTests.cs index b4419b1a7..3505d8bb3 100644 --- a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/AddAWSLambdaBeforeSnapshotRequestTests.cs +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/AddAWSLambdaBeforeSnapshotRequestTests.cs @@ -14,7 +14,6 @@ namespace Amazon.Lambda.AspNetCoreServer.Hosting.Tests; /// public class AddAWSLambdaBeforeSnapshotRequestTests { - #if NET8_0_OR_GREATER [Theory] [InlineData(LambdaEventSource.HttpApi)] [InlineData(LambdaEventSource.RestApi)] @@ -55,5 +54,4 @@ await Task.WhenAny( Assert.True(callbackDidTheCallback); } - #endif } diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingHostingTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingHostingTests.cs new file mode 100644 index 000000000..f70f91629 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingHostingTests.cs @@ -0,0 +1,254 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +using System.Runtime.Versioning; +using Amazon.Lambda.AspNetCoreServer.Hosting.Internal; +using Amazon.Lambda.AspNetCoreServer.Test; +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Amazon.Lambda.AspNetCoreServer.Hosting.Tests; + +/// +/// Tests for response streaming integration in hosting (Requirement 10). +/// +[RequiresPreviewFeatures] +public class ResponseStreamingHostingTests +{ + [Fact] + public void EnableResponseStreaming_DefaultsToFalse() + { + var options = new HostingOptions(); + Assert.False(options.EnableResponseStreaming); + } + + [Fact] + public void EnableResponseStreaming_CanBeSetToTrue() + { + var options = new HostingOptions { EnableResponseStreaming = true }; + Assert.True(options.EnableResponseStreaming); + } + + [Fact] + public void AddAWSLambdaHosting_ConfigureCallback_CanSetEnableResponseStreamingTrue() + { + var services = new ServiceCollection(); + using var envHelper = new EnvironmentVariableHelper("AWS_LAMBDA_FUNCTION_NAME", "test-function"); + +#pragma warning disable CA2252 + services.AddAWSLambdaHosting(LambdaEventSource.HttpApi, options => + { + options.EnableResponseStreaming = true; + }); +#pragma warning restore CA2252 + + var sp = services.BuildServiceProvider(); + var hostingOptions = sp.GetService(); + + Assert.NotNull(hostingOptions); + Assert.True(hostingOptions.EnableResponseStreaming); + } + + [Fact] + public void AddAWSLambdaHosting_WithoutCallback_EnableResponseStreamingRemainsDefault() + { + var services = new ServiceCollection(); + using var envHelper = new EnvironmentVariableHelper("AWS_LAMBDA_FUNCTION_NAME", "test-function"); + + services.AddAWSLambdaHosting(LambdaEventSource.HttpApi); + + var sp = services.BuildServiceProvider(); + var hostingOptions = sp.GetService(); + + Assert.NotNull(hostingOptions); + Assert.False(hostingOptions.EnableResponseStreaming); + } + + + // Helper: build a minimal IServiceProvider with the given HostingOptions + private static IServiceProvider BuildServiceProvider(HostingOptions hostingOptions) + { + var services = new ServiceCollection(); + services.AddSingleton(hostingOptions); + services.AddSingleton(new DefaultLambdaJsonSerializer()); + services.AddLogging(); + return services.BuildServiceProvider(); + } + + // ---- APIGatewayHttpApiV2 ---- + + [Fact] + public void HttpApiV2_CreateHandlerWrapper_StreamingFalse_TargetsFunctionHandlerAsync() + { + var options = new HostingOptions { EnableResponseStreaming = false }; + var sp = BuildServiceProvider(options); + + var server = new TestableHttpApiV2Server(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + + // The handler delegate target method should be FunctionHandlerAsync (not streaming) + var methodName = GetHandlerDelegateMethodName(wrapper); + Assert.Contains("FunctionHandlerAsync", methodName); + Assert.DoesNotContain("Streaming", methodName); + } + + [Fact] + public void HttpApiV2_CreateHandlerWrapper_StreamingTrue_TargetsFunctionHandlerAsync() + { + var options = new HostingOptions { EnableResponseStreaming = true }; + var sp = BuildServiceProvider(options); + + var server = new TestableHttpApiV2Server(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + + var methodName = GetHandlerDelegateMethodName(wrapper); + Assert.Contains("FunctionHandlerAsync", methodName); + } + + // ---- APIGatewayRestApi ---- + + [Fact] + public void RestApi_CreateHandlerWrapper_StreamingFalse_TargetsFunctionHandlerAsync() + { + var options = new HostingOptions { EnableResponseStreaming = false }; + var sp = BuildServiceProvider(options); + + var server = new TestableRestApiServer(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + + var methodName = GetHandlerDelegateMethodName(wrapper); + Assert.Contains("FunctionHandlerAsync", methodName); + Assert.DoesNotContain("Streaming", methodName); + } + + [Fact] + public void RestApi_CreateHandlerWrapper_StreamingTrue_TargetsFunctionHandlerAsync() + { + var options = new HostingOptions { EnableResponseStreaming = true }; + var sp = BuildServiceProvider(options); + + var server = new TestableRestApiServer(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + + var methodName = GetHandlerDelegateMethodName(wrapper); + Assert.Contains("FunctionHandlerAsync", methodName); + } + + // ---- ApplicationLoadBalancer ---- + + [Fact] + public void Alb_CreateHandlerWrapper_StreamingFalse_TargetsFunctionHandlerAsync() + { + var options = new HostingOptions { EnableResponseStreaming = false }; + var sp = BuildServiceProvider(options); + + var server = new TestableAlbServer(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + + var methodName = GetHandlerDelegateMethodName(wrapper); + Assert.Contains("FunctionHandlerAsync", methodName); + Assert.DoesNotContain("Streaming", methodName); + } + + [Fact] + public void Alb_CreateHandlerWrapper_StreamingTrue_TargetsFunctionHandlerAsync() + { + var options = new HostingOptions { EnableResponseStreaming = true }; + var sp = BuildServiceProvider(options); + + var server = new TestableAlbServer(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + + var methodName = GetHandlerDelegateMethodName(wrapper); + Assert.Contains("FunctionHandlerAsync", methodName); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// + /// Extracts the method name from the delegate stored inside a HandlerWrapper. + /// HandlerWrapper.Handler is a LambdaBootstrapHandler (a delegate). The actual + /// user-supplied delegate is captured in a closure, so we walk the closure's + /// fields to find the innermost Func/delegate and read its Method.Name. + /// + private static string GetHandlerDelegateMethodName(HandlerWrapper wrapper) + { + // HandlerWrapper.Handler is the LambdaBootstrapHandler delegate. + // It is an async lambda that closes over the user-supplied handler delegate. + // We use reflection to dig through the closure chain until we find a field + // whose type is a delegate with a Method.Name we can inspect. + var handler = wrapper.Handler; + return FindDelegateMethodName(handler.Target, visited: new HashSet(ReferenceEqualityComparer.Instance)); + } + + private static string FindDelegateMethodName(object? target, HashSet visited) + { + if (target == null || !visited.Add(target)) + return string.Empty; + + foreach (var field in target.GetType().GetFields( + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Public)) + { + var value = field.GetValue(target); + if (value == null) continue; + + if (value is Delegate d) + { + var name = d.Method.Name; + // Skip compiler-generated method names (lambdas / state machines) + if (!name.StartsWith("<") && !name.Contains("MoveNext")) + return name; + + // Recurse into the delegate's own closure + var inner = FindDelegateMethodName(d.Target, visited); + if (!string.IsNullOrEmpty(inner)) + return inner; + } + else if (value.GetType().IsClass && !value.GetType().IsPrimitive + && value.GetType().Namespace?.StartsWith("System") == false) + { + var inner = FindDelegateMethodName(value, visited); + if (!string.IsNullOrEmpty(inner)) + return inner; + } + } + + return string.Empty; + } + + // ------------------------------------------------------------------------- + // Testable server subclasses that expose CreateHandlerWrapper publicly + // ------------------------------------------------------------------------- + + private class TestableHttpApiV2Server : APIGatewayHttpApiV2LambdaRuntimeSupportServer + { + public TestableHttpApiV2Server(IServiceProvider sp) : base(sp) { } + + public HandlerWrapper PublicCreateHandlerWrapper(IServiceProvider sp) + => CreateHandlerWrapper(sp); + } + + private class TestableRestApiServer : APIGatewayRestApiLambdaRuntimeSupportServer + { + public TestableRestApiServer(IServiceProvider sp) : base(sp) { } + + public HandlerWrapper PublicCreateHandlerWrapper(IServiceProvider sp) + => CreateHandlerWrapper(sp); + } + + private class TestableAlbServer : ApplicationLoadBalancerLambdaRuntimeSupportServer + { + public TestableAlbServer(IServiceProvider sp) : base(sp) { } + + public HandlerWrapper PublicCreateHandlerWrapper(IServiceProvider sp) + => CreateHandlerWrapper(sp); + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingPropertyTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingPropertyTests.cs new file mode 100644 index 000000000..43ebc4dd4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Hosting.Tests/ResponseStreamingPropertyTests.cs @@ -0,0 +1,129 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.Versioning; + +using Amazon.Lambda.AspNetCoreServer.Hosting.Internal; +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; + +namespace Amazon.Lambda.AspNetCoreServer.Hosting.Tests; + +[RequiresPreviewFeatures] +public class ResponseStreamingPropertyTests +{ + private static IServiceProvider BuildServiceProvider(HostingOptions hostingOptions) + { + var services = new ServiceCollection(); + services.AddSingleton(hostingOptions); + services.AddSingleton(new DefaultLambdaJsonSerializer()); + services.AddLogging(); + return services.BuildServiceProvider(); + } + + private static string GetHandlerDelegateMethodName(HandlerWrapper wrapper) + { + var handler = wrapper.Handler; + return FindDelegateMethodName(handler.Target, new HashSet(ReferenceEqualityComparer.Instance)); + } + + private static string FindDelegateMethodName(object? target, HashSet visited) + { + if (target == null || !visited.Add(target)) + return string.Empty; + + foreach (var field in target.GetType().GetFields( + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Public)) + { + var value = field.GetValue(target); + if (value == null) continue; + + if (value is Delegate d) + { + var name = d.Method.Name; + if (!name.StartsWith("<") && !name.Contains("MoveNext")) + return name; + var inner = FindDelegateMethodName(d.Target, visited); + if (!string.IsNullOrEmpty(inner)) return inner; + } + else if (value.GetType().IsClass && !value.GetType().IsPrimitive + && value.GetType().Namespace?.StartsWith("System") == false) + { + var inner = FindDelegateMethodName(value, visited); + if (!string.IsNullOrEmpty(inner)) return inner; + } + } + + return string.Empty; + } + + private class TestableHttpApiV2Server : APIGatewayHttpApiV2LambdaRuntimeSupportServer + { + public TestableHttpApiV2Server(IServiceProvider sp) : base(sp) { } + public HandlerWrapper PublicCreateHandlerWrapper(IServiceProvider sp) => CreateHandlerWrapper(sp); + } + + private class TestableRestApiServer : APIGatewayRestApiLambdaRuntimeSupportServer + { + public TestableRestApiServer(IServiceProvider sp) : base(sp) { } + public HandlerWrapper PublicCreateHandlerWrapper(IServiceProvider sp) => CreateHandlerWrapper(sp); + } + + private class TestableAlbServer : ApplicationLoadBalancerLambdaRuntimeSupportServer + { + public TestableAlbServer(IServiceProvider sp) : base(sp) { } + public HandlerWrapper PublicCreateHandlerWrapper(IServiceProvider sp) => CreateHandlerWrapper(sp); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Property9_HttpApiV2_StreamingFlag_RoutesCorrectly(bool enableStreaming) + { + var options = new HostingOptions { EnableResponseStreaming = enableStreaming }; + var sp = BuildServiceProvider(options); + var server = new TestableHttpApiV2Server(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + var methodName = GetHandlerDelegateMethodName(wrapper); + + Assert.Contains("FunctionHandlerAsync", methodName); + Assert.DoesNotContain("StreamingFunctionHandlerAsync", methodName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Property9_RestApi_StreamingFlag_RoutesCorrectly(bool enableStreaming) + { + var options = new HostingOptions { EnableResponseStreaming = enableStreaming }; + var sp = BuildServiceProvider(options); + var server = new TestableRestApiServer(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + var methodName = GetHandlerDelegateMethodName(wrapper); + + Assert.Contains("FunctionHandlerAsync", methodName); + Assert.DoesNotContain("StreamingFunctionHandlerAsync", methodName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Property9_Alb_StreamingFlag_RoutesCorrectly(bool enableStreaming) + { + var options = new HostingOptions { EnableResponseStreaming = enableStreaming }; + var sp = BuildServiceProvider(options); + var server = new TestableAlbServer(sp); + var wrapper = server.PublicCreateHandlerWrapper(sp); + var methodName = GetHandlerDelegateMethodName(wrapper); + + Assert.Contains("FunctionHandlerAsync", methodName); + Assert.DoesNotContain("StreamingFunctionHandlerAsync", methodName); + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.csproj b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.csproj index 9ace52777..6d2a4d012 100644 --- a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.csproj +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/Amazon.Lambda.AspNetCoreServer.Test.csproj @@ -1,7 +1,9 @@  + + - net6.0;net8.0 + net8.0;net10.0 Amazon.Lambda.AspNetCoreServer.Test Library Amazon.Lambda.AspNetCoreServer.Test @@ -55,5 +57,6 @@ + diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/BuildStreamingPreludeTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/BuildStreamingPreludeTests.cs new file mode 100644 index 000000000..c2971d0ab --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/BuildStreamingPreludeTests.cs @@ -0,0 +1,267 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +using System.Net; +using System.Runtime.Versioning; + +using Amazon.Lambda.AspNetCoreServer.Internal; +using Microsoft.AspNetCore.Http.Features; +using Xunit; + +namespace Amazon.Lambda.AspNetCoreServer.Test +{ + [RequiresPreviewFeatures] + public class BuildStreamingPreludeTests + { + // Subclass that skips host startup entirely and + // just exposes BuildStreamingPrelude directly without needing a running host. + private class StandalonePreludeBuilder : APIGatewayHttpApiV2ProxyFunction + { + // Use the StartupMode.FirstRequest constructor so no host is started eagerly. + public StandalonePreludeBuilder() + : base(StartupMode.FirstRequest) { } + + public Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude + InvokeBuildStreamingPrelude(IHttpResponseFeature responseFeature) + => BuildStreamingPrelude(responseFeature); + } + + private static StandalonePreludeBuilder CreateBuilder() => new StandalonePreludeBuilder(); + + // Helper: create an InvokeFeatures, set StatusCode and Headers, return as IHttpResponseFeature. + private static IHttpResponseFeature MakeResponseFeature(int statusCode, System.Collections.Generic.Dictionary headers = null) + { + var features = new InvokeFeatures(); + var rf = (IHttpResponseFeature)features; + rf.StatusCode = statusCode; + if (headers != null) + { + foreach (var kvp in headers) + rf.Headers[kvp.Key] = new Microsoft.Extensions.Primitives.StringValues(kvp.Value); + } + return rf; + } + + // ----------------------------------------------------------------------- + // 6.1 Status code is copied correctly for values 100–599 + // ----------------------------------------------------------------------- + [Theory] + [InlineData(100)] + [InlineData(200)] + [InlineData(201)] + [InlineData(204)] + [InlineData(301)] + [InlineData(302)] + [InlineData(400)] + [InlineData(401)] + [InlineData(403)] + [InlineData(404)] + [InlineData(500)] + [InlineData(503)] + [InlineData(599)] + public void StatusCode_IsCopiedCorrectly(int statusCode) + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(statusCode); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Equal((HttpStatusCode)statusCode, prelude.StatusCode); + } + + // ----------------------------------------------------------------------- + // 6.2 Status code defaults to 200 when IHttpResponseFeature.StatusCode is 0 + // ----------------------------------------------------------------------- + [Fact] + public void StatusCode_DefaultsTo200_WhenFeatureStatusCodeIsZero() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(0); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Equal(HttpStatusCode.OK, prelude.StatusCode); + } + + // ----------------------------------------------------------------------- + // 6.3 Non-Set-Cookie headers appear in MultiValueHeaders with all values preserved + // ----------------------------------------------------------------------- + [Fact] + public void NonSetCookieHeaders_AppearInMultiValueHeaders_WithAllValuesPreserved() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(200, new System.Collections.Generic.Dictionary + { + ["Content-Type"] = new[] { "application/json" }, + ["X-Custom"] = new[] { "val1", "val2" }, + ["Cache-Control"] = new[] { "no-cache", "no-store" } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.True(prelude.MultiValueHeaders.ContainsKey("Content-Type")); + Assert.Equal(new[] { "application/json" }, prelude.MultiValueHeaders["Content-Type"]); + + Assert.True(prelude.MultiValueHeaders.ContainsKey("X-Custom")); + Assert.Equal(new[] { "val1", "val2" }, prelude.MultiValueHeaders["X-Custom"]); + + Assert.True(prelude.MultiValueHeaders.ContainsKey("Cache-Control")); + Assert.Equal(new[] { "no-cache", "no-store" }, prelude.MultiValueHeaders["Cache-Control"]); + } + + [Fact] + public void NonSetCookieHeaders_MultiValueHeaders_PreservesMultipleValues() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(200, new System.Collections.Generic.Dictionary + { + ["Accept"] = new[] { "text/html", "application/xhtml+xml", "application/xml" } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Equal(new[] { "text/html", "application/xhtml+xml", "application/xml" }, + prelude.MultiValueHeaders["Accept"]); + } + + // ----------------------------------------------------------------------- + // 6.4 Set-Cookie header values are moved to Cookies and absent from MultiValueHeaders + // ----------------------------------------------------------------------- + [Fact] + public void SetCookieHeader_MovedToCookies_AbsentFromMultiValueHeaders() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(200, new System.Collections.Generic.Dictionary + { + ["Set-Cookie"] = new[] { "session=abc123; Path=/; HttpOnly" }, + ["Content-Type"] = new[] { "text/html" } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + // Cookie value is in Cookies + Assert.Contains("session=abc123; Path=/; HttpOnly", prelude.Cookies); + + // Set-Cookie is NOT in MultiValueHeaders + Assert.False(prelude.MultiValueHeaders.ContainsKey("Set-Cookie")); + Assert.False(prelude.MultiValueHeaders.ContainsKey("set-cookie")); + + // Other headers are still present + Assert.True(prelude.MultiValueHeaders.ContainsKey("Content-Type")); + } + + [Fact] + public void SetCookieHeader_IsCaseInsensitive() + { + // The implementation uses StringComparison.OrdinalIgnoreCase, so + // "set-cookie" (lowercase) should also be routed to Cookies. + var builder = CreateBuilder(); + var features = new InvokeFeatures(); + var rf = (IHttpResponseFeature)features; + rf.StatusCode = 200; + // HeaderDictionary is case-insensitive, so "set-cookie" and "Set-Cookie" are the same key. + rf.Headers["set-cookie"] = new Microsoft.Extensions.Primitives.StringValues("id=xyz; Path=/"); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Contains("id=xyz; Path=/", prelude.Cookies); + Assert.False(prelude.MultiValueHeaders.ContainsKey("Set-Cookie")); + Assert.False(prelude.MultiValueHeaders.ContainsKey("set-cookie")); + } + + // ----------------------------------------------------------------------- + // 6.5 Multiple Set-Cookie values all appear in Cookies + // ----------------------------------------------------------------------- + [Fact] + public void MultipleSetCookieValues_AllAppearInCookies() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(200, new System.Collections.Generic.Dictionary + { + ["Set-Cookie"] = new[] + { + "session=abc; Path=/; HttpOnly", + "theme=dark; Path=/", + "lang=en; Path=/; SameSite=Strict" + } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Equal(3, prelude.Cookies.Count); + Assert.Contains("session=abc; Path=/; HttpOnly", prelude.Cookies); + Assert.Contains("theme=dark; Path=/", prelude.Cookies); + Assert.Contains("lang=en; Path=/; SameSite=Strict", prelude.Cookies); + + // None of them should be in MultiValueHeaders + Assert.False(prelude.MultiValueHeaders.ContainsKey("Set-Cookie")); + } + + [Fact] + public void MultipleSetCookieValues_WithOtherHeaders_CookiesAndHeadersAreSeparated() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(201, new System.Collections.Generic.Dictionary + { + ["Set-Cookie"] = new[] { "a=1", "b=2" }, + ["Location"] = new[] { "/new-resource" } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Equal((HttpStatusCode)201, prelude.StatusCode); + Assert.Equal(2, prelude.Cookies.Count); + Assert.Contains("a=1", prelude.Cookies); + Assert.Contains("b=2", prelude.Cookies); + Assert.True(prelude.MultiValueHeaders.ContainsKey("Location")); + Assert.False(prelude.MultiValueHeaders.ContainsKey("Set-Cookie")); + } + + [Fact] + public void EmptyHeaders_ProducesEmptyMultiValueHeadersAndCookies() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(204); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.Equal(HttpStatusCode.NoContent, prelude.StatusCode); + Assert.Empty(prelude.MultiValueHeaders); + Assert.Empty(prelude.Cookies); + } + + // ----------------------------------------------------------------------- + // Content-Length and Transfer-Encoding are excluded from the prelude + // ----------------------------------------------------------------------- + [Fact] + public void ContentLengthHeader_ExcludedFromPrelude() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(200, new System.Collections.Generic.Dictionary + { + ["Content-Type"] = new[] { "application/json" }, + ["Content-Length"] = new[] { "42" } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.True(prelude.MultiValueHeaders.ContainsKey("Content-Type")); + Assert.False(prelude.MultiValueHeaders.ContainsKey("Content-Length")); + } + + [Fact] + public void TransferEncodingHeader_ExcludedFromPrelude() + { + var builder = CreateBuilder(); + var rf = MakeResponseFeature(200, new System.Collections.Generic.Dictionary + { + ["Content-Type"] = new[] { "text/plain" }, + ["Transfer-Encoding"] = new[] { "chunked" } + }); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + Assert.True(prelude.MultiValueHeaders.ContainsKey("Content-Type")); + Assert.False(prelude.MultiValueHeaders.ContainsKey("Transfer-Encoding")); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/ResponseStreamingPropertyTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/ResponseStreamingPropertyTests.cs new file mode 100644 index 000000000..d10149f07 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/ResponseStreamingPropertyTests.cs @@ -0,0 +1,478 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.AspNetCoreServer.Internal; +using Amazon.Lambda.Core; +using Amazon.Lambda.TestUtilities; + +using Microsoft.AspNetCore.Http.Features; + +using Xunit; + +namespace Amazon.Lambda.AspNetCoreServer.Test +{ + [RequiresPreviewFeatures] + public class ResponseStreamingPropertyTests + { + // ----------------------------------------------------------------------- + // Shared test infrastructure + // ----------------------------------------------------------------------- + + private class PropertyTestStreamingFunction : APIGatewayHttpApiV2ProxyFunction + { + public InvokeFeatures CapturedFeatures { get; private set; } + public MemoryStream CapturedLambdaStream { get; private set; } + public bool MarshallResponseCalled { get; private set; } + + public PropertyTestStreamingFunction() + : base(StartupMode.FirstRequest) + { + EnableResponseStreaming = true; + } + + public void PublicMarshallRequest(InvokeFeatures features, + APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context) + => MarshallRequest(features, request, context); + + protected override void PostMarshallItemsFeatureFeature( + IItemsFeature aspNetCoreItemFeature, + APIGatewayHttpApiV2ProxyRequest lambdaRequest, + ILambdaContext lambdaContext) + { + CapturedFeatures = aspNetCoreItemFeature as InvokeFeatures; + base.PostMarshallItemsFeatureFeature(aspNetCoreItemFeature, lambdaRequest, lambdaContext); + } + + [RequiresPreviewFeatures] + protected override Stream CreateLambdaResponseStream( + Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude prelude) + { + var ms = new MemoryStream(); + CapturedLambdaStream = ms; + return ms; + } + + protected override APIGatewayHttpApiV2ProxyResponse MarshallResponse( + IHttpResponseFeature responseFeatures, + ILambdaContext lambdaContext, + int statusCodeIfNotSet = 200) + { + MarshallResponseCalled = true; + return base.MarshallResponse(responseFeatures, lambdaContext, statusCodeIfNotSet); + } + } + + private class StandalonePreludeBuilder : APIGatewayHttpApiV2ProxyFunction + { + public StandalonePreludeBuilder() : base(StartupMode.FirstRequest) { } + + public Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude + InvokeBuildStreamingPrelude(IHttpResponseFeature responseFeature) + => BuildStreamingPrelude(responseFeature); + } + + private static APIGatewayHttpApiV2ProxyRequest MakeRequest( + string method = "GET", string path = "/api/values", + Dictionary headers = null, string body = null) + => new APIGatewayHttpApiV2ProxyRequest + { + RequestContext = new APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext + { + Http = new APIGatewayHttpApiV2ProxyRequest.HttpDescription { Method = method, Path = path }, + Stage = "$default" + }, + RawPath = path, + Headers = headers ?? new Dictionary { ["accept"] = "application/json" }, + Body = body + }; + + + public static IEnumerable RequestMarshallingCases() => + [ + ["GET", "/api/values", null, null], + ["POST", "/api/values", new Dictionary{["content-type"]="application/json"}, "{\"k\":\"v\"}"], + ["PUT", "/api/items/42", new Dictionary{["x-custom-header"]="abc"}, null], + ["DELETE", "/api/items/1", null, null], + ["PATCH", "/api/values", new Dictionary{["accept"]="text/html"}, null], + ]; + + [Theory] + [MemberData(nameof(RequestMarshallingCases))] + public void Property1_RequestMarshalling_IdenticalInStreamingAndBufferedModes( + string method, string path, Dictionary headers, string body) + { + var function = new PropertyTestStreamingFunction(); + var context = new TestLambdaContext(); + + // Warm up so the host is started + function.FunctionHandlerAsync(MakeRequest(), context).GetAwaiter().GetResult(); + + var request = MakeRequest(method, path, headers, body); + function.FunctionHandlerAsync(request, context).GetAwaiter().GetResult(); + var streamingReq = (IHttpRequestFeature)function.CapturedFeatures; + + var bufferedFeatures = new InvokeFeatures(); + function.PublicMarshallRequest(bufferedFeatures, request, context); + var bufferedReq = (IHttpRequestFeature)bufferedFeatures; + + Assert.NotNull(streamingReq); + Assert.Equal(bufferedReq.Method, streamingReq.Method); + Assert.Equal(bufferedReq.Path, streamingReq.Path); + Assert.Equal(bufferedReq.PathBase, streamingReq.PathBase); + Assert.Equal(bufferedReq.QueryString, streamingReq.QueryString); + Assert.Equal(bufferedReq.Scheme, streamingReq.Scheme); + + foreach (var key in bufferedReq.Headers.Keys) + { + Assert.True(streamingReq.Headers.ContainsKey(key), + $"Streaming features missing header '{key}'"); + Assert.Equal(bufferedReq.Headers[key], streamingReq.Headers[key]); + } + } + + + public static IEnumerable BufferedModeCases() => + [ + ["GET", "/api/values", null, null], + ["POST", "/api/values", null, "{\"key\":\"value\"}"], + ["PUT", "/api/items/5", null, null], + ["DELETE", "/api/items/5", null, null], + ["GET", "/api/values", new Dictionary{["accept"]="text/html"}, null], + ]; + + [Theory] + [MemberData(nameof(BufferedModeCases))] + public void Property2_BufferedMode_Unaffected( + string method, string path, Dictionary headers, string body) + { + // Use a fresh function with streaming OFF + var function = new PropertyTestStreamingFunction(); + function.EnableResponseStreaming = false; + var context = new TestLambdaContext(); + + var response = function.FunctionHandlerAsync(MakeRequest(method, path, headers, body), context) + .GetAwaiter().GetResult(); + + Assert.NotNull(response); + Assert.True(function.MarshallResponseCalled, "MarshallResponse must be called in buffered mode"); + Assert.IsType(response); + Assert.True(response.StatusCode >= 100 && response.StatusCode <= 599, + $"Status code {response.StatusCode} out of valid range"); + } + + + public static IEnumerable PreludeStatusAndHeaderCases() => + [ + // (statusCode, headerKey, headerValues[]) + [0, "accept", new[] { "application/json" }], + [200, "content-type", new[] { "text/plain" }], + [201, "x-request-id", new[] { "abc-123" }], + [404, "cache-control", new[] { "no-cache", "no-store" }], + [500, "x-custom-header", new[] { "val1", "val2", "val3" }], + ]; + + [Theory] + [MemberData(nameof(PreludeStatusAndHeaderCases))] + public void Property3_Prelude_StatusCodeAndNonCookieHeaders_Correct( + int statusCode, string headerKey, string[] headerValues) + { + var builder = new StandalonePreludeBuilder(); + var features = new InvokeFeatures(); + var rf = (IHttpResponseFeature)features; + rf.StatusCode = statusCode; + rf.Headers[headerKey] = new Microsoft.Extensions.Primitives.StringValues(headerValues); + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + int expectedStatus = statusCode == 0 ? 200 : statusCode; + Assert.Equal((System.Net.HttpStatusCode)expectedStatus, prelude.StatusCode); + + Assert.True(prelude.MultiValueHeaders.ContainsKey(headerKey), + $"Header '{headerKey}' missing from MultiValueHeaders"); + Assert.Equal(headerValues, prelude.MultiValueHeaders[headerKey].ToArray()); + + Assert.False(prelude.MultiValueHeaders.ContainsKey("Set-Cookie")); + Assert.False(prelude.MultiValueHeaders.ContainsKey("set-cookie")); + } + + + public static IEnumerable SetCookieCases() => + [ + [new[] { "session=abc; Path=/" }], + [new[] { "a=1; Path=/", "b=2; Path=/" }], + [new[] { "x=foo; Path=/", "y=bar; Path=/", "z=baz; Path=/" }], + ]; + + [Theory] + [MemberData(nameof(SetCookieCases))] + public void Property4_SetCookieHeaders_MovedToCookies_AbsentFromMultiValueHeaders(string[] cookies) + { + var builder = new StandalonePreludeBuilder(); + var features = new InvokeFeatures(); + var rf = (IHttpResponseFeature)features; + rf.StatusCode = 200; + rf.Headers["Set-Cookie"] = new Microsoft.Extensions.Primitives.StringValues(cookies); + rf.Headers["content-type"] = "application/json"; + + var prelude = builder.InvokeBuildStreamingPrelude(rf); + + foreach (var cookie in cookies) + Assert.Contains(cookie, prelude.Cookies); + + Assert.False(prelude.MultiValueHeaders.ContainsKey("Set-Cookie"), + "Set-Cookie must not appear in MultiValueHeaders"); + Assert.False(prelude.MultiValueHeaders.ContainsKey("set-cookie"), + "set-cookie must not appear in MultiValueHeaders"); + + Assert.True(prelude.MultiValueHeaders.ContainsKey("content-type")); + } + + + public static IEnumerable BodyBytesCases() => + [ + [new[] { new byte[] { 1, 2, 3 } }], + [new[] { new byte[] { 10, 20 }, new byte[] { 30, 40, 50 } }], + [new[] { new byte[] { 0xFF }, new byte[] { 0x00 }, new byte[] { 0xAB, 0xCD } }], + [new[] { Encoding.UTF8.GetBytes("hello "), Encoding.UTF8.GetBytes("world") }], + ]; + + [Theory] + [MemberData(nameof(BodyBytesCases))] + public async Task Property5_BodyBytes_ForwardedToLambdaResponseStream_InOrder(byte[][] chunks) + { + var lambdaStream = new MemoryStream(); + var invokeFeatures = new InvokeFeatures(); + var feature = new StreamingResponseBodyFeature( + (IHttpResponseFeature)invokeFeatures, + () => Task.FromResult(lambdaStream)); + + await feature.StartAsync(); + + foreach (var chunk in chunks) + await feature.Stream.WriteAsync(chunk, 0, chunk.Length); + + lambdaStream.Position = 0; + var actual = lambdaStream.ToArray(); + var expected = chunks.SelectMany(c => c).ToArray(); + + Assert.Equal(expected, actual); + } + + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(5)] + public async Task Property6_OnStartingCallbacks_FireBeforeFirstByte(int cbCount) + { + int sequenceCounter = 0; + var callbackSequences = new List(); + int firstWriteSequence = -1; + + var trackingStream = new WriteTrackingStream(() => firstWriteSequence = sequenceCounter++); + var invokeFeatures = new InvokeFeatures(); + var responseFeature = (IHttpResponseFeature)invokeFeatures; + + for (int i = 0; i < cbCount; i++) + { + responseFeature.OnStarting(_ => + { + callbackSequences.Add(sequenceCounter++); + return Task.CompletedTask; + }, null); + } + + var feature = new StreamingResponseBodyFeature( + responseFeature, + () => Task.FromResult(trackingStream)); + + await feature.StartAsync(); + var bytes = new byte[] { 1, 2, 3 }; + await feature.Stream.WriteAsync(bytes, 0, bytes.Length); + + Assert.Equal(cbCount, callbackSequences.Count); + Assert.True(firstWriteSequence >= 0, "No write reached the lambda stream"); + foreach (var seq in callbackSequences) + Assert.True(seq < firstWriteSequence, + $"Callback (seq={seq}) did not fire before first write (seq={firstWriteSequence})"); + } + + + public static IEnumerable FileRangeCases() => + [ + // (fileBytes, offset, count) — null count means read to end + [new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 0L, (long?)8L], + [new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 2L, (long?)4L], + [new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 0L, (long?)null], + [new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 5L, (long?)null], + [new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }, 1L, (long?)2L], + ]; + + [Theory] + [MemberData(nameof(FileRangeCases))] + public async Task Property7_SendFileAsync_WritesCorrectByteRange( + byte[] fileBytes, long offset, long? count) + { + var lambdaStream = new MemoryStream(); + var invokeFeatures = new InvokeFeatures(); + var feature = new StreamingResponseBodyFeature( + (IHttpResponseFeature)invokeFeatures, + () => Task.FromResult(lambdaStream)); + + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, fileBytes); + await feature.SendFileAsync(tempFile, offset, count); + + lambdaStream.Position = 0; + var actual = lambdaStream.ToArray(); + + long actualCount = count ?? (fileBytes.Length - offset); + var expected = fileBytes.Skip((int)offset).Take((int)actualCount).ToArray(); + + Assert.Equal(expected, actual); + } + finally + { + File.Delete(tempFile); + } + } + + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(5)] + public void Property8_OnCompletedCallbacks_FireAfterStreamClose(int cbCount) + { + int sequenceCounter = 0; + var completedSequences = new List(); + int streamClosedSequence = -1; + + var function = new OnCompletedTrackingFunction( + cbCount: cbCount, + completedSequences: completedSequences, + getAndIncrementCounter: () => sequenceCounter++, + onStreamClosed: () => streamClosedSequence = sequenceCounter++); + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + function.FunctionHandlerAsync(request, context).GetAwaiter().GetResult(); + + Assert.Equal(cbCount, completedSequences.Count); + Assert.True(streamClosedSequence >= 0, "Stream was never closed"); + foreach (var seq in completedSequences) + Assert.True(seq > streamClosedSequence, + $"OnCompleted callback (seq={seq}) fired before stream closed (seq={streamClosedSequence})"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private class WriteTrackingStream : MemoryStream + { + private readonly Action _onFirstWrite; + private bool _fired; + + public WriteTrackingStream(Action onFirstWrite) => _onFirstWrite = onFirstWrite; + + public override void Write(byte[] buffer, int offset, int count) + { + FireOnce(); + base.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + FireOnce(); + return base.WriteAsync(buffer, offset, count, cancellationToken); + } + + private void FireOnce() + { + if (!_fired) { _fired = true; _onFirstWrite?.Invoke(); } + } + } + + private class OnCompletedTrackingFunction : APIGatewayHttpApiV2ProxyFunction + { + private readonly int _cbCount; + private readonly List _completedSequences; + private readonly Func _getAndIncrementCounter; + private readonly Action _onStreamClosed; + + public OnCompletedTrackingFunction( + int cbCount, + List completedSequences, + Func getAndIncrementCounter, + Action onStreamClosed) + : base(StartupMode.FirstRequest) + { + EnableResponseStreaming = true; + _cbCount = cbCount; + _completedSequences = completedSequences; + _getAndIncrementCounter = getAndIncrementCounter; + _onStreamClosed = onStreamClosed; + } + + protected override void PostMarshallItemsFeatureFeature( + IItemsFeature aspNetCoreItemFeature, + APIGatewayHttpApiV2ProxyRequest lambdaRequest, + ILambdaContext lambdaContext) + { + var responseFeature = (IHttpResponseFeature)aspNetCoreItemFeature; + for (int i = 0; i < _cbCount; i++) + { + responseFeature.OnCompleted(_ => + { + _completedSequences.Add(_getAndIncrementCounter()); + return Task.CompletedTask; + }, null); + } + base.PostMarshallItemsFeatureFeature(aspNetCoreItemFeature, lambdaRequest, lambdaContext); + } + + [RequiresPreviewFeatures] + protected override Stream CreateLambdaResponseStream( + Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude prelude) + { + return new CloseTrackingStream(_onStreamClosed); + } + } + + private class CloseTrackingStream : MemoryStream + { + private readonly Action _onClose; + private bool _closed; + + public CloseTrackingStream(Action onClose) => _onClose = onClose; + + protected override void Dispose(bool disposing) + { + if (!_closed) { _closed = true; _onClose?.Invoke(); } + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + if (!_closed) { _closed = true; _onClose?.Invoke(); } + return base.DisposeAsync(); + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingFunctionHandlerAsyncTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingFunctionHandlerAsyncTests.cs new file mode 100644 index 000000000..b006ec11f --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingFunctionHandlerAsyncTests.cs @@ -0,0 +1,703 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.Versioning; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.AspNetCoreServer.Internal; +using Amazon.Lambda.Core; +using Amazon.Lambda.TestUtilities; + +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Xunit; + +namespace Amazon.Lambda.AspNetCoreServer.Test +{ + /// + /// Unit tests for the streaming path in + /// when EnableResponseStreaming is true. + /// + /// overrides CreateLambdaResponseStream to inject + /// a instead of calling LambdaResponseStreamFactory.CreateHttpStream, + /// allowing tests to run without the Lambda runtime. + /// + [RequiresPreviewFeatures] + public class StreamingFunctionHandlerAsyncTests + { + // ----------------------------------------------------------------------- + // Base testable subclass — overrides CreateLambdaResponseStream + // ----------------------------------------------------------------------- + + private class TestableStreamingFunction : APIGatewayHttpApiV2ProxyFunction + { + // Captured in PostMarshallItemsFeatureFeature — the InvokeFeatures after MarshallRequest + public InvokeFeatures CapturedFeatures { get; private set; } + + // The MemoryStream used as the Lambda response stream + public MemoryStream CapturedLambdaStream { get; private set; } + + // Whether CreateLambdaResponseStream was called (stream was opened) + public bool StreamOpened { get; private set; } + + // Whether MarshallResponse was called (buffered mode check) + public bool MarshallResponseCalled { get; private set; } + + // Optional setup action invoked inside PostMarshallItemsFeatureFeature + public Func PipelineSetupAction { get; set; } + + public TestableStreamingFunction() + : base(StartupMode.FirstRequest) + { + EnableResponseStreaming = true; + } + + // Expose MarshallRequest publicly so tests can call it after the host is started + public void PublicMarshallRequest(InvokeFeatures features, + APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context) + => MarshallRequest(features, request, context); + + protected override void PostMarshallItemsFeatureFeature( + IItemsFeature aspNetCoreItemFeature, + APIGatewayHttpApiV2ProxyRequest lambdaRequest, + ILambdaContext lambdaContext) + { + CapturedFeatures = aspNetCoreItemFeature as InvokeFeatures; + PipelineSetupAction?.Invoke(CapturedFeatures); + base.PostMarshallItemsFeatureFeature(aspNetCoreItemFeature, lambdaRequest, lambdaContext); + } + + [RequiresPreviewFeatures] + protected override Stream CreateLambdaResponseStream( + Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude prelude) + { + var ms = new MemoryStream(); + CapturedLambdaStream = ms; + StreamOpened = true; + return ms; + } + + protected override APIGatewayHttpApiV2ProxyResponse MarshallResponse( + IHttpResponseFeature responseFeatures, + ILambdaContext lambdaContext, + int statusCodeIfNotSet = 200) + { + MarshallResponseCalled = true; + return base.MarshallResponse(responseFeatures, lambdaContext, statusCodeIfNotSet); + } + } + + // ----------------------------------------------------------------------- + // Helper: build a minimal APIGatewayHttpApiV2ProxyRequest + // ----------------------------------------------------------------------- + private static APIGatewayHttpApiV2ProxyRequest MakeRequest( + string method = "GET", + string path = "/api/values", + Dictionary headers = null, + string body = null) + { + return new APIGatewayHttpApiV2ProxyRequest + { + RequestContext = new APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext + { + Http = new APIGatewayHttpApiV2ProxyRequest.HttpDescription + { + Method = method, + Path = path + }, + Stage = "$default" + }, + RawPath = path, + Headers = headers ?? new Dictionary + { + ["accept"] = "application/json" + }, + Body = body + }; + } + + [Fact] + public async Task RequestMarshalling_ProducesSameHttpRequestFeatureState_AsBufferedMode() + { + var function = new TestableStreamingFunction(); + var context = new TestLambdaContext(); + var request = MakeRequest( + method: "POST", + path: "/api/values", + headers: new Dictionary + { + ["content-type"] = "application/json", + ["x-custom-header"] = "test-value" + }, + body: "{\"key\":\"value\"}" + ); + + // Run the streaming path first — this starts the host and captures features + await function.FunctionHandlerAsync(request, context); + var streamingReq = (IHttpRequestFeature)function.CapturedFeatures; + + // Now call MarshallRequest directly (host is started, _logger is initialized) + var bufferedFeatures = new InvokeFeatures(); + function.PublicMarshallRequest(bufferedFeatures, request, context); + var bufferedReq = (IHttpRequestFeature)bufferedFeatures; + + Assert.NotNull(streamingReq); + Assert.Equal(bufferedReq.Method, streamingReq.Method); + Assert.Equal(bufferedReq.Path, streamingReq.Path); + Assert.Equal(bufferedReq.PathBase, streamingReq.PathBase); + Assert.Equal(bufferedReq.QueryString, streamingReq.QueryString); + Assert.Equal(bufferedReq.Scheme, streamingReq.Scheme); + } + + [Fact] + public async Task RequestMarshalling_PreservesHeaders_InStreamingMode() + { + var function = new TestableStreamingFunction(); + var context = new TestLambdaContext(); + var request = MakeRequest( + headers: new Dictionary + { + ["x-forwarded-for"] = "1.2.3.4", + ["accept"] = "text/html" + } + ); + + // Run streaming path first to start the host + await function.FunctionHandlerAsync(request, context); + var streamingReq = (IHttpRequestFeature)function.CapturedFeatures; + + // Compare with buffered path + var bufferedFeatures = new InvokeFeatures(); + function.PublicMarshallRequest(bufferedFeatures, request, context); + var bufferedReq = (IHttpRequestFeature)bufferedFeatures; + + foreach (var key in bufferedReq.Headers.Keys) + { + Assert.True(streamingReq.Headers.ContainsKey(key), + $"Streaming features missing header '{key}' that buffered features has"); + Assert.Equal(bufferedReq.Headers[key], streamingReq.Headers[key]); + } + } + + [Fact] + public async Task AfterSetup_BodyFeature_IsStreamingResponseBodyFeature() + { + IHttpResponseBodyFeature capturedBodyFeature = null; + + var function = new TestableStreamingFunction(); + function.PipelineSetupAction = features => + { + var responseFeature = (IHttpResponseFeature)features; + responseFeature.OnStarting(_ => + { + capturedBodyFeature = (IHttpResponseBodyFeature)features[typeof(IHttpResponseBodyFeature)]; + return Task.CompletedTask; + }, null); + return Task.CompletedTask; + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + // Verify via CapturedFeatures directly — the body feature was replaced before pipeline ran + var bodyFeatureFromCapture = function.CapturedFeatures[typeof(IHttpResponseBodyFeature)]; + Assert.IsType(bodyFeatureFromCapture); + } + + [Fact] + public async Task AfterSetup_BodyFeature_IsStreamingResponseBodyFeature_ViaOnStarting() + { + IHttpResponseBodyFeature capturedBodyFeature = null; + + var function = new TestableStreamingFunction(); + function.PipelineSetupAction = features => + { + var responseFeature = (IHttpResponseFeature)features; + responseFeature.OnStarting(_ => + { + capturedBodyFeature = (IHttpResponseBodyFeature)features[typeof(IHttpResponseBodyFeature)]; + return Task.CompletedTask; + }, null); + return Task.CompletedTask; + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + if (capturedBodyFeature != null) + { + Assert.IsType(capturedBodyFeature); + } + else + { + var bodyFeature = function.CapturedFeatures[typeof(IHttpResponseBodyFeature)]; + Assert.IsType(bodyFeature); + } + } + + [Fact] + public async Task FunctionHandlerAsync_BufferedMode_StillReturnsResponse_ViaMarshallResponse() + { + // Buffered mode: EnableResponseStreaming defaults to false + var function = new TestableStreamingFunction(); + function.EnableResponseStreaming = false; + var context = new TestLambdaContext(); + var request = MakeRequest(); + + var response = await function.FunctionHandlerAsync(request, context); + + Assert.NotNull(response); + Assert.True(function.MarshallResponseCalled, + "MarshallResponse should have been called in buffered mode"); + Assert.IsType(response); + } + + [Fact] + public async Task FunctionHandlerAsync_BufferedMode_ReturnsStatusCode_FromPipeline() + { + var function = new TestableStreamingFunction(); + function.EnableResponseStreaming = false; + var context = new TestLambdaContext(); + var request = MakeRequest(path: "/api/values"); + + var response = await function.FunctionHandlerAsync(request, context); + + Assert.Equal(200, response.StatusCode); + } + + [Fact] + public async Task FunctionHandlerAsync_BufferedMode_DoesNotOpenLambdaStream() + { + var function = new TestableStreamingFunction(); + function.EnableResponseStreaming = false; + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.False(function.StreamOpened, + "FunctionHandlerAsync (buffered mode) should not open the Lambda response stream"); + } + + // ----------------------------------------------------------------------- + // 7.4 OnCompleted callbacks fire after LambdaResponseStream is closed + // on success path + // ----------------------------------------------------------------------- + [Fact] + public async Task OnCompleted_FiresAfterStreamClosed_OnSuccessPath() + { + bool callbackFired = false; + + var function = new TestableStreamingFunction(); + function.PipelineSetupAction = features => + { + var responseFeature = (IHttpResponseFeature)features; + responseFeature.OnCompleted(_ => + { + callbackFired = true; + return Task.CompletedTask; + }, null); + return Task.CompletedTask; + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.True(callbackFired, "OnCompleted callback should have fired on the success path"); + } + + [Fact] + public async Task OnCompleted_MultipleCallbacks_AllFire() + { + int firedCount = 0; + + var function = new TestableStreamingFunction(); + function.PipelineSetupAction = features => + { + var responseFeature = (IHttpResponseFeature)features; + for (int i = 0; i < 3; i++) + { + responseFeature.OnCompleted(_ => + { + firedCount++; + return Task.CompletedTask; + }, null); + } + return Task.CompletedTask; + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.Equal(3, firedCount); + } + + [Fact] + public async Task ExceptionBeforeStreamOpen_StreamClosedCleanly_OnCompletedFires() + { + bool onCompletedFired = false; + + var function = new ThrowingBeforeStreamOpenFunction( + onCompleted: () => onCompletedFired = true); + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.False(function.StreamOpened, + "Stream should not have been opened when exception occurs before stream open"); + Assert.True(onCompletedFired, + "OnCompleted should fire even when exception occurs before stream open"); + } + + [Fact] + public async Task ExceptionBeforeStreamOpen_WithIncludeExceptionDetail_Writes500ErrorBody() + { + const string exceptionMessage = "Deliberate test failure for 500 response"; + + var function = new ThrowingBeforeStreamOpenFunction( + exceptionMessage: exceptionMessage, + onCompleted: null) + { + IncludeUnhandledExceptionDetailInResponse = true + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.True(function.StreamOpened, + "An error stream should have been opened for the 500 response"); + Assert.NotNull(function.CapturedLambdaStream); + + var errorBody = Encoding.UTF8.GetString(function.CapturedLambdaStream.ToArray()); + Assert.Contains(exceptionMessage, errorBody); + } + + [Fact] + public async Task ExceptionBeforeStreamOpen_WithoutIncludeExceptionDetail_NoStreamOpened() + { + var function = new ThrowingBeforeStreamOpenFunction( + exceptionMessage: "Should not appear in response", + onCompleted: null) + { + IncludeUnhandledExceptionDetailInResponse = false + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.False(function.StreamOpened, + "Stream should not be opened when IncludeUnhandledExceptionDetailInResponse=false"); + } + + // ----------------------------------------------------------------------- + // 7.7 Exception after stream open → stream closed after logging, OnCompleted fires + // ----------------------------------------------------------------------- + [Fact] + public async Task ExceptionAfterStreamOpen_StreamClosedAfterLogging_OnCompletedFires() + { + bool onCompletedFired = false; + + var function = new ThrowingAfterStreamOpenFunction( + onCompleted: () => onCompletedFired = true); + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.True(function.StreamOpened, + "Stream should have been opened before the exception"); + Assert.True(onCompletedFired, + "OnCompleted should fire even when exception occurs after stream open"); + } + + [Fact] + public async Task ExceptionAfterStreamOpen_DoesNotWriteNewErrorBody() + { + var function = new ThrowingAfterStreamOpenFunction(onCompleted: null) + { + IncludeUnhandledExceptionDetailInResponse = true + }; + + var context = new TestLambdaContext(); + var request = MakeRequest(); + + await function.FunctionHandlerAsync(request, context); + + Assert.True(function.StreamOpened); + var streamContent = function.CapturedLambdaStream.ToArray(); + var bodyText = Encoding.UTF8.GetString(streamContent); + Assert.DoesNotContain("InvalidOperationException", bodyText); + } + + [Fact] + public void FunctionHandlerAsync_HasLambdaSerializerAttribute() + { + var method = typeof(APIGatewayHttpApiV2ProxyFunction) + .GetMethod(nameof(APIGatewayHttpApiV2ProxyFunction.FunctionHandlerAsync)); + + Assert.NotNull(method); + + var attr = method.GetCustomAttribute(); + Assert.NotNull(attr); + Assert.Equal( + typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer), + attr.SerializerType); + } + + [Fact] + public void EnableResponseStreaming_Property_HasRequiresPreviewFeaturesAttribute() + { + var prop = typeof(APIGatewayHttpApiV2ProxyFunction) + .GetProperty(nameof(APIGatewayHttpApiV2ProxyFunction.EnableResponseStreaming)); + + Assert.NotNull(prop); + + var attr = prop.GetCustomAttribute(); + Assert.NotNull(attr); + } + + [Fact] + public void EnableResponseStreaming_Property_DefaultsToFalse() + { + var function = new TestableStreamingFunction(); + function.EnableResponseStreaming = false; // reset to default + Assert.False(function.EnableResponseStreaming); + } + + [Fact] + public void FunctionHandlerAsync_ReturnsTaskOfT() + { + var method = typeof(APIGatewayHttpApiV2ProxyFunction) + .GetMethod(nameof(APIGatewayHttpApiV2ProxyFunction.FunctionHandlerAsync)); + + Assert.NotNull(method); + Assert.True(method.ReturnType.IsGenericType); + Assert.Equal(typeof(Task<>), method.ReturnType.GetGenericTypeDefinition()); + } + + [Fact] + public void FunctionHandlerAsync_IsPublicVirtual() + { + var method = typeof(APIGatewayHttpApiV2ProxyFunction) + .GetMethod(nameof(APIGatewayHttpApiV2ProxyFunction.FunctionHandlerAsync)); + + Assert.NotNull(method); + Assert.True(method.IsPublic); + Assert.True(method.IsVirtual); + } + + // ----------------------------------------------------------------------- + // Helper subclasses for exception-path tests + // ----------------------------------------------------------------------- + + /// + /// Base class for exception-path tests. Overrides ExecuteStreamingRequestAsync + /// indirectly by overriding the pipeline via a custom ProcessRequest-equivalent. + /// Uses EnableResponseStreaming = true so FunctionHandlerAsync takes the + /// streaming path, then injects custom pipeline logic via . + /// + private abstract class CustomPipelineStreamingFunction + : APIGatewayHttpApiV2ProxyFunction + { + public MemoryStream CapturedLambdaStream { get; protected set; } + public bool StreamOpened { get; protected set; } + + protected CustomPipelineStreamingFunction() + : base(StartupMode.FirstRequest) + { + EnableResponseStreaming = true; + } + + [RequiresPreviewFeatures] + protected override Stream CreateLambdaResponseStream( + Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude prelude) + { + var ms = new MemoryStream(); + CapturedLambdaStream = ms; + StreamOpened = true; + return ms; + } + + // Override FunctionHandlerAsync to inject custom pipeline logic. + // We replicate the streaming setup from ExecuteStreamingRequestAsync so we can + // call RunPipelineAsync instead of the real ASP.NET Core pipeline. + public override async Task FunctionHandlerAsync( + APIGatewayHttpApiV2ProxyRequest request, + ILambdaContext lambdaContext) + { + if (!IsStarted) Start(); + + var features = new InvokeFeatures(); + MarshallRequest(features, request, lambdaContext); + + var itemFeatures = (IItemsFeature)features; + itemFeatures.Items = new System.Collections.Generic.Dictionary(); + itemFeatures.Items[LAMBDA_CONTEXT] = lambdaContext; + itemFeatures.Items[LAMBDA_REQUEST_OBJECT] = request; + PostMarshallItemsFeatureFeature(itemFeatures, request, lambdaContext); + + var responseFeature = (IHttpResponseFeature)features; + + async Task OpenStream() + { + var prelude = BuildStreamingPrelude(responseFeature); + return CreateLambdaResponseStream(prelude); + } + + var streamingBodyFeature = new StreamingResponseBodyFeature(_logger, responseFeature, OpenStream); + features[typeof(IHttpResponseBodyFeature)] = streamingBodyFeature; + + var scope = this._hostServices.CreateScope(); + Exception pipelineException = null; + try + { + ((IServiceProvidersFeature)features).RequestServices = scope.ServiceProvider; + + try + { + try + { + await RunPipelineAsync(features, streamingBodyFeature); + } + catch (Exception e) + { + pipelineException = e; + + if (!StreamOpened && IncludeUnhandledExceptionDetailInResponse) + { + var errorPrelude = new Amazon.Lambda.Core.ResponseStreaming.HttpResponseStreamPrelude + { + StatusCode = System.Net.HttpStatusCode.InternalServerError + }; + var errorStream = CreateLambdaResponseStream(errorPrelude); + var errorBytes = Encoding.UTF8.GetBytes(ErrorReport(e)); + await errorStream.WriteAsync(errorBytes, 0, errorBytes.Length); + } + else if (StreamOpened) + { + _logger.LogError(e, $"Unhandled exception after response stream was opened: {ErrorReport(e)}"); + } + else + { + _logger.LogError(e, $"Unknown error responding to request: {ErrorReport(e)}"); + } + } + } + finally + { + if (features.ResponseCompletedEvents != null) + { + await features.ResponseCompletedEvents.ExecuteAsync(); + } + } + } + finally + { + scope.Dispose(); + } + + return default; + } + + protected abstract Task RunPipelineAsync( + InvokeFeatures features, + StreamingResponseBodyFeature bodyFeature); + } + + private class ThrowingBeforeStreamOpenFunction : CustomPipelineStreamingFunction + { + private readonly string _exceptionMessage; + private readonly Action _onCompleted; + + public ThrowingBeforeStreamOpenFunction( + string exceptionMessage = "Test exception before stream open", + Action onCompleted = null) + { + _exceptionMessage = exceptionMessage; + _onCompleted = onCompleted; + } + + protected override void PostMarshallItemsFeatureFeature( + IItemsFeature aspNetCoreItemFeature, + APIGatewayHttpApiV2ProxyRequest lambdaRequest, + ILambdaContext lambdaContext) + { + if (_onCompleted != null) + { + ((IHttpResponseFeature)aspNetCoreItemFeature).OnCompleted(_ => + { + _onCompleted(); + return Task.CompletedTask; + }, null); + } + base.PostMarshallItemsFeatureFeature(aspNetCoreItemFeature, lambdaRequest, lambdaContext); + } + + protected override Task RunPipelineAsync( + InvokeFeatures features, + StreamingResponseBodyFeature bodyFeature) + { + throw new InvalidOperationException(_exceptionMessage); + } + } + + private class ThrowingAfterStreamOpenFunction : CustomPipelineStreamingFunction + { + private readonly Action _onCompleted; + + public ThrowingAfterStreamOpenFunction(Action onCompleted = null) + { + _onCompleted = onCompleted; + } + + protected override void PostMarshallItemsFeatureFeature( + IItemsFeature aspNetCoreItemFeature, + APIGatewayHttpApiV2ProxyRequest lambdaRequest, + ILambdaContext lambdaContext) + { + if (_onCompleted != null) + { + ((IHttpResponseFeature)aspNetCoreItemFeature).OnCompleted(_ => + { + _onCompleted(); + return Task.CompletedTask; + }, null); + } + base.PostMarshallItemsFeatureFeature(aspNetCoreItemFeature, lambdaRequest, lambdaContext); + } + + protected override async Task RunPipelineAsync( + InvokeFeatures features, + StreamingResponseBodyFeature bodyFeature) + { + await bodyFeature.StartAsync(); + var partial = Encoding.UTF8.GetBytes("partial"); + await bodyFeature.Stream.WriteAsync(partial, 0, partial.Length); + throw new InvalidOperationException("Test exception after stream open"); + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingResponseBodyFeatureTests.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingResponseBodyFeatureTests.cs new file mode 100644 index 000000000..cdbd403e4 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/StreamingResponseBodyFeatureTests.cs @@ -0,0 +1,286 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Threading.Tasks; + +using Amazon.Lambda.AspNetCoreServer.Internal; +using Microsoft.AspNetCore.Http.Features; +using Xunit; + +namespace Amazon.Lambda.AspNetCoreServer.Test +{ + [RequiresPreviewFeatures] + public class StreamingResponseBodyFeatureTests + { + // Helper: creates a StreamingResponseBodyFeature backed by a MemoryStream stand-in. + // Returns the feature and the MemoryStream that acts as the LambdaResponseStream. + private static (StreamingResponseBodyFeature feature, MemoryStream lambdaStream, InvokeFeatures invokeFeatures) + CreateFeature() + { + var lambdaStream = new MemoryStream(); + var invokeFeatures = new InvokeFeatures(); + var feature = new StreamingResponseBodyFeature( + (IHttpResponseFeature)invokeFeatures, + () => Task.FromResult(lambdaStream)); + return (feature, lambdaStream, invokeFeatures); + } + + [Fact] + public async Task PreStartBytes_AreBuffered_ThenFlushedToLambdaStream_OnStartAsync() + { + var (feature, lambdaStream, _) = CreateFeature(); + + // Write before StartAsync — should go to the pre-start buffer, NOT to lambdaStream yet. + var preBytes = new byte[] { 1, 2, 3 }; + await feature.Stream.WriteAsync(preBytes, 0, preBytes.Length); + + Assert.Equal(0, lambdaStream.Length); // nothing in lambda stream yet + + // Now call StartAsync — buffered bytes should be flushed. + await feature.StartAsync(); + + lambdaStream.Position = 0; + var result = lambdaStream.ToArray(); + Assert.Equal(preBytes, result); + } + + [Fact] + public async Task PostStartBytes_GoDirectlyToLambdaStream() + { + var (feature, lambdaStream, _) = CreateFeature(); + + await feature.StartAsync(); + + var postBytes = new byte[] { 10, 20, 30, 40 }; + await feature.Stream.WriteAsync(postBytes, 0, postBytes.Length); + + lambdaStream.Position = 0; + var result = lambdaStream.ToArray(); + Assert.Equal(postBytes, result); + } + + [Fact] + public async Task OnStartingCallbacks_FireBeforeFirstByteReachesLambdaStream() + { + var lambdaStream = new SequenceTrackingStream(); + var invokeFeatures = new InvokeFeatures(); + var responseFeature = (IHttpResponseFeature)invokeFeatures; + + int callbackSequence = -1; + int writeSequence = -1; + int sequenceCounter = 0; + + // Register an OnStarting callback that records its sequence number. + responseFeature.OnStarting(_ => + { + callbackSequence = sequenceCounter++; + return Task.CompletedTask; + }, null); + + // The stream opener records the sequence when the stream is first written to. + var feature = new StreamingResponseBodyFeature( + responseFeature, + () => + { + lambdaStream.OnFirstWrite = () => writeSequence = sequenceCounter++; + return Task.FromResult(lambdaStream); + }); + + // Write a byte — this should trigger StartAsync internally (via Stream property + // returning the pre-start buffer), but we explicitly call StartAsync here. + await feature.StartAsync(); + + // Write after start to trigger the first actual write to lambdaStream. + var bytes = new byte[] { 0xFF }; + await feature.Stream.WriteAsync(bytes, 0, bytes.Length); + + Assert.True(callbackSequence >= 0, "OnStarting callback was never called"); + Assert.True(writeSequence >= 0, "No write reached the lambda stream"); + Assert.True(callbackSequence < writeSequence, + $"OnStarting callback (seq={callbackSequence}) should fire before first write (seq={writeSequence})"); + } + + [Fact] + public async Task DisableBuffering_IsNoOp_DoesNotThrow_DoesNotChangeBehavior() + { + var (feature, lambdaStream, _) = CreateFeature(); + + // Should not throw. + feature.DisableBuffering(); + + // Behavior should be unchanged: bytes still flow through normally. + await feature.StartAsync(); + var bytes = new byte[] { 7, 8, 9 }; + await feature.Stream.WriteAsync(bytes, 0, bytes.Length); + + lambdaStream.Position = 0; + Assert.Equal(bytes, lambdaStream.ToArray()); + } + + [Fact] + public void DisableBuffering_BeforeStart_DoesNotThrow() + { + var (feature, _, _) = CreateFeature(); + var ex = Record.Exception(() => feature.DisableBuffering()); + Assert.Null(ex); + } + + [Fact] + public async Task SendFileAsync_WritesFullFile_WhenNoOffsetOrCount() + { + var (feature, lambdaStream, _) = CreateFeature(); + + var fileBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, fileBytes); + + await feature.SendFileAsync(tempFile, 0, null); + + lambdaStream.Position = 0; + Assert.Equal(fileBytes, lambdaStream.ToArray()); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task SendFileAsync_WritesCorrectByteRange_WithOffsetAndCount() + { + var (feature, lambdaStream, _) = CreateFeature(); + + var fileBytes = new byte[] { 10, 20, 30, 40, 50, 60, 70, 80 }; + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, fileBytes); + + // Read bytes at offset=2, count=4 → should get [30, 40, 50, 60] + await feature.SendFileAsync(tempFile, offset: 2, count: 4); + + lambdaStream.Position = 0; + Assert.Equal(new byte[] { 30, 40, 50, 60 }, lambdaStream.ToArray()); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task SendFileAsync_WithOffset_SkipsLeadingBytes() + { + var (feature, lambdaStream, _) = CreateFeature(); + + var fileBytes = new byte[] { 1, 2, 3, 4, 5 }; + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, fileBytes); + + // offset=3, count=null → should get [4, 5] + await feature.SendFileAsync(tempFile, offset: 3, count: null); + + lambdaStream.Position = 0; + Assert.Equal(new byte[] { 4, 5 }, lambdaStream.ToArray()); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task CompleteAsync_CallsStartAsync_WhenNotYetStarted() + { + bool streamOpenerCalled = false; + var lambdaStream = new MemoryStream(); + var invokeFeatures = new InvokeFeatures(); + + var feature = new StreamingResponseBodyFeature( + (IHttpResponseFeature)invokeFeatures, + () => + { + streamOpenerCalled = true; + return Task.FromResult(lambdaStream); + }); + + Assert.False(streamOpenerCalled); + + await feature.CompleteAsync(); + + Assert.True(streamOpenerCalled, "CompleteAsync should have triggered StartAsync which calls the stream opener"); + } + + [Fact] + public async Task CompleteAsync_WhenAlreadyStarted_DoesNotCallStreamOpenerAgain() + { + int streamOpenerCallCount = 0; + var lambdaStream = new MemoryStream(); + var invokeFeatures = new InvokeFeatures(); + + var feature = new StreamingResponseBodyFeature( + (IHttpResponseFeature)invokeFeatures, + () => + { + streamOpenerCallCount++; + return Task.FromResult(lambdaStream); + }); + + await feature.StartAsync(); + await feature.CompleteAsync(); + + Assert.Equal(1, streamOpenerCallCount); + } + + [Fact] + public async Task PreAndPostStartBytes_AreForwardedInOrder() + { + var (feature, lambdaStream, _) = CreateFeature(); + + var preBytes = new byte[] { 1, 2, 3 }; + var postBytes = new byte[] { 4, 5, 6 }; + + await feature.Stream.WriteAsync(preBytes, 0, preBytes.Length); + await feature.StartAsync(); + await feature.Stream.WriteAsync(postBytes, 0, postBytes.Length); + + lambdaStream.Position = 0; + var result = lambdaStream.ToArray(); + Assert.Equal(new byte[] { 1, 2, 3, 4, 5, 6 }, result); + } + + private class SequenceTrackingStream : MemoryStream + { + public Action OnFirstWrite { get; set; } + private bool _firstWriteDone; + + public override void Write(byte[] buffer, int offset, int count) + { + FireFirstWrite(); + base.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, + System.Threading.CancellationToken cancellationToken) + { + FireFirstWrite(); + return base.WriteAsync(buffer, offset, count, cancellationToken); + } + + private void FireFirstWrite() + { + if (!_firstWriteDone) + { + _firstWriteDone = true; + OnFirstWrite?.Invoke(); + } + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayHttpApiV2Calls.cs b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayHttpApiV2Calls.cs index 1b844bf1e..e6a80d38c 100644 --- a/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayHttpApiV2Calls.cs +++ b/Libraries/test/Amazon.Lambda.AspNetCoreServer.Test/TestApiGatewayHttpApiV2Calls.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.IO; -using System.IO.Compression; using System.Linq; using System.Net; using System.Reflection; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -285,7 +282,6 @@ public async Task TestTraceIdSetFromLambdaContext() } } - #if NET8_0_OR_GREATER /// /// Verifies that is invoked during startup. /// @@ -313,7 +309,6 @@ public async Task TestSnapStartInitialization() Assert.True(SnapStartController.Invoked); } - #endif private async Task InvokeAPIGatewayRequest(string fileName, bool configureApiToReturnExceptionDetail = false) { diff --git a/Libraries/test/Amazon.Lambda.Logging.AspNetCore.Tests/Amazon.Lambda.Logging.AspNetCore.Tests.csproj b/Libraries/test/Amazon.Lambda.Logging.AspNetCore.Tests/Amazon.Lambda.Logging.AspNetCore.Tests.csproj index df332b1a5..148cb7155 100644 --- a/Libraries/test/Amazon.Lambda.Logging.AspNetCore.Tests/Amazon.Lambda.Logging.AspNetCore.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Logging.AspNetCore.Tests/Amazon.Lambda.Logging.AspNetCore.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net10.0 Amazon.Lambda.Logging.AspNetCore.Tests Amazon.Lambda.Logging.AspNetCore.Tests true diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj index d206a1f1c..7a4aeb901 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Amazon.Lambda.RuntimeSupport.IntegrationTests.csproj @@ -19,6 +19,7 @@ + diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs new file mode 100644 index 000000000..8938345a6 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs @@ -0,0 +1,437 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Amazon.CloudFormation; +using Amazon.CloudFormation.Model; +using Amazon.Lambda.RuntimeSupport.IntegrationTests.Helpers; +using Xunit; +using Xunit.Abstractions; + +using InvalidOperationException = System.InvalidOperationException; + +namespace Amazon.Lambda.RuntimeSupport.IntegrationTests +{ + // ───────────────────────────────────────────────────────────────────────────── + // Shared test logic + // ───────────────────────────────────────────────────────────────────────────── + + /// + /// Base class containing all streaming integration test scenarios. + /// Subclasses provide the fixture for a specific deployment type + /// (API Gateway REST API or Lambda Function URL). + /// + public abstract class StreamingTestBase + { + private readonly StreamingFixture _fixture; + protected readonly ITestOutputHelper Output; + + protected StreamingTestBase(StreamingFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + Output = output; + } + + [Fact] + public async Task RootEndpoint_ReturnsWelcomeMessage() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var response = await httpClient.GetWithRetryAsync(apiUrl); + + Output.WriteLine($"Status: {response.StatusCode}"); + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Body: {body}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Welcome to ASP.NET Core streaming on Lambda", body); + } + + [Fact] + public async Task StreamingEndpoint_ReturnsAllLines() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var response = await httpClient.GetWithRetryAsync($"{apiUrl}streaming-test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Body length: {body.Length}"); + + Assert.Contains("Line 1", body); + Assert.Contains("Line 50", body); + Assert.Contains("Line 100", body); + } + + [Fact] + public async Task StreamingEndpoint_ContentTypeIsTextPlain() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var response = await httpClient.GetWithRetryAsync($"{apiUrl}streaming-test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task JsonEndpoint_ReturnsValidJson() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var response = await httpClient.GetWithRetryAsync($"{apiUrl}json-response"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Body: {body}"); + + var doc = JsonDocument.Parse(body); + Assert.True(doc.RootElement.TryGetProperty("message", out var msg)); + Assert.Equal("Hello from streaming Lambda", msg.GetString()); + } + + [Fact] + public async Task StreamingErrorEndpoint_StreamIsTruncated() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + try + { + var response = await httpClient.GetWithRetryAsync($"{apiUrl}streaming-error"); + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Status: {response.StatusCode}"); + Output.WriteLine($"Body: {body}"); + + if (response.StatusCode == HttpStatusCode.OK) + { + Assert.Contains("Line 1", body); + } + } + catch (HttpRequestException ex) + { + Output.WriteLine($"Expected error: {ex.Message}"); + } + } + + [Fact] + public async Task OnCompletedCallback_IsExecuted() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var response = await httpClient.GetWithRetryAsync($"{apiUrl}oncompleted-test"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Body: {body}"); + Assert.Contains("OnCompleted callback registered", body); + + var verifyResponse = await httpClient.GetWithRetryAsync($"{apiUrl}oncompleted-verify"); + Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode); + var verifyBody = await verifyResponse.Content.ReadAsStringAsync(); + Output.WriteLine($"Verify body: {verifyBody}"); + + var doc = JsonDocument.Parse(verifyBody); + Assert.True(doc.RootElement.GetProperty("onCompletedExecuted").GetBoolean(), + "OnCompleted callback should have been executed"); + } + + [Fact] + public async Task CustomHeaders_PassedThrough() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var response = await httpClient.GetWithRetryAsync($"{apiUrl}custom-headers", HttpStatusCode.Created); + + Output.WriteLine($"Status: {response.StatusCode}"); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Custom headers response", body); + + Assert.True(response.Headers.Contains("X-Custom-Header"), "X-Custom-Header should be present"); + Assert.Equal("custom-value", response.Headers.GetValues("X-Custom-Header").First()); + Assert.True(response.Headers.Contains("X-Another-Header"), "X-Another-Header should be present"); + Assert.Equal("another-value", response.Headers.GetValues("X-Another-Header").First()); + } + + [Fact] + public async Task SetCookie_PassedThrough() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + var handler = new HttpClientHandler { UseCookies = false }; + using var httpClient = new HttpClient(handler); + + var response = await httpClient.GetWithRetryAsync($"{apiUrl}set-cookie"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Body: {body}"); + Assert.Contains("Cookies set", body); + + Assert.True(response.Headers.Contains("Set-Cookie"), "Set-Cookie header should be present"); + var cookies = response.Headers.GetValues("Set-Cookie").ToList(); + Output.WriteLine($"Cookies: {string.Join("; ", cookies)}"); + Assert.True(cookies.Any(c => c.Contains("session=abc123")), "session cookie should be present"); + Assert.True(cookies.Any(c => c.Contains("theme=dark")), "theme cookie should be present"); + } + + [Fact] + public async Task PostWithBody_EchoesRequestBody() + { + var apiUrl = await _fixture.GetApiUrlAsync(); + using var httpClient = new HttpClient(); + + var content = new StringContent("Hello from integration test", Encoding.UTF8, "text/plain"); + var response = await httpClient.PostAsync($"{apiUrl}echo-body", content); + + Output.WriteLine($"Status: {response.StatusCode}"); + var body = await response.Content.ReadAsStringAsync(); + Output.WriteLine($"Body: {body}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Echo: Hello from integration test", body); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Concrete test classes + // ───────────────────────────────────────────────────────────────────────────── + + /// + /// Tests streaming through API Gateway REST API. + /// + public class RestApiStreamingTests : StreamingTestBase, IClassFixture + { + public RestApiStreamingTests(RestApiStreamingFixture fixture, ITestOutputHelper output) + : base(fixture, output) { } + } + + /// + /// Tests streaming through Lambda Function URL. + /// Function URL uses the same payload format as HTTP API v2. + /// + public class FunctionUrlStreamingTests : StreamingTestBase, IClassFixture + { + public FunctionUrlStreamingTests(FunctionUrlStreamingFixture fixture, ITestOutputHelper output) + : base(fixture, output) { } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Fixtures + // ───────────────────────────────────────────────────────────────────────────── + + public class RestApiStreamingFixture : StreamingFixture + { + public RestApiStreamingFixture() + : base("serverless-restapi.template", "RestApi") { } + } + + public class FunctionUrlStreamingFixture : StreamingFixture + { + public FunctionUrlStreamingFixture() + : base("serverless-functionurl.template", "FunctionUrl") { } + } + + /// + /// Shared fixture that deploys the ASP.NET Core streaming test app to AWS using + /// "dotnet lambda deploy-serverless" and tears it down after tests complete. + /// Parameterized by template file and deployment type. + /// + public class StreamingFixture : IAsyncLifetime + { + private static readonly RegionEndpoint TestRegion = BaseCustomRuntimeTest.TestRegion; + + private readonly string _templateFile; + private readonly string _deploymentType; + private readonly string _stackName; + + private string _apiUrl; + private string _toolPath; + private string _testAppPath; + private bool _deployed; + private string _s3BucketName; + + protected StreamingFixture(string templateFile, string deploymentType) + { + _templateFile = templateFile; + _deploymentType = deploymentType; + _stackName = $"IntegTest-Streaming-{deploymentType}-{DateTime.UtcNow.Ticks}"; + } + + public Task GetApiUrlAsync() + { + if (!_deployed) + { + throw new InvalidOperationException("Test infrastructure not deployed. InitializeAsync must complete first."); + } + return Task.FromResult(_apiUrl); + } + + public async Task InitializeAsync() + { + _toolPath = await LambdaToolsHelper.InstallLambdaTools(); + + _testAppPath = LambdaToolsHelper.GetTempTestAppDirectory( + "../../../../../../..", + "Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest"); + + var lambdaToolPath = Path.Combine(_toolPath, "dotnet-lambda"); + _s3BucketName = await GetOrCreateDeploymentBucketAsync(); + await CommandLineWrapper.Run( + lambdaToolPath, + $"deploy-serverless --stack-name {_stackName} --template {_templateFile} --s3-bucket {_s3BucketName} --region {TestRegion.SystemName} --disable-interactive true", + _testAppPath); + + _apiUrl = await GetStackOutputAsync(_stackName, "ApiURL"); + if (!_apiUrl.EndsWith("/")) + { + _apiUrl += "/"; + } + + _deployed = true; + + await WaitForEndpointAsync(); + } + + public async Task DisposeAsync() + { + if (_deployed) + { + try + { + var lambdaToolPath = Path.Combine(_toolPath, "dotnet-lambda"); + await CommandLineWrapper.Run( + lambdaToolPath, + $"delete-serverless --stack-name {_stackName} --region {TestRegion.SystemName}", + _testAppPath); + + if (_s3BucketName != null) + { + using var s3Client = new Amazon.S3.AmazonS3Client(TestRegion); + try + { + await Amazon.S3.Util.AmazonS3Util.DeleteS3BucketWithObjectsAsync(s3Client, _s3BucketName); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to delete S3 bucket {_s3BucketName}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to delete stack {_stackName}: {ex.Message}"); + } + } + +#if !DEBUG + LambdaToolsHelper.CleanUp(_toolPath); + LambdaToolsHelper.CleanUp(_testAppPath); +#endif + } + + private async Task GetStackOutputAsync(string stackName, string outputKey) + { + using var cfnClient = new AmazonCloudFormationClient(TestRegion); + var response = await cfnClient.DescribeStacksAsync(new DescribeStacksRequest + { + StackName = stackName + }); + + var stack = response.Stacks.FirstOrDefault() + ?? throw new Exception($"Stack {stackName} not found"); + + var output = stack.Outputs.FirstOrDefault(o => o.OutputKey == outputKey) + ?? throw new Exception($"Output {outputKey} not found in stack {stackName}"); + + return output.OutputValue; + } + + private async Task GetOrCreateDeploymentBucketAsync() + { + using var stsClient = new Amazon.SecurityToken.AmazonSecurityTokenServiceClient(TestRegion); + var identity = await stsClient.GetCallerIdentityAsync(new Amazon.SecurityToken.Model.GetCallerIdentityRequest()); + var name = $"integ-test-streaming-{identity.Account}-{TestRegion.SystemName}"; + using var s3Client = new Amazon.S3.AmazonS3Client(TestRegion); + try + { + await s3Client.PutBucketAsync(new Amazon.S3.Model.PutBucketRequest + { + BucketName = name, + UseClientRegion = true + }); + } + catch (Amazon.S3.AmazonS3Exception ex) when (ex.ErrorCode == "BucketAlreadyOwnedByYou") + { + // Bucket already exists from a previous run — reuse it + } + + return name; + } + + private async Task WaitForEndpointAsync() + { + using var httpClient = new HttpClient(); + var maxRetries = 10; + for (var i = 0; i < maxRetries; i++) + { + try + { + var response = await httpClient.GetAsync(_apiUrl); + if (response.StatusCode != HttpStatusCode.InternalServerError) + { + return; + } + } + catch + { + // Ignore — endpoint may not be ready yet + } + await Task.Delay(TimeSpan.FromSeconds(5)); + } + } + } + + internal static class HttpClientExtension + { + public static async Task GetWithRetryAsync( + this HttpClient httpClient, string url, + HttpStatusCode expectedCode = HttpStatusCode.OK, + int maxRetries = 5, int delaySeconds = 5) + { + for (var i = 0; i < maxRetries; i++) + { + try + { + var response = await httpClient.GetAsync(url); + if (response.StatusCode == expectedCode) + { + return response; + } + } + catch + { + // Ignore and retry + } + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + throw new Exception($"Failed to get expected status code {expectedCode} from {url} after {maxRetries} attempts"); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs index ea6fd059e..c6b73f896 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs @@ -41,7 +41,7 @@ public static async Task Run(string command, string arguments, string workingDir if (!string.IsNullOrEmpty(args.Data)) { Console.WriteLine(args.Data); - output.Append(args.Data); + output.AppendLine(args.Data); } }; @@ -50,7 +50,7 @@ public static async Task Run(string command, string arguments, string workingDir if (!string.IsNullOrEmpty(args.Data)) { Console.WriteLine(args.Data); - output.Append(args.Data); + output.AppendLine(args.Data); } }; diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/AspNetCoreStreamingApiGatewayTest.csproj b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/AspNetCoreStreamingApiGatewayTest.csproj new file mode 100644 index 000000000..6c72dc80c --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/AspNetCoreStreamingApiGatewayTest.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + true + Lambda + true + true + + + + + diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/Program.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/Program.cs new file mode 100644 index 000000000..fbebed76b --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/Program.cs @@ -0,0 +1,133 @@ +#pragma warning disable CA2252 + +using Amazon.Lambda.AspNetCoreServer.Hosting; + +var builder = WebApplication.CreateBuilder(args); + +// Determine the event source from environment variable. +// RestApi for API Gateway REST API, HttpApi for Lambda Function URL +// (Function URL uses the same payload format as HTTP API v2). +var eventSourceType = Environment.GetEnvironmentVariable("LAMBDA_EVENT_SOURCE") ?? "RestApi"; +var eventSource = eventSourceType.Equals("HttpApi", StringComparison.OrdinalIgnoreCase) + ? LambdaEventSource.HttpApi + : LambdaEventSource.RestApi; + +builder.Services.AddAWSLambdaHosting(eventSource, options => +{ + options.EnableResponseStreaming = true; +}); + +var app = builder.Build(); + +app.MapGet("/", () => "Welcome to ASP.NET Core streaming on Lambda"); + +app.MapGet("/streaming-test", async (HttpContext context) => +{ + context.Response.ContentType = "text/plain"; + context.Response.StatusCode = 200; + + var stream = context.Response.BodyWriter.AsStream(); + using var writer = new StreamWriter(stream, leaveOpen: true); + + for (var i = 1; i <= 100; i++) + { + await writer.WriteLineAsync($"Line {i}"); + if (i % 10 == 0) + { + await writer.FlushAsync(); + } + } +}); + +app.MapGet("/streaming-error", async (HttpContext context) => +{ + context.Response.ContentType = "text/plain"; + context.Response.StatusCode = 200; + + var stream = context.Response.BodyWriter.AsStream(); + using var writer = new StreamWriter(stream, leaveOpen: true); + + for (var i = 1; i <= 10; i++) + { + await writer.WriteLineAsync($"Line {i}"); + } + await writer.FlushAsync(); + + throw new InvalidOperationException("Midstream error for testing"); +}); + +app.MapGet("/json-response", (HttpContext context) => +{ + return Results.Json(new { message = "Hello from streaming Lambda", timestamp = DateTime.UtcNow.ToString("o") }); +}); + +app.MapGet("/oncompleted-test", async (HttpContext context) => +{ + // Register an OnCompleted callback that writes a marker to a response header. + // Since headers are sent in the prelude before the body, we use a different approach: + // write a marker into the body from the OnCompleted callback via a shared flag. + var completedMarker = new CompletedMarker(); + context.Response.RegisterForDispose(completedMarker); + + context.Response.OnCompleted(async (state) => + { + var marker = (CompletedMarker)state; + marker.WasExecuted = true; + // Write to a static so the next request can verify it ran + CompletedMarkerStore.LastMarkerExecuted = true; + }, completedMarker); + + context.Response.ContentType = "text/plain"; + context.Response.StatusCode = 200; + + var stream = context.Response.BodyWriter.AsStream(); + using var writer = new StreamWriter(stream, leaveOpen: true); + await writer.WriteAsync("OnCompleted callback registered"); + await writer.FlushAsync(); +}); + +app.MapGet("/oncompleted-verify", (HttpContext context) => +{ + // Returns whether the OnCompleted callback from the previous request was executed + return Results.Json(new { onCompletedExecuted = CompletedMarkerStore.LastMarkerExecuted }); +}); + +app.MapGet("/custom-headers", (HttpContext context) => +{ + context.Response.StatusCode = 201; + context.Response.ContentType = "text/plain"; + context.Response.Headers["X-Custom-Header"] = "custom-value"; + context.Response.Headers["X-Another-Header"] = "another-value"; + return Results.Text("Custom headers response", "text/plain", statusCode: 201); +}); + +app.MapGet("/set-cookie", (HttpContext context) => +{ + context.Response.Cookies.Append("session", "abc123", new CookieOptions + { + Path = "/", + HttpOnly = true + }); + context.Response.Cookies.Append("theme", "dark"); + return Results.Text("Cookies set"); +}); + +app.MapPost("/echo-body", async (HttpContext context) => +{ + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + return Results.Text($"Echo: {body}"); +}); + +app.Run(); + +class CompletedMarker : IDisposable +{ + public bool WasExecuted { get; set; } + public void Dispose() { } +} + +static class CompletedMarkerStore +{ + public static bool LastMarkerExecuted { get; set; } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/aws-lambda-tools-defaults.json b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/aws-lambda-tools-defaults.json new file mode 100644 index 000000000..99fead300 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/aws-lambda-tools-defaults.json @@ -0,0 +1,17 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help" + ], + "profile": "", + "region": "", + "configuration": "Release", + "function-runtime": "dotnet10", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "AspNetCoreStreamingApiGatewayTest", + "template": "serverless-restapi.template", + "template-parameters": "", + "s3-prefix": "AspNetCoreStreamingApiGatewayTest/" +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-functionurl.template b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-functionurl.template new file mode 100644 index 000000000..496398dd1 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-functionurl.template @@ -0,0 +1,38 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "Integration test for ASP.NET Core response streaming through Lambda Function URL.", + "Resources": { + "AspNetCoreFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "AspNetCoreStreamingApiGatewayTest", + "Runtime": "dotnet10", + "CodeUri": "", + "MemorySize": 512, + "Timeout": 30, + "Role": null, + "Policies": [ + "AWSLambda_FullAccess" + ], + "Environment": { + "Variables": { + "LAMBDA_EVENT_SOURCE": "HttpApi", + "LAMBDA_NET_SERIALIZER_DEBUG": "true", + "LAMBDA_RUNTIMESUPPORT_DEBUG": "true" + } + }, + "FunctionUrlConfig": { + "AuthType": "NONE", + "InvokeMode": "RESPONSE_STREAM" + } + } + } + }, + "Outputs": { + "ApiURL": { + "Description": "Lambda Function URL endpoint", + "Value": { "Fn::GetAtt": ["AspNetCoreFunctionUrl", "FunctionUrl"] } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template new file mode 100644 index 000000000..e1b3c3887 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template @@ -0,0 +1,100 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "Integration test for ASP.NET Core response streaming through API Gateway.", + "Resources": { + "ApiAccessLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "RetentionInDays": 1 + } + }, + "StreamingApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "prod", + "AccessLogSetting": { + "DestinationArn": { "Fn::GetAtt": ["ApiAccessLogGroup", "Arn"] }, + "Format": "$context.requestId $context.identity.sourceIp $context.httpMethod $context.routeKey $context.status $context.responseLength $context.requestTime" + }, + "MethodSettings": [ + { + "ResourcePath": "/*", + "HttpMethod": "*", + "LoggingLevel": "INFO", + "DataTraceEnabled": true, + "MetricsEnabled": true + } + ], + "DefinitionBody": { + "openapi": "3.0.1", + "info": { + "title": "ASP.NET Core Streaming Integration Test", + "version": "1.0" + }, + "paths": { + "/": { + "x-amazon-apigateway-any-method": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "uri": { "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2021-11-15/functions/${AspNetCoreFunction.Arn}/response-streaming-invocations" }, + "responseTransferMode": "STREAM", + "timeoutInMillis": 29000 + } + } + }, + "/{proxy+}": { + "x-amazon-apigateway-any-method": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "uri": { "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2021-11-15/functions/${AspNetCoreFunction.Arn}/response-streaming-invocations" }, + "responseTransferMode": "STREAM", + "timeoutInMillis": 29000 + } + } + } + } + } + } + }, + "AspNetCoreFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "AspNetCoreStreamingApiGatewayTest", + "Runtime": "dotnet10", + "CodeUri": "", + "MemorySize": 512, + "Timeout": 30, + "Role": null, + "Policies": [ + "AWSLambda_FullAccess" + ], + "Environment" : { + "Variables" : { + "LAMBDA_NET_SERIALIZER_DEBUG": "true", + "LAMBDA_RUNTIMESUPPORT_DEBUG": "true" + } + } + } + }, + "ApiPermission": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { "Ref": "AspNetCoreFunction" }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { "Fn::Sub": "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${StreamingApi}/*/*/*" } + } + } + }, + "Outputs": { + "ApiURL": { + "Description": "API endpoint URL for Prod environment", + "Value": { "Fn::Sub": "https://${StreamingApi}.execute-api.${AWS::Region}.amazonaws.com/prod/" } + } + } +} diff --git a/Libraries/test/TestMinimalAPIApp/TestMinimalAPIApp.csproj b/Libraries/test/TestMinimalAPIApp/TestMinimalAPIApp.csproj index 60080ae84..94bb22bb2 100644 --- a/Libraries/test/TestMinimalAPIApp/TestMinimalAPIApp.csproj +++ b/Libraries/test/TestMinimalAPIApp/TestMinimalAPIApp.csproj @@ -1,7 +1,7 @@  - net6.0 + net10.0 enable enable diff --git a/Libraries/test/TestWebApp/TestWebApp.csproj b/Libraries/test/TestWebApp/TestWebApp.csproj index e5607beb2..37a8b67fa 100644 --- a/Libraries/test/TestWebApp/TestWebApp.csproj +++ b/Libraries/test/TestWebApp/TestWebApp.csproj @@ -1,7 +1,7 @@  - net6.0;net8.0 + net8.0;net10.0 true TestWebApp Exe From 249ce0fecaae4b648f78b39518169cbae7a3ab94 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 16 Apr 2026 11:22:40 -0400 Subject: [PATCH 42/47] Add [SNSEvent] annotation attribute and source generator support (#2322) * Add CODEOWNERS file * Add [SNSEvent] annotation attribute and source generator support - SNSEventAttribute with Topic, ResourceName, FilterPolicy, Enabled - SNSEventAttributeBuilder for Roslyn AttributeData parsing - Source generator wiring (TypeFullNames, SyntaxReceiver, EventTypeBuilder, AttributeModelBuilder) - CloudFormationWriter ProcessSNSAttribute (SAM SNS event subscription) - LambdaFunctionValidator ValidateSNSEvents - DiagnosticDescriptors InvalidSNSEventAttribute - SNSEventAttributeTests (attribute unit tests) - SNSEventsTests (CloudFormation writer tests) - E2E source generator snapshot tests - Integration test (SNSEventSubscription) - Sample function (SnsMessageProcessing) - .autover change file - README documentation pr comments fix tests fix tests fix tests * add header --------- Co-authored-by: AlexDaines <55813219+AlexDaines@users.noreply.github.com> --- .autover/changes/add-snsevent-annotation.json | 11 + .../Diagnostics/AnalyzerReleases.Unshipped.md | 1 + .../Diagnostics/DiagnosticDescriptors.cs | 7 + .../Attributes/AttributeModelBuilder.cs | 9 + .../Attributes/SNSEventAttributeBuilder.cs | 43 +++ .../Models/EventType.cs | 1 + .../Models/EventTypeBuilder.cs | 4 + .../SyntaxReceiver.cs | 3 +- .../TypeFullNames.cs | 6 +- .../Validation/LambdaFunctionValidator.cs | 51 ++++ .../Writers/CloudFormationWriter.cs | 42 +++ .../src/Amazon.Lambda.Annotations/README.md | 20 ++ .../SNS/SNSEventAttribute.cs | 116 ++++++++ .../SQS/SQSEventAttribute.cs | 2 +- ....Annotations.SourceGenerators.Tests.csproj | 1 + .../CSharpSourceGeneratorVerifier.cs | 2 + .../SNSEventAttributeTests.cs | 193 +++++++++++++ ...Events_ProcessMessagesAsync_Generated.g.cs | 57 ++++ ...idSNSEvents_ProcessMessages_Generated.g.cs | 57 ++++ .../ServerlessTemplates/snsEvents.template | 93 +++++++ .../SourceGeneratorTests.cs | 47 ++++ .../WriterTests/SNSEventsTests.cs | 260 ++++++++++++++++++ .../CloudFormationHelper.cs | 22 +- .../DeploymentScript.ps1 | 9 + .../IntegrationTestContextFixture.cs | 7 +- .../SNSEventSubscription.cs | 44 +++ .../TestServerlessApp.IntegrationTests.csproj | 1 + .../SNSEventExamples/ValidSNSEvents.cs.txt | 32 +++ .../TestServerlessApp/SnsMessageProcessing.cs | 20 ++ .../TestServerlessApp.csproj | 1 + .../aws-lambda-tools-defaults.json | 4 +- .../TestServerlessApp/serverless.template | 49 ++++ 32 files changed, 1200 insertions(+), 15 deletions(-) create mode 100644 .autover/changes/add-snsevent-annotation.json create mode 100644 Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SNSEventAttributeBuilder.cs create mode 100644 Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/SNSEventAttributeTests.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/SNS/ValidSNSEvents_ProcessMessagesAsync_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/SNS/ValidSNSEvents_ProcessMessages_Generated.g.cs create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Snapshots/ServerlessTemplates/snsEvents.template create mode 100644 Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/WriterTests/SNSEventsTests.cs create mode 100644 Libraries/test/TestServerlessApp.IntegrationTests/SNSEventSubscription.cs create mode 100644 Libraries/test/TestServerlessApp/SNSEventExamples/ValidSNSEvents.cs.txt create mode 100644 Libraries/test/TestServerlessApp/SnsMessageProcessing.cs diff --git a/.autover/changes/add-snsevent-annotation.json b/.autover/changes/add-snsevent-annotation.json new file mode 100644 index 000000000..9192eb91b --- /dev/null +++ b/.autover/changes/add-snsevent-annotation.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Annotations", + "Type": "Minor", + "ChangelogMessages": [ + "Added [SNSEvent] annotation attribute for declaratively configuring SNS topic-triggered Lambda functions with support for topic reference, filter policy, and enabled state." + ] + } + ] +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md index d1a9a89d0..dc97dd7de 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/AnalyzerReleases.Unshipped.md @@ -21,3 +21,4 @@ AWSLambda0133 | AWSLambdaCSharpGenerator | Error | ALB Listener Reference Not Fo AWSLambda0134 | AWSLambdaCSharpGenerator | Error | FromRoute not supported on ALB functions AWSLambda0135 | AWSLambdaCSharpGenerator | Error | Unmapped parameter on ALB function AWSLambda0136 | AWSLambdaCSharpGenerator | Error | Invalid S3EventAttribute +AWSLambda0138 | AWSLambdaCSharpGenerator | Error | Invalid SNSEventAttribute diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs index e1a11087f..569fd4116 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Diagnostics/DiagnosticDescriptors.cs @@ -281,5 +281,12 @@ public static class DiagnosticDescriptors category: "AWSLambdaCSharpGenerator", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidSnsEventAttribute = new DiagnosticDescriptor(id: "AWSLambda0138", + title: "Invalid SNSEventAttribute", + messageFormat: "Invalid SNSEventAttribute encountered: {0}", + category: "AWSLambdaCSharpGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs index d8715c047..add735fe5 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/AttributeModelBuilder.cs @@ -113,6 +113,15 @@ public static AttributeModel Build(AttributeData att, GeneratorExecutionContext Type = TypeModelBuilder.Build(att.AttributeClass, context) }; } + else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.SNSEventAttribute), SymbolEqualityComparer.Default)) + { + var data = SNSEventAttributeBuilder.Build(att); + model = new AttributeModel + { + Data = data, + Type = TypeModelBuilder.Build(att.AttributeClass, context) + }; + } else if (att.AttributeClass.Equals(context.Compilation.GetTypeByMetadataName(TypeFullNames.HttpApiAuthorizerAttribute), SymbolEqualityComparer.Default)) { var data = HttpApiAuthorizerAttributeBuilder.Build(att); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SNSEventAttributeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SNSEventAttributeBuilder.cs new file mode 100644 index 000000000..9e9890334 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/Attributes/SNSEventAttributeBuilder.cs @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations.SNS; +using Microsoft.CodeAnalysis; +using System; + +namespace Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes +{ + /// + /// Builder for . + /// + public class SNSEventAttributeBuilder + { + public static SNSEventAttribute Build(AttributeData att) + { + if (att.ConstructorArguments.Length != 1) + { + throw new NotSupportedException($"{TypeFullNames.SNSEventAttribute} must have constructor with 1 argument."); + } + var topic = att.ConstructorArguments[0].Value as string; + var data = new SNSEventAttribute(topic); + + foreach (var pair in att.NamedArguments) + { + if (pair.Key == nameof(data.ResourceName) && pair.Value.Value is string resourceName) + { + data.ResourceName = resourceName; + } + else if (pair.Key == nameof(data.FilterPolicy) && pair.Value.Value is string filterPolicy) + { + data.FilterPolicy = filterPolicy; + } + else if (pair.Key == nameof(data.Enabled) && pair.Value.Value is bool enabled) + { + data.Enabled = enabled; + } + } + + return data; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs index 1b392572d..15eea9db4 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventType.cs @@ -9,6 +9,7 @@ public enum EventType API, S3, SQS, + SNS, DynamoDB, Schedule, Authorizer, diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs index d3c1f7fd0..960b83d97 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Models/EventTypeBuilder.cs @@ -34,6 +34,10 @@ public static HashSet Build(IMethodSymbol lambdaMethodSymbol, { events.Add(EventType.S3); } + else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.SNSEventAttribute) + { + events.Add(EventType.SNS); + } else if (attribute.AttributeClass.ToDisplayString() == TypeFullNames.HttpApiAuthorizerAttribute || attribute.AttributeClass.ToDisplayString() == TypeFullNames.RestApiAuthorizerAttribute) { diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs index 230525edd..e54d97b44 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/SyntaxReceiver.cs @@ -27,7 +27,8 @@ internal class SyntaxReceiver : ISyntaxContextReceiver { "FunctionUrlAttribute", "FunctionUrl" }, { "SQSEventAttribute", "SQSEvent" }, { "ALBApiAttribute", "ALBApi" }, - { "S3EventAttribute", "S3Event" } + { "S3EventAttribute", "S3Event" }, + { "SNSEventAttribute", "SNSEvent" } }; public List LambdaMethods { get; } = new List(); diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs index 4c66c1875..40059193a 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/TypeFullNames.cs @@ -62,6 +62,9 @@ public static class TypeFullNames public const string S3Event = "Amazon.Lambda.S3Events.S3Event"; public const string S3EventAttribute = "Amazon.Lambda.Annotations.S3.S3EventAttribute"; + public const string SNSEvent = "Amazon.Lambda.SNSEvents.SNSEvent"; + public const string SNSEventAttribute = "Amazon.Lambda.Annotations.SNS.SNSEventAttribute"; + public const string LambdaSerializerAttribute = "Amazon.Lambda.Core.LambdaSerializerAttribute"; public const string DefaultLambdaSerializer = "Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer"; @@ -91,7 +94,8 @@ public static class TypeFullNames FunctionUrlAttribute, SQSEventAttribute, ALBApiAttribute, - S3EventAttribute + S3EventAttribute, + SNSEventAttribute }; } } diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs index 4ea09acdf..7193779ed 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Validation/LambdaFunctionValidator.cs @@ -7,6 +7,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.Extensions; using Amazon.Lambda.Annotations.SourceGenerator.Models; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; +using Amazon.Lambda.Annotations.SNS; using Amazon.Lambda.Annotations.SQS; using Microsoft.CodeAnalysis; using System.Collections.Generic; @@ -64,6 +65,7 @@ internal static bool ValidateFunction(GeneratorExecutionContext context, IMethod // Validate Events ValidateApiGatewayEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateSqsEvents(lambdaFunctionModel, methodLocation, diagnostics); + ValidateSnsEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateAlbEvents(lambdaFunctionModel, methodLocation, diagnostics); ValidateS3Events(lambdaFunctionModel, methodLocation, diagnostics); @@ -114,6 +116,16 @@ internal static bool ValidateDependencies(GeneratorExecutionContext context, IMe } } + // Check for references to "Amazon.Lambda.SNSEvents" if the Lambda method is annotated with SNSEvent attribute. + if (lambdaMethodSymbol.HasAttribute(context, TypeFullNames.SNSEventAttribute)) + { + if (context.Compilation.ReferencedAssemblyNames.FirstOrDefault(x => x.Name == "Amazon.Lambda.SNSEvents") == null) + { + diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.MissingDependencies, methodLocation, "Amazon.Lambda.SNSEvents")); + return false; + } + } + return true; } @@ -424,6 +436,45 @@ private static void ValidateS3Events(LambdaFunctionModel lambdaFunctionModel, Lo } } + private static void ValidateSnsEvents(LambdaFunctionModel lambdaFunctionModel, Location methodLocation, List diagnostics) + { + if (!lambdaFunctionModel.LambdaMethod.Events.Contains(EventType.SNS)) + { + return; + } + + // Validate SNSEventAttributes + foreach (var att in lambdaFunctionModel.Attributes) + { + if (att.Type.FullName != TypeFullNames.SNSEventAttribute) + continue; + + var snsEventAttribute = ((AttributeModel)att).Data; + var validationErrors = snsEventAttribute.Validate(); + validationErrors.ForEach(errorMessage => diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidSnsEventAttribute, methodLocation, errorMessage))); + } + + // Validate method parameters - When using SNSEventAttribute, the method signature must be (SNSEvent snsEvent) or (SNSEvent snsEvent, ILambdaContext context) + var parameters = lambdaFunctionModel.LambdaMethod.Parameters; + if (parameters.Count == 0 || + parameters.Count > 2 || + (parameters.Count == 1 && parameters[0].Type.FullName != TypeFullNames.SNSEvent) || + (parameters.Count == 2 && (parameters[0].Type.FullName != TypeFullNames.SNSEvent || parameters[1].Type.FullName != TypeFullNames.ILambdaContext))) + { + var errorMessage = $"When using the {nameof(SNSEventAttribute)}, the Lambda method can accept at most 2 parameters. " + + $"The first parameter is required and must be of type {TypeFullNames.SNSEvent}. " + + $"The second parameter is optional and must be of type {TypeFullNames.ILambdaContext}."; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + + // Validate method return type - When using SNSEventAttribute, the return type must be either void or Task + if (!lambdaFunctionModel.LambdaMethod.ReturnsVoid && !lambdaFunctionModel.LambdaMethod.ReturnsVoidTask) + { + var errorMessage = $"When using the {nameof(SNSEventAttribute)}, the Lambda method can return either void or {TypeFullNames.Task}"; + diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.InvalidLambdaMethodSignature, methodLocation, errorMessage)); + } + } + private static bool ReportDiagnostics(DiagnosticReporter diagnosticReporter, List diagnostics) { var isValid = true; diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs index adfa53ae5..f34f2d8bb 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Writers/CloudFormationWriter.cs @@ -6,6 +6,7 @@ using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics; using Amazon.Lambda.Annotations.SourceGenerator.FileIO; using Amazon.Lambda.Annotations.SourceGenerator.Models; +using Amazon.Lambda.Annotations.SNS; using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes; using Amazon.Lambda.Annotations.S3; using Amazon.Lambda.Annotations.SQS; @@ -241,6 +242,10 @@ private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable la _templateWriter.SetToken($"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedFunctionUrlConfig", true); hasFunctionUrl = true; break; + case AttributeModel snsAttributeModel: + eventName = ProcessSnsAttribute(lambdaFunction, snsAttributeModel.Data, currentSyncedEventProperties); + currentSyncedEvents.Add(eventName); + break; } } @@ -673,6 +678,43 @@ private string ProcessSqsAttribute(ILambdaFunctionSerializable lambdaFunction, S return att.ResourceName; } + /// + /// Writes all properties associated with to the serverless template. + /// + private string ProcessSnsAttribute(ILambdaFunctionSerializable lambdaFunction, SNSEventAttribute att, Dictionary> syncedEventProperties) + { + var eventName = att.ResourceName; + var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events.{eventName}"; + + _templateWriter.SetToken($"{eventPath}.Type", "SNS"); + + // Topic - SNS topics use Ref to get the ARN + _templateWriter.RemoveToken($"{eventPath}.Properties.Topic"); + if (!att.Topic.StartsWith("@")) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Topic", att.Topic); + } + else + { + var topic = att.Topic.Substring(1); + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, $"Topic.{REF}", topic); + } + + // FilterPolicy + if (att.IsFilterPolicySet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "FilterPolicy", att.FilterPolicy); + } + + // Enabled + if (att.IsEnabledSet) + { + SetEventProperty(syncedEventProperties, lambdaFunction.ResourceName, eventName, "Enabled", att.Enabled); + } + + return att.ResourceName; + } + /// /// Writes all properties associated with to the serverless template. /// diff --git a/Libraries/src/Amazon.Lambda.Annotations/README.md b/Libraries/src/Amazon.Lambda.Annotations/README.md index 45bce6783..56cb7bbd7 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/README.md +++ b/Libraries/src/Amazon.Lambda.Annotations/README.md @@ -1074,6 +1074,24 @@ The `ALBApi` attribute requires an existing ALB listener. Here is a minimal exam Then your Lambda function references `@MyListener` in the `ALBApi` attribute. +## SNS Event Example +This example shows how to use the `SNSEvent` attribute to subscribe a Lambda function to an SNS topic. + +The `SNSEvent` attribute contains the following properties: +* **Topic** (Required) - The SNS topic ARN or a reference to an SNS topic resource prefixed with "@". +* **ResourceName** (Optional) - The CloudFormation resource name for the SNS event. +* **FilterPolicy** (Optional) - A JSON filter policy applied to the subscription. +* **Enabled** (Optional) - If false, the event source is disabled. Default is true. + +```csharp +[LambdaFunction(ResourceName = "SNSMessageHandler", Policies = "AWSLambdaSNSTopicExecutionRole")] +[SNSEvent("@TestTopic", ResourceName = "TestTopicEvent", FilterPolicy = "{ \"store\": [\"example_corp\"] }")] +public void HandleMessage(SNSEvent evnt, ILambdaContext lambdaContext) +{ + lambdaContext.Logger.Log($"Received {evnt.Records.Count} messages"); +} +``` + ## Lambda Function URL Example [Lambda Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) provide a dedicated HTTPS endpoint for your Lambda function without needing API Gateway or an Application Load Balancer. The `FunctionUrl` attribute configures the function to be invoked via a Function URL. Function URLs use the same payload format as HTTP API v2 (`APIGatewayHttpApiV2ProxyRequest`/`APIGatewayHttpApiV2ProxyResponse`). @@ -1525,6 +1543,8 @@ parameter to the `LambdaFunction` must be the event object and the event source * Marks a Lambda function as a REST API (API Gateway V1) custom authorizer. The authorizer name is automatically derived from the method name. Other functions reference it via `RestApi.Authorizer` using `nameof()`. Use the `Type` property to choose between `Token` and `Request` authorizer types. * SQSEvent * Sets up event source mapping between the Lambda function and SQS queues. The SQS queue ARN is required to be set on the attribute. If users want to pass a reference to an existing SQS queue resource defined in their CloudFormation template, they can pass the SQS queue resource name prefixed with the '@' symbol. +* SNSEvent + * Subscribes the Lambda function to an SNS topic. The topic ARN or resource reference (prefixed with '@') is required. * ALBApi * Configures the Lambda function to be called from an Application Load Balancer. The listener ARN (or `@ResourceName` template reference), path pattern, and priority are required. The source generator creates standalone CloudFormation resources (TargetGroup, ListenerRule, Lambda Permission) rather than SAM event types. * FunctionUrl diff --git a/Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs new file mode 100644 index 000000000..044b26499 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.Annotations/SNS/SNSEventAttribute.cs @@ -0,0 +1,116 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Amazon.Lambda.Annotations.SNS +{ + /// + /// This attribute defines the SNS event source configuration for a Lambda function. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class SNSEventAttribute : Attribute + { + private static readonly Regex _resourceNameRegex = new Regex("^[a-zA-Z0-9]+$"); + + /// + /// The SNS topic that will act as the event trigger for the Lambda function. + /// This can either be the topic ARN or reference to the SNS topic resource that is already defined in the serverless template. + /// To reference an SNS topic resource in the serverless template, prefix the resource name with "@" symbol. + /// + public string Topic { get; set; } + + /// + /// The CloudFormation resource name for the SNS event. By default this is set to the SNS topic name if the is set to an SNS topic ARN. + /// If is set to an existing CloudFormation resource, than that is used as the default value without the "@" prefix. + /// + public string ResourceName + { + get + { + if (IsResourceNameSet) + { + return resourceName; + } + if (string.IsNullOrEmpty(Topic)) + { + return string.Empty; + } + if (Topic.StartsWith("@")) + { + return Topic.Substring(1); + } + + var arnTokens = Topic.Split(new char[] { ':' }, 6); + if (arnTokens.Length < 6) + { + return Topic; + } + var topicName = arnTokens[5]; + var sanitizedTopicName = string.Join(string.Empty, topicName.Where(char.IsLetterOrDigit)); + return sanitizedTopicName; + } + set => resourceName = value; + } + + private string resourceName { get; set; } = null; + internal bool IsResourceNameSet => resourceName != null; + + /// + /// A JSON filter policy that is applied to the SNS subscription. + /// Only messages matching the filter policy will be delivered to the Lambda function. + /// + public string FilterPolicy { get; set; } = null; + internal bool IsFilterPolicySet => FilterPolicy != null; + + /// + /// If set to false, the event source will be disabled. + /// Default value is true. + /// + public bool Enabled + { + get => enabled.GetValueOrDefault(true); + set => enabled = value; + } + private bool? enabled { get; set; } + internal bool IsEnabledSet => enabled.HasValue; + + /// + /// Creates an instance of the class. + /// + /// property + public SNSEventAttribute(string topic) + { + Topic = topic; + } + + internal List Validate() + { + var validationErrors = new List(); + + if (string.IsNullOrEmpty(Topic)) + { + validationErrors.Add($"{nameof(SNSEventAttribute.Topic)} is required and must not be null or empty"); + return validationErrors; + } + + if (!Topic.StartsWith("@")) + { + var arnTokens = Topic.Split(new char[] { ':' }, 6); + if (arnTokens.Length != 6) + { + validationErrors.Add($"{nameof(SNSEventAttribute.Topic)} = {Topic}. The SNS topic ARN is invalid. The ARN format is 'arn::sns:::'"); + } + } + if (IsResourceNameSet && !_resourceNameRegex.IsMatch(ResourceName)) + { + validationErrors.Add($"{nameof(SNSEventAttribute.ResourceName)} = {ResourceName}. It must only contain alphanumeric characters and must not be an empty string"); + } + + return validationErrors; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs b/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs index 3358eee36..e3d72464f 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs +++ b/Libraries/src/Amazon.Lambda.Annotations/SQS/SQSEventAttribute.cs @@ -60,7 +60,7 @@ public string ResourceName /// public bool Enabled { - get => enabled.GetValueOrDefault(); + get => enabled.GetValueOrDefault(true); set => enabled = value; } private bool? enabled { get; set; } diff --git a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj index 56da7d597..a68fced1f 100644 --- a/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj +++ b/Libraries/test/Amazon.Lambda.Annotations.SourceGenerators.Tests/Amazon.Lambda.Annotations.SourceGenerators.Tests.csproj @@ -208,6 +208,7 @@ + + diff --git a/Libraries/test/TestServerlessApp/SNSEventExamples/ValidSNSEvents.cs.txt b/Libraries/test/TestServerlessApp/SNSEventExamples/ValidSNSEvents.cs.txt new file mode 100644 index 000000000..9c42ebc70 --- /dev/null +++ b/Libraries/test/TestServerlessApp/SNSEventExamples/ValidSNSEvents.cs.txt @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.SNS; +using Amazon.Lambda.SNSEvents; +using System; +using System.Threading.Tasks; + +namespace TestServerlessApp.SNSEventExamples +{ + // This file represents valid usage of the SNSEventAttribute. This is added as .txt file since we do not want to deploy these functions during our integration tests. + // This file is only sent as input to the source generator unit tests. + + public class ValidSNSEvents + { + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("arn:aws:sns:us-east-2:444455556666:MyTopic", FilterPolicy = "{ \"store\": [\"example_corp\"] }")] + [SNSEvent("@testTopic", ResourceName = "testTopicEvent")] + public void ProcessMessages(SNSEvent evnt) + { + Console.WriteLine($"Event processed: {evnt}"); + } + + [LambdaFunction(PackageType = LambdaPackageType.Image)] + [SNSEvent("arn:aws:sns:us-east-2:444455556666:MyTopic")] + public async Task ProcessMessagesAsync(SNSEvent evnt) + { + await Console.Out.WriteLineAsync($"Event processed: {evnt}"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/SnsMessageProcessing.cs b/Libraries/test/TestServerlessApp/SnsMessageProcessing.cs new file mode 100644 index 000000000..91004a5b9 --- /dev/null +++ b/Libraries/test/TestServerlessApp/SnsMessageProcessing.cs @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Annotations.SNS; +using Amazon.Lambda.Core; +using Amazon.Lambda.SNSEvents; + +namespace TestServerlessApp +{ + public class SnsMessageProcessing + { + [LambdaFunction(ResourceName = "SNSMessageHandler", Policies = "AWSLambdaBasicExecutionRole", PackageType = LambdaPackageType.Image)] + [SNSEvent("@TestTopic", ResourceName = "TestTopicEvent", FilterPolicy = "{ \"store\": [\"example_corp\"] }")] + public void HandleMessage(SNSEvent evnt, ILambdaContext lambdaContext) + { + lambdaContext.Logger.Log($"Received {evnt.Records.Count} messages"); + } + } +} diff --git a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj index 83d7cf89e..9875acee9 100644 --- a/Libraries/test/TestServerlessApp/TestServerlessApp.csproj +++ b/Libraries/test/TestServerlessApp/TestServerlessApp.csproj @@ -27,6 +27,7 @@ + diff --git a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json index 71f6d708b..03fe9926f 100644 --- a/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp/aws-lambda-tools-defaults.json @@ -13,7 +13,7 @@ "template": "serverless.template", "template-parameters": "", "docker-host-build-output-dir": "./bin/Release/lambda-publish", -"s3-bucket" : "test-serverless-app-535afbc5", -"stack-name" : "test-serverless-app-535afbc5", +"s3-bucket" : "test-serverless-app-784dfb1d", +"stack-name" : "test-serverless-app-784dfb1d", "function-architecture" : "x86_64" } diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index b5753ecfd..10e3740f3 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1414,6 +1414,55 @@ ] } } + }, + "SNSMessageHandler": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations", + "SyncedEvents": [ + "TestTopicEvent" + ], + "SyncedEventProperties": { + "TestTopicEvent": [ + "Topic.Ref", + "FilterPolicy" + ] + } + }, + "Properties": { + "MemorySize": 512, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Image", + "Events": { + "TestTopicEvent": { + "Type": "SNS", + "Properties": { + "FilterPolicy": "{ \"store\": [\"example_corp\"] }", + "Topic": { + "Ref": "TestTopic" + } + } + } + }, + "ImageUri": ".", + "ImageConfig": { + "Command": [ + "TestServerlessApp::TestServerlessApp.SnsMessageProcessing_HandleMessage_Generated::HandleMessage" + ] + } + } + }, + "TestQueue": { + "Type": "AWS::SQS::Queue" + }, + "TestS3Bucket": { + "Type": "AWS::S3::Bucket" + }, + "TestTopic": { + "Type": "AWS::SNS::Topic" } } } \ No newline at end of file From 8d87c2165c3a2a8ff46694534ceed6384d46270b Mon Sep 17 00:00:00 2001 From: aws-sdk-dotnet-automation <43080735+aws-sdk-dotnet-automation@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:02:52 -0400 Subject: [PATCH 43/47] Release 2026-04-16 (#2335) * release_2026-04-16 * Update test app CloudFormation templates * Updated changelog --- .autover/changes/add-snsevent-annotation.json | 11 ----------- CHANGELOG.md | 5 +++++ .../Amazon.Lambda.Annotations.SourceGenerator.csproj | 2 +- .../Amazon.Lambda.Annotations.csproj | 2 +- .../test/TestCustomAuthorizerApp/serverless.template | 2 +- .../TestExecutableServerlessApp/serverless.template | 2 +- .../test/TestServerlessApp.ALB/serverless.template | 2 +- .../test/TestServerlessApp.NET8/serverless.template | 2 +- Libraries/test/TestServerlessApp/serverless.template | 2 +- 9 files changed, 12 insertions(+), 18 deletions(-) delete mode 100644 .autover/changes/add-snsevent-annotation.json diff --git a/.autover/changes/add-snsevent-annotation.json b/.autover/changes/add-snsevent-annotation.json deleted file mode 100644 index 9192eb91b..000000000 --- a/.autover/changes/add-snsevent-annotation.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Projects": [ - { - "Name": "Amazon.Lambda.Annotations", - "Type": "Minor", - "ChangelogMessages": [ - "Added [SNSEvent] annotation attribute for declaratively configuring SNS topic-triggered Lambda functions with support for topic reference, filter policy, and enabled state." - ] - } - ] -} diff --git a/CHANGELOG.md b/CHANGELOG.md index 54300c8e3..134cf9e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Release 2026-04-16 + +### Amazon.Lambda.Annotations (1.14.0) +* Added [SNSEvent] annotation attribute for declaratively configuring SNS topic-triggered Lambda functions with support for topic reference, filter policy, and enabled state. + ## Release 2026-04-14 ### Amazon.Lambda.TestTool.BlazorTester (0.17.1) diff --git a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj index 79a18d2b8..925cb3715 100644 --- a/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations.SourceGenerator/Amazon.Lambda.Annotations.SourceGenerator.csproj @@ -20,7 +20,7 @@ true false - 1.13.0 + 1.14.0 true diff --git a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj index fb365ff5d..1c8dfe8c6 100644 --- a/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj +++ b/Libraries/src/Amazon.Lambda.Annotations/Amazon.Lambda.Annotations.csproj @@ -11,7 +11,7 @@ ..\..\..\buildtools\public.snk true - 1.13.0 + 1.14.0 true diff --git a/Libraries/test/TestCustomAuthorizerApp/serverless.template b/Libraries/test/TestCustomAuthorizerApp/serverless.template index d50b5f6ef..ebce2bc30 100644 --- a/Libraries/test/TestCustomAuthorizerApp/serverless.template +++ b/Libraries/test/TestCustomAuthorizerApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", diff --git a/Libraries/test/TestExecutableServerlessApp/serverless.template b/Libraries/test/TestExecutableServerlessApp/serverless.template index a4112a9eb..efa6636d9 100644 --- a/Libraries/test/TestExecutableServerlessApp/serverless.template +++ b/Libraries/test/TestExecutableServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", + "Description": "An AWS Serverless Application. This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Parameters": { "ArchitectureTypeParameter": { "Type": "String", diff --git a/Libraries/test/TestServerlessApp.ALB/serverless.template b/Libraries/test/TestServerlessApp.ALB/serverless.template index e07e2e226..a8992babb 100644 --- a/Libraries/test/TestServerlessApp.ALB/serverless.template +++ b/Libraries/test/TestServerlessApp.ALB/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", + "Description": "ALB Integration Test Stack - VPC and ALB infrastructure for testing Lambda ALB annotations This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Resources": { "ALBTestVPC": { "Type": "AWS::EC2::VPC", diff --git a/Libraries/test/TestServerlessApp.NET8/serverless.template b/Libraries/test/TestServerlessApp.NET8/serverless.template index 03b6cb0d5..ee3617fb0 100644 --- a/Libraries/test/TestServerlessApp.NET8/serverless.template +++ b/Libraries/test/TestServerlessApp.NET8/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Resources": { "TestServerlessAppNET8FunctionsToUpperGenerated": { "Type": "AWS::Serverless::Function", diff --git a/Libraries/test/TestServerlessApp/serverless.template b/Libraries/test/TestServerlessApp/serverless.template index 10e3740f3..65fcfee31 100644 --- a/Libraries/test/TestServerlessApp/serverless.template +++ b/Libraries/test/TestServerlessApp/serverless.template @@ -1,7 +1,7 @@ { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", - "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.13.0.0).", + "Description": "This template is partially managed by Amazon.Lambda.Annotations (v1.14.0.0).", "Resources": { "AnnotationsHttpApi": { "Type": "AWS::Serverless::HttpApi", From 88745fd26d3d963dff5fac535700597bce5cc158 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 20 Apr 2026 11:33:47 -0700 Subject: [PATCH 44/47] Add logging for test failures --- .../ApiGatewayStreamingTests.cs | 19 ++++++++++++------- .../Helpers/CommandLineWrapper.cs | 11 +++++++++-- .../Helpers/LambdaToolsHelper.cs | 4 ++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs index 8938345a6..c7b2fc9db 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs @@ -238,14 +238,14 @@ public FunctionUrlStreamingTests(FunctionUrlStreamingFixture fixture, ITestOutpu public class RestApiStreamingFixture : StreamingFixture { - public RestApiStreamingFixture() - : base("serverless-restapi.template", "RestApi") { } + public RestApiStreamingFixture(ITestOutputHelper outputHelper) + : base("serverless-restapi.template", "RestApi", outputHelper) { } } public class FunctionUrlStreamingFixture : StreamingFixture { - public FunctionUrlStreamingFixture() - : base("serverless-functionurl.template", "FunctionUrl") { } + public FunctionUrlStreamingFixture(ITestOutputHelper outputHelper) + : base("serverless-functionurl.template", "FunctionUrl", outputHelper) { } } /// @@ -267,11 +267,14 @@ public class StreamingFixture : IAsyncLifetime private bool _deployed; private string _s3BucketName; - protected StreamingFixture(string templateFile, string deploymentType) + private ITestOutputHelper _outputHelper; + + protected StreamingFixture(string templateFile, string deploymentType, ITestOutputHelper outputHelper) { _templateFile = templateFile; _deploymentType = deploymentType; _stackName = $"IntegTest-Streaming-{deploymentType}-{DateTime.UtcNow.Ticks}"; + _outputHelper = outputHelper; } public Task GetApiUrlAsync() @@ -296,7 +299,8 @@ public async Task InitializeAsync() await CommandLineWrapper.Run( lambdaToolPath, $"deploy-serverless --stack-name {_stackName} --template {_templateFile} --s3-bucket {_s3BucketName} --region {TestRegion.SystemName} --disable-interactive true", - _testAppPath); + _testAppPath, + _outputHelper); _apiUrl = await GetStackOutputAsync(_stackName, "ApiURL"); if (!_apiUrl.EndsWith("/")) @@ -319,7 +323,8 @@ public async Task DisposeAsync() await CommandLineWrapper.Run( lambdaToolPath, $"delete-serverless --stack-name {_stackName} --region {TestRegion.SystemName}", - _testAppPath); + _testAppPath, + _outputHelper); if (_s3BucketName != null) { diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs index c6b73f896..d68c73d32 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/CommandLineWrapper.cs @@ -4,12 +4,13 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using Xunit.Abstractions; namespace Amazon.Lambda.RuntimeSupport.IntegrationTests.Helpers; public static class CommandLineWrapper { - public static async Task Run(string command, string arguments, string workingDirectory, CancellationToken cancellationToken = default) + public static async Task Run(string command, string arguments, string workingDirectory, ITestOutputHelper outputHelper, CancellationToken cancellationToken = default) { var processStartInfo = new ProcessStartInfo { @@ -88,7 +89,13 @@ public static async Task Run(string command, string arguments, string workingDir process.Kill(); } } - + + if (process.ExitCode != 0 && outputHelper != null) + { + outputHelper.WriteLine($"Command '{command} {arguments}' failed."); + outputHelper.WriteLine(output.ToString()); + } + Assert.True(process.ExitCode == 0, $"Command '{command} {arguments}' failed."); } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs index 154c84f75..5f649d923 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/Helpers/LambdaToolsHelper.cs @@ -30,7 +30,7 @@ public static async Task InstallLambdaTools() await CommandLineWrapper.Run( "dotnet", $"tool install Amazon.Lambda.Tools --tool-path {customToolPath}", - Directory.GetCurrentDirectory()); + Directory.GetCurrentDirectory(), null); return customToolPath; } @@ -40,7 +40,7 @@ public static async Task LambdaPackage(string toolPath, string framework, string await CommandLineWrapper.Run( lambdaToolPath, $"package -c Release --framework {framework} --function-architecture {FunctionArchitecture}", - workingDirectory); + workingDirectory, null); } public static void CleanUp(string toolPath) From 66c446c1df23279ca531ee2de6ff33d34989dec0 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 20 Apr 2026 11:51:18 -0700 Subject: [PATCH 45/47] Fix injection --- .../ApiGatewayStreamingTests.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs index c7b2fc9db..4401612c7 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.IntegrationTests/ApiGatewayStreamingTests.cs @@ -37,6 +37,7 @@ protected StreamingTestBase(StreamingFixture fixture, ITestOutputHelper output) { _fixture = fixture; Output = output; + _fixture.Initialize(output); } [Fact] @@ -238,14 +239,14 @@ public FunctionUrlStreamingTests(FunctionUrlStreamingFixture fixture, ITestOutpu public class RestApiStreamingFixture : StreamingFixture { - public RestApiStreamingFixture(ITestOutputHelper outputHelper) - : base("serverless-restapi.template", "RestApi", outputHelper) { } + public RestApiStreamingFixture() + : base("serverless-restapi.template", "RestApi") { } } public class FunctionUrlStreamingFixture : StreamingFixture { - public FunctionUrlStreamingFixture(ITestOutputHelper outputHelper) - : base("serverless-functionurl.template", "FunctionUrl", outputHelper) { } + public FunctionUrlStreamingFixture() + : base("serverless-functionurl.template", "FunctionUrl") { } } /// @@ -269,11 +270,15 @@ public class StreamingFixture : IAsyncLifetime private ITestOutputHelper _outputHelper; - protected StreamingFixture(string templateFile, string deploymentType, ITestOutputHelper outputHelper) + protected StreamingFixture(string templateFile, string deploymentType) { _templateFile = templateFile; _deploymentType = deploymentType; _stackName = $"IntegTest-Streaming-{deploymentType}-{DateTime.UtcNow.Ticks}"; + } + + public void Initialize(ITestOutputHelper outputHelper) + { _outputHelper = outputHelper; } From 387b442787404526df8567f3ea57c5829994f50f Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 20 Apr 2026 12:31:35 -0700 Subject: [PATCH 46/47] Fix integ test --- .../serverless-restapi.template | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template index e1b3c3887..09443beb5 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template @@ -3,20 +3,10 @@ "Transform": "AWS::Serverless-2016-10-31", "Description": "Integration test for ASP.NET Core response streaming through API Gateway.", "Resources": { - "ApiAccessLogGroup": { - "Type": "AWS::Logs::LogGroup", - "Properties": { - "RetentionInDays": 1 - } - }, "StreamingApi": { "Type": "AWS::Serverless::Api", "Properties": { "StageName": "prod", - "AccessLogSetting": { - "DestinationArn": { "Fn::GetAtt": ["ApiAccessLogGroup", "Arn"] }, - "Format": "$context.requestId $context.identity.sourceIp $context.httpMethod $context.routeKey $context.status $context.responseLength $context.requestTime" - }, "MethodSettings": [ { "ResourcePath": "/*", From 7e4e78ec93b01271a92f1efd1a3c9b5c95ad2860 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Mon, 20 Apr 2026 14:55:23 -0700 Subject: [PATCH 47/47] Remove more API Gateway logging --- .../serverless-restapi.template | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template index 09443beb5..f0c338b79 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/AspNetCoreStreamingApiGatewayTest/serverless-restapi.template @@ -10,10 +10,7 @@ "MethodSettings": [ { "ResourcePath": "/*", - "HttpMethod": "*", - "LoggingLevel": "INFO", - "DataTraceEnabled": true, - "MetricsEnabled": true + "HttpMethod": "*" } ], "DefinitionBody": {