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 )