File Coverage

blib/lib/PAGI/Context.pm
Criterion Covered Total %
statement 147 147 100.0
branch 36 38 94.7
condition 46 59 77.9
subroutine 41 41 100.0
pod 12 31 38.7
total 282 316 89.2


line stmt bran cond sub pod time code
1             package PAGI::Context;
2              
3 18     18   1604409 use strict;
  18         24  
  18         675  
4 18     18   94 use warnings;
  18         34  
  18         845  
5 18     18   70 use Carp qw(croak);
  18         29  
  18         862  
6 18     18   69 use Scalar::Util qw(blessed);
  18         19  
  18         598  
7 18     18   903 use Future::AsyncAwait;
  18         33041  
  18         96  
8 18     18   753 use Future;
  18         23  
  18         44014  
9              
10             =head1 NAME
11              
12             PAGI::Context - Per-request context with protocol-specific subclasses
13              
14             =head1 SYNOPSIS
15              
16             use PAGI::Context;
17             use Future::AsyncAwait;
18              
19             # Factory returns the right subclass based on scope type
20             my $ctx = PAGI::Context->new($scope, $receive, $send);
21              
22             # Shared methods (all protocol types)
23             my $type = $ctx->type; # 'http', 'websocket', 'sse'
24             my $path = $ctx->path;
25             my $stash = $ctx->stash; # PAGI::Stash
26             my $session = $ctx->session; # PAGI::Session
27              
28             # WebSocket context - protocol ops directly on $ctx
29             await $ctx->accept;
30             await $ctx->send_json({ msg => 'hello' });
31             my $text = await $ctx->receive_text;
32             await $ctx->close;
33              
34             # SSE context - same idea
35             await $ctx->send_event(event => 'update', data => $payload);
36             await $ctx->keepalive(25);
37              
38             # Event dispatcher - works on any protocol type
39             my $reason = await $ctx
40             ->on('websocket.receive', async sub { ... })
41             ->on('chat.message', async sub { ... })
42             ->on_error(sub { ... })
43             ->run; # returns 'disconnect', 'stop', or 'error'
44              
45             # Underlying protocol objects still available
46             my $ws = $ctx->websocket; # PAGI::WebSocket (WS only)
47             my $sse = $ctx->sse; # PAGI::SSE (SSE only)
48             my $req = $ctx->request; # PAGI::Request (HTTP only)
49             my $res = $ctx->response; # PAGI::Response (HTTP only)
50              
51             =head1 DESCRIPTION
52              
53             PAGI::Context is a factory and base class that provides a unified entry
54             point for per-request context. Calling C<< PAGI::Context->new(...) >>
55             inspects C<< $scope->{type} >> and returns the appropriate subclass:
56             L, L, or
57             L.
58              
59             Shared methods (scope accessors, stash, session, event dispatcher) live
60             on the base class. Protocol-specific methods are delegated from
61             subclasses so you can use C<$ctx> as your single object:
62              
63             # Instead of:
64             my $ws = $ctx->websocket;
65             await $ws->send_json($data); # closes over $ws in every handler
66              
67             # Just do:
68             await $ctx->send_json($data); # $ctx is already in scope
69              
70             =head2 Protocol Shape
71              
72             Each context type has a different set of available methods. Calling a
73             method that belongs to a different protocol type raises a standard Perl
74             C error.
75              
76             Method HTTP WebSocket SSE
77             ────────────────── ────── ────────── ──────
78             request, response yes - -
79             method yes - -
80             accept - yes -
81             send_text - yes -
82             send_bytes - yes -
83             send_json - yes yes
84             send - - yes
85             send_event - - yes
86             send_comment - - yes
87             start - - yes
88             close - yes yes
89             query / query_param - yes(query) yes(query_param)
90             is_connected base* WS override -
91             is_closed - yes yes
92             is_started - - yes
93             keepalive - yes yes
94             each_text, etc. - yes -
95             each, every - - yes
96              
97             *is_connected on WebSocket contexts checks WS handshake state,
98             not the TCP-level pagi.connection that the base class uses.
99              
100             See L and L for the
101             full method reference on each subclass.
102              
103             =head1 EXTENSIBILITY
104              
105             Override C<_type_map> to add or replace protocol types:
106              
107             package MyApp::Context;
108             our @ISA = ('PAGI::Context');
109              
110             sub _type_map {
111             my ($class) = @_;
112             return {
113             %{ $class->SUPER::_type_map },
114             grpc => 'MyApp::Context::GRPC',
115             };
116             }
117              
118             Override C<_resolve_class> for custom resolution logic beyond the type map.
119              
120             =head1 CONSTRUCTOR
121              
122             =head2 new
123              
124             my $ctx = PAGI::Context->new($scope, $receive, $send);
125              
126             Factory constructor. Returns a subclass instance based on
127             C<< $scope->{type} >>. Defaults to HTTP if type is missing or unknown.
128              
129             =cut
130              
131             sub new {
132 177     177 1 2040541 my ($class, $scope, $receive, $send) = @_;
133 177         526 my $subclass = $class->_resolve_class($scope);
134 177         778 return bless {
135             scope => $scope,
136             receive => $receive,
137             send => $send,
138             }, $subclass;
139             }
140              
141             =head1 CLASS METHODS
142              
143             =head2 _type_map
144              
145             my $map = PAGI::Context->_type_map;
146              
147             Returns a hashref mapping scope type strings to subclass package names.
148             Override in a subclass to add or replace protocol types.
149              
150             =cut
151              
152             sub _type_map {
153             return {
154 184     184   184272 http => 'PAGI::Context::HTTP',
155             websocket => 'PAGI::Context::WebSocket',
156             sse => 'PAGI::Context::SSE',
157             };
158             }
159              
160             =head2 _resolve_class
161              
162             my $class = PAGI::Context->_resolve_class($scope);
163              
164             Resolves the scope to a subclass package name. Looks up
165             C<< $scope->{type} >> in C<_type_map>; defaults to the C mapping
166             if the type is missing or unknown. Override for custom resolution logic.
167              
168             =cut
169              
170             sub _resolve_class {
171 181     181   2825 my ($class, $scope) = @_;
172 181   100     444 my $type = $scope->{type} // 'http';
173 181   66     382 return $class->_type_map->{$type} // $class->_type_map->{http};
174             }
175              
176             =head1 METHODS
177              
178             =head2 Scope Accessors
179              
180             $ctx->scope; # raw $scope hashref
181             $ctx->type; # $scope->{type}
182             $ctx->path; # $scope->{path}
183             $ctx->raw_path; # $scope->{raw_path} // $scope->{path}
184             $ctx->query_string; # $scope->{query_string} // ''
185             $ctx->scheme; # $scope->{scheme} // 'http'
186             $ctx->client; # $scope->{client}
187             $ctx->server; # $scope->{server}
188             $ctx->headers; # $scope->{headers} arrayref of [name, value]
189              
190             =cut
191              
192 1     1 0 14 sub scope { shift->{scope} }
193 36     36 0 1575 sub type { shift->{scope}{type} }
194 4     4 0 19 sub path { shift->{scope}{path} }
195 3   66 3 0 9 sub raw_path { my $s = shift; $s->{scope}{raw_path} // $s->{scope}{path} }
  3         20  
196 2   100 2 0 14 sub query_string { shift->{scope}{query_string} // '' }
197 2   100 2 0 13 sub scheme { shift->{scope}{scheme} // 'http' }
198 1     1 0 6 sub client { shift->{scope}{client} }
199 1     1 0 5 sub server { shift->{scope}{server} }
200 1     1 0 5 sub headers { shift->{scope}{headers} }
201              
202             =head2 Path Parameters
203              
204             my $params = $ctx->path_params; # hashref
205             my $id = $ctx->path_param('id'); # strict: dies if missing
206             my $id = $ctx->path_param('id', strict => 0); # returns undef
207              
208             C returns the C<< $scope->{path_params} >> hashref (set by
209             the router), defaulting to C<{}> if not present.
210              
211             C returns a single parameter by name. By default it dies if
212             the key is not found (strict mode). Pass C<< strict => 0 >> to return
213             C for missing keys instead.
214              
215             =cut
216              
217             sub path_params {
218 8     8 0 23 my ($self) = @_;
219 8   100     30 return $self->{scope}{path_params} // {};
220             }
221              
222             sub path_param {
223 6     6 0 456 my ($self, $name, %opts) = @_;
224 6 100       14 my $strict = exists $opts{strict} ? $opts{strict} : 1;
225 6         13 my $params = $self->path_params;
226              
227 6 100 100     22 if ($strict && !exists $params->{$name}) {
228 1         5 my @available = sort keys %$params;
229 1 50       11 die "path_param '$name' not found. "
230             . (@available ? "Available: " . join(', ', @available) : "No path params set")
231             . "\n";
232             }
233              
234 5         20 return $params->{$name};
235             }
236              
237             =head2 Protocol Introspection
238              
239             $ctx->is_http; # true if type eq 'http'
240             $ctx->is_websocket; # true if type eq 'websocket'
241             $ctx->is_sse; # true if type eq 'sse'
242              
243             =cut
244              
245 5   50 5 0 46 sub is_http { (shift->{scope}{type} // '') eq 'http' }
246 5   50 5 0 26 sub is_websocket { (shift->{scope}{type} // '') eq 'websocket' }
247 4   50 4 0 43 sub is_sse { (shift->{scope}{type} // '') eq 'sse' }
248              
249             =head2 header
250              
251             my $value = $ctx->header('Content-Type');
252              
253             Returns the last value for the named header (case-insensitive), or
254             C if not found.
255              
256             =cut
257              
258             sub header {
259 12     12 1 60 my ($self, $name) = @_;
260 12         26 $name = lc($name);
261 12         38 my $value;
262 12   50     16 for my $pair (@{$self->{scope}{headers} // []}) {
  12         39  
263 25 100       47 if (lc($pair->[0]) eq $name) {
264 10         19 $value = $pair->[1];
265             }
266             }
267 12         31 return $value;
268             }
269              
270             =head2 receive
271              
272             my $receive = $ctx->receive;
273              
274             Returns the raw C<$receive> coderef. Calling it returns a L that
275             resolves to the next protocol event hashref from the client.
276              
277             # Read an HTTP request body event
278             my $event = await $ctx->receive->();
279             # $event = { type => 'http.request', body => '...' }
280              
281             # Read a WebSocket message
282             my $msg = await $ctx->receive->();
283             # $msg = { type => 'websocket.receive', text => 'hello' }
284              
285             Most users should prefer the protocol helpers (C<< $ctx->request >>,
286             C<< $ctx->websocket >>, C<< $ctx->sse >>) which handle the event
287             protocol internally. Use C only for raw protocol access.
288              
289             =head2 send
290              
291             my $send = $ctx->send;
292              
293             Returns the raw C<$send> coderef. Calling it with an event hashref
294             returns a L that resolves when the event has been sent.
295              
296             # Send an HTTP response (two events: start + body)
297             await $ctx->send->({ type => 'http.response.start', status => 200,
298             headers => [['content-type', 'text/plain']] });
299             await $ctx->send->({ type => 'http.response.body', body => 'Hello' });
300              
301             # Accept a WebSocket connection
302             await $ctx->send->({ type => 'websocket.accept' });
303              
304             Most users should prefer the protocol helpers (C<< $ctx->response >>,
305             C<< $ctx->websocket >>, C<< $ctx->sse >>) which build and send events
306             for you. Use C only for raw protocol access.
307              
308             =cut
309              
310 1     1 1 40 sub receive { shift->{receive} }
311 1     1 1 4 sub send { shift->{send} }
312              
313             =head2 stash
314              
315             my $stash = $ctx->stash; # PAGI::Stash instance
316              
317             Returns a L wrapping C<< $scope->{'pagi.stash'} >>.
318             Lazy-constructed and cached.
319              
320             =head2 session
321              
322             my $session = $ctx->session; # PAGI::Session instance
323              
324             Returns a L wrapping C<< $scope->{'pagi.session'} >>.
325             Lazy-constructed and cached. Dies if session middleware has not run.
326             Use C to check availability first.
327              
328             =head2 has_session
329              
330             if ($ctx->has_session) {
331             my $user_id = $ctx->session->get('user_id');
332             }
333              
334             Returns true if session middleware has populated C<< $scope->{'pagi.session'} >>.
335              
336             =head2 state
337              
338             my $state = $ctx->state; # hashref
339              
340             Returns C<< $scope->{state} >> - the app/endpoint-level shared state.
341              
342             =cut
343              
344             sub stash {
345 17     17 1 1871 my ($self) = @_;
346 17   66     93 return $self->{_stash} //= do {
347 14         1863 require PAGI::Stash;
348 14         70 PAGI::Stash->new($self->{scope});
349             };
350             }
351              
352             sub session {
353 3     3 1 25 my ($self) = @_;
354 3   100     11 return $self->{_session} //= do {
355 2         10 require PAGI::Session;
356 2         10 PAGI::Session->new($self->{scope});
357             };
358             }
359              
360             sub has_session {
361 2     2 1 12 my ($self) = @_;
362 2 100       8 return exists $self->{scope}{'pagi.session'} ? 1 : 0;
363             }
364              
365             sub state {
366 11     11 1 45 my ($self) = @_;
367 11   100     67 return $self->{scope}{state} // {};
368             }
369              
370             =head2 Connection State
371              
372             $ctx->connection; # PAGI::Server::ConnectionState object
373             $ctx->is_connected; # boolean
374             $ctx->is_disconnected; # boolean
375             $ctx->disconnect_reason; # string or undef
376             $ctx->on_disconnect($cb); # register callback
377              
378             Delegates to C<< $scope->{'pagi.connection'} >>.
379              
380             =cut
381              
382             sub connection {
383 11     11 0 21 my ($self) = @_;
384 11         19 return $self->{scope}{'pagi.connection'};
385             }
386              
387             sub is_connected {
388 6     6 0 294 my ($self) = @_;
389 6         9 my $conn = $self->connection;
390 6 100       21 return 0 unless $conn;
391 4         7 return $conn->is_connected;
392             }
393              
394             sub is_disconnected {
395 3     3 0 358 my ($self) = @_;
396 3         6 return !$self->is_connected;
397             }
398              
399             sub disconnect_reason {
400 3     3 0 330 my ($self) = @_;
401 3         5 my $conn = $self->connection;
402 3 100       7 return undef unless $conn;
403 2         5 return $conn->disconnect_reason;
404             }
405              
406             sub on_disconnect {
407 1     1 0 272 my ($self, $cb) = @_;
408 1         32 my $conn = $self->connection;
409 1 50       3 return unless $conn;
410 1         4 $conn->on_disconnect($cb);
411             }
412              
413             =head1 EVENT DISPATCHER
414              
415             The event dispatcher provides a generic, protocol-agnostic way to handle
416             PAGI events. It is most useful when the receive stream carries a mix of
417             protocol events and application-level events injected by middleware such
418             as C.
419              
420             my $ctx = PAGI::Context->new($scope, $receive, $send);
421              
422             $ctx->on('websocket.receive', async sub {
423             my ($ctx, $event) = @_;
424             my $text = $event->{text} // '';
425             await $ctx->send->({ type => 'websocket.send', text => "echo: $text" });
426             });
427              
428             $ctx->on('chat.message', async sub {
429             my ($ctx, $event) = @_;
430             # handle a channel-injected event
431             });
432              
433             $ctx->on_error(sub {
434             my ($ctx, $error, $source) = @_;
435             warn "[$source] $error";
436             });
437              
438             my $reason = await $ctx->run; # 'disconnect', 'stop', or 'error'
439              
440             =head2 on
441              
442             $ctx->on($event_type, $callback); # returns $ctx
443              
444             Register a handler for a raw PAGI event type string. Multiple handlers
445             may be registered for the same type; they are called in registration order.
446             Handlers receive C<($ctx, $event)>. Handlers may be plain coderefs or
447             Cs; if a handler returns a L, C awaits it before
448             continuing.
449              
450             Returns C<$ctx> for chaining.
451              
452             =head2 on_error
453              
454             $ctx->on_error($callback); # returns $ctx
455              
456             Register an error callback. It is called when C<$receive-E()> fails
457             (C<$source = 'receive'>) or when a registered handler throws (C<$source =
458             'handler'>). Callbacks receive C<($ctx, $error, $source)>.
459              
460             Multiple callbacks may be registered and are called in order. Callbacks
461             may be Cs; if a callback returns a L, it is awaited.
462             If no callbacks are registered, errors are emitted via C.
463              
464             Returns C<$ctx> for chaining.
465              
466             # Avoid circular references - weaken if the callback closes over $ctx
467             use Scalar::Util qw(weaken);
468             my $weak = $ctx;
469             weaken $weak;
470             $ctx->on_error(sub { my ($ctx, $err, $src) = @_; warn "[$src] $err" });
471              
472             =head2 stop
473              
474             $ctx->stop; # returns $ctx
475              
476             Signal the C loop to exit cleanly after the current handler
477             finishes. C will resolve with reason C<'stop'>.
478              
479             Returns C<$ctx> for chaining.
480              
481             =head2 run
482              
483             my $reason = await $ctx->run;
484              
485             Start the event dispatch loop. Reads events from the receive stream and
486             dispatches each to registered handlers. The loop runs until one of:
487              
488             =over 4
489              
490             =item * The protocol's terminal disconnect event arrives (C,
491             C, C) - resolves with C<'disconnect'>
492              
493             =item * C was called - resolves with C<'stop'>
494              
495             =item * C<$receive-E()> fails - fires C callbacks and resolves
496             with C<'error'>
497              
498             =back
499              
500             C always resolves successfully (never rejects). The caller does
501             not need to C it.
502              
503             Calling C a second time while already running throws synchronously.
504              
505             When run() resolves, all registered handlers and error callbacks are
506             cleared to break closure-based reference cycles.
507              
508             =cut
509              
510             # ---------------------------------------------------------------------------
511             # Event dispatcher - on(), on_error(), stop(), run()
512             # ---------------------------------------------------------------------------
513              
514             # Terminal event type by scope type (websocket.*, sse.*, http.* are reserved)
515             my %_TERMINAL = (
516             websocket => 'websocket.disconnect',
517             sse => 'sse.disconnect',
518             http => 'http.disconnect',
519             );
520              
521             sub _terminal_event_type {
522 32     32   36 my ($self) = @_;
523 32   50     55 return $_TERMINAL{ $self->type // '' };
524             }
525              
526             # Register a handler for a raw PAGI event type string.
527             # Returns $self for chaining.
528             sub on {
529 26     26 1 443 my ($self, $type, $cb) = @_;
530 26         29 push @{ $self->{_handlers}{$type} }, $cb;
  26         56  
531 26         36 return $self;
532             }
533              
534             # Register an error handler. Called for both $receive->() failures
535             # (source='receive') and handler exceptions (source='handler').
536             # Returns $self for chaining.
537             sub on_error {
538 13     13 1 96 my ($self, $cb) = @_;
539 13         24 push @{ $self->{_on_error} }, $cb;
  13         23  
540 13         30 return $self;
541             }
542              
543             # Signal the run() loop to exit after the current handler finishes.
544             sub stop {
545 2     2 1 11 my ($self) = @_;
546 2         4 $self->{_stopped} = 1;
547 2         2 return $self;
548             }
549              
550             # Internal: fire error callbacks with ($ctx, $error, $source).
551 12     12   16 async sub _trigger_ctx_error {
552 12         24 my ($self, $error, $source) = @_;
553              
554 12         11 for my $cb (@{ $self->{_on_error} }) {
  12         23  
555 11         19 eval {
556 11         23 my $r = $cb->($self, $error, $source);
557 10 100 66     102 if (blessed($r) && $r->isa('Future')) {
558 2         8 await $r;
559             }
560             };
561 11 100       50 if ($@) {
562 2         13 warn "PAGI::Context on_error callback error: $@";
563             }
564             }
565              
566 12 100       14 if (!@{ $self->{_on_error} }) {
  12         56  
567 4         34 warn "PAGI::Context error ($source): $error";
568             }
569             }
570              
571             # Run the event dispatch loop.
572             # Always resolves (never rejects). Returns reason: 'disconnect', 'stop', 'error'.
573 33     33 1 205 async sub run {
574 33         41 my ($self) = @_;
575              
576             croak "PAGI::Context run() called while already running"
577 33 100       173 if $self->{_running};
578              
579 32         50 $self->{_running} = 1;
580 32         59 $self->{_stopped} = 0;
581 32   100     101 $self->{_on_error} //= [];
582              
583 32         37 my $reason = 'stop';
584 32         60 my $terminal = $self->_terminal_event_type;
585              
586 32         63 LOOP: while (!$self->{_stopped}) {
587 49         43 my $event = eval { await $self->{receive}->() };
  49         79  
588 49 100       3096 if (my $err = $@) {
589 9         32 await $self->_trigger_ctx_error($err, 'receive');
590 9         307 $reason = 'error';
591 9         18 last LOOP;
592             }
593              
594 40   50     90 my $type = $event->{type} // '';
595              
596             # Snapshot before iterating - on() calls from inside a handler
597             # must not affect the current iteration.
598 40   100     31 my @handlers = @{ $self->{_handlers}{$type} // [] };
  40         135  
599              
600 40 100 66     113 if (@handlers) {
    100 100        
601 19         69 for my $cb (@handlers) {
602 21         23 eval {
603 21         55 my $r = $cb->($self, $event);
604 18 100 100     109 if (blessed($r) && $r->isa('Future')) {
605 1         3 await $r;
606             }
607             };
608 21 100       63 if (my $err = $@) {
609 3         7 await $self->_trigger_ctx_error($err, 'handler');
610             }
611             }
612             } elsif ($ENV{PAGI_DEBUG} && !($terminal && $type eq $terminal)) {
613 1         9 warn "PAGI::Context: unhandled event type '$type'\n";
614             }
615              
616 40 100 66     223 if ($terminal && $type eq $terminal) {
617 21         23 $reason = 'disconnect';
618 21         48 last LOOP;
619             }
620             }
621              
622 32 100 66     67 $reason = 'stop' if $self->{_stopped} && $reason eq 'stop';
623              
624             # Clear callbacks to break any closure-based reference cycles.
625 32         116 $self->{_handlers} = {};
626 32         80 $self->{_on_error} = [];
627 32         36 $self->{_running} = 0;
628 32         30 $self->{_stopped} = 0;
629              
630 32         122 return $reason;
631             }
632              
633             # Load subclasses
634             require PAGI::Context::HTTP;
635             require PAGI::Context::WebSocket;
636             require PAGI::Context::SSE;
637              
638             1;
639              
640             __END__