DEV Community

John Napiorkowski
John Napiorkowski

Posted on

Perl PAGI Middleware

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:

  1. Logger — wrap the request, time it, log method/path in and status/duration out.
  2. Authenticator — inspect a header, inject context for downstream layers on success, or short-circuit with a 401 on failure.
  3. 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 ...
    };
}
Enter fullscreen mode Exit fullscreen mode

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 ...
    };
}
Enter fullscreen mode Exit fullscreen mode

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 sets request.state.user_id. PAGI uses modify_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 subclasses BaseHTTPMiddleware and overrides dispatch(request, call_next) — which reads beautifully: you await call_next(request) and get a Response back. The ergonomics are lovely; the trouble is how Starlette implements call_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. A call_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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

$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);
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

The JSON body for the authorized profile request is, verbatim:

{"name":"Alice Perl","status":"Fully Delegated Architecture","user_id":456}
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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)