File Coverage

blib/lib/PAGI/App/Router.pm
Criterion Covered Total %
statement 356 389 91.5
branch 117 134 87.3
condition 60 87 68.9
subroutine 31 35 88.5
pod 11 20 55.0
total 575 665 86.4


line stmt bran cond sub pod time code
1             package PAGI::App::Router;
2             $PAGI::App::Router::VERSION = '0.002001';
3 13     13   1279630 use strict;
  13         21  
  13         552  
4 13     13   76 use warnings;
  13         37  
  13         596  
5 13     13   443 use Future::AsyncAwait;
  13         16700  
  13         78  
6 13     13   650 use Scalar::Util qw(blessed);
  13         21  
  13         676  
7 13     13   57 use Carp qw(croak);
  13         17  
  13         497  
8 13     13   2865 use PAGI::Utils ();
  13         23  
  13         87389  
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 143     143 0 1304947 my ($class, %args) = @_;
88              
89             return bless {
90             routes => [],
91             websocket_routes => [],
92             sse_routes => [],
93             mounts => [],
94             not_found => $args{not_found},
95 143         1075 _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 851 my ($self, $prefix, @rest) = @_;
104 20         45 $prefix =~ s{/$}{}; # strip trailing slash
105 20         38 my ($middleware, $app_or_router) = $self->_parse_route_args(@rest);
106              
107 20         27 my $sub_router;
108 20 100 100     116 if (blessed($app_or_router) && $app_or_router->isa('PAGI::App::Router')) {
109 3         3 $sub_router = $app_or_router; # Keep reference for ->as()
110             }
111 20         41 my $app = PAGI::Utils::to_app($app_or_router);
112              
113 18         70 my $mount = {
114             prefix => $prefix,
115             app => $app,
116             middleware => $middleware,
117             sub_router => $sub_router, # Keep reference for ->as()
118             };
119 18         21 push @{$self->{mounts}}, $mount;
  18         32  
120 18         27 $self->{_last_mount} = $mount;
121 18         24 $self->{_last_route} = undef; # Clear route tracking
122              
123 18         40 return $self;
124             }
125              
126 143     143 0 2852 sub get { my ($self, $path, @rest) = @_; $self->route('GET', $path, @rest) }
  143         291  
127 8     8 0 59 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         8  
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 88 my ($self, $path, @rest) = @_;
136              
137             # Parse optional trailing key-value args (method => [...])
138 10         13 my %opts;
139 10 100 100     63 if (@rest >= 2 && !ref($rest[-2]) && $rest[-2] eq 'method') {
      66        
140 4         11 %opts = splice(@rest, -2);
141             }
142              
143 10   100     34 my $method = $opts{method} // '*';
144 10 100       23 if (ref($method) eq 'ARRAY') {
145 4         8 $method = [map { uc($_) } @$method];
  8         20  
146             }
147              
148 10         22 $self->route($method, $path, @rest);
149             }
150              
151             sub group {
152 24     24 1 521 my ($self, $prefix, @rest) = @_;
153 24         42 $prefix =~ s{/$}{}; # strip trailing slash
154              
155 24         55 my ($middleware, $target) = $self->_parse_route_args(@rest);
156              
157 24         32 my %names_before = map { $_ => 1 } keys %{$self->{_named_routes}};
  2         6  
  24         51  
158              
159 24 100 66     79 if (ref($target) eq 'CODE') {
    100          
    100          
160 14         15 push @{$self->{_group_stack}}, {
  14         58  
161             prefix => $prefix,
162             middleware => [@$middleware],
163             };
164 14         30 $target->($self);
165 14         24 pop @{$self->{_group_stack}};
  14         18  
166             }
167             elsif (blessed($target) && $target->isa('PAGI::App::Router')) {
168 6         7 push @{$self->{_group_stack}}, {
  6         20  
169             prefix => $prefix,
170             middleware => [@$middleware],
171             };
172 6         18 $self->_include_router($target);
173 6         6 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         4 local $@;
  3         4  
180 3 100       270 eval "require $pkg; 1" or croak "Failed to load '$pkg': $@";
181             }
182 2 100       118 croak "'$pkg' does not have a router() method" unless $pkg->can('router');
183 1         4 my $router_obj = $pkg->router;
184 1 50 0     8 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         1 push @{$self->{_group_stack}}, {
  1         5  
189             prefix => $prefix,
190             middleware => [@$middleware],
191             };
192 1         3 $self->_include_router($router_obj);
193 1         2 pop @{$self->{_group_stack}};
  1         10  
194             }
195             else {
196 1   50     115 croak "group() target must be a coderef, PAGI::App::Router, or package name, got "
197             . (ref($target) || 'scalar');
198             }
199              
200 21         62 my @new_names = grep { !$names_before{$_} } keys %{$self->{_named_routes}};
  13         29  
  21         40  
