-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe Aws Lambda Api Gateway
This recipe wires Plumber to an AWS Lambda function fronted by API Gateway (REST or HTTP API). The Lambda receives an APIGatewayProxyRequest, your pipeline turns it into an APIGatewayProxyResponse, and the function returns it. The pipeline shape is (APIGatewayProxyRequest, APIGatewayProxyResponse).
What makes this scenario interesting is cold start. AWS Lambda freezes the execution environment between invocations, so anything you build during the first request is reused for every subsequent 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 field initializer, return it from a static readonly, 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 other packages the template gave you (Amazon.Lambda.Core, Amazon.Lambda.Serialization.SystemTextJson) stay as-is.
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.0.0" />
</ItemGroup>
</Project>The aws-lambda-tools-defaults.json the template generated handles deployment; nothing in it is Plumber-specific.
Keep the pipeline definition in its own static class. The split between CreateBuilder and Configure mirrors the README convention and pays off when you write tests.
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 illustrative middleware: an outer error boundary, a route extractor, a JWT authenticator, and the business logic.
The outermost middleware 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" },
};
}
}
}API Gateway hands you the path and method on the proxy request. Pull them into context.Data so downstream middleware 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);
}
}A short-circuit middleware: if the Authorization header is missing or the token fails verification, set a 401 response and skip the rest of the pipeline.
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 is the safe pattern even though HmacJwtVerifier is registered as a singleton — if 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; 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);
}Note: 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. If you prefer to assert non-null at the boundary, 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.
- Each invocation creates a fresh DI scope and a fresh
RequestContext. Singletons are shared across requests; method-injected dependencies are resolved fresh.
What you give up: 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 is the same as 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 if you ever need to (Lambda's container shutdown will let Function be collected, but it does not call Dispose).
If you need deterministic disposal — for example, if the handler holds 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 doesn't promise it'll 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:
Distinguishing timeouts from caller cancellation. The FunctionHandler signature accepts a CancellationToken from ILambdaContext.RemainingTime if you want to respect it. 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\"}" };
}Per-route validation. 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. Test the pipeline directly with PlumberApplicationFactory:
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. See Testing for the full strategy and PlumberApplicationFactory for every customization hook.
- .NET 10
-
MSL.Plumber.Pipeline3.0.0 -
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