Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 27 additions & 27 deletions Directory.Packages.props
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,61 +13,61 @@
<PackageVersion Include="Amazon.Lambda.KinesisEvents" Version="4.0.0" />
<PackageVersion Include="Amazon.Lambda.KinesisFirehoseEvents" Version="3.0.0" />
<PackageVersion Include="Amazon.Lambda.RuntimeSupport" Version="2.2.0" />
<PackageVersion Include="Amazon.Lambda.Serialization.SystemTextJson" Version="3.0.0" />
<PackageVersion Include="Amazon.Lambda.Serialization.SystemTextJson" Version="3.0.1" />
<PackageVersion Include="Amazon.Lambda.TestUtilities" Version="4.2.0" />
<PackageVersion Include="Autofac.Extensions.DependencyInjection" Version="11.0.2" />
<PackageVersion Include="AutoFixture.AutoNSubstitute" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.3.20" />
<PackageVersion Include="AWSSDK.Lambda" Version="4.0.13.1" />
<PackageVersion Include="Basic.Reference.Assemblies.Net100" Version="1.8.10" />
<PackageVersion Include="Basic.Reference.Assemblies.Net110" Version="1.8.10" />
<PackageVersion Include="Basic.Reference.Assemblies.Net80" Version="1.8.10" />
<PackageVersion Include="Basic.Reference.Assemblies.Net100" Version="1.8.11" />
<PackageVersion Include="Basic.Reference.Assemblies.Net110" Version="1.8.11" />
<PackageVersion Include="Basic.Reference.Assemblies.Net80" Version="1.8.11" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="LayeredCraft.SourceGeneratorTools.Generator" Version="0.1.0-beta.10" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Routing" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.12" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.12" />
<PackageVersion Include="Microsoft.AspNetCore.Routing" Version="2.3.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="10.0.10" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.301" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.400" />
<!-- Source Gen Libraries -->
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.CodeAnalysis" Version="[5.6.0]" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="[5.6.0]" />
<PackageVersion Include="Microsoft.CodeAnalysis" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.4" />
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
<PackageVersion Include="PolySharp" Version="1.16.0" />
<PackageVersion Include="Scriban" Version="7.2.6" />
<PackageVersion Include="Scriban" Version="7.2.7" />
<!-- OTEL Libraries -->
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AWSLambda" Version="1.17.0" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AWSLambda" Version="1.18.0" />
<!-- Testing Libraries -->
<PackageVersion Include="AwesomeAssertions" Version="9.5.0" />
<PackageVersion Include="Basic.Reference.Assemblies.Net90" Version="1.8.10" />
<PackageVersion Include="AwesomeAssertions" Version="9.6.0" />
<PackageVersion Include="Basic.Reference.Assemblies.Net90" Version="1.8.11" />
<PackageVersion Include="JetBrains.Annotations" Version="2026.2.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.9.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.11.0" />
<PackageVersion Include="System.Formats.Asn1" Version="6.0.1" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.28.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Verify.XunitV3" Version="32.0.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="10.8.0" />
<!-- Lambda Event Libraries -->
<PackageVersion Include="Amazon.Lambda.APIGatewayEvents" Version="3.0.0" />
<PackageVersion Include="Amazon.Lambda.APIGatewayEvents" Version="3.0.1" />
<PackageVersion Include="Amazon.Lambda.ApplicationLoadBalancerEvents" Version="3.0.0" />
<PackageVersion Include="Amazon.Lambda.SNSEvents" Version="3.0.0" />
<PackageVersion Include="Amazon.Lambda.SQSEvents" Version="3.0.0" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
<PackageVersion Include="Amazon.Lambda.SNSEvents" Version="3.0.1" />
<PackageVersion Include="Amazon.Lambda.SQSEvents" Version="3.0.1" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.0" />
</ItemGroup>
<!-- Conditional versions based on target framework -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
namespace MinimalLambda;