201 21 100       43 $self->{_last_group_names} = @new_names ? \@new_names : undef;
202              
203 21         25 $self->{_last_route} = undef;
204 21         29 $self->{_last_mount} = undef;
205              
206 21         54 return $self;
207             }
208              
209             sub websocket {
210 10     10 1 105 my ($self, $path, @rest) = @_;
211 10         30 my ($middleware, $app) = $self->_parse_route_args(@rest);
212 10         31 $app = PAGI::Utils::to_app($app);
213              
214             # Apply accumulated group context (reverse: innermost prefix first)
215 10         16 for my $ctx (reverse @{$self->{_group_stack}}) {
  10         24  
216 1         3 $path = $ctx->{prefix} . $path;
217 1         2 unshift @$middleware, @{$ctx->{middleware}};
  1         2  
218             }
219              
220 10         26 my ($regex, $names, $constraints) = $self->_compile_path($path);
221 10         57 my $route = {
222             path => $path,
223             regex => $regex,
224             names => $names,
225             constraints => $constraints,
226             app => $app,
227             middleware => $middleware,
228             };
229 10         17 push @{$self->{websocket_routes}}, $route;
  10         22  
230 10         19 $self->{_last_route} = $route;
231 10         18 $self->{_last_mount} = undef;
232              
233 10         65 return $self;
234             }
235              
236             sub sse {
237 10     10 1 75 my ($self, $path, @rest) = @_;
238 10         29 my ($middleware, $app) = $self->_parse_route_args(@rest);
239 10         29 $app = PAGI::Utils::to_app($app);
240              
241             # Apply accumulated group context (reverse: innermost prefix first)
242 10         12 for my $ctx (reverse @{$self->{_group_stack}}) {
  10         24  
243 1         3 $path = $ctx->{prefix} . $path;
244 1         2 unshift @$middleware, @{$ctx->{middleware}};
  1         2  
245             }
246              
247 10         26 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         16 push @{$self->{sse_routes}}, $route;
  10         20  
257 10         23 $self->{_last_route} = $route;
258 10         15 $self->{_last_mount} = undef;
259              
260 10         27 return $self;
261             }
262              
263             sub route {
264 174     174 0 298 my ($self, $method, $path, @rest) = @_;
265              
266 174         347 my ($middleware, $app) = $self->_parse_route_args(@rest);
267 171         355 $app = PAGI::Utils::to_app($app);
268              
269             # Apply accumulated group context (reverse: innermost prefix first)
270 171         201 for my $ctx (reverse @{$self->{_group_stack}}) {
  171         393  
271 32         52 $path = $ctx->{prefix} . $path;
272 32         37 unshift @$middleware, @{$ctx->{middleware}};
  32         49  
273             }
274              
275 171         294 my ($regex, $names, $constraints) = $self->_compile_path($path);
276 171 100       1039 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 171         178 push @{$self->{routes}}, $route;
  171         305  
286 171         255 $self->{_last_route} = $route;
287 171         201 $self->{_last_mount} = undef; # Clear mount tracking
288              
289 171         468 return $self;
290             }
291              
292             sub _compile_path {
293 191     191   279 my ($self, $path) = @_;
294              
295 191         207 my @names;
296             my @constraints;
297 191         248 my $regex = '';
298              
299             # Tokenize the path
300 191         237 my $remaining = $path;
301 191         314 while (length $remaining) {
302             # {name:pattern} — constrained parameter
303 260 100       1518 if ($remaining =~ s/^\{(\w+):([^}]+)\}//) {
    100          
    100          
    100          
    50          
304 11         24 push @names, $1;
305 11         26 push @constraints, [$1, $2];
306 11         25 $regex .= "([^/]+)";
307             }
308             # {name} — unconstrained parameter (same as :name)
309             elsif ($remaining =~ s/^\{(\w+)\}//) {
310 2         5 push @names, $1;
311 2         6 $regex .= "([^/]+)";
312             }
313             # *name — wildcard/splat
314             elsif ($remaining =~ s/^\*(\w+)//) {
315 3         10 push @names, $1;
316 3         10 $regex .= "(.+)";
317             }
318             # :name — named parameter (legacy syntax)
319             elsif ($remaining =~ s/^:(\w+)//) {
320 48         89 push @names, $1;
321 48         84 $regex .= "([^/]+)";
322             }
323             # Literal text up to next special token
324             elsif ($remaining =~ s/^([^{:*]+)//) {
325 196         569 $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 191         3127 return (qr{^$regex$}, \@names, \@constraints);
334             }
335              
336             sub _check_constraints {
337 148     148   197 my ($self, $route, $params) = @_;
338 148   50     537 for my $constraints_list ($route->{constraints} // [], $route->{_user_constraints} // []) {
      100        
339 288         410 for my $c (@$constraints_list) {
340 28         51 my ($name, $pattern) = @$c;
341 28   50     50 my $value = $params->{$name} // return 0;
342 28 100       539 return 0 unless $value =~ m/^(?:$pattern)$/;
343             }
344             }
345 135         289 return 1;
346             }
347              
348             # ============================================================
349             # Named Routes
350             # ============================================================
351              
352             sub name {
353 42     42 1 89 my ($self, $name) = @_;
354              
355 42 100       257 croak "name() called without a preceding route" unless $self->{_last_route};
356 40 100 66     195 croak "Route name required" unless defined $name && length $name;
357 39 100       233 croak "Named route '$name' already exists" if exists $self->{_named_routes}{$name};
358              
359 38         68 my $route = $self->{_last_route};
360 38         63 $route->{name} = $name;
361             $self->{_named_routes}{$name} = {
362             path => $route->{path},
363             names => $route->{names},
364 38         114 prefix => '',
365             };
366              
367 38         70 return $self;
368             }
369              
370             sub constraints {
371 9     9 1 51 my ($self, %new_constraints) = @_;
372              
373 9 100       221 croak "constraints() called without a preceding route" unless $self->{_last_route};
374              
375 8         11 my $route = $self->{_last_route};
376 8   50     31 my $user_constraints = $route->{_user_constraints} //= [];
377              
378 8         18 for my $name (keys %new_constraints) {
379 8         13 my $pattern = $new_constraints{$name};
380 8 100       134 croak "Constraint for '$name' must be a Regexp (qr//), got " . ref($pattern)
381             unless ref($pattern) eq 'Regexp';
382 7         17 push @$user_constraints, [$name, $pattern];
383             }
384              
385 7         14 return $self;
386             }
387              
388             sub as {
389 9     9 1 49 my ($self, $namespace) = @_;
390              
391 9 50 33     35 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         8  
395 3         3 for my $name (@{$self->{_last_group_names}}) {
  3         6  
396 5         7 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       8 if exists $self->{_named_routes}{$full_name};
400 5         11 $self->{_named_routes}{$full_name} = $info;
401             }
402 3         4 $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       259 unless $self->{_last_mount};
409              
410 4         5 my $mount = $self->{_last_mount};
411 4         4 my $sub_router = $mount->{sub_router};
412              
413 4 100       90 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         5 my $prefix = $mount->{prefix};
418 3         3 for my $name (keys %{$sub_router->{_named_routes}}) {
  3         8  
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     21 prefix => $prefix . ($info->{prefix} // ''),
425             };
426             }
427              
428 3         6 return $self;
429             }
430              
431             sub named_routes {
432 3     3 1 7 my ($self) = @_;
433 3         4 return { %{$self->{_named_routes}} };
  3         34  
434             }
435              
436             sub uri_for {
437 32     32 1 2292 my ($self, $name, $path_params, $query_params) = @_;
438              
439 32   100     84 $path_params //= {};
440 32   100     86 $query_params //= {};
441              
442 32 100       231 my $info = $self->{_named_routes}{$name}
443             or croak "Unknown route name: '$name'";
444              
445 31         48 my $path = $info->{path};
446 31   50     54 my $prefix = $info->{prefix} // '';
447              
448             # Substitute path parameters
449 31         32 for my $param_name (@{$info->{names}}) {
  31         58  
450 16 100       61 unless (exists $path_params->{$param_name}) {
451 1         94 croak "Missing required path parameter '$param_name' for route '$name'";
452             }
453 15         24 my $value = $path_params->{$param_name};
454 15 100 100     264 $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       61 $path = $prefix . $path if $prefix;
461              
462             # Add query string if any
463 30 100       48 if (%$query_params) {
464 4         5 my @pairs;
465 4         12 for my $key (sort keys %$query_params) {
466 5         6 my $value = $query_params->{$key};
467             # Simple URL encoding
468 5         10 $key =~ s/([^A-Za-z0-9\-_.~])/sprintf("%%%02X", ord($1))/ge;
  0         0  
469 5         10 $value =~ s/([^A-Za-z0-9\-_.~])/sprintf("%%%02X", ord($1))/ge;
  2         9  
470 5         13 push @pairs, "$key=$value";
471             }
472 4         44 $path .= '?' . join('&', @pairs);
473             }
474              
475 30         129 return $path;
476             }
477              
478             sub _include_router {
479 7     7   10 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         10 for my $route (@{$source->{routes}}) {
  7         11  
484             $self->route(
485             $route->{method},
486             $route->{path},
487 11         26 [@{$route->{middleware}}],
488             $route->{app},
489 11         18 );
490 11 100       22 if ($route->{name}) {
491 6         10 $self->name($route->{name});
492             }
493 11 100 66     31 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         10 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         8 for my $route (@{$source->{sse_routes}}) {
  7         10  
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 238     238   319 my ($self, @args) = @_;
534              
535 238 100 66     675 if (@args == 2 && ref($args[0]) eq 'ARRAY') {
    50          
536             # (\@middleware, $app)
537 37         54 my ($middleware, $app) = @args;
538 37         74 $self->_validate_middleware($middleware);
539 34         66 return ($middleware, $app);
540             }
541             elsif (@args == 1) {
542             # ($app) - no middleware
543 201         508 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 37     37   47 my ($self, $middleware) = @_;
552              
553 37         53 for my $mw (@$middleware) {
554 28 100 100     84 if (ref($mw) eq 'CODE') {
    100          
555 23         39 next;
556             }
557             elsif (blessed($mw) && $mw->can('wrap')) {
558 2         5 next;
559             }
560             else {
561 3   100     8 my $type = ref($mw) || 'scalar';
562 3         392 croak "Invalid middleware: expected coderef factory or object with ->wrap method, got $type";
563             }
564             }
565             }
566              
567             sub _build_middleware_chain {
568 165     165   243 my ($self, $middlewares, $app) = @_;
569              
570 165 100 66     619 return $app unless $middlewares && @$middlewares;
571              
572 22         21 my $chain = $app;
573 22         29 for my $mw (reverse @$middlewares) {
574 27 100       97 $chain = ref($mw) eq 'CODE' ? $mw->($chain) : $mw->wrap($chain);
575             }
576 22         136 return $chain;
577             }
578              
579             sub to_app {
580 109     109 1 351 my ($self) = @_;
581              
582 109         139 my @routes = @{$self->{routes}};
  109         289  
583 109         163 my @websocket_routes = @{$self->{websocket_routes}};
  109         151  
584 109         99 my @sse_routes = @{$self->{sse_routes}};
  109         164  
585 109         109 my @mounts = @{$self->{mounts}};
  109         134  
586 109         130 my $not_found = $self->{not_found};
587              
588             # Pre-build middleware chains for efficiency
589 109         184 for my $route (@routes, @websocket_routes, @sse_routes) {
590 150         280 $route->{_handler} = $self->_build_middleware_chain($route->{middleware}, $route->{app});
591             }
592 109         151 for my $m (@mounts) {
593 15         28 $m->{_handler} = $self->_build_middleware_chain($m->{middleware}, $m->{app});
594             }
595              
596             # Helper to check mounts
597 40     40   61 my $check_mounts = async sub {
598 40         60 my ($scope, $receive, $send, $path) = @_;
599 40         82 for my $m (sort { length($b->{prefix}) <=> length($a->{prefix}) } @mounts) {
  4         14  
600 17         34 my $prefix = $m->{prefix};
601 17 100 100     262 if ($path eq $prefix || $path =~ m{^\Q$prefix\E(/.*)$}) {
602 15   50     45 my $sub_path = $1 // '/';
603             my $new_scope = {
604             %$scope,
605             path => $sub_path,
606 15   100     98 root_path => ($scope->{root_path} // '') . $prefix,
607             };
608 15         50 await $m->{_handler}->($new_scope, $receive, $send);
609 15         1775 return 1; # Matched
610             }
611             }
612 25         139 return 0; # No match
613 109         446 };
614              
615             # Emit an unmatched-route response using the event family that matches the
616             # scope. HTTP uses plain http.response.*; SSE and WebSocket scopes only
617             # accept their namespaced decline events (sse.http.response.* /
618             # websocket.http.response.*) before a stream/handshake starts — a plain
619             # http.response.* on those scopes raises on a conforming server. A custom
620             # not_found app, if configured, takes over for every scope type.
621 25     25   31 my $send_not_found = async sub {
622 25         39 my ($scope, $receive, $send) = @_;
623 25 100       38 if ($not_found) {
624 3         5 await $not_found->($scope, $receive, $send);
625 3         77 return;
626             }
627 22   100     58 my $type = $scope->{type} // 'http';
628 22 100       48 my $prefix = $type eq 'http' ? 'http.response' : "$type.http.response";
629 22         99 await $send->({
630             type => "$prefix.start",
631             status => 404,
632             headers => [['content-type', 'text/plain']],
633             });
634 22         591 await $send->({ type => "$prefix.body", body => 'Not Found', more => 0 });
635 109         301 };
636              
637 172     172   31289 return async sub {
638 172         250 my ($scope, $receive, $send) = @_;
639 172   100     448 my $type = $scope->{type} // 'http';
640 172   100     461 my $method = uc($scope->{method} // '');
641 172   100     342 my $path = $scope->{path} // '/';
642              
643             # Ignore lifespan events
644 172 100       332 return if $type eq 'lifespan';
645              
646             # WebSocket routes (path-only matching) - check before mounts
647 170 100       278 if ($type eq 'websocket') {
648 14         28 for my $route (@websocket_routes) {
649 12 100       94 if (my @captures = ($path =~ $route->{regex})) {
650 10         20 my %params;
651 10         14 for my $i (0 .. $#{$route->{names}}) {
  10         28  
652 6         19 $params{$route->{names}[$i]} = $captures[$i];
653             }
654              
655             # Check constraints — skip route if any fail
656 10 100       33 next unless $self->_check_constraints($route, \%params);
657              
658             my $new_scope = {
659             %$scope,
660             path_params => \%params,
661             'pagi.router' => { route => $route->{path} },
662 9         44 };
663 9         29 await $route->{_handler}->($new_scope, $receive, $send);
664 9         892 return;
665             }
666             }
667             # No websocket route matched - try mounts as fallback
668 5 50       19 if (await $check_mounts->($scope, $receive, $send, $path)) {
669 0         0 return;
670             }
671             # No mount matched either - 404
672 5         187 await $send_not_found->($scope, $receive, $send);
673 5         203 return;
674             }
675              
676             # SSE routes (path-only matching) - check before mounts
677 156 100       270 if ($type eq 'sse') {
678 12         20 for my $route (@sse_routes) {
679 10 50       84 if (my @captures = ($path =~ $route->{regex})) {
680 10         15 my %params;
681 10         16 for my $i (0 .. $#{$route->{names}}) {
  10         26  
682 6         18 $params{$route->{names}[$i]} = $captures[$i];
683             }
684              
685             # Check constraints — skip route if any fail
686 10 100       33 next unless $self->_check_constraints($route, \%params);
687              
688             my $new_scope = {
689             %$scope,
690             path_params => \%params,
691             'pagi.router' => { route => $route->{path} },
692 9         45 };
693 9         26 await $route->{_handler}->($new_scope, $receive, $send);
694 8         701 return;
695             }
696             }
697             # No SSE route matched - try mounts as fallback
698 3 50       7 if (await $check_mounts->($scope, $receive, $send, $path)) {
699 0         0 return;
700             }
701             # No mount matched either - 404
702 3         84 await $send_not_found->($scope, $receive, $send);
703 3         113 return;
704             }
705              
706             # HTTP routes (method + path matching) - check routes first
707             # HEAD should match GET routes
708 144 100       267 my $match_method = $method eq 'HEAD' ? 'GET' : $method;
709              
710 144         168 my @method_matches;
711              
712 144         237 for my $route (@routes) {
713 189 100       1131 if (my @captures = ($path =~ $route->{regex})) {
714              
715             # Build params FIRST (needed for constraint checking)
716 128         159 my %params;
717 128         149 for my $i (0 .. $#{$route->{names}}) {
  128         326  
718 43         108 $params{$route->{names}[$i]} = $captures[$i];
719             }
720              
721             # Check constraints — skip route if any fail
722 128 100       279 next unless $self->_check_constraints($route, \%params);
723              
724             # Check method
725 117         224 my $route_method = $route->{method};
726             my $method_match = ref($route_method) eq 'ARRAY'
727 117 100 66     470 ? (grep { $_ eq $match_method || $_ eq $method } @$route_method)
  14 100       61  
728             : ($route_method eq '*' || $route_method eq $match_method || $route_method eq $method);
729              
730 117 100       206 if ($method_match) {
731             my $new_scope = {
732             %$scope,
733             path_params => \%params,
734             'pagi.router' => { route => $route->{path} },
735 104         459 };
736              
737 104         275 return await $route->{_handler}->($new_scope, $receive, $send);
738             }
739              
740 13 100       34 if (ref($route->{method}) eq 'ARRAY') {
    50          
741 3         5 push @method_matches, @{$route->{method}};
  3         11  
742             } elsif ($route->{method} ne '*') {
743 10         27 push @method_matches, $route->{method};
744             }
745             }
746             }
747              
748             # Path matched but method didn't - 405
749 40 100       84 if (@method_matches) {
750 8         10 my $allowed = join ', ', sort keys %{{ map { $_ => 1 } @method_matches }};
  8         12  
  13         53  
751 8         44 await $send->({
752             type => 'http.response.start',
753             status => 405,
754             headers => [
755             ['content-type', 'text/plain'],
756             ['allow', $allowed],
757             ],
758             });
759 8         279 await $send->({ type => 'http.response.body', body => 'Method Not Allowed', more => 0 });
760 8         190 return;
761             }
762              
763             # No HTTP route matched - try mounts as fallback
764 32 100       66 if (await $check_mounts->($scope, $receive, $send, $path)) {
765 15         366 return;
766             }
767              
768             # No mount matched either - 404
769 17         603 await $send_not_found->($scope, $receive, $send);
770 109         708 };
771             }
772              
773             1;
774              
775             __END__