File Coverage

blib/lib/PAGI/App/Router.pm
Criterion Covered Total %
statement 363 399 90.9
branch 122 142 85.9
condition 57 85 67.0
subroutine 31 35 88.5
pod 11 20 55.0
total 584 681 85.7


line stmt bran cond sub pod time code
1             package PAGI::App::Router;
2              
3 8     8   853019 use strict;
  8         16  
  8         282  
4 8     8   35 use warnings;
  8         17  
  8         328  
5 8     8   421 use Future::AsyncAwait;
  8         16412  
  8         56  
6 8     8   444 use Scalar::Util qw(blessed);
  8         10  
  8         427  
7 8     8   32 use Carp qw(croak);
  8         10  
  8         60445  
8              
9             =encoding UTF-8
10              
11             =head1 NAME
12              
13             PAGI::App::Router - Unified routing for HTTP, WebSocket, and SSE
14              
15             =head1 SYNOPSIS
16              
17             use PAGI::App::Router;
18              
19             my $router = PAGI::App::Router->new;
20              
21             # HTTP routes (method + path)
22             $router->get('/users/:id' => $get_user);
23             $router->post('/users' => $create_user);
24             $router->delete('/users/:id' => $delete_user);
25              
26             # Routes with middleware
27             $router->get('/admin' => [$auth_mw, $log_mw] => $admin_handler);
28             $router->post('/api/data' => [$rate_limit] => $data_handler);
29              
30             # WebSocket routes (path only)
31             $router->websocket('/ws/chat/:room' => $chat_handler);
32              
33             # SSE routes (path only)
34             $router->sse('/events/:channel' => $events_handler);
35              
36             # Mount with middleware (applies to all sub-routes)
37             $router->mount('/api' => [$auth_mw] => $api_router->to_app);
38              
39             # Mount from a package (auto-require + to_app)
40             $router->mount('/admin' => 'MyApp::Admin');
41              
42             # Static files as fallback
43             $router->mount('/' => $static_files);
44              
45             # Named routes for URL generation
46             $router->get('/users/:id' => $get_user)->name('users.get');
47             $router->post('/users' => $create_user)->name('users.create');
48              
49             my $url = $router->uri_for('users.get', { id => 42 });
50             # Returns: "/users/42"
51              
52             # Namespace mounted routers
53             $router->mount('/api/v1' => $api_router)->as('api');
54             $router->uri_for('api.users.get', { id => 42 });
55             # Returns: "/api/v1/users/42"
56              
57             # Match any HTTP method
58             $router->any('/health' => $health_handler);
59             $router->any('/resource' => $handler, method => ['GET', 'POST']);
60              
61             # Path constraints (inline)
62             $router->get('/users/{id:\d+}' => $get_user);
63              
64             # Path constraints (chained)
65             $router->get('/posts/:slug' => $get_post)
66             ->constraints(slug => qr/^[a-z0-9-]+$/);
67              
68             # Route grouping (flattened into parent)
69             $router->group('/api' => [$auth_mw] => sub {
70             my ($r) = @_;
71             $r->get('/users' => $list_users);
72             $r->post('/users' => $create_user);
73             });
74              
75             # Include routes from another router
76             $router->group('/api/v2' => $v2_router);
77              
78             # Include routes from a package
79             $router->group('/api/users' => 'MyApp::Routes::Users');
80              
81             my $app = $router->to_app; # Handles all scope types
82              
83             =cut
84              
85             sub new {
86 119     119 0 1110745 my ($class, %args) = @_;
87              
88             return bless {
89             routes => [],
90             websocket_routes => [],
91             sse_routes => [],
92             mounts => [],
93             not_found => $args{not_found},
94 119         819 _group_stack => [], # for group() prefix/middleware accumulation
95             _named_routes => {}, # name => route info
96             _last_route => undef, # for ->name() chaining
97             _last_mount => undef, # for ->as() chaining
98             }, $class;
99             }
100              
101             sub mount {
102 19     19 1 767 my ($self, $prefix, @rest) = @_;
103 19         38 $prefix =~ s{/$}{}; # strip trailing slash
104 19         42 my ($middleware, $app_or_router) = $self->_parse_route_args(@rest);
105              
106 19         27 my $sub_router;
107             my $app;
108 19 100 66     71 if (blessed($app_or_router) && $app_or_router->isa('PAGI::App::Router')) {
    100          
109 3         3 $sub_router = $app_or_router;
110 3         8 $app = $sub_router->to_app;
111             }
112             elsif (!ref($app_or_router)) {
113             # String form: auto-require and call ->to_app
114 4         6 my $pkg = $app_or_router;
115             {
116 4         4 local $@;
  4         4  
117 4 100       366 eval "require $pkg; 1" or croak "Failed to load '$pkg': $@";
118             }
119 3 100       124 croak "'$pkg' does not have a to_app() method" unless $pkg->can('to_app');
120 2         5 $app = $pkg->to_app;
121             }
122             else {
123 12         15 $app = $app_or_router;
124             }
125              
126 17         51 my $mount = {
127             prefix => $prefix,
128             app => $app,
129             middleware => $middleware,
130             sub_router => $sub_router, # Keep reference for ->as()
131             };
132 17         18 push @{$self->{mounts}}, $mount;
  17         31  
133 17         25 $self->{_last_mount} = $mount;
134 17         23 $self->{_last_route} = undef; # Clear route tracking
135              
136 17         36 return $self;
137             }
138              
139 112     112 0 2644 sub get { my ($self, $path, @rest) = @_; $self->route('GET', $path, @rest) }
  112         234  
