File Coverage

blib/lib/PAGI/Endpoint/Router.pm
Criterion Covered Total %
statement 139 177 78.5
branch 23 36 63.8
condition 9 18 50.0
subroutine 33 45 73.3
pod 4 5 80.0
total 208 281 74.0


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