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 9     9   815756 use strict;
  9         17  
  9         313  
4 9     9   44 use warnings;
  9         21  
  9         415  
5 9     9   434 use Future::AsyncAwait;
  9         21413  
  9         65  
6 9     9   448 use Scalar::Util qw(blessed);
  9         16  
  9         454  
7 9     9   36 use Carp qw(croak);
  9         13  
  9         71121  
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 125     125 0 1078359 my ($class, %args) = @_;
87              
88             return bless {
89             routes => [],
90             websocket_routes => [],
91             sse_routes => [],
92             mounts => [],
93             not_found => $args{not_found},
94 125         832 _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 772 my ($self, $prefix, @rest) = @_;
103 19         35 $prefix =~ s{/$}{}; # strip trailing slash
104 19         44 my ($middleware, $app_or_router) = $self->_parse_route_args(@rest);
105              
106 19         31 my $sub_router;
107             my $app;
108 19 100 66     67 if (blessed($app_or_router) && $app_or_router->isa('PAGI::App::Router')) {
    100          
109 3         4 $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         4 my $pkg = $app_or_router;
115             {
116 4         4 local $@;
  4         4  
117 4 100       284 eval "require $pkg; 1" or croak "Failed to load '$pkg': $@";
118             }
119 3 100       119 croak "'$pkg' does not have a to_app() method" unless $pkg->can('to_app');
120 2         4 $app = $pkg->to_app;
121             }
122             else {
123 12         16 $app = $app_or_router;
124             }
125              
126 17         55 my $mount = {
127             prefix => $prefix,
128             app => $app,
129             middleware => $middleware,
130             sub_router => $sub_router, # Keep reference for ->as()
131             };
132 17         45 push @{$self->{mounts}}, $mount;
  17         30  
133 17         24 $self->{_last_mount} = $mount;
134 17         26 $self->{_last_route} = undef; # Clear route tracking
135              
136 17         34 return $self;
137             }
138              
139 117     117 0 2664 sub get { my ($self, $path, @rest) = @_; $self->route('GET', $path, @rest) }
  117         290  
140 8     8 0 53 sub post { my ($self, $path, @rest) = @_; $self->route('POST', $path, @rest) }
  8         21  
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         8  
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 82 my ($self, $path, @rest) = @_;
149              
150             # Parse optional trailing key-value args (method => [...])
151 10         11 my %opts;
152 10 100 100     44 if (@rest >= 2 && !ref($rest[-2]) && $rest[-2] eq 'method') {
      66        
153 4         11 %opts = splice(@rest, -2);
154             }
155              
156 10   100     31 my $method = $opts{method} // '*';
157 10 100       25 if (ref($method) eq 'ARRAY') {
158 4         8 $method = [map { uc($_) } @$method];
  8         32  
159             }
160              
161 10         38 $self->route($method, $path, @rest);
162             }
163              
164             sub group {
165 24     24 1 492 my ($self, $prefix, @rest) = @_;
166 24         42 $prefix =~ s{/$}{}; # strip trailing slash
167              
168 24         52 my ($middleware, $target) = $self->_parse_route_args(@rest);
169              
170 24         24 my %names_before = map { $_ => 1 } keys %{$self->{_named_routes}};
  2         5  
  24         51  
171              
172 24 100 66     91 if (ref($target) eq 'CODE') {
    100          
    100          
173 14         14 push @{$self->{_group_stack}}, {
  14         37  
174             prefix => $prefix,
175             middleware => [@$middleware],
176             };
177 14         49 $target->($self);
178 14         20 pop @{$self->{_group_stack}};
  14         19  
179             }
180             elsif (blessed($target) && $target->isa('PAGI::App::Router')) {
181 6         7 push @{$self->{_group_stack}}, {
  6         19  
182             prefix => $prefix,
183             middleware => [@$middleware],
184             };
185 6         16 $self->_include_router($target);
186 6         7 pop @{$self->{_group_stack}};
  6         6  
187             }
188             elsif (!ref($target)) {
189             # String form: auto-require and call ->router
190 3         3 my $pkg = $target;
191             {
192 3         4 local $@;
  3         4  
193 3 100       260 eval "require $pkg; 1" or croak "Failed to load '$pkg': $@";
194             }
195 2 100       134 croak "'$pkg' does not have a router() method" unless $pkg->can('router');
196 1         5 my $router_obj = $pkg->router;
197 1 50 0     7 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         4  
202             prefix => $prefix,
203             middleware => [@$middleware],
204             };
205 1         3 $self->_include_router($router_obj);
206 1         2 pop @{$self->{_group_stack}};
  1         10  
207             }
208             else {
209 1   50     100 croak "group() target must be a coderef, PAGI::App::Router, or package name, got "
210             . (ref($target) || 'scalar');
211             }
212              
213 21         42 my @new_names = grep { !$names_before{$_} } keys %{$self->{_named_routes}};
  13         30  
  21         41  
214 21 100       42 $self->{_last_group_names} = @new_names ? \@new_names : undef;
215              
216 21         26 $self->{_last_route} = undef;
217 21         26 $self->{_last_mount} = undef;
218              
219 21         50 return $self;
220             }
221              
222             sub websocket {
223 10     10 1 77 my ($self, $path, @rest) = @_;
224 10         33 my ($middleware, $app) = $self->_parse_route_args(@rest);
225              
226             # Apply accumulated group context (reverse: innermost prefix first)
227 10         15 for my $ctx (reverse @{$self->{_group_stack}}) {
  10         38  
228 1         3 $path = $ctx->{prefix} . $path;
229 1         2 unshift @$middleware, @{$ctx->{middleware}};
  1         2  
230             }
231              
232 10         28 my ($regex, $names, $constraints) = $self->_compile_path($path);
233 10         78 my $route = {
234             path => $path,
235             regex => $regex,
236             names => $names,
237             constraints => $constraints,
238             app => $app,
239             middleware => $middleware,
240             };
241 10         14 push @{$self->{websocket_routes}}, $route;
  10         24  
242 10         17 $self->{_last_route} = $route;
243 10         19 $self->{_last_mount} = undef;
244              
245 10         25 return $self;
246             }
247              
248             sub sse {
249 10     10 1 69 my ($self, $path, @rest) = @_;
250 10         30 my ($middleware, $app) = $self->_parse_route_args(@rest);
251              
252             # Apply accumulated group context (reverse: innermost prefix first)
253 10         15 for my $ctx (reverse @{$self->{_group_stack}}) {
  10         25  
254 1         3 $path = $ctx->{prefix} . $path;
255 1         3 unshift @$middleware, @{$ctx->{middleware}};
  1         2  
256             }
257              
258 10         25 my ($regex, $names, $constraints) = $self->_compile_path($path);
259 10         89 my $route = {
260             path => $path,
261             regex => $regex,
262             names => $names,
263             constraints => $constraints,
264             app => $app,
265             middleware => $middleware,
266             };
267 10         18 push @{$self->{sse_routes}}, $route;
  10         19  
268 10         20 $self->{_last_route} = $route;
269 10         20 $self->{_last_mount} = undef;
270              
271 10         25 return $self;
272             }
273              
274             sub route {
275 148     148 0 304 my ($self, $method, $path, @rest) = @_;
276              
277 148         284 my ($middleware, $app) = $self->_parse_route_args(@rest);
278              
279             # Apply accumulated group context (reverse: innermost prefix first)
280 145         158 for my $ctx (reverse @{$self->{_group_stack}}) {
  145         263  
281 32         46 $path = $ctx->{prefix} . $path;
282 32         34 unshift @$middleware, @{$ctx->{middleware}};
  32         41  
283             }
284              
285 145         262 my ($regex, $names, $constraints) = $self->_compile_path($path);
286 145 100       876 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 145         154 push @{$self->{routes}}, $route;
  145         214  
296 145         197 $self->{_last_route} = $route;
297 145         164 $self->{_last_mount} = undef; # Clear mount tracking
298              
299 145         439 return $self;
300             }
301              
302             sub _compile_path {
303 165     165   212 my ($self, $path) = @_;
304              
305 165         181 my @names;
306             my @constraints;
307 165         201 my $regex = '';
308              
309             # Tokenize the path
310 165         185 my $remaining = $path;
311 165         323 while (length $remaining) {
312             # {name:pattern} — constrained parameter
313 234 100       1372 if ($remaining =~ s/^\{(\w+):([^}]+)\}//) {
    100          
    100          
    100          
    50          
314 11         22 push @names, $1;
315 11         25 push @constraints, [$1, $2];
316 11         33 $regex .= "([^/]+)";
317             }
318             # {name} — unconstrained parameter (same as :name)
319             elsif ($remaining =~ s/^\{(\w+)\}//) {
320 2         4 push @names, $1;
321 2         5 $regex .= "([^/]+)";
322             }
323             # *name — wildcard/splat
324             elsif ($remaining =~ s/^\*(\w+)//) {
325 3         6 push @names, $1;
326 3         34 $regex .= "(.+)";
327             }
328             # :name — named parameter (legacy syntax)
329             elsif ($remaining =~ s/^:(\w+)//) {
330 48         91 push @names, $1;
331 48         82 $regex .= "([^/]+)";
332             }
333             # Literal text up to next special token
334             elsif ($remaining =~ s/^([^{:*]+)//) {
335 170         485 $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 165         2554 return (qr{^$regex$}, \@names, \@constraints);
344             }
345              
346             sub _check_constraints {
347 136     136   170 my ($self, $route, $params) = @_;
348 136   50     450 for my $constraints_list ($route->{constraints} // [], $route->{_user_constraints} // []) {
      100        
349 264         355 for my $c (@$constraints_list) {
350 28         45 my ($name, $pattern) = @$c;
351 28   50     47 my $value = $params->{$name} // return 0;
352 28 100       549 return 0 unless $value =~ m/^(?:$pattern)$/;
353             }
354             }
355 123         252 return 1;
356             }
357              
358             # ============================================================
359             # Named Routes
360             # ============================================================
361              
362             sub name {
363 42     42 1 88 my ($self, $name) = @_;
364              
365 42 100       252 croak "name() called without a preceding route" unless $self->{_last_route};
366 40 100 66     200 croak "Route name required" unless defined $name && length $name;
367 39 100       224 croak "Named route '$name' already exists" if exists $self->{_named_routes}{$name};
368              
369 38         41 my $route = $self->{_last_route};
370 38         64 $route->{name} = $name;
371             $self->{_named_routes}{$name} = {
372             path => $route->{path},
373             names => $route->{names},
374 38         128 prefix => '',
375             };
376              
377 38         55 return $self;
378             }
379              
380             sub constraints {
381 9     9 1 49 my ($self, %new_constraints) = @_;
382              
383 9 100       244 croak "constraints() called without a preceding route" unless $self->{_last_route};
384              
385 8         9 my $route = $self->{_last_route};
386 8   50     29 my $user_constraints = $route->{_user_constraints} //= [];
387              
388 8         19 for my $name (keys %new_constraints) {
389 8         11 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         13 push @$user_constraints, [$name, $pattern];
393             }
394              
395 7         13 return $self;
396             }
397              
398             sub as {
399 9     9 1 49 my ($self, $namespace) = @_;
400              
401 9 50 33     27 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         4 for my $name (@{$self->{_last_group_names}}) {
  3         5  
406 5         9 my $info = delete $self->{_named_routes}{$name};
407 5         6 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         37 $self->{_named_routes}{$full_name} = $info;
411             }
412 3         5 $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       202 unless $self->{_last_mount};
419              
420 4         4 my $mount = $self->{_last_mount};
421 4         7 my $sub_router = $mount->{sub_router};
422              
423 4 100       131 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         4 my $prefix = $mount->{prefix};
428 3         3 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     22 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         4 return { %{$self->{_named_routes}} };
  3         17  
444             }
445              
446             sub uri_for {
447 32     32 1 2160 my ($self, $name, $path_params, $query_params) = @_;
448              
449 32   100     78 $path_params //= {};
450 32   100     85 $query_params //= {};
451              
452 32 100       250 my $info = $self->{_named_routes}{$name}
453             or croak "Unknown route name: '$name'";
454              
455 31         44 my $path = $info->{path};
456 31   50     54 my $prefix = $info->{prefix} // '';
457              
458             # Substitute path parameters
459 31         30 for my $param_name (@{$info->{names}}) {
  31         56  
460 16 100       29 unless (exists $path_params->{$param_name}) {
461 1         94 croak "Missing required path parameter '$param_name' for route '$name'";
462             }
463 15         19 my $value = $path_params->{$param_name};
464 15 100 100     251 $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       56 $path = $prefix . $path if $prefix;
471              
472             # Add query string if any
473 30 100       46 if (%$query_params) {
474 4         4 my @pairs;
475 4         11 for my $key (sort keys %$query_params) {
476 5         8 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         11  
480 5         13 push @pairs, "$key=$value";
481             }
482 4         10 $path .= '?' . join('&', @pairs);
483             }
484              
485 30         154 return $path;
486             }
487              
488             sub _include_router {
489 7     7   13 my ($self, $source) = @_;
490              
491             # Re-register HTTP routes through route() (stack applies prefix/middleware)
492 7         8 for my $route (@{$source->{routes}}) {
  7         11  
493             $self->route(
494             $route->{method},
495             $route->{path},
496 11         26 [@{$route->{middleware}}],
497             $route->{app},
498 11         15 );
499 11 100       21 if ($route->{name}) {
500 6         9 $self->name($route->{name});
501             }
502 11 100 66     23 if ($route->{_user_constraints} && @{$route->{_user_constraints}}) {
  1         3  
503 1         1 my %uc = map { $_->[0] => $_->[1] } @{$route->{_user_constraints}};
  1         4  
  1         3  
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         11  
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 211     211   302 my ($self, @args) = @_;
543              
544 211 100 66     606 if (@args == 2 && ref($args[0]) eq 'ARRAY') {
    50          
545             # (\@middleware, $app)
546 38         87 my ($middleware, $app) = @args;
547 38         75 $self->_validate_middleware($middleware);
548 35         79 return ($middleware, $app);
549             }
550             elsif (@args == 1) {
551             # ($app) - no middleware
552 173         461 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 38     38   47 my ($self, $middleware) = @_;
561              
562 38         55 for my $mw (@$middleware) {
563 30 100 100     72 if (ref($mw) eq 'CODE') {
    100          
564             # Coderef is valid
565 25         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     8 my $type = ref($mw) || 'scalar';
573 3         417 croak "Invalid middleware: expected coderef or object with ->call method, got $type";
574             }
575             }
576             }
577              
578             sub _build_middleware_chain {
579 138     138   179 my ($self, $middlewares, $app) = @_;
580              
581 138 100 66     529 return $app unless $middlewares && @$middlewares;
582              
583 23         23 my $chain = $app;
584              
585 23         27 for my $mw (reverse @$middlewares) {
586 29         20 my $next = $chain;
587              
588 29 100       49 if (ref($mw) eq 'CODE') {
589             # Coderef with $next signature
590 28     28   40 $chain = async sub {
591 28         35 my ($scope, $receive, $send) = @_;
592             await $mw->($scope, $receive, $send, async sub {
593             await $next->($scope, $receive, $send);
594 28         94 });
595 27         79 };
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         6 };
603             }
604             }
605              
606 23         86 return $chain;
607             }
608              
609             sub to_app {
610 94     94 1 307 my ($self) = @_;
611              
612 94         91 my @routes = @{$self->{routes}};
  94         183  
613 94         118 my @websocket_routes = @{$self->{websocket_routes}};
  94         122  
614 94         119 my @sse_routes = @{$self->{sse_routes}};
  94         125  
615 94         93 my @mounts = @{$self->{mounts}};
  94         112  
616 94         217 my $not_found = $self->{not_found};
617              
618             # Pre-build middleware chains for efficiency
619 94         146 for my $route (@routes, @websocket_routes, @sse_routes) {
620 124         229 $route->{_handler} = $self->_build_middleware_chain($route->{middleware}, $route->{app});
621             }
622 94         119 for my $m (@mounts) {
623 14         25 $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         54 my ($scope, $receive, $send, $path) = @_;
629 33         77 for my $m (sort { length($b->{prefix}) <=> length($a->{prefix}) } @mounts) {
  4         34  
630 16         26 my $prefix = $m->{prefix};
631 16 100 100     251 if ($path eq $prefix || $path =~ m{^\Q$prefix\E(/.*)$}) {
632 14   50     45 my $sub_path = $1 // '/';
633             my $new_scope = {
634             %$scope,
635             path => $sub_path,
636 14   100     94 root_path => ($scope->{root_path} // '') . $prefix,
637             };
638 14         52 await $m->{_handler}->($new_scope, $receive, $send);
639 14         660 return 1; # Matched
640             }
641             }
642 19         101 return 0; # No match
643 94         334 };
644              
645 153     153   25323 return async sub {
646 153         203 my ($scope, $receive, $send) = @_;
647 153   100     391 my $type = $scope->{type} // 'http';
648 153   100     358 my $method = uc($scope->{method} // '');
649 153   100     261 my $path = $scope->{path} // '/';
650              
651             # Ignore lifespan events
652 153 100       295 return if $type eq 'lifespan';
653              
654             # WebSocket routes (path-only matching) - check before mounts
655 151 100       225 if ($type eq 'websocket') {
656 12         18 for my $route (@websocket_routes) {
657 12 100       82 if (my @captures = ($path =~ $route->{regex})) {
658 10         15 my %params;
659 10         12 for my $i (0 .. $#{$route->{names}}) {
  10         26  
660 6         17 $params{$route->{names}[$i]} = $captures[$i];
661             }
662              
663             # Check constraints — skip route if any fail
664 10 100       29 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 9         43 };
671 9         27 await $route->{_handler}->($new_scope, $receive, $send);
672 9         870 return;
673             }
674             }
675             # No websocket route matched - try mounts as fallback
676 3 50       8 if (await $check_mounts->($scope, $receive, $send, $path)) {
677 0         0 return;
678             }
679             # No mount matched either - 404
680 3 50       94 if ($not_found) {
681 0         0 await $not_found->($scope, $receive, $send);
682             } else {
683 3         11 await $send->({
684             type => 'http.response.start',
685             status => 404,
686             headers => [['content-type', 'text/plain']],
687             });
688 3         71 await $send->({ type => 'http.response.body', body => 'Not Found', more => 0 });
689             }
690 3         64 return;
691             }
692              
693             # SSE routes (path-only matching) - check before mounts
694 139 100       210 if ($type eq 'sse') {
695 10         17 for my $route (@sse_routes) {
696 10 50       76 if (my @captures = ($path =~ $route->{regex})) {
697 10         12 my %params;
698 10         13 for my $i (0 .. $#{$route->{names}}) {
  10         74  
699 6         20 $params{$route->{names}[$i]} = $captures[$i];
700             }
701              
702             # Check constraints — skip route if any fail
703 10 100       30 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 9         46 };
710 9         30 await $route->{_handler}->($new_scope, $receive, $send);
711 8         701 return;
712             }
713             }
714             # No SSE route matched - try mounts as fallback
715 1 50       2 if (await $check_mounts->($scope, $receive, $send, $path)) {
716 0         0 return;
717             }
718             # No mount matched either - 404
719 1 50       31 if ($not_found) {
720 0         0 await $not_found->($scope, $receive, $send);
721             } else {
722 1         4 await $send->({
723             type => 'http.response.start',
724             status => 404,
725             headers => [['content-type', 'text/plain']],
726             });
727 1         24 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 129 100       224 my $match_method = $method eq 'HEAD' ? 'GET' : $method;
735              
736 129         148 my @method_matches;
737              
738 129         180 for my $route (@routes) {
739 170 100       973 if (my @captures = ($path =~ $route->{regex})) {
740              
741             # Build params FIRST (needed for constraint checking)
742 116         135 my %params;
743 116         127 for my $i (0 .. $#{$route->{names}}) {
  116         279  
744 43         109 $params{$route->{names}[$i]} = $captures[$i];
745             }
746              
747             # Check constraints — skip route if any fail
748 116 100       269 next unless $self->_check_constraints($route, \%params);
749              
750             # Check method
751 105         160 my $route_method = $route->{method};
752             my $method_match = ref($route_method) eq 'ARRAY'
753 105 100 66     356 ? (grep { $_ eq $match_method || $_ eq $method } @$route_method)
  14 100       37  
754             : ($route_method eq '*' || $route_method eq $match_method || $route_method eq $method);
755              
756 105 100       158 if ($method_match) {
757             my $new_scope = {
758             %$scope,
759             path_params => \%params,
760             'pagi.router' => { route => $route->{path} },
761 92         416 };
762              
763 92         236 await $route->{_handler}->($new_scope, $receive, $send);
764 92         10819 return;
765             }
766              
767 13 100       32 if (ref($route->{method}) eq 'ARRAY') {
    50          
768 3         6 push @method_matches, @{$route->{method}};
  3         10  
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       69 if (@method_matches) {
777 8         10 my $allowed = join ', ', sort keys %{{ map { $_ => 1 } @method_matches }};
  8         9  
  13         55  
778 8         46 await $send->({
779             type => 'http.response.start',
780             status => 405,
781             headers => [
782             ['content-type', 'text/plain'],
783             ['allow', $allowed],
784             ],
785             });
786 8         277 await $send->({ type => 'http.response.body', body => 'Method Not Allowed', more => 0 });
787 8         187 return;
788             }
789              
790             # No HTTP route matched - try mounts as fallback
791 29 100       54 if (await $check_mounts->($scope, $receive, $send, $path)) {
792 14         346 return;
793             }
794              
795             # No mount matched either - 404
796 15 50       549 if ($not_found) {
797 0         0 await $not_found->($scope, $receive, $send);
798             } else {
799 15         62 await $send->({
800             type => 'http.response.start',
801             status => 404,
802             headers => [['content-type', 'text/plain']],
803             });
804 15         403 await $send->({ type => 'http.response.body', body => 'Not Found', more => 0 });
805             }
806 94         576 };
807             }
808              
809             1;
810              
811             __END__