Middleware in PAGI
A port of the sample app from What Is Middleware? — which builds the same three-layer stack in Plack/PSGI (Perl) and Starlette/ASGI (Python) — to PAGI, an async, ASGI-style application interface for Perl.
The app is deliberately tiny but exercises the three things middleware exists to do:
- Logger — wrap the request, time it, log method/path in and status/duration out.
- Authenticator — inspect a header, inject context for downstream layers on success, or short-circuit with a 401 on failure.
- ProfileRouter — answer one specific route from inside the stack, reading the context the Authenticator injected.
All code below was run under perl-5.40.0 with PAGI::Test::Client; the log lines and responses shown in Running it are the actual captured output, not hand-written.
The PAGI middleware contract
A PAGI application is, in the spec's words, "a single coderef returning a Future": an async sub over the ($scope, $receive, $send) triple — the same shape as ASGI. $scope is the per-connection metadata hash (type, method, path, headers, …), $receive pulls inbound events, $send pushes outbound ones (http.response.start, then http.response.body), and the Future it returns resolving is what tells the server the response is complete.
Middleware is just as plain: a subroutine that takes an application and returns a new application, wrapping the inner one. That is the whole spec-level contract — app in, app out:
sub middleware {
my ($app) = @_;
return async sub ($scope, $receive, $send) {
# ... before ...
await $app->($scope, $receive, $send); # call the inner app
# ... after ...
};
}
A middleware propagates the inner app's Future — its completion and any exception flow straight through — and never reads its return value, which the spec defines as inert; to observe or rewrite the response it wraps $send instead, and to add per-request context it clones $scope (top-level edits stay visible downward only).
PAGI::Middleware, from PAGI-Tools rather than the spec, is a thin convenience layer over exactly that contract. Instead of a bare sub you get a small class whose wrap($app) returns the same app-to-app coderef:
sub wrap ($self, $app) {
return async sub ($scope, $receive, $send) {
# ... before ...
await $app->($scope, $receive, $send); # call the inner app
# ... after ...
};
}
It earns its keep mainly through PAGI::Middleware::Builder: you get a new/_init config constructor, the modify_scope / intercept_send helpers, and a wrap method the builder's enable knows how to call. The three middleware below all take this form — but it's sugar over the plain subroutine above, not a different thing.
From that single seam you get every middleware behaviour:
| Behaviour | How |
|---|---|
| Observe the response | wrap $send in your own async sub and watch the events flow by |
| Inject per-request context |
$self->modify_scope($scope, { key => $value }) and pass the copy down |
| Short-circuit | render your own response with $send and don't call $app
|
| Pass non-HTTP through | check $scope->{type} and delegate untouched |
Two notes on how this maps from the original article:
-
Context injection. PSGI mutates
$env->{'custom.user_id'}; Starlette setsrequest.state.user_id. PAGI usesmodify_scope, which shallow-copies the scope and merges your additions, then hands the copy to the inner app. The data is visible to everything downstream but never leaks back up to outer middleware — injection without global mutation. -
Responses are values.
PAGI::Response->json($data)builds a response object you can pass around;->respond($send)is what actually writes it to the connection. Short-circuiting is just "build a response and respond, skip$app."
Why
wrap($app)as the base shape? The Starlette version subclassesBaseHTTPMiddlewareand overridesdispatch(request, call_next)— which reads beautifully: youawait call_next(request)and get aResponseback. The ergonomics are lovely; the trouble is how Starlette implementscall_next. It runs the inner app in a separate task feeding an in-memory stream, and that plumbing is what adds overhead and is known to trip over streaming responses, background tasks, and context propagation. It's also HTTP-only — anything touching WebSocket or lifespan traffic, or transforming a streaming body, has to drop to the raw ASGI form regardless. So PAGI makes the raw form —wrap($app)over($scope, $receive, $send)— the substrate every middleware is built on: it can express everything, with no hidden task or stream in the path. Acall_next-style value-passing convenience can be layered on top of that substrate without inheriting the plumbing; what you don't want is to make the lossy, HTTP-only version the foundation.
Logger middleware
Times the request across the inner app and logs a line on the way in and on the way out. To learn the final status, it wraps $send and remembers the status off the http.response.start event.
package MyApp::Middleware::Logger;
use v5.40;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;
use Time::HiRes qw(time);
sub wrap ($self, $app) {
return async sub ($scope, $receive, $send) {
# Pass through anything that isn't an HTTP request
# (lifespan, websocket, sse) untouched.
return await $app->($scope, $receive, $send)
unless $scope->{type} eq 'http';
my $start_time = time();
say "[LOG] Incoming: $scope->{method} $scope->{path}";
# Intercept the outgoing stream so we can observe the final status.
my $status;
my $wrapped_send = async sub ($event) {
$status = $event->{status}
if $event->{type} eq 'http.response.start';
await $send->($event);
};
await $app->($scope, $receive, $wrapped_send);
my $elapsed = time() - $start_time;
printf "[LOG] Outgoing Status: %d (Processed in %.4f seconds)\n",
$status, $elapsed;
};
}
1;
The PSGI original returns a [$status, $headers, $body] arrayref it can read directly. In PAGI (as in ASGI) the response is streamed as events, so the idiomatic move is to wrap $send and observe http.response.start — exactly what the bundled PAGI::Middleware::Runtime and PAGI::Middleware::AccessLog do.
Authenticator middleware
Reads X-Auth-Token. On the magic value it logs, injects user_id => 456 for the layers below, and continues. Otherwise it logs and short-circuits with a 401 — the inner app is never called.
package MyApp::Middleware::Authenticator;
use v5.40;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;
use PAGI::Request;
use PAGI::Response;
sub wrap ($self, $app) {
return async sub ($scope, $receive, $send) {
return await $app->($scope, $receive, $send)
unless $scope->{type} eq 'http';
my $req = PAGI::Request->new($scope, $receive);
my $token = $req->header('X-Auth-Token');
unless (defined $token && $token eq 'secret-password-123') {
say "[AUTH] Access Denied. Short-circuiting.";
# Short-circuit: never call $app. A response is just a value we
# render onto the connection ourselves.
return await PAGI::Response->text('Unauthorized', status => 401)
->respond($send);
}
say "[AUTH] Valid token. Granting access to User #456.";
# Inject per-request context for downstream layers. modify_scope
# shallow-copies the scope so the addition is only visible to the
# inner app, never leaking back out to middleware above us.
my $authed_scope = $self->modify_scope($scope, { user_id => 456 });
await $app->($authed_scope, $receive, $send);
};
}
1;
PAGI::Request is a convenience wrapper over the raw scope — $req->header('X-Auth-Token') is case-insensitive, just like $req->header(...) in the Plack version. (For a real app, PAGI::Middleware::Auth::Bearer ships this pattern as a configurable building block.)
ProfileRouter middleware
If the path is /api/profile, it answers directly with JSON — reading the user_id the Authenticator injected, defaulting to 'Guest' if it somehow ran without auth. Any other path falls through to whatever is below.
package MyApp::Middleware::ProfileRouter;
use v5.40;
use parent 'PAGI::Middleware';
use Future::AsyncAwait;
use PAGI::Response;
sub wrap ($self, $app) {
return async sub ($scope, $receive, $send) {
# Not our route — hand off to the inner app.
return await $app->($scope, $receive, $send)
unless $scope->{type} eq 'http' && $scope->{path} eq '/api/profile';
# Read the context the Authenticator injected upstream.
say "[ROUTER] Handling /api/profile directly inside middleware.";
my $data = {
user_id => $scope->{user_id} // 'Guest',
name => 'Alice Perl',
status => 'Fully Delegated Architecture',
};
await PAGI::Response->json($data)->respond($send);
};
}
1;
$scope->{user_id} here is the PAGI counterpart to $env->{'custom.user_id'} (PSGI) and request.state.user_id (Starlette). It is present because modify_scope put it on the copy that flowed down from the Authenticator.
Assembling the app
PAGI ships a Plack::Builder-style DSL in PAGI::Middleware::Builder. The shape mirrors the original builder { enable ...; $fallback } almost line for line:
#!/usr/bin/env perl
use v5.40;
use lib 'lib';
use PAGI::Middleware::Builder;
use PAGI::Response;
# Compose the stack, outermost first. Logger wraps Authenticator wraps
# ProfileRouter wraps the 404 fallback. The '^' prefix means "this is a
# fully-qualified class name", the PAGI equivalent of Plack's '+'.
#
# The fallback is just a Response value: the builder coerces its final
# expression through PAGI::Utils::to_app, and a PAGI::Response knows how to
# turn itself into an app. It fires only when no middleware short-circuited.
builder {
enable '^MyApp::Middleware::Logger';
enable '^MyApp::Middleware::Authenticator';
enable '^MyApp::Middleware::ProfileRouter';
PAGI::Response->text('Resource Not Found', status => 404);
};
Because a response is a value in PAGI, the fallback needs no wrapper coderef — the builder accepts the PAGI::Response object directly and calls its to_app for you. (The original PSGI version needs the explicit sub { [404, …] } because PSGI's inner app must be a callable.)
The one syntactic difference worth flagging: where Plack writes enable '+MyApp::Middleware::Logger' to mean "don't prepend the framework namespace," PAGI writes enable '^MyApp::Middleware::Logger'. Bare names like enable 'Runtime' are resolved to PAGI::Middleware::Runtime; the ^ opts out of that prefixing.
Middleware runs outermost-first, so the request flows Logger → Authenticator → ProfileRouter → fallback and the response unwinds back out through the same layers — which is why the Logger sees the final status of whatever any inner layer produced.
Running it
The example is exercised in-process with PAGI::Test::Client, which constructs the ($scope, $receive, $send) messages and invokes the app directly — no socket required.
use v5.40;
use lib 'lib';
use Test2::V0;
use JSON::MaybeXS qw(decode_json);
use PAGI::Test::Client;
my $app = do './app.pl';
my $client = PAGI::Test::Client->new(app => $app);
subtest 'no token: Authenticator short-circuits with 401' => sub {
my $res = $client->get('/api/profile');
is $res->status, 401, 'status is 401';
is $res->text, 'Unauthorized', 'body is Unauthorized';
};
subtest 'valid token + /api/profile: ProfileRouter answers with JSON' => sub {
my $res = $client->get('/api/profile',
headers => { 'X-Auth-Token' => 'secret-password-123' },
);
is $res->status, 200, 'status is 200';
is decode_json($res->text),
{
user_id => 456,
name => 'Alice Perl',
status => 'Fully Delegated Architecture',
},
'profile payload, with user_id injected by the Authenticator';
};
subtest 'valid token + unknown path: falls through to 404' => sub {
my $res = $client->get('/somewhere/else',
headers => { 'X-Auth-Token' => 'secret-password-123' },
);
is $res->status, 404, 'status is 404';
is $res->text, 'Resource Not Found', 'fallback body';
};
done_testing;
Test2::V0's is does a deep, structural comparison, so the whole profile payload is checked in one assertion instead of field by field.
Actual output (prove -v), with the middleware's own [LOG]/[AUTH]/[ROUTER] lines interleaved:
[LOG] Incoming: GET /api/profile
[AUTH] Access Denied. Short-circuiting.
[LOG] Outgoing Status: 401 (Processed in 0.0001 seconds)
ok 1 - no token: Authenticator short-circuits with 401 {
ok 1 - status is 401
ok 2 - body is Unauthorized
1..2
}
[LOG] Incoming: GET /api/profile
[AUTH] Valid token. Granting access to User #456.
[ROUTER] Handling /api/profile directly inside middleware.
[LOG] Outgoing Status: 200 (Processed in 0.0002 seconds)
ok 2 - valid token + /api/profile: ProfileRouter answers with JSON {
ok 1 - status is 200
ok 2 - profile payload, with user_id injected by the Authenticator
1..2
}
[LOG] Incoming: GET /somewhere/else
[AUTH] Valid token. Granting access to User #456.
[LOG] Outgoing Status: 404 (Processed in 0.0001 seconds)
ok 3 - valid token + unknown path: falls through to 404 {
ok 1 - status is 404
ok 2 - fallback body
1..2
}
1..3
ok
All tests successful.
The JSON body for the authorized profile request is, verbatim:
{"name":"Alice Perl","status":"Fully Delegated Architecture","user_id":456}
(PAGI::Response->json encodes with sorted keys, which is why they come out alphabetical.)
To run it against a real server instead of the test client, the same app.pl is a complete PAGI application — point a PAGI server at it:
pagi-server --app app.pl --port 5000
curl http://localhost:5000/api/profile # 401
curl -H 'X-Auth-Token: secret-password-123' http://localhost:5000/api/profile # JSON
How it lines up with the original
| Concern | Plack / PSGI | Starlette / ASGI | PAGI |
|---|---|---|---|
| Middleware unit |
Plack::Middleware + call($env)
|
BaseHTTPMiddleware + dispatch(request, call_next)
|
PAGI::Middleware + wrap($app) returning an async sub |
| Inner app handle | $self->app->($env) |
await call_next(request) |
await $app->($scope, $receive, $send) |
| Read a header | $req->header('X-Auth-Token') |
request.headers.get(...) |
$req->header('X-Auth-Token') |
| Inject context | $env->{'custom.user_id'} = 456 |
request.state.user_id = 456 |
$self->modify_scope($scope, { user_id => 456 }) |
| Short-circuit | return [401, …]
|
return PlainTextResponse(...)
|
build a PAGI::Response, ->respond($send), skip $app
|
| Compose stack | builder { enable '+...'; $fallback } |
Starlette(middleware => [...]) |
builder { enable '^...'; $fallback } |
The structure ports essentially one-to-one. The only conceptual shift from PSGI is the one the Python version already makes: responses are streamed events rather than a returned tuple, so "look at the response" means wrapping $send, and "context" lives on a copied scope rather than a mutated environment.
Top comments (0)