/// <summary>
/// Optional capability of <see cref="IInvocationDataFeature" /> that allows buffering the
/// invocation event stream into memory so it can be read outside of event deserialization.
/// </summary>
/// <remarks>
/// Implemented alongside <see cref="IInvocationDataFeature" /> by implementations that support
/// it, rather than registered as its own entry in <see cref="IFeatureCollection" />. Probe the
/// currently active <see cref="IInvocationDataFeature" /> for this capability (for example,
/// <c>context.Features.Get&lt;IInvocationDataFeature&gt;() is IInvocationDataBufferingFeature</c>,
/// or the <c>context.EnableEventBuffering()</c> convenience extension) rather than looking this
/// type up in the feature collection directly or assuming every
/// <see cref="IInvocationDataFeature" /> implementation supports buffering. Looking it up
/// separately in the feature collection risks it becoming out of sync with whichever
/// <see cref="IInvocationDataFeature" /> is currently registered, if middleware replaces that
/// registration.
/// </remarks>
public interface IInvocationDataBufferingFeature
{
/// <summary>
/// Ensures <see cref="IInvocationDataFeature.EventStream" /> 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.
/// </summary>
/// <remarks>
/// Call before reading <see cref="IInvocationDataFeature.EventStream" />. After reading, reset
/// <c>EventStream.Position</c> to <c>0</c> 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.
/// </remarks>
void EnableBuffering();
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,5 +105,32 @@ public T GetRequiredResponse<T>()

return responseT;
}

/// <summary>
/// Ensures the raw Lambda event stream is seekable, buffering it into memory first if
/// necessary. Call from middleware before reading
/// <see cref="IInvocationDataFeature.EventStream" /> directly (for example, to log the raw
/// payload), then reset its <c>Position</c> to <c>0</c> afterward so event deserialization
/// can still consume it.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when no <see cref="IInvocationDataBufferingFeature" /> is available in the
/// context.
/// </exception>
public void EnableEventBuffering()
{
ArgumentNullException.ThrowIfNull(context);

if (context.Features.Get<IInvocationDataFeature>() is IInvocationDataBufferingFeature
bufferingFeature)
{
bufferingFeature.EnableBuffering();
return;
}

throw new InvalidOperationException(
$"Feature of type '{typeof(IInvocationDataBufferingFeature).FullName}' is not "
+ "available in the context.");
}
}
}
26 changes: 23 additions & 3 deletions src/MinimalLambda/Core/Features/InvocationDataFeature.cs
Original file line numberDiff line numberDiff line change
@@ -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;
}

/// <summary>
/// Dispose the underlying stream. We only dispose of the event stream, not the response
/// stream as the Lambda bootstrap will dispose of it.
/// </summary>
public void Dispose() => EventStream.Dispose();
public void Dispose() => _eventStream.Dispose();
}
21 changes: 21 additions & 0 deletions src/MinimalLambda/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<IInvocationDataFeature>();

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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<IInvocationDataFeature, IInvocationDataBufferingFeature>();
features.Get<IInvocationDataFeature>().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<IInvocationDataFeature>().Returns((IInvocationDataFeature?)null);

// Act & Assert
var act = () => context.EnableEventBuffering();
act
.Should()
.ThrowExactly<InvalidOperationException>()
.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<IInvocationDataFeature>().Returns(invocationDataFeature);

// Act & Assert
var act = () => context.EnableEventBuffering();
act
.Should()
.ThrowExactly<InvalidOperationException>()
.WithMessage(
$"Feature of type '{typeof(IInvocationDataBufferingFeature).FullName}' is not available in the context.");
}

#endregion

#region Null Context Tests

[Fact]
Expand DownExpand Up@@ -607,6 +667,14 @@ public void GetRequiredResponse_ThrowsArgumentNullExceptionWhenContextIsNull()
act.Should().ThrowExactly<ArgumentNullException>();
}

[Fact]
public void EnableEventBuffering_ThrowsArgumentNullExceptionWhenContextIsNull()
{
// Act & Assert
var act = () => ((ILambdaInvocationContext?)null)!.EnableEventBuffering();
act.Should().ThrowExactly<ArgumentNullException>();
}

#endregion

#region Test Fixtures
Expand Down
Loading
Loading