-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe Azure Functions Http
This recipe wires Plumber to an Azure Function in the .NET isolated worker model, triggered by an HTTP request. The function receives an HttpRequestData, your pipeline produces an HttpResponseData, and the function returns it. The pipeline shape is (HttpRequestData, HttpResponseData).
What makes this scenario interesting is the isolated worker's own DI host. Unlike the in-process model, the isolated worker runs your code in a separate .NET process with its own IHostBuilder. Plumber can either build its own DI container alongside the worker host, or — better — it can be registered as a singleton inside the worker's DI and reuse the host's services. This recipe leads with the host-mode approach.
Cold start matters here too: the worker process is recycled after periods of inactivity, and a Plumber handler held by DI lives for the lifetime of the worker process. Build it once, share it across every invocation.
Start from the isolated worker template:
dotnet new install Microsoft.Azure.Functions.Worker.ProjectTemplates
dotnet new func --worker-runtime dotnetIsolated --name MyApi.Functions --target-framework net10.0
cd MyApi.FunctionsAdd Plumber. The template already pulls in Microsoft.Azure.Functions.Worker, Microsoft.Azure.Functions.Worker.Sdk, and Microsoft.Azure.Functions.Worker.Extensions.Http:
dotnet add package MSL.Plumber.PipelineA representative MyApi.Functions.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="MSL.Plumber.Pipeline" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="host.json"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>
<None Update="local.settings.json"><CopyToPublishDirectory>Never</CopyToPublishDirectory></None>
</ItemGroup>
</Project>The template generated host.json and local.settings.json are unchanged.
Two production patterns: build a standalone Plumber handler with its own DI, or build a host-mode handler that reuses the worker's DI. The host-mode option is the better fit because it lets every middleware see the same IConfiguration, ILoggerFactory, and registered services as the rest of your worker code.
The Pipeline class only knows how to add middleware — there is no builder, because the DI container is owned by the worker host.
using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
namespace MyApi.Functions;
internal static class Pipeline
{
public static RequestHandler<HttpRequestData, HttpResponseData> Configure(
RequestHandler<HttpRequestData, HttpResponseData> handler) =>
handler
.Use<ErrorBoundaryMiddleware>()
.Use<RouteExtractionMiddleware>()
.Use<AuthenticationMiddleware>()
.Use<GreetingMiddleware>();
}The handler is created from the host's IServiceProvider via the RequestHandler.Create static factory — see the entry point section below.
If you'd rather isolate the pipeline's DI from the worker host's, fall back to RequestHandlerBuilder:
internal static class Pipeline
{
public static RequestHandlerBuilder<HttpRequestData, HttpResponseData> CreateBuilder() =>
RequestHandlerBuilder
.Create<HttpRequestData, HttpResponseData>()
.AddDefaultConfigurationSources()
.ConfigureLogging(logging => logging.AddConsole())
.ConfigureServices((services, configuration) => services
.AddSingleton<IGreetingService, GreetingService>());
public static RequestHandler<HttpRequestData, HttpResponseData> Configure(
RequestHandler<HttpRequestData, HttpResponseData> handler) =>
handler
.Use<ErrorBoundaryMiddleware>()
.Use<RouteExtractionMiddleware>()
.Use<GreetingMiddleware>();
}This works, but you'll be duplicating service registrations between the worker host and the Plumber builder. Reach for it when the pipeline is genuinely independent of the host (for example, a queue-trigger Function whose pipeline you also want to run from a CLI test harness with its own DI).
Three illustrative middleware: an outer error boundary, a route extractor, and the greeting business logic. The fourth (authentication) is left as an exercise — the pattern is identical to the API Gateway recipe.
Catch everything, log it, and produce a 500 response so the Functions host always returns something well-formed.
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Plumber;
using System.Net;
namespace MyApi.Functions;
internal sealed class ErrorBoundaryMiddleware(
RequestMiddleware<HttpRequestData, HttpResponseData> next,
ILogger<ErrorBoundaryMiddleware> logger)
{
public async Task InvokeAsync(RequestContext<HttpRequestData, HttpResponseData> context)
{
try
{
await next(context);
}
catch (Exception ex)
{
logger.LogError(ex, "request {Id} failed", context.Id);
var response = context.Request.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteStringAsync("{\"error\":\"internal server error\"}");
response.Headers.Add("Content-Type", "application/json");
context.Response = response;
}
}
}HttpRequestData.CreateResponse is the canonical factory for HttpResponseData in the isolated worker — it ties the response to the originating request so the worker's framework can flush it correctly.
Extract the path and method into context.Data so downstream middleware reads keyed values.
using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
namespace MyApi.Functions;
internal sealed class RouteExtractionMiddleware(
RequestMiddleware<HttpRequestData, HttpResponseData> next)
{
public Task InvokeAsync(RequestContext<HttpRequestData, HttpResponseData> context)
{
var request = context.Request;
context.Data["route.path"] = request.Url.AbsolutePath;
context.Data["route.method"] = request.Method;
return next(context);
}
}Short-circuit with a 401 if the bearer token is missing or invalid. The verifier is method-injected so it picks up the per-request scope.
using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
using System.Net;
namespace MyApi.Functions;
internal sealed class AuthenticationMiddleware(
RequestMiddleware<HttpRequestData, HttpResponseData> next)
{
public async Task InvokeAsync(
RequestContext<HttpRequestData, HttpResponseData> context,
IJwtVerifier verifier)
{
if (!TryGetBearerToken(context.Request, out var token)
|| !verifier.TryVerify(token, out var subject))
{
var response = context.Request.CreateResponse(HttpStatusCode.Unauthorized);
await response.WriteStringAsync("{\"error\":\"unauthorized\"}");
response.Headers.Add("Content-Type", "application/json");
context.Response = response;
return; // short-circuit
}
context.Data["auth.subject"] = subject;
await next(context);
}
private static bool TryGetBearerToken(HttpRequestData request, out string token)
{
token = string.Empty;
if (!request.Headers.TryGetValues("Authorization", out var values)) return false;
var header = values.FirstOrDefault() ?? string.Empty;
if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return false;
token = header["Bearer ".Length..].Trim();
return token.Length > 0;
}
}The terminal business logic. By the time it runs, the path is in context.Data and the authenticated subject (when present) is too.
using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
using System.Net;
using System.Text.Json;
namespace MyApi.Functions;
internal sealed class GreetingMiddleware(
RequestMiddleware<HttpRequestData, HttpResponseData> next)
{
public async Task InvokeAsync(
RequestContext<HttpRequestData, HttpResponseData> context,
IGreetingService greetings)
{
var path = (string)context.Data["route.path"]!;
var subject = context.TryGetValue<string>("auth.subject", out var s) ? s : "anonymous";
if (path != "/api/greet")
{
var notFound = context.Request.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteStringAsync("{\"error\":\"not found\"}");
notFound.Headers.Add("Content-Type", "application/json");
context.Response = notFound;
await next(context);
return;
}
var greeting = greetings.For(subject);
var response = context.Request.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json");
await response.WriteStringAsync(JsonSerializer.Serialize(new { greeting }));
context.Response = response;
await next(context);
}
}The isolated worker has two pieces: the Program.cs that builds the host, and the [Function]-attributed method that gets the trigger. Plumber slots into both.
Build the Plumber handler from the host's IServiceProvider and register it as a singleton. Configure it inside the registration so the middleware list is fixed at construction time.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MyApi.Functions;
using Plumber;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services
.AddSingleton<IJwtVerifier, HmacJwtVerifier>()
.AddSingleton<IGreetingService, GreetingService>()
.AddSingleton(sp => Pipeline.Configure(
RequestHandler.Create<HttpRequestData, HttpResponseData>(sp)));
builder.Build().Run();RequestHandler.Create<TReq, TRes>(serviceProvider) is the host-mode factory — Plumber reuses the supplied provider rather than building its own. The handler does not own the provider; when the host shuts down, the worker disposes the provider, and the handler's disposal is a no-op.
The Function method takes the registered handler from DI and delegates to it.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Plumber;
namespace MyApi.Functions;
public sealed class GreetingFunction(RequestHandler<HttpRequestData, HttpResponseData> handler)
{
[Function(nameof(Greet))]
public async Task<HttpResponseData> Greet(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "{*path}")]
HttpRequestData request,
FunctionContext functionContext)
{
var response = await handler.InvokeAsync(request, functionContext.CancellationToken);
return response ?? request.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
}
}The Route = "{*path}" catch-all sends every request that hits this Function through Plumber; the route extraction middleware decides what to do with the path. If you'd rather use the standard [HttpTrigger] per-route routing, declare one Function per route and let each forward into a shared pipeline (or into separate pipelines per route).
FunctionContext.CancellationToken is the worker's cancellation signal — flowing it through to InvokeAsync lets your middleware honour shutdown and per-invocation cancellation.
The Plumber handler lives as a singleton in the worker's DI container. The worker process is long-lived — Functions hosts only recycle on idle timeout, deployment, or scale events — so the handler is built once and reused for every invocation on the same worker.
Cold start cost in the host-mode setup:
- The worker starts the host, which builds the DI container and resolves the singleton handler. Plumber wires up its component list at this point, but the actual middleware pipeline is built lazily on the first
InvokeAsynccall. - The first request after a cold start pays the lazy pipeline build (one expression-tree compile per class middleware). Subsequent requests are warm.
Things that help:
- Keep singleton constructors lightweight. Network calls in
IGreetingService's constructor add to cold-start latency on every container. - Use
[FunctionsStartup](or just lines inProgram.cs) to eagerly resolve the handler if you want the lazy pipeline build to happen at startup rather than on the first request.var _ = serviceProvider.GetRequiredService<RequestHandler<HttpRequestData, HttpResponseData>>();followed by a noopInvokeAsyncwarms it. - Premium and Dedicated plans keep the worker resident, so cold starts are rare; on Consumption, every scale-out event pays it once.
The ErrorBoundaryMiddleware catches every exception and produces a 500. Two finer-grained patterns:
Distinguishing handler timeout from caller cancellation. Pass a CancellationTokenSource linked to a per-request budget:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(functionContext.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(20));
var response = await handler.InvokeAsync(request, cts.Token);In the error boundary, separate the catches:
catch (TimeoutException) { /* Plumber's handler-wide timeout fired */ }
catch (OperationCanceledException) { /* host or per-request budget cancelled */ }Set the handler-wide timeout at construction:
builder.Services.AddSingleton(sp => Pipeline.Configure(
RequestHandler.Create<HttpRequestData, HttpResponseData>(sp, TimeSpan.FromSeconds(30))));Per-route validation. Earlier middleware can short-circuit with a 400 by setting context.Response and returning without calling next.
The Function class is a thin shim. The pipeline is testable via PlumberApplicationFactory — construct an HttpRequestData (the isolated worker has helpers for this in Microsoft.Azure.Functions.Worker.Testing, or fake one) and invoke the factory.
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Plumber.Testing;
using Xunit;
namespace MyApi.Functions.Tests;
public sealed class GreetingPipelineTests
{
[Fact]
public async Task ValidRequestReturns200Async()
{
// Test-only standalone pipeline that mirrors Configure() without the host.
using var factory = new PlumberApplicationFactory<HttpRequestData, HttpResponseData>(
_ => RequestHandlerBuilder
.Create<HttpRequestData, HttpResponseData>()
.ConfigureServices((services, _) => services
.AddSingleton<IGreetingService, GreetingService>()
.AddSingleton<IJwtVerifier>(new StubJwtVerifier(subject: "alice"))),
Pipeline.Configure);
var request = FakeHttpRequestData.Get("/api/greet", bearer: "test-token");
var response = await factory.InvokeAsync(request);
Assert.NotNull(response);
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
}Faking HttpRequestData is the only awkward bit — the type is abstract and tightly coupled to the worker context. Two options: use the helpers from Microsoft.Azure.Functions.Worker.Testing (still in preview at time of writing), or build a small FakeHttpRequestData : HttpRequestData in your test project that overrides the members you actually exercise. The pipeline itself is easy to test; the runtime types around it are the rough edge.
See Testing for the full strategy and PlumberApplicationFactory for the customization hooks.
- .NET 10
-
MSL.Plumber.Pipeline3.0.0 -
Microsoft.Azure.Functions.Worker2.0.0 -
Microsoft.Azure.Functions.Worker.Sdk2.0.0 -
Microsoft.Azure.Functions.Worker.Extensions.Http3.3.0 - Azure Functions runtime v4
The isolated worker SDK ships frequent updates. Check the Microsoft.Azure.Functions.Worker* packages on NuGet before copying these versions into a new project.
- Recipe: AWS Lambda behind API Gateway — the AWS equivalent
-
Recipe: ASP.NET Core integration — host-mode
RequestHandler.Createfrom ASP.NET Core - Building a Pipeline — the full builder surface
- Middleware — class vs delegate, method injection
-
Request Lifecycle —
RequestContext, short-circuit, cancellation - Testing and PlumberApplicationFactory
-
Advanced — host-mode
RequestHandler.Create(serviceProvider)
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