-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe Aws Lambda Api Gateway
An AWS Lambda function fronted by API Gateway (REST or HTTP API) that receives an APIGatewayProxyRequest, runs it through a Plumber pipeline, and returns an APIGatewayProxyResponse. The pipeline shape is (APIGatewayProxyRequest, APIGatewayProxyResponse).
The interesting wrinkle is cold start. AWS Lambda freezes the execution environment between invocations, so anything built during the first request is reused for every warm invocation on the same container. A Plumber handler is built once and invoked many times — that maps directly onto Lambda's lifecycle. Build the handler in a static readonly initializer and the cold start cost shows up exactly once per container.
Start from the AWS Lambda template for an empty function:
dotnet new install Amazon.Lambda.Templates
dotnet new lambda.EmptyFunction --name MyApi.Lambda
cd MyApi.Lambda/src/MyApi.LambdaAdd the API Gateway event types and Plumber:
dotnet add package Amazon.Lambda.APIGatewayEvents
dotnet add package MSL.Plumber.PipelineThe template's other packages stay as-is:
Amazon.Lambda.CoreAmazon.Lambda.Serialization.SystemTextJson
A representative MyApi.Lambda.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AWSProjectType>Lambda</AWSProjectType>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Amazon.Lambda.Core" Version="2.7.0" />
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.5" />
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.2" />
<PackageReference Include="MSL.Plumber.Pipeline" Version="3.*" />
</ItemGroup>
</Project>The aws-lambda-tools-defaults.json the template generated handles deployment; nothing in it is Plumber-specific.
A static Pipeline class with a CreateBuilder / Configure split. The split mirrors the README convention and pays off when you write tests — CreateBuilder is the production wiring, Configure is the middleware list, and tests can replace the builder while keeping the chain intact.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Plumber;
using Amazon.Lambda.APIGatewayEvents;
namespace MyApi.Lambda;
internal static class Pipeline
{
public static RequestHandlerBuilder<APIGatewayProxyRequest, APIGatewayProxyResponse> CreateBuilder() =>
RequestHandlerBuilder
.Create<APIGatewayProxyRequest, APIGatewayProxyResponse>()
.AddDefaultConfigurationSources()
.ConfigureLogging(logging => logging
.SetMinimumLevel(LogLevel.Information)
.AddJsonConsole())
.ConfigureServices((services, configuration) => services
.AddSingleton<IJwtVerifier, HmacJwtVerifier>()
.AddSingleton<IGreetingService, GreetingService>());
public static RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Configure(
RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> handler) =>
handler
.Use<ErrorBoundaryMiddleware>()
.Use<RouteExtractionMiddleware>()
.Use<AuthenticationMiddleware>()
.Use<GreetingMiddleware>();
public static RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Build() =>
Configure(CreateBuilder().Build());
}AddJsonConsole plays well with CloudWatch Logs — every log line lands as a structured JSON object you can query in Logs Insights.
Four middleware:
-
ErrorBoundaryMiddleware— translates uncaught exceptions to 500 responses -
RouteExtractionMiddleware— pulls path and method intocontext.Data -
AuthenticationMiddleware— verifies a bearer JWT, short-circuits with 401 -
GreetingMiddleware— the terminal business logic
Translates exceptions into 500 responses so the function host always sees a well-formed APIGatewayProxyResponse. Register it first.
using Microsoft.Extensions.Logging;
using Plumber;
using Amazon.Lambda.APIGatewayEvents;
namespace MyApi.Lambda;
internal sealed class ErrorBoundaryMiddleware(
RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next,
ILogger<ErrorBoundaryMiddleware> logger)
{
public async Task InvokeAsync(RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context)
{
try
{
await next(context);
}
catch (Exception ex)
{
logger.LogError(ex, "request {Id} failed", context.Id);
context.Response = new APIGatewayProxyResponse
{
StatusCode = 500,
Body = "{\"error\":\"internal server error\"}",
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
};
}
}
}Pulls the path and method off the proxy request into context.Data. Downstream middleware then reads keyed values instead of re-parsing the request.
using Plumber;
using Amazon.Lambda.APIGatewayEvents;
namespace MyApi.Lambda;
internal sealed class RouteExtractionMiddleware(
RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next)
{
public Task InvokeAsync(RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context)
{
var request = context.Request;
context.Data["route.path"] = request.Path ?? "/";
context.Data["route.method"] = request.HttpMethod ?? "GET";
return next(context);
}
}Short-circuits with a 401 when the Authorization header is missing or the token fails verification.
using Plumber;
using Amazon.Lambda.APIGatewayEvents;
namespace MyApi.Lambda;
internal sealed class AuthenticationMiddleware(
RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next)
{
public Task InvokeAsync(
RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context,
IJwtVerifier verifier)
{
if (!TryGetBearerToken(context.Request, out var token)
|| !verifier.TryVerify(token, out var subject))
{
context.Response = Unauthorized();
return Task.CompletedTask; // short-circuit — no next() call
}
context.Data["auth.subject"] = subject;
return next(context);
}
private static bool TryGetBearerToken(APIGatewayProxyRequest request, out string token)
{
token = string.Empty;
if (request.Headers is null) return false;
if (!request.Headers.TryGetValue("Authorization", out var header)) return false;
if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return false;
token = header["Bearer ".Length..].Trim();
return token.Length > 0;
}
private static APIGatewayProxyResponse Unauthorized() => new()
{
StatusCode = 401,
Body = "{\"error\":\"unauthorized\"}",
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
};
}IJwtVerifier is method-injected, resolved from the per-request DI scope on every request. That stays correct even though HmacJwtVerifier is registered as a singleton — when you later swap it for a verifier that depends on a scoped HTTP client (for JWKS lookup, say), the middleware code stays the same.
The terminal business logic. By the time it runs the path, method, and authenticated subject are already in context.Data.
using System.Text.Json;
using Plumber;
using Amazon.Lambda.APIGatewayEvents;
namespace MyApi.Lambda;
internal sealed class GreetingMiddleware(
RequestMiddleware<APIGatewayProxyRequest, APIGatewayProxyResponse> next)
{
public Task InvokeAsync(
RequestContext<APIGatewayProxyRequest, APIGatewayProxyResponse> context,
IGreetingService greetings)
{
var path = (string)context.Data["route.path"]!;
var subject = (string)context.Data["auth.subject"]!;
if (path != "/greet")
{
context.Response = NotFound();
return next(context);
}
var greeting = greetings.For(subject);
context.Response = new APIGatewayProxyResponse
{
StatusCode = 200,
Body = JsonSerializer.Serialize(new { greeting }),
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
};
return next(context);
}
private static APIGatewayProxyResponse NotFound() => new()
{
StatusCode = 404,
Body = "{\"error\":\"not found\"}",
Headers = new Dictionary<string, string> { ["Content-Type"] = "application/json" },
};
}For more than a handful of routes, replace the if (path != "/greet") ladder with a dispatch table or a separate per-route middleware. The shape stays the same.
The Lambda handler is a single method on a single class. Build the Plumber handler once in a static readonly initializer and reuse it across every warm invocation.
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Core;
using Plumber;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace MyApi.Lambda;
public sealed class Function
{
// Built once per cold start, reused for every warm invocation.
private static readonly RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Handler =
Pipeline.Build();
public Task<APIGatewayProxyResponse?> FunctionHandler(
APIGatewayProxyRequest request,
ILambdaContext context) =>
Handler.InvokeAsync(request);
}The static initializer runs on the worker thread that processed the cold-start invocation — not on a background thread. The first request pays the build cost. Every subsequent request on the same container reuses Handler.
The FunctionHandler signature returns Task<APIGatewayProxyResponse?> because InvokeAsync returns nullable. In practice the ErrorBoundaryMiddleware guarantees a non-null response on every path, so the nullable is paperwork rather than a real concern.
To assert non-null at the boundary instead, do it there:
public async Task<APIGatewayProxyResponse> FunctionHandler(
APIGatewayProxyRequest request,
ILambdaContext context) =>
await Handler.InvokeAsync(request)
?? throw new InvalidOperationException("pipeline produced no response");Plumber handlers are designed to be reused. The DI container, the configuration root, the compiled middleware dispatch — all of it survives between invocations. Build the handler once per Lambda container and keep it in a static field.
What that buys you on a warm invocation:
- No DI container construction.
- No configuration providers re-loaded.
- No middleware reflection or expression-tree compilation.
- A fresh DI scope and a fresh
RequestContextper invocation. Singletons are shared across requests; method-injected dependencies are resolved fresh.
The trade-off: any work the handler does at construction time (reading configuration, resolving singletons that hit the network) lives on the cold-start path. Keep singleton constructors lightweight, or warm them lazily inside the first middleware that needs them.
A common alternative is an instance field on the function class:
public sealed class Function
{
private readonly RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> handler;
public Function() => handler = Pipeline.Build();
public Task<APIGatewayProxyResponse?> FunctionHandler(
APIGatewayProxyRequest request,
ILambdaContext context) =>
handler.InvokeAsync(request);
}The Lambda runtime constructs Function once per container and reuses the instance, so the lifetime matches the static-field version. The static field is a touch easier to share across multiple handler classes in the same project. The instance field is easier to dispose deterministically (Lambda's container shutdown lets Function be collected, but it does not call Dispose).
For handlers that hold open connections you want to flush, wire up an AppDomain.ProcessExit handler:
private static readonly RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> Handler =
BuildAndRegisterShutdown();
private static RequestHandler<APIGatewayProxyRequest, APIGatewayProxyResponse> BuildAndRegisterShutdown()
{
var handler = Pipeline.Build();
AppDomain.CurrentDomain.ProcessExit += (_, _) => handler.Dispose();
return handler;
}Lambda makes no promise to fire ProcessExit before recycling a container, but for graceful shutdown of well-behaved containers it does the job.
ErrorBoundaryMiddleware is the catch-all. Two finer-grained patterns are worth knowing.
The FunctionHandler signature can accept a CancellationToken linked to ILambdaContext.RemainingTime. Pass it through:
public Task<APIGatewayProxyResponse?> FunctionHandler(
APIGatewayProxyRequest request,
ILambdaContext context)
{
using var cts = new CancellationTokenSource(context.RemainingTime - TimeSpan.FromMilliseconds(500));
return Handler.InvokeAsync(request, cts.Token);
}The 500-ms buffer gives the response time to serialize before Lambda kills the container. Inside ErrorBoundaryMiddleware, separate the catches:
catch (TimeoutException)
{
logger.LogWarning("request {Id} exceeded handler timeout", context.Id);
context.Response = new APIGatewayProxyResponse { StatusCode = 504, Body = "{\"error\":\"timeout\"}" };
}
catch (OperationCanceledException)
{
logger.LogWarning("request {Id} cancelled (Lambda time exhausted)", context.Id);
context.Response = new APIGatewayProxyResponse { StatusCode = 504, Body = "{\"error\":\"timeout\"}" };
}Earlier middleware can short-circuit with a 400 response without touching the error boundary. Set context.Response and return Task.CompletedTask instead of calling next.
The Lambda function class is a thin shim — the interesting code lives in the middleware. PlumberApplicationFactory<TReq, TRes> builds the real pipeline once, lets you swap selected services for stubs, and invokes the chain end to end. See Testing and PlumberApplicationFactory for the full surface.
using Plumber.Testing;
using Microsoft.Extensions.DependencyInjection;
using Amazon.Lambda.APIGatewayEvents;
using Xunit;
namespace MyApi.Lambda.Tests;
public sealed class GreetingPipelineTests
{
[Fact]
public async Task ValidJwtReturns200Async()
{
using var factory = new PlumberApplicationFactory<APIGatewayProxyRequest, APIGatewayProxyResponse>(
_ => Pipeline.CreateBuilder(),
Pipeline.Configure)
.WithServices(services =>
services.AddSingleton<IJwtVerifier>(new StubJwtVerifier(subject: "alice")));
var request = new APIGatewayProxyRequest
{
Path = "/greet",
HttpMethod = "GET",
Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer test-token" },
};
var response = await factory.InvokeAsync(request);
Assert.NotNull(response);
Assert.Equal(200, response.StatusCode);
Assert.Contains("alice", response.Body);
}
[Fact]
public async Task MissingAuthorizationReturns401Async()
{
using var factory = new PlumberApplicationFactory<APIGatewayProxyRequest, APIGatewayProxyResponse>(
_ => Pipeline.CreateBuilder(),
Pipeline.Configure);
var response = await factory.InvokeAsync(new APIGatewayProxyRequest
{
Path = "/greet",
HttpMethod = "GET",
Headers = new Dictionary<string, string>(),
});
Assert.NotNull(response);
Assert.Equal(401, response.StatusCode);
}
}The factory builds the real pipeline once, swaps IJwtVerifier for a stub, and exercises the full chain.
- .NET 10
-
MSL.Plumber.Pipeline3.* -
MSL.Plumber.Pipeline.Testing3.* (test project) -
Amazon.Lambda.Core2.7.0 -
Amazon.Lambda.Serialization.SystemTextJson2.4.5 -
Amazon.Lambda.APIGatewayEvents2.7.2 - AWS Lambda .NET 10 managed runtime
Lambda runtime types and SDK versions move quickly. Check the Amazon.Lambda.* packages on NuGet before copying these versions into a new project.
- Recipe: AWS Lambda for SQS — partial-batch failure semantics for the queue-trigger case
- Recipe: Azure Functions HTTP — the Azure equivalent
- Building a Pipeline — the full builder surface
- Middleware — class vs delegate, method injection, generic middleware
-
Request Lifecycle —
RequestContext, short-circuit, timeout vs cancellation - Testing and PlumberApplicationFactory
Documents Plumber v4.x · Repository · MIT License · Report an issue
Getting Started
Pipeline (core)
Testing
Serilog Extensions
Diagnostics
Recipes
- AWS Lambda — API Gateway
- AWS Lambda — SQS
- Azure Functions — HTTP
- SQS polling console
- ASP.NET Core integration
- BackgroundService worker
- Webhook receiver
- Multi-command CLI
- File watcher
- Configuration reload
Repo · NuGet · NuGet — Testing · NuGet — Serilog · NuGet — Diagnostics