File Coverage

blib/lib/PAGI/Endpoint/Router.pm
Criterion Covered Total %
statement 148 182 81.3
branch 21 32 65.6
condition 4 6 66.6
subroutine 36 48 75.0
pod 4 5 80.0
total 213 273 78.0


line stmt bran cond sub pod time code
1             package PAGI::Endpoint::Router;
2             $PAGI::Endpoint::Router::VERSION = '0.002001';
3 5     5   173856 use strict;
  5         19  
  5         186  
4 5     5   32 use warnings;
  5         25  
  5         219  
5              
6 5     5   25 use Future::AsyncAwait;
  5         8  
  5         34  
7 5     5   265 use Carp qw(croak);
  5         12  
  5         293  
8 5     5   24 use Scalar::Util qw(blessed);
  5         7  
  5         186  
9 5     5   2514 use Module::Load qw(load);
  5         8936  
  5         54  
10              
11              
12             sub new {
13 25     25 0 231646 my ($class, %args) = @_;
14 25         122 return bless {
15             _state => {},
16             }, $class;
17             }
18              
19             # Worker-local state (NOT shared across workers)
20             sub state {
21 6     6 1 32 my ($self) = @_;
22 6         33 return $self->{_state};
23             }
24              
25             # Override in subclass to use a custom context class
26 44     44 1 73 sub context_class { 'PAGI::Context' }
27              
28             # Override in subclass to define routes
29             sub routes {
30 1     1 1 7 my ($self, $r) = @_;
31             # Default: no routes
32             }
33              
34              
35             sub to_app {
36 23     23 1 228919 my ($class) = @_;
37              
38             # Create instance that lives for app lifetime
39 23 100       132 my $instance = blessed($class) ? $class : $class->new;
40              
41             # Build internal router
42 23         102 load('PAGI::App::Router');
43 23         1526 my $internal_router = PAGI::App::Router->new;
44              
45             # Let subclass define routes
46 23         165 $instance->_build_routes($internal_router);
47              
48 20         59 my $app = $internal_router->to_app;
49 20         68 my $state = $instance->{_state};
50              
51 31     31   5025 return async sub {
52 31         46 my ($scope, $receive, $send) = @_;
53              
54             # Inject instance state into scope (allows $ctx->state to work)
55 31   66     116 $scope->{state} //= $state;
56              
57             # HTTP route apps respond at their own boundary (via _build_value_app).
58             # WS/SSE/mount/404/405 all handle their own sending.
59 31         73 await $app->($scope, $receive, $send);
60 20         98 };
61             }
62              
63             sub _build_routes {
64 23     23   50 my ($self, $r) = @_;
65              
66             # Create a wrapper router that intercepts route registration
67 23         115 my $wrapper = PAGI::Endpoint::Router::RouteBuilder->new($self, $r);
68 23         94 $self->routes($wrapper);
69             }
70              
71             # Internal route builder that wraps handlers
72             package PAGI::Endpoint::Router::RouteBuilder;
73             $PAGI::Endpoint::Router::RouteBuilder::VERSION = '0.002001';
74 5     5   2562 use strict;
  5         10  
  5         100  
75 5     5   16 use warnings;
  5         8  
  5         214  
76 5     5   24 use Future::AsyncAwait;
  5         8  
  5         27  
77 5     5   237 use Carp qw(croak);
  5         11  
  5         295  
78 5     5   1676 use PAGI::Utils qw(is_response);
  5         13  
  5         14205  
