PAGI-Tools
view release on metacpan or search on metacpan
lib/PAGI/Tools/Cookbook.pod view on Meta::CPAN
my ($scope, $receive, $send) = @_;
my $ws = PAGI::WebSocket->new($scope, $receive, $send);
await $ws->accept;
await $ws->each_text(async sub {
my ($text) = @_;
await $ws->send_text("Echo: $text");
});
});
# WebSocket with path parameters
$router->websocket('/ws/chat/:room' => async sub {
my ($scope, $receive, $send) = @_;
my $ws = PAGI::WebSocket->new($scope, $receive, $send);
# Access room parameter
my $room = $scope->{path_params}{room};
await $ws->accept;
await $ws->send_text("Joined room: $room");
# ... chat logic ...
});
$router->to_app;
=head3 SSE Routes
Route Server-Sent Events by path:
use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::SSE;
my $router = PAGI::App::Router->new;
# SSE stream
$router->sse('/events' => async sub {
my ($scope, $receive, $send) = @_;
my $sse = PAGI::SSE->new($scope, $receive, $send);
await $sse->keepalive(30); # Prevent proxy timeouts
await $sse->send_event(data => "Connected at: " . time);
await $sse->run; # Wait for disconnect
});
# SSE with path parameters
$router->sse('/events/:channel' => async sub {
my ($scope, $receive, $send) = @_;
my $sse = PAGI::SSE->new($scope, $receive, $send);
# Access channel parameter
my $channel = $scope->{path_params}{channel};
await $sse->start;
await $sse->send_event(data => "Subscribed to: $channel");
# ... streaming logic ...
});
$router->to_app;
=head2 Route-Level Middleware
Apply middleware to specific routes by passing an arrayref:
use strict;
use warnings;
use Future::AsyncAwait;
use PAGI::App::Router;
use PAGI::Request;
use PAGI::Response;
my $router = PAGI::App::Router->new;
# Define middleware
my $auth_mw = sub {
my ($app) = @_;
async sub {
my ($scope, $receive, $send) = @_;
# Check authorization
my $token = '';
for my $h (@{$scope->{headers}}) {
if (lc($h->[0]) eq 'authorization') {
$token = $h->[1];
last;
}
}
unless ($token eq 'Bearer secret123') {
my $res = PAGI::Response->new($scope);
await $res->status(401)->json({ error => 'Unauthorized' })->respond($send);
return; # Don't call $app->()
}
# Authorized - continue to handler
await $app->($scope, $receive, $send);
};
};
# Apply middleware to specific routes
$router->get('/admin' => [$auth_mw] => async sub {
my ($scope, $receive, $send) = @_;
my $res = PAGI::Response->new($scope);
await $res->text('Admin panel')->respond($send);
});
# Multiple middleware
my $log_mw = sub {
my ($app) = @_;
async sub {
my ($scope, $receive, $send) = @_;
warn "Request: $scope->{method} $scope->{path}\n";
await $app->($scope, $receive, $send);
};
};
lib/PAGI/Tools/Cookbook.pod view on Meta::CPAN
# WebSocket with path parameters
async sub handle_chat {
my ($self, $ctx) = @_;
my $ws = $ctx->websocket;
# Access path parameter via $ws->path_param()
my $room = $ws->path_param('room');
await $ws->accept;
await $ws->send_text("Joined room: $room");
# ... chat logic ...
}
1;
=head3 SSE Handlers
SSE handlers receive C<($self, $ctx)>; call C<< $ctx->sse >> for the channel:
package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;
sub routes {
my ($self, $r) = @_;
$r->get('/' => 'home');
$r->sse('/events' => 'handle_events');
$r->sse('/events/:channel' => 'handle_channel');
}
async sub home {
my ($self, $ctx) = @_;
return $ctx->html('<h1>Home</h1>');
}
# SSE handler receives ($self, $ctx)
async sub handle_events {
my ($self, $ctx) = @_;
my $sse = $ctx->sse;
await $sse->keepalive(30);
await $sse->send_event(data => "Connected at: " . time);
await $sse->run;
}
# SSE with path parameters
async sub handle_channel {
my ($self, $ctx) = @_;
my $sse = $ctx->sse;
# Access path parameter
my $channel = $sse->path_param('channel');
await $sse->start;
await $sse->send_event(data => "Channel: $channel");
# ... streaming logic ...
}
1;
=head3 Initializing Resources
C<PAGI::Endpoint::Router> calls your C<routes> method once, when the app is
built, on the instance that lives for the app's lifetime. That makes C<routes>
the natural place to initialize per-worker resources and seed state: whatever
you store in C<< $self->state >> is visible to every handler.
package MyApp;
use parent 'PAGI::Endpoint::Router';
use strict;
use warnings;
use Future::AsyncAwait;
sub routes {
my ($self, $r) = @_;
# Initialize resources once, at build time
$self->state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
$self->state->{started_at} = time();
$r->get('/' => 'home');
}
async sub home {
my ($self, $ctx) = @_;
# Access state initialized in routes()
my $db = $self->state->{db};
my $uptime = time() - $self->state->{started_at};
return $ctx->json({
uptime => $uptime,
database => defined($db) ? 'connected' : 'disconnected',
});
}
1;
Note: C<$self-E<gt>state> is per-worker in multi-worker mode. For shared state, use an external store (Redis, database, etc.).
For B<asynchronous> startup and shutdown hooks -- running code on the
C<lifespan.startup> and C<lifespan.shutdown> protocol events -- wrap the app
with L<PAGI::Lifespan>, which runs C<startup>/C<shutdown> callbacks around the
application:
use PAGI::Lifespan;
my $app = PAGI::Lifespan->new(
startup => async sub ($state) {
$state->{db} = DBI->connect('dbi:SQLite:dbname=app.db');
},
shutdown => async sub ($state) {
$state->{db}->disconnect if $state->{db};
},
app => MyApp->to_app,
)->to_app;
lib/PAGI/Tools/Cookbook.pod view on Meta::CPAN
use IO::File::WithPath;
# This works - IO::File::WithPath provides the path method
my $fh = IO::File::WithPath->new('/path/to/file.bin', 'r');
await $send->({ type => 'http.response.body', fh => $fh });
# This does NOT trigger X-Sendfile (no path method)
open my $plain_fh, '<', '/path/to/file.bin';
await $send->({ type => 'http.response.body', fh => $plain_fh });
For plain filehandles, either use C<file =E<gt> $path> directly, or use
L<IO::File::WithPath>. See L<PAGI::Middleware::XSendfile> for details.
=head1 RESPONSE STATE & LIFECYCLE
A C<PAGI::Response> is a detached value: building it is fully separate from
sending it. That split means there is no single "is this done?" flag â instead
there are a few distinct signals, each answering a different question. This
section maps them so you can tell, at any point, what is going on.
=head2 The three phases
A response moves through three phases. Each has its own signal:
=over 4
=item 1. B<Build> â the handler accumulates status, headers, and a body source
on the value. No connection, no I/O. The signal here is L<PAGI::Response/has_body_source>:
"has a body source been registered?"
=item 2. B<Send committed> â C<< $ctx->respond($res) >> (or a raw
C<< $res->respond($send) >> behind the context's guard) has begun emitting the
response and has taken the connection. The signal is L<PAGI::Response/is_sent>:
"has this response gone out?" It is set at the moment the send is committed, so
a second send is rejected.
=item 3. B<Finished> â every byte has been emitted; for a stream, the callback
has run to completion and the writer is closed. There is no flag for this: it is
the resolution of the Future returned by C<respond>. C<await>-ing the send B<is>
waiting for "finished".
=back
The reason "finished" is a Future and not a flag is the same reason the value is
detached: PAGI never models a half-sent live byte-sink as state on the value.
"Committed" is a flag; "finished" is the thing you C<await>.
=head2 The signals you can inspect
Signal Phase Question it answers
-------------------------- ------------- -------------------------------------
has_status build Was a status code set explicitly?
has_header($name) build Was this header set?
has_content_type build Was Content-Type set?
has_body_source build Was a body (bytes/file/stream) set?
is_sent send Has the response been emitted?
(Future from respond) finished Has every byte been written?
$writer->bytes_written finished* How many bytes streamed so far?
$writer->is_closed finished* Has the stream writer been closed?
The C<$writer> signals (*) apply only while streaming, on the
L<PAGI::Response::Writer> handed to your C<stream> callback or returned by
C<writer>. They describe the live stream; the response value itself only carries
the build and send signals.
=head2 has_body_source is intent, not bytes
The signal most likely to surprise you is C<has_body_source>. It reports that a
body B<source> is registered â not that bytes exist, and not that anything was
sent. For a stream this matters: the callback is stored at build time and does
not run until C<respond>, so a freshly-registered stream has produced zero bytes
yet C<has_body_source> is already true. That is deliberate and is the only
coherent meaning â C<respond> is what drives the stream, so "are there bytes
yet?" is unanswerable at build time and irrelevant to "is there a body to send?"
This mirrors the distinction Node.js draws between C<writableEnded> ("the
producer declared it is done") and C<writableFinished> ("all data has been
flushed"). C<has_body_source> is the former, decoupled from any I/O;
C<await>-ing C<respond> is the latter.
Intentional empty bodies count as a registered source: C<empty>, C<redirect>,
and C<send_raw('')> all set an (empty) body, so C<has_body_source> is true for
them. Only a response that never had a body method called reports false.
=head2 Deciding "did the handler produce a response?"
A framework that lets handlers populate C<< $ctx->res >> and then auto-sends it
needs to know, after the handler returns, whether to send, skip, or fall through
to the next route. That is a three-way decision, and it is a precedence ladder â
not a single predicate:
# After running the handler, which mutated $ctx->res:
if ($ctx->res->is_sent) {
# The handler already took the connection itself â e.g. it called
# ->writer($send) for live streaming, or used SSE/WebSocket. Do nothing;
# sending again would be a double-send.
}
elsif ($ctx->res->has_body_source || $ctx->res->has_status) {
# The handler registered a body, or set a status (a bare 204 / redirect
# has no body but IS a response). Send it once, through the guard.
await $ctx->respond($ctx->res);
}
else {
# The handler touched nothing send-able â fall through to the next match
# (or a 404).
}
Three things make this ladder correct:
=over 4
=item * B<Check C<is_sent> first.> A handler that called C<< ->writer($send) >>
has already emitted headers (C<writer> marks the response sent), but
C<has_body_source> stays false because the live-writer path bypasses the body
slots. Without the C<is_sent> guard you would mistake an actively-streaming
response for "nothing produced".
=item * B<C<|| has_status> is required.> A C<302> redirect or a C<204> can have
no body source, yet it is a real response. C<has_status> catches the
status-only case so you do not 404 a legitimate empty-body response.
=item * B<Never inspect the private slots.> Use C<has_body_source>, not
C<< exists $res->{_stream} >> and friends â those are private and may change
(see L<PAGI::Response/SUBCLASSING (FRAMEWORK INTEGRATION)>). The predicate is
the supported way to ask.
=back
If instead your framework has handlers B<return> the response (PAGI's own
endpoint contract), you do not need this ladder at all: the returned value is
the answer, and "fall through" is an explicit sentinel you define, not an
inference from an empty response.
=head1 TESTING
Use L<PAGI::Test::Client> to test apps directly without a running server:
use strict;
use warnings;
use Test2::V0;
use PAGI::Test::Client;
# Load your app
my $app = require './app.pl';
my $client = PAGI::Test::Client->new(app => $app);
subtest 'GET /' => sub {
my $res = $client->get('/');
is $res->status, 200, 'status is 200';
like $res->text, qr/Hello/, 'body contains Hello';
};
subtest 'POST /api/users' => sub {
my $res = $client->post('/api/users',
json => { name => 'Alice' },
);
is $res->status, 201, 'status is 201';
is $res->json->{id}, 1, 'returns user id';
};
subtest 'form submission' => sub {
my $res = $client->post('/login',
form => { user => 'admin', pass => 'secret' },
);
is $res->status, 302, 'redirects after login';
# Session cookies persist across requests
my $dashboard = $client->get('/dashboard');
is $dashboard->status, 200, 'authenticated access';
};
subtest 'custom headers' => sub {
my $res = $client->get('/api/data',
headers => { Authorization => 'Bearer token123' },
);
( run in 1.474 second using v1.01-cache-2.11-cpan-6aa56a78535 )