File Coverage

lib/PAGI/App/URLMap.pm
Criterion Covered Total %
statement 44 48 91.6
branch 7 8 87.5
condition 6 10 60.0
subroutine 8 9 88.8
pod 2 4 50.0
total 67 79 84.8


line stmt bran cond sub pod time code
1             package PAGI::App::URLMap;
2             $PAGI::App::URLMap::VERSION = '0.002000';
3 1     1   206487 use strict;
  1         1  
  1         34  
4 1     1   3 use warnings;
  1         1  
  1         55  
5 1     1   4 use Future::AsyncAwait;
  1         2  
  1         9  
6 1     1   475 use PAGI::Utils ();
  1         2  
  1         730  
7              
8             =head1 NAME
9              
10             PAGI::App::URLMap - Mount apps at URL path prefixes
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::App::URLMap;
15              
16             my $map = PAGI::App::URLMap->new;
17             $map->mount('/api' => $api_app);
18             $map->mount('/static' => PAGI::App::File->new(root => $dir));
19             my $app = $map->to_app;
20              
21             =cut
22              
23             sub new {
24 6     6 0 209614 my ($class, %args) = @_;
25              
26             return bless {
27             mounts => [],
28 6 100       36 default => defined $args{default} ? PAGI::Utils::to_app($args{default}) : undef,
29             }, $class;
30             }
31              
32             sub mount {
33 9     9 1 77 my ($self, $path, $app) = @_;
34              
35 9         102 $path =~ s{/+$}{}; # Remove trailing slashes
36 9         11 push @{$self->{mounts}}, [$path, PAGI::Utils::to_app($app)];
  9         28  
37             # Keep sorted by length (longest first) for proper matching
38 9         36 @{$self->{mounts}} = sort { length($b->[0]) <=> length($a->[0]) } @{$self->{mounts}};
  9         13  
  3         10  
  9         22  
39 9         14 return $self;
40             }
41              
42             sub map {
43 0     0 1 0 my ($self, $mapping) = @_;
44              
45 0         0 while (my ($path, $app) = each %$mapping) {
46 0         0 $self->mount($path, $app);
47             }
48 0         0 return $self;
49             }
50              
51             sub to_app {
52 6     6 0 20 my ($self) = @_;
53              
54 6         4 my @mounts = @{$self->{mounts}};
  6         12  
55 6         7 my $default = $self->{default};
56              
57 9     9   1638 return async sub {
58 9         12 my ($scope, $receive, $send) = @_;
59 9   50     19 my $path = $scope->{path} // '/';
60              
61 9         15 for my $mount (@mounts) {
62 11         19 my ($prefix, $app) = @$mount;
63              
64 11 100 33     155 if ($prefix eq '' || $path eq $prefix || $path =~ /^\Q$prefix\E\//) {
      66        
65             # Match found - adjust path for mounted app
66 7         8 my $new_path = $path;
67 7         38 $new_path =~ s/^\Q$prefix\E//;
68 7 50       13 $new_path = '/' if $new_path eq '';
69              
70             my $new_scope = {
71             %$scope,
72             path => $new_path,
73 7   100     41 root_path => ($scope->{root_path} // '') . $prefix,
74             };
75              
76 7         13 await $app->($new_scope, $receive, $send);
77 7         726 return;
78             }
79             }
80              
81             # No match - use default or 404
82 2 100       4 if ($default) {
83 1         3 await $default->($scope, $receive, $send);
84             } else {
85 1         6 await $send->({
86             type => 'http.response.start',
87             status => 404,
88             headers => [['content-type', 'text/plain']],
89             });
90 1         44 await $send->({ type => 'http.response.body', body => 'Not Found', more => 0 });
91             }
92 6         26 };
93             }
94              
95             1;
96              
97             __END__