79              
80             sub new {
81 23     23   54 my ($class, $endpoint, $router) = @_;
82 23         81 return bless {
83             endpoint => $endpoint,
84             router => $router,
85             }, $class;
86             }
87              
88             # HTTP methods
89 37     37   227 sub get { shift->_add_http_route('GET', @_) }
90 1     1   2 sub post { shift->_add_http_route('POST', @_) }
91 0     0   0 sub put { shift->_add_http_route('PUT', @_) }
92 0     0   0 sub patch { shift->_add_http_route('PATCH', @_) }
93 0     0   0 sub delete { shift->_add_http_route('DELETE', @_) }
94 0     0   0 sub head { shift->_add_http_route('HEAD', @_) }
95 0     0   0 sub options { shift->_add_http_route('OPTIONS', @_) }
96              
97             sub _add_http_route {
98 38     38   90 my ($self, $method, $path, @rest) = @_;
99              
100 38         88 my ($middleware, $handler) = $self->_parse_route_args(@rest);
101              
102 38         85 my $app = $self->_build_value_app($middleware, $handler);
103              
104 37         67 my $router_method = lc($method);
105 37         112 $self->{router}->$router_method($path, $app);
106              
107 37         116 return $self;
108             }
109              
110             sub _parse_route_args {
111 46     46   89 my ($self, @args) = @_;
112              
113 46 100 66     194 if (@args == 2 && ref($args[0]) eq 'ARRAY') {
    50          
114 27         71 return ($args[0], $args[1]);
115             }
116             elsif (@args == 1) {
117 19         54 return ([], $args[0]);
118             }
119             else {
120 0         0 die "Invalid route arguments";
121             }
122             }
123              
124             sub _build_value_app {
125 38     38   57 my ($self, $method_mw, $handler) = @_;
126              
127 38         78 my $endpoint = $self->{endpoint};
128 38         97 my $ctx_class = $endpoint->context_class;
129              
130 38         74 my @mw = map { $self->_resolve_value_mw($_) } @$method_mw; # async sub($ctx,$next)->Response
  26         50  
131 37         78 my $handler_code = $self->_resolve_handler($handler); # async sub($ctx)->Response
132              
133 22     22   27 return async sub {
134 22         66 my ($scope, $receive, $send) = @_;
135              
136 22         1667 require PAGI::Context;
137 22         95 my $ctx = $ctx_class->new($scope, $receive, $send);
138              
139 22         78 my $next = async sub { await $handler_code->($ctx) };
  19         39  
  19         63  
140 22         42 for my $m (reverse @mw) {
141 12         15 my $inner = $next;
142 12         37 $next = async sub { await $m->($ctx, $inner) };
  12         20  
  12         36  
143             }
144              
145 22         55 await $ctx->respond(await $next->());
146 37         191 };
147             }
148              
149             sub _resolve_handler {
150 37     37   188 my ($self, $handler) = @_;
151              
152 37         63 my $endpoint = $self->{endpoint};
153              
154 37 50       66 if (!ref($handler)) {
155 37 50       116 my $method = $endpoint->can($handler)
156             or die "No such method: $handler in " . ref($endpoint);
157 19     19   33 return async sub {
158 19         29 my ($ctx) = @_;
159 19         56 my $res = await $endpoint->$method($ctx);
160 19 50       899 croak "handler did not return a response"
161             unless is_response($res);
162 19         59 return $res;
163 37         131 };
164             }
165              
166 0     0   0 return async sub {
167 0         0 my ($ctx) = @_;
168 0         0 my $res = await $handler->($ctx);
169 0 0       0 croak "handler did not return a response"
170             unless is_response($res);
171 0         0 return $res;
172 0         0 };
173             }
174              
175             sub websocket {
176 4     4   31 my ($self, $path, @rest) = @_;
177              
178 4         16 my ($middleware, $handler) = $self->_parse_route_args(@rest);
179              
180 4 100       25 die "WebSocket routes do not support route-level middleware; "
181             . "apply event middleware at the mount or group level\n"
182             if @$middleware;
183              
184 3         9 my $wrapped = $self->_wrap_websocket_handler($handler);
185              
186 3         14 $self->{router}->websocket($path, $wrapped);
187              
188 3         10 return $self;
189             }
190              
191             sub _wrap_websocket_handler {
192 3     3   7 my ($self, $handler) = @_;
193              
194 3         97 my $endpoint = $self->{endpoint};
195 3         16 my $context_class = $endpoint->context_class;
196              
197 3 50       8 if (!ref($handler)) {
198 3         6 my $method_name = $handler;
199 3 50       122 my $method = $endpoint->can($method_name)
200             or die "No such method: $method_name";
201              
202 3     3   6 return async sub {
203 3         7 my ($scope, $receive, $send) = @_;
204              
205 3         17 require PAGI::Context;
206              
207 3         23 my $ctx = $context_class->new($scope, $receive, $send);
208              
209 3         23 await $endpoint->$method($ctx);
210 3         17 };
211             }
212              
213 0     0   0 return async sub {
214 0         0 my ($scope, $receive, $send) = @_;
215              
216 0         0 require PAGI::Context;
217              
218 0         0 my $ctx = $context_class->new($scope, $receive, $send);
219              
220 0         0 await $handler->($ctx);
221 0         0 };
222             }
223              
224             sub sse {
225 4     4   26 my ($self, $path, @rest) = @_;
226              
227 4         13 my ($middleware, $handler) = $self->_parse_route_args(@rest);
228              
229 4 100       22 die "SSE routes do not support route-level middleware; "
230             . "apply event middleware at the mount or group level\n"
231             if @$middleware;
232              
233 3         11 my $wrapped = $self->_wrap_sse_handler($handler);
234              
235 3         26 $self->{router}->sse($path, $wrapped);
236              
237 3         8 return $self;
238             }
239              
240             sub _wrap_sse_handler {
241 3     3   6 my ($self, $handler) = @_;
242              
243 3         4 my $endpoint = $self->{endpoint};
244 3         14 my $context_class = $endpoint->context_class;
245              
246 3 50       9 if (!ref($handler)) {
247 3         4 my $method_name = $handler;
248 3 50       19 my $method = $endpoint->can($method_name)
249             or die "No such method: $method_name";
250              
251 3     3   6 return async sub {
252 3         6 my ($scope, $receive, $send) = @_;
253              
254 3         21 require PAGI::Context;
255              
256 3         18 my $ctx = $context_class->new($scope, $receive, $send);
257              
258 3         10 await $endpoint->$method($ctx);
259 3         14 };
260             }
261              
262 0     0   0 return async sub {
263 0         0 my ($scope, $receive, $send) = @_;
264              
265 0         0 require PAGI::Context;
266              
267 0         0 my $ctx = $context_class->new($scope, $receive, $send);
268              
269 0         0 await $handler->($ctx);
270 0         0 };
271             }
272              
273             sub _resolve_value_mw {
274 26     26   62 my ($self, $mw) = @_;
275              
276 26         36 my $endpoint = $self->{endpoint};
277              
278             # String = endpoint method name → value-flow route middleware.
279 26 100       46 if (!ref($mw)) {
280 25 50       115 my $method = $endpoint->can($mw)
281             or die "No such middleware method: $mw";
282 12     12   15 return async sub {
283 12         18 my ($ctx, $next) = @_;
284 12         32 my $res = await $endpoint->$method($ctx, $next);
285 12 100       622 croak "route middleware '$mw' did not return a response"
286             unless is_response($res);
287 11         27 return $res;
288 25         131 };
289             }
290              
291             # Coderefs/objects are standard (event) middleware: they belong at the
292             # outer layer (App::Router mount/group), not inside an Endpoint route's
293             # value-flow chain.
294 1         37 die "Standard middleware (coderef/object) belong at the mount or group "
295             . "level, not in an Endpoint route's middleware list; route middleware "
296             . "are value-flow endpoint methods that return a response.\n";
297             }
298              
299             # Pass through mount to internal router
300             sub mount {
301 2     2   5 my ($self, @args) = @_;
302 2         5 $self->{router}->mount(@args);
303 2         6 return $self;
304             }
305              
306             # Pass through name() to internal router
307             sub name {
308 0     0     my ($self, $name) = @_;
309 0           $self->{router}->name($name);
310 0           return $self;
311             }
312              
313             # Pass through as() to internal router
314             sub as {
315 0     0     my ($self, $namespace) = @_;
316 0           $self->{router}->as($namespace);
317 0           return $self;
318             }
319              
320             # Pass through uri_for() to internal router
321             sub uri_for {
322 0     0     my ($self, @args) = @_;
323 0           return $self->{router}->uri_for(@args);
324             }
325              
326             # Pass through named_routes() to internal router
327             sub named_routes {
328 0     0     my ($self) = @_;
329 0           return $self->{router}->named_routes;
330             }
331              
332             1;
333              
334             __END__