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();
+ }
+}