140 8     8 0 58 sub post { my ($self, $path, @rest) = @_; $self->route('POST', $path, @rest) }
  8         18  
141 0     0 0 0 sub put { my ($self, $path, @rest) = @_; $self->route('PUT', $path, @rest) }
  0         0  
142 0     0 0 0 sub patch { my ($self, $path, @rest) = @_; $self->route('PATCH', $path, @rest) }
  0         0  
143 2     2 0 19 sub delete { my ($self, $path, @rest) = @_; $self->route('DELETE', $path, @rest) }
  2         6  
144 0     0 0 0 sub head { my ($self, $path, @rest) = @_; $self->route('HEAD', $path, @rest) }
  0         0  
145 0     0 0 0 sub options { my ($self, $path, @rest) = @_; $self->route('OPTIONS', $path, @rest) }
  0         0  
146              
147             sub any {
148 10     10 1 141 my ($self, $path, @rest) = @_;
149              
150             # Parse optional trailing key-value args (method => [...])
151 10         16 my %opts;
152 10 100 100     51 if (@rest >= 2 && !ref($rest[-2]) && $rest[-2] eq 'method') {
      66        
153 4         14 %opts = splice(@rest, -2);
154             }
155              
156 10   100     39 my $method = $opts{method} // '*';
157 10 100       23 if (ref($method) eq 'ARRAY') {
158 4         9 $method = [map { uc($_) } @$method];
  8         22  
159             }
160              
161 10         26 $self->route($method, $path, @rest);
162             }
163              
164             sub group {
165 24     24 1 567 my ($self, $prefix, @rest) = @_;
166 24         44 $prefix =~ s{/$}{}; # strip trailing slash
167              
168 24         52 my ($middleware, $target) = $self->_parse_route_args(@rest);
169              
170 24         27 my %names_before = map { $_ => 1 } keys %{$self->{_named_routes}};
  2         6  
  24         59  
171              
172 24 100 66     71 if (ref($target) eq 'CODE') {
    100          
    100          
173 14         14 push @{$self->{_group_stack}}, {
  14         34  
174             prefix => $prefix,
175             middleware => [@$middleware],
176             };
177 14         29 $target->($self);
178 14         16 pop @{$self->{_group_stack}};
  14         39  
179             }
180             elsif (blessed($target) && $target->isa('PAGI::App::Router')) {
181 6         7 push @{$self->{_group_stack}}, {
  6         17  
182             prefix => $prefix,
183             middleware => [@$middleware],
184             };
185 6         16 $self->_include_router($target);
186 6         7 pop @{$self->{_group_stack}};
  6         9  
187             }
188             elsif (!ref($target)) {
189             # String form: auto-require and call ->router
190 3         6 my $pkg = $target;
191             {
192 3         3 local $@;
  3         4  
193 3 100       289 eval "require $pkg; 1" or croak "Failed to load '$pkg': $@";
194             }
195 2 100       135 croak "'$pkg' does not have a router() method" unless $pkg->can('router');
196 1         4 my $router_obj = $pkg->router;
197 1 50 0     6 croak "'${pkg}->router()' must return a PAGI::App::Router, got "
      33        
198             . (ref($router_obj) || 'scalar')
199             unless blessed($router_obj) && $router_obj->isa('PAGI::App::Router');
200              
201 1         2 push @{$self->{_group_stack}}, {
  1         3  
202             prefix => $prefix,
203             middleware => [@$middleware],
204             };
205 1         3 $self->_include_router($router_obj);
206 1         1 pop @{$self->{_group_stack}};
  1         10  
207             }
208             else {
209 1   50     106 croak "group() target must be a coderef, PAGI::App::Router, or package name, got "
210             . (ref($target) || 'scalar');
211             }
212              
213 21         40 my @new_names = grep { !$names_before{$_} } keys %{$self->{_named_routes}};
  13         31  
  21         40  
214 21 100       38 $self->{_last_group_names} = @new_names ? \@new_names : undef;
215              
216 21         40 $self->{_last_route} = undef;
217 21         24 $self->{_last_mount} = undef;
218              
219 21         51 return $self;
220             }
221              
222             sub websocket {
223 9     9 1 78 my ($self, $path, @rest) = @_;
224 9         37 my ($middleware, $app) = $self->_parse_route_args(@rest);
225              
226             # Apply accumulated group context (reverse: innermost prefix first)
227 9         14 for my $ctx (reverse @{$self->{_group_stack}}) {
  9         22  
228 1         2 $path = $ctx->{prefix} . $path;
229 1         2 unshift @$middleware, @{$ctx->{middleware}};
  1         3  
230             }
231              
232 9         25 my ($regex, $names, $constraints) = $self->_compile_path($path);
233 9         47 my $route = {
234             path => $path,
235             regex => $regex,
236             names => $names,
237             constraints => $constraints,
238             app => $app,
239             middleware => $middleware,
240             };
241 9         14 push @{$self->{websocket_routes}}, $route;
  9         19  
242 9         16 $self->{_last_route} = $route;
243 9         17 $self->{_last_mount} = undef;
244              
245 9         21 return $self;
246             }
247              
248             sub sse {
249 9     9 1 63 my ($self, $path, @rest) = @_;
250 9         22 my ($middleware, $app) = $self->_parse_route_args(@rest);
251              
252             # Apply accumulated group context (reverse: innermost prefix first)
253 9         15 for my $ctx (reverse @{$self->{_group_stack}}) {
  9         20  
254 1         3 $path = $ctx->{prefix} . $path;
255 1         2 unshift @$middleware, @{$ctx->{middleware}};
  1         2  
256             }
257              
258 9         21 my ($regex, $names, $constraints) = $self->_compile_path($path);
259 9         47 my $route = {
260             path => $path,
261             regex => $regex,
262             names => $names,
263             constraints => $constraints,
264             app => $app,
265             middleware => $middleware,
266             };
267 9         12 push @{$self->{sse_routes}}, $route;
  9         18  
268 9         32 $self->{_last_route} = $route;
269 9         16 $self->{_last_mount} = undef;
270              
271 9         23 return $self;
272             }
273              
274             sub route {
275 143     143 0 259 my ($self, $method, $path, @rest) = @_;
276              
277 143         344 my ($middleware, $app) = $self->_parse_route_args(@rest);
278              
279             # Apply accumulated group context (reverse: innermost prefix first)
280 140         207 for my $ctx (reverse @{$self->{_group_stack}}) {
  140         271  
281 32         57 $path = $ctx->{prefix} . $path;
282 32         34 unshift @$middleware, @{$ctx->{middleware}};
  32         48  
283             }
284              
285 140         275 my ($regex, $names, $constraints) = $self->_compile_path($path);
286 140 100       1462 my $route = {
    100          
287             method => ref($method) eq 'ARRAY' ? $method : ($method eq '*' ? '*' : uc($method)),
288             path => $path,
289             regex => $regex,
290             names => $names,
291             constraints => $constraints,
292             app => $app,
293             middleware => $middleware,
294             };
295 140         156 push @{$self->{routes}}, $route;
  140         256  
296 140         181 $self->{_last_route} = $route;
297 140         161 $self->{_last_mount} = undef; # Clear mount tracking
298              
299 140         422 return $self;
300             }
301              
302             sub _compile_path {
303 158     158   222 my ($self, $path) = @_;
304              
305 158         177 my @names;
306             my @constraints;
307 158         198 my $regex = '';
308              
309             # Tokenize the path
310 158         167 my $remaining = $path;
311 158         325 while (length $remaining) {
312             # {name:pattern} — constrained parameter
313 224 100       1344 if ($remaining =~ s/^\{(\w+):([^}]+)\}//) {
    100          
    100          
    100          
    50          
314 11         24 push @names, $1;
315 11         28 push @constraints, [$1, $2];
316 11         23 $regex .= "([^/]+)";
317             }
318             # {name} — unconstrained parameter (same as :name)
319             elsif ($remaining =~ s/^\{(\w+)\}//) {
320 2         6 push @names, $1;
321 2         5 $regex .= "([^/]+)";
322             }
323             # *name — wildcard/splat
324             elsif ($remaining =~ s/^\*(\w+)//) {
325 3         8 push @names, $1;
326 3         7 $regex .= "(.+)";
327             }
328             # :name — named parameter (legacy syntax)
329             elsif ($remaining =~ s/^:(\w+)//) {
330 45         95 push @names, $1;
331 45         101 $regex .= "([^/]+)";
332             }
333             # Literal text up to next special token
334             elsif ($remaining =~ s/^([^{:*]+)//) {
335 163         494 $regex .= quotemeta($1);
336             }
337             # Safety: consume one character to avoid infinite loop
338             else {
339 0         0 $regex .= quotemeta(substr($remaining, 0, 1, ''));
340             }
341             }
342              
343 158         2618 return (qr{^$regex$}, \@names, \@constraints);
344             }
345              
346             sub _check_constraints {
347 128     128   187 my ($self, $route, $params) = @_;
348 128   50     437 for my $constraints_list ($route->{constraints} // [], $route->{_user_constraints} // []) {
      100        
349 248         329 for my $c (@$constraints_list) {
350 28         51 my ($name, $pattern) = @$c;
351 28   50     54 my $value = $params->{$name} // return 0;
352 28 100       665 return 0 unless $value =~ m/^(?:$pattern)$/;
353             }
354             }
355 115         245 return 1;
356             }
357              
358             # ============================================================
359             # Named Routes
360             # ============================================================
361              
362             sub name {
363 42     42 1 93 my ($self, $name) = @_;
364              
365 42 100       323 croak "name() called without a preceding route" unless $self->{_last_route};
366 40 100 66     221 croak "Route name required" unless defined $name && length $name;
367 39 100       251 croak "Named route '$name' already exists" if exists $self->{_named_routes}{$name};
368              
369 38         41 my $route = $self->{_last_route};
370 38         51 $route->{name} = $name;
371             $self->{_named_routes}{$name} = {
372             path => $route->{path},
373             names => $route->{names},
374 38         102 prefix => '',
375             };
376              
377 38         65 return $self;
378             }
379              
380             sub constraints {
381 9     9 1 52 my ($self, %new_constraints) = @_;
382              
383 9 100       217 croak "constraints() called without a preceding route" unless $self->{_last_route};
384              
385 8         11 my $route = $self->{_last_route};
386 8   50     37 my $user_constraints = $route->{_user_constraints} //= [];
387              
388 8         19 for my $name (keys %new_constraints) {
389 8         14 my $pattern = $new_constraints{$name};
390 8 100       110 croak "Constraint for '$name' must be a Regexp (qr//), got " . ref($pattern)
391             unless ref($pattern) eq 'Regexp';
392 7         18 push @$user_constraints, [$name, $pattern];
393             }
394              
395 7         16 return $self;
396             }
397              
398             sub as {
399 9     9 1 47 my ($self, $namespace) = @_;
400              
401 9 50 33     30 croak "Namespace required" unless defined $namespace && length $namespace;
402              
403             # Handle group namespacing
404 9 100 66     21 if ($self->{_last_group_names} && @{$self->{_last_group_names}}) {
  3         8  
405 3         3 for my $name (@{$self->{_last_group_names}}) {
  3         34  
406 5         8 my $info = delete $self->{_named_routes}{$name};
407 5         10 my $full_name = "$namespace.$name";
408             croak "Named route '$full_name' already exists"
409 5 50       9 if exists $self->{_named_routes}{$full_name};
410 5         9 $self->{_named_routes}{$full_name} = $info;
411             }
412 3         4 $self->{_last_group_names} = undef;
413 3         5 return $self;
414             }
415              
416             # Handle mount namespacing
417             croak "as() called without a preceding mount or group"
418 6 100       214 unless $self->{_last_mount};
419              
420 4         5 my $mount = $self->{_last_mount};
421 4         5 my $sub_router = $mount->{sub_router};
422              
423 4 100       89 croak "as() requires mounting a router object, not an app coderef"
424             unless $sub_router;
425              
426             # Import all named routes from sub-router with namespace prefix
427 3         2 my $prefix = $mount->{prefix};
428 3         4 for my $name (keys %{$sub_router->{_named_routes}}) {
  3         7  
429 6         7 my $info = $sub_router->{_named_routes}{$name};
430 6         7 my $full_name = "$namespace.$name";
431             $self->{_named_routes}{$full_name} = {
432             path => $info->{path},
433             names => $info->{names},
434 6   50     24 prefix => $prefix . ($info->{prefix} // ''),
435             };
436             }
437              
438 3         5 return $self;
439             }
440              
441             sub named_routes {
442 3     3 1 6 my ($self) = @_;
443 3         3 return { %{$self->{_named_routes}} };
  3         19  
444             }
445              
446             sub uri_for {
447 32     32 1 2127 my ($self, $name, $path_params, $query_params) = @_;
448              
449 32   100     78 $path_params //= {};
450 32   100     88 $query_params //= {};
451              
452 32 100       222 my $info = $self->{_named_routes}{$name}
453             or croak "Unknown route name: '$name'";
454              
455 31         43 my $path = $info->{path};
456 31   50     53 my $prefix = $info->{prefix} // '';
457              
458             # Substitute path parameters
459 31         30 for my $param_name (@{$info->{names}}) {
  31         52  
460 16 100       28 unless (exists $path_params->{$param_name}) {
461 1         90 croak "Missing required path parameter '$param_name' for route '$name'";
462             }
463 15         21 my $value = $path_params->{$param_name};
464 15 100 100     246 $path =~ s/:$param_name\b/$value/
465             || $path =~ s/\{$param_name(?::[^}]*)?\}/$value/
466             || $path =~ s/\*$param_name\b/$value/;
467             }
468              
469             # Prepend mount prefix
470 30 100       58 $path = $prefix . $path if $prefix;
471              
472             # Add query string if any
473 30 100       44 if (%$query_params) {
474 4         5 my @pairs;
475 4         11 for my $key (sort keys %$query_params) {
476 5         7 my $value = $query_params->{$key};
477             # Simple URL encoding
478 5         9 $key =~ s/([^A-Za-z0-9\-_.~])/sprintf("%%%02X", ord($1))/ge;
  0         0  
479 5         11 $value =~ s/([^A-Za-z0-9\-_.~])/sprintf("%%%02X", ord($1))/ge;
  2         9  
480 5         12 push @pairs, "$key=$value";
481             }
482 4         11 $path .= '?' . join('&', @pairs);
483             }
484              
485 30         125 return $path;
486             }
487              
488             sub _include_router {
489 7     7   12 my ($self, $source) = @_;
490              
491             # Re-register HTTP routes through route() (stack applies prefix/middleware)
492 7         7 for my $route (@{$source->{routes}}) {
  7         11  
493             $self->route(
494             $route->{method},
495             $route->{path},
496 11         24 [@{$route->{middleware}}],
497             $route->{app},
498 11         19 );
499 11 100       25 if ($route->{name}) {
500 6         12 $self->name($route->{name});
501             }
502 11 100 66     25 if ($route->{_user_constraints} && @{$route->{_user_constraints}}) {
  1         3  
503 1         2 my %uc = map { $_->[0] => $_->[1] } @{$route->{_user_constraints}};
  1         5  
  1         2  
504 1         2 $self->constraints(%uc);
505             }
506             }
507              
508             # Re-register WebSocket routes
509 7         8 for my $route (@{$source->{websocket_routes}}) {
  7         11  
510             $self->websocket(
511             $route->{path},
512 0         0 [@{$route->{middleware}}],
513             $route->{app},
514 0         0 );
515 0 0       0 if ($route->{name}) {
516 0         0 $self->name($route->{name});
517             }
518 0 0 0     0 if ($route->{_user_constraints} && @{$route->{_user_constraints}}) {
  0         0  
519 0         0 my %uc = map { $_->[0] => $_->[1] } @{$route->{_user_constraints}};
  0         0  
  0         0  
520 0         0 $self->constraints(%uc);
521             }
522             }
523              
524             # Re-register SSE routes
525 7         8 for my $route (@{$source->{sse_routes}}) {
  7         12  
526             $self->sse(
527             $route->{path},
528 0         0 [@{$route->{middleware}}],
529             $route->{app},
530 0         0 );
531 0 0       0 if ($route->{name}) {
532 0         0 $self->name($route->{name});
533             }
534 0 0 0     0 if ($route->{_user_constraints} && @{$route->{_user_constraints}}) {
  0         0  
535 0         0 my %uc = map { $_->[0] => $_->[1] } @{$route->{_user_constraints}};
  0         0  
  0         0  
536 0         0 $self->constraints(%uc);
537             }
538             }
539             }
540              
541             sub _parse_route_args {
542 204     204   284 my ($self, @args) = @_;
543              
544 204 100 66     595 if (@args == 2 && ref($args[0]) eq 'ARRAY') {
    50          
545             # (\@middleware, $app)
546 37         118 my ($middleware, $app) = @args;
547 37         74 $self->_validate_middleware($middleware);
548 34         66 return ($middleware, $app);
549             }
550             elsif (@args == 1) {
551             # ($app) - no middleware
552 167         381 return ([], $args[0]);
553             }
554             else {
555 0         0 croak 'Invalid route arguments: expected ($app) or (\@middleware => $app)';
556             }
557             }
558              
559             sub _validate_middleware {
560 37     37   57 my ($self, $middleware) = @_;
561              
562 37         52 for my $mw (@$middleware) {
563 29 100 100     92 if (ref($mw) eq 'CODE') {
    100          
564             # Coderef is valid
565 24         43 next;
566             }
567             elsif (blessed($mw) && $mw->can('call')) {
568             # PAGI::Middleware instance with call() method
569 2         4 next;
570             }
571             else {
572 3   100     10 my $type = ref($mw) || 'scalar';
573 3         466 croak "Invalid middleware: expected coderef or object with ->call method, got $type";
574             }
575             }
576             }
577              
578             sub _build_middleware_chain {
579 131     131   171 my ($self, $middlewares, $app) = @_;
580              
581 131 100 66     476 return $app unless $middlewares && @$middlewares;
582              
583 22         24 my $chain = $app;
584              
585 22         30 for my $mw (reverse @$middlewares) {
586 28         25 my $next = $chain;
587              
588 28 100       73 if (ref($mw) eq 'CODE') {
589             # Coderef with $next signature
590 26     26   49 $chain = async sub {
591 26         35 my ($scope, $receive, $send) = @_;
592             await $mw->($scope, $receive, $send, async sub {
593             await $next->($scope, $receive, $send);
594 26         89 });
595 26         92 };
596             }
597             else {
598             # PAGI::Middleware instance - use existing call()
599 2     2   3 $chain = async sub {
600 2         2 my ($scope, $receive, $send) = @_;
601 2         6 await $mw->call($scope, $receive, $send, $next);
602 2         7 };
603             }
604             }
605              
606 22         39 return $chain;
607             }
608              
609             sub to_app {
610 88     88 1 269 my ($self) = @_;
611              
612 88         84 my @routes = @{$self->{routes}};
  88         156  
613 88         93 my @websocket_routes = @{$self->{websocket_routes}};
  88         127  
614 88         86 my @sse_routes = @{$self->{sse_routes}};
  88         130  
615 88         93 my @mounts = @{$self->{mounts}};
  88         116  
616 88         101 my $not_found = $self->{not_found};
617              
618             # Pre-build middleware chains for efficiency
619 88         146 for my $route (@routes, @websocket_routes, @sse_routes) {
620 117         252 $route->{_handler} = $self->_build_middleware_chain($route->{middleware}, $route->{app});
621             }
622 88         114 for my $m (@mounts) {
623 14         26 $m->{_handler} = $self->_build_middleware_chain($m->{middleware}, $m->{app});
624             }
625              
626             # Helper to check mounts
627 33     33   39 my $check_mounts = async sub {
628 33         48 my ($scope, $receive, $send, $path) = @_;
629 33         75 for my $m (sort { length($b->{prefix}) <=> length($a->{prefix}) } @mounts) {
  4         16  
630 16         22 my $prefix = $m->{prefix};
631 16 100 100     263 if ($path eq $prefix || $path =~ m{^\Q$prefix\E(/.*)$}) {
632 14   50     48 my $sub_path = $1 // '/';
633             my $new_scope = {
634             %$scope,
635             path => $sub_path,
636 14   100     87 root_path => ($scope->{root_path} // '') . $prefix,
637             };
638 14         38 await $m->{_handler}->($new_scope, $receive, $send);
639 14         657 return 1; # Matched
640             }
641             }
642 19         105 return 0; # No match
643 88         296 };
644              
645 145     145   27753 return async sub {
646 145         190 my ($scope, $receive, $send) = @_;
647 145   100     403 my $type = $scope->{type} // 'http';
648 145   100     338 my $method = uc($scope->{method} // '');
649 145   100     247 my $path = $scope->{path} // '/';
650              
651             # Ignore lifespan events
652 145 100       279 return if $type eq 'lifespan';
653              
654             # WebSocket routes (path-only matching) - check before mounts
655 143 100       212 if ($type eq 'websocket') {
656 11         18 for my $route (@websocket_routes) {
657 11 100       93 if (my @captures = ($path =~ $route->{regex})) {
658 9         14 my %params;
659 9         13 for my $i (0 .. $#{$route->{names}}) {
  9         27  
660 5         14 $params{$route->{names}[$i]} = $captures[$i];
661             }
662              
663             # Check constraints — skip route if any fail
664 9 100       27 next unless $self->_check_constraints($route, \%params);
665              
666             my $new_scope = {
667             %$scope,
668             path_params => \%params,
669             'pagi.router' => { route => $route->{path} },
670 8         45 };
671 8         24 await $route->{_handler}->($new_scope, $receive, $send);
672 8         855 return;
673             }
674             }
675             # No websocket route matched - try mounts as fallback
676 3 50       6 if (await $check_mounts->($scope, $receive, $send, $path)) {
677 0         0 return;
678             }
679             # No mount matched either - 404
680 3 50       110 if ($not_found) {
681 0         0 await $not_found->($scope, $receive, $send);
682             } else {
683 3         13 await $send->({
684             type => 'http.response.start',
685             status => 404,
686             headers => [['content-type', 'text/plain']],
687             });
688 3         74 await $send->({ type => 'http.response.body', body => 'Not Found', more => 0 });
689             }
690 3         63 return;
691             }
692              
693             # SSE routes (path-only matching) - check before mounts
694 132 100       207 if ($type eq 'sse') {
695 9         12 for my $route (@sse_routes) {
696 9 50       540 if (my @captures = ($path =~ $route->{regex})) {
697 9         14 my %params;
698 9         12 for my $i (0 .. $#{$route->{names}}) {
  9         51  
699 5         16 $params{$route->{names}[$i]} = $captures[$i];
700             }
701              
702             # Check constraints — skip route if any fail
703 9 100       63 next unless $self->_check_constraints($route, \%params);
704              
705             my $new_scope = {
706             %$scope,
707             path_params => \%params,
708             'pagi.router' => { route => $route->{path} },
709 8         40 };
710 8         26 await $route->{_handler}->($new_scope, $receive, $send);
711 7         607 return;
712             }
713             }
714             # No SSE route matched - try mounts as fallback
715 1 50       3 if (await $check_mounts->($scope, $receive, $send, $path)) {
716 0         0 return;
717             }
718             # No mount matched either - 404
719 1 50       44 if ($not_found) {
720 0         0 await $not_found->($scope, $receive, $send);
721             } else {
722 1         6 await $send->({
723             type => 'http.response.start',
724             status => 404,
725             headers => [['content-type', 'text/plain']],
726             });
727 1         26 await $send->({ type => 'http.response.body', body => 'Not Found', more => 0 });
728             }
729 1         21 return;
730             }
731              
732             # HTTP routes (method + path matching) - check routes first
733             # HEAD should match GET routes
734 123 100       259 my $match_method = $method eq 'HEAD' ? 'GET' : $method;
735              
736 123         147 my @method_matches;
737              
738 123         176 for my $route (@routes) {
739 163 100       1035 if (my @captures = ($path =~ $route->{regex})) {
740              
741             # Build params FIRST (needed for constraint checking)
742 110         122 my %params;
743 110         128 for my $i (0 .. $#{$route->{names}}) {
  110         288  
744 42         129 $params{$route->{names}[$i]} = $captures[$i];
745             }
746              
747             # Check constraints — skip route if any fail
748 110 100       273 next unless $self->_check_constraints($route, \%params);
749              
750             # Check method
751 99         194 my $route_method = $route->{method};
752             my $method_match = ref($route_method) eq 'ARRAY'
753 99 100 66     395 ? (grep { $_ eq $match_method || $_ eq $method } @$route_method)
  14 100       43  
754             : ($route_method eq '*' || $route_method eq $match_method || $route_method eq $method);
755              
756 99 100       150 if ($method_match) {
757             my $new_scope = {
758             %$scope,
759             path_params => \%params,
760             'pagi.router' => { route => $route->{path} },
761 86         383 };
762              
763 86         216 await $route->{_handler}->($new_scope, $receive, $send);
764 86         12880 return;
765             }
766              
767 13 100       39 if (ref($route->{method}) eq 'ARRAY') {
    50          
768 3         6 push @method_matches, @{$route->{method}};
  3         13  
769             } elsif ($route->{method} ne '*') {
770 10         26 push @method_matches, $route->{method};
771             }
772             }
773             }
774              
775             # Path matched but method didn't - 405
776 37 100       66 if (@method_matches) {
777 8         11 my $allowed = join ', ', sort keys %{{ map { $_ => 1 } @method_matches }};
  8         11  
  13         64  
778 8         52 await $send->({
779             type => 'http.response.start',
780             status => 405,
781             headers => [
782             ['content-type', 'text/plain'],
783             ['allow', $allowed],
784             ],
785             });
786 8         290 await $send->({ type => 'http.response.body', body => 'Method Not Allowed', more => 0 });
787 8         206 return;
788             }
789              
790             # No HTTP route matched - try mounts as fallback
791 29 100       58 if (await $check_mounts->($scope, $receive, $send, $path)) {
792 14         320 return;
793             }
794              
795             # No mount matched either - 404
796 15 50       488 if ($not_found) {
797 0         0 await $not_found->($scope, $receive, $send);
798             } else {
799 15         56 await $send->({
800             type => 'http.response.start',
801             status => 404,
802             headers => [['content-type', 'text/plain']],
803             });
804 15         392 await $send->({ type => 'http.response.body', body => 'Not Found', more => 0 });
805             }
806 88         656 };
807             }
808              
809             1;
810              
811             __END__