diff --git a/Directory.Packages.props b/Directory.Packages.props index 142b28ea..81dbe99e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,61 +13,61 @@ - + - - - + + + - - - + + + - + - - - + + + - + - - - - - + + + + + - - + + - - + + - - + + - + - - - + + + diff --git a/src/MinimalLambda.Abstractions/Features/IInvocationDataBufferingFeature.cs b/src/MinimalLambda.Abstractions/Features/IInvocationDataBufferingFeature.cs new file mode 100644 index 00000000..b9768995 --- /dev/null +++ b/src/MinimalLambda.Abstractions/Features/IInvocationDataBufferingFeature.cs @@ -0,0 +1,33 @@ +namespace MinimalLambda; + +/// +/// Optional capability of that allows buffering the +/// invocation event stream into memory so it can be read outside of event deserialization. +/// +/// +/// Implemented alongside by implementations that support +/// it, rather than registered as its own entry in . Probe the +/// currently active for this capability (for example, +/// context.Features.Get<IInvocationDataFeature>() is IInvocationDataBufferingFeature, +/// or the context.EnableEventBuffering() convenience extension) rather than looking this +/// type up in the feature collection directly or assuming every +/// implementation supports buffering. Looking it up +/// separately in the feature collection risks it becoming out of sync with whichever +/// is currently registered, if middleware replaces that +/// registration. +/// +public interface IInvocationDataBufferingFeature +{ + /// + /// Ensures is seekable, buffering it into + /// memory first if necessary. Enables middleware to read the raw event payload (for example, + /// to log it) without consuming the stream that event deserialization depends on. + /// + /// + /// Call before reading . After reading, reset + /// EventStream.Position to 0 so downstream event deserialization can still + /// consume it. Opt in per-invocation, since it buffers the event payload into memory even + /// when it is already seekable. + /// + void EnableBuffering(); +} diff --git a/src/MinimalLambda/Core/Features/FeatureLambdaInvocationContextExtensions.cs b/src/MinimalLambda/Core/Features/FeatureLambdaInvocationContextExtensions.cs index d9f1c539..06f2ac58 100644 --- a/src/MinimalLambda/Core/Features/FeatureLambdaInvocationContextExtensions.cs +++ b/src/MinimalLambda/Core/Features/FeatureLambdaInvocationContextExtensions.cs @@ -105,5 +105,32 @@ public T GetRequiredResponse() return responseT; } + + /// + /// Ensures the raw Lambda event stream is seekable, buffering it into memory first if + /// necessary. Call from middleware before reading + /// directly (for example, to log the raw + /// payload), then reset its Position to 0 afterward so event deserialization + /// can still consume it. + /// + /// + /// Thrown when no is available in the + /// context. + /// + public void EnableEventBuffering() + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Features.Get() is IInvocationDataBufferingFeature + bufferingFeature) + { + bufferingFeature.EnableBuffering(); + return; + } + + throw new InvalidOperationException( + $"Feature of type '{typeof(IInvocationDataBufferingFeature).FullName}' is not " + + "available in the context."); + } } } diff --git a/src/MinimalLambda/Core/Features/InvocationDataFeature.cs b/src/MinimalLambda/Core/Features/InvocationDataFeature.cs index faa8fc03..e7e41687 100644 --- a/src/MinimalLambda/Core/Features/InvocationDataFeature.cs +++ b/src/MinimalLambda/Core/Features/InvocationDataFeature.cs @@ -1,13 +1,33 @@ namespace MinimalLambda; -internal sealed class InvocationDataFeature : IInvocationDataFeature +internal sealed class InvocationDataFeature + : IInvocationDataFeature, IInvocationDataBufferingFeature { - public required Stream EventStream { get; init; } + private Stream _eventStream = null!; + + public required Stream EventStream + { + get => _eventStream; + init => _eventStream = value; + } + public Stream ResponseStream { get; set; } = new MemoryStream(); + public void EnableBuffering() + { + if (_eventStream.CanSeek) + return; + + var buffered = new MemoryStream(); + _eventStream.CopyTo(buffered); + _eventStream.Dispose(); + buffered.Position = 0L; + _eventStream = buffered; + } + /// /// Dispose the underlying stream. We only dispose of the event stream, not the response /// stream as the Lambda bootstrap will dispose of it. /// - public void Dispose() => EventStream.Dispose(); + public void Dispose() => _eventStream.Dispose(); } diff --git a/src/MinimalLambda/README.md b/src/MinimalLambda/README.md index 47bd1bda..b9c45660 100644 --- a/src/MinimalLambda/README.md +++ b/src/MinimalLambda/README.md @@ -166,6 +166,27 @@ lambda.UseMiddleware(async (context, next) => }); ``` +To log the raw event payload from middleware, call `EnableEventBuffering()` first—by default the +underlying stream may not be seekable and is meant to be read once by event deserialization: + +```csharp +lambda.UseMiddleware(async (context, next) => +{ + context.EnableEventBuffering(); + var invocationData = context.Features.GetRequired(); + + using (var reader = new StreamReader(invocationData.EventStream, leaveOpen: true)) + logger.LogInformation("Request: {Raw}", await reader.ReadToEndAsync()); + invocationData.EventStream.Position = 0; // reset for event deserialization + + await next(context); +}); +``` + +The response stream can always be replaced (for example, with a `Stream` wrapper that tees writes +to a logger) since `IInvocationDataFeature.ResponseStream` is serialized to after the middleware +pipeline completes. + ### Lambda Lifecycle The framework manages initialization and shutdown phases automatically. Add as many callbacks as diff --git a/tests/MinimalLambda.UnitTests/Core/Features/FeatureLambdaInvocationContextExtensionsTest.cs b/tests/MinimalLambda.UnitTests/Core/Features/FeatureLambdaInvocationContextExtensionsTest.cs index 67b0a7dc..c163ccec 100644 --- a/tests/MinimalLambda.UnitTests/Core/Features/FeatureLambdaInvocationContextExtensionsTest.cs +++ b/tests/MinimalLambda.UnitTests/Core/Features/FeatureLambdaInvocationContextExtensionsTest.cs @@ -557,6 +557,66 @@ public void GetRequiredResponse_WorksWithDifferentResponseTypes( #endregion + #region EnableEventBuffering Tests + + [Theory] + [AutoNSubstituteData] + public void EnableEventBuffering_CallsEnableBufferingWhenActiveFeatureSupportsBuffering( + [Frozen] IFeatureCollection features, + ILambdaInvocationContext context) + { + // Arrange + var invocationDataFeature = + Substitute.For(); + features.Get().Returns(invocationDataFeature); + + // Act + context.EnableEventBuffering(); + + // Assert + ((IInvocationDataBufferingFeature)invocationDataFeature).Received(1).EnableBuffering(); + } + + [Theory] + [AutoNSubstituteData] + public void EnableEventBuffering_ThrowsInvalidOperationExceptionWhenNoInvocationDataFeature( + [Frozen] IFeatureCollection features, + ILambdaInvocationContext context) + { + // Arrange + features.Get().Returns((IInvocationDataFeature?)null); + + // Act & Assert + var act = () => context.EnableEventBuffering(); + act + .Should() + .ThrowExactly() + .WithMessage( + $"Feature of type '{typeof(IInvocationDataBufferingFeature).FullName}' is not available in the context."); + } + + [Theory] + [AutoNSubstituteData] + public void + EnableEventBuffering_ThrowsInvalidOperationExceptionWhenActiveFeatureDoesNotSupportBuffering( + [Frozen] IFeatureCollection features, + ILambdaInvocationContext context, + IInvocationDataFeature invocationDataFeature) + { + // Arrange + features.Get().Returns(invocationDataFeature); + + // Act & Assert + var act = () => context.EnableEventBuffering(); + act + .Should() + .ThrowExactly() + .WithMessage( + $"Feature of type '{typeof(IInvocationDataBufferingFeature).FullName}' is not available in the context."); + } + + #endregion + #region Null Context Tests [Fact] @@ -607,6 +667,14 @@ public void GetRequiredResponse_ThrowsArgumentNullExceptionWhenContextIsNull() act.Should().ThrowExactly(); } + [Fact] + public void EnableEventBuffering_ThrowsArgumentNullExceptionWhenContextIsNull() + { + // Act & Assert + var act = () => ((ILambdaInvocationContext?)null)!.EnableEventBuffering(); + act.Should().ThrowExactly(); + } + #endregion #region Test Fixtures diff --git a/tests/MinimalLambda.UnitTests/Core/Features/InvocationDataFeatureTests.cs b/tests/MinimalLambda.UnitTests/Core/Features/InvocationDataFeatureTests.cs new file mode 100644 index 00000000..690085ed --- /dev/null +++ b/tests/MinimalLambda.UnitTests/Core/Features/InvocationDataFeatureTests.cs @@ -0,0 +1,103 @@ +using System.Text; + +namespace MinimalLambda.UnitTests.Core.Features; + +[TestSubject(typeof(InvocationDataFeature))] +public class InvocationDataFeatureTests +{ + [Fact] + public void EnableBuffering_WhenEventStreamAlreadySeekable_DoesNotReplaceStream() + { + // Arrange + var eventStream = new MemoryStream("payload"u8.ToArray()); + var feature = new InvocationDataFeature { EventStream = eventStream }; + + // Act + feature.EnableBuffering(); + + // Assert + feature.EventStream.Should().BeSameAs(eventStream); + } + + [Fact] + public void EnableBuffering_WhenEventStreamNotSeekable_ReplacesWithSeekableCopy() + { + // Arrange + var payload = "payload"u8.ToArray(); + var eventStream = new NonSeekableStream(payload); + var feature = new InvocationDataFeature { EventStream = eventStream }; + + // Act + feature.EnableBuffering(); + + // Assert + feature.EventStream.CanSeek.Should().BeTrue(); + } + + [Fact] + public void EnableBuffering_WhenEventStreamNotSeekable_PreservesContentAndResetsPosition() + { + // Arrange + var payload = "payload"u8.ToArray(); + var eventStream = new NonSeekableStream(payload); + var feature = new InvocationDataFeature { EventStream = eventStream }; + + // Act + feature.EnableBuffering(); + + // Assert + feature.EventStream.Position.Should().Be(0L); + using var reader = new StreamReader(feature.EventStream, Encoding.UTF8); + reader.ReadToEnd().Should().Be("payload"); + } + + [Fact] + public void + EnableBuffering_AfterReadingBufferedStream_AllowsResettingPositionForRedeserialization() + { + // Arrange + var payload = "payload"u8.ToArray(); + var eventStream = new NonSeekableStream(payload); + var feature = new InvocationDataFeature { EventStream = eventStream }; + feature.EnableBuffering(); + + using (var reader = new StreamReader(feature.EventStream, Encoding.UTF8, leaveOpen: true)) + reader.ReadToEnd(); + + // Act + feature.EventStream.Position = 0L; + + // Assert + using var reader2 = new StreamReader(feature.EventStream, Encoding.UTF8); + reader2.ReadToEnd().Should().Be("payload"); + } + + private sealed class NonSeekableStream(byte[] data) : Stream + { + private readonly MemoryStream _inner = new(data); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + _inner.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } +}