File Coverage

blib/lib/PAGI/App/Router.pm
Criterion Covered Total %
statement 361 397 90.9
branch 116 136 85.2
condition 58 85 68.2
subroutine 32 36 88.8
pod 11 20 55.0
total 578 674 85.7


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