File Coverage

blib/lib/Klonk/Routes.pm
Criterion Covered Total %
statement 12 133 9.0
branch 0 78 0.0
condition 0 19 0.0
subroutine 4 11 36.3
pod 3 4 75.0
total 19 245 7.7


line stmt bran cond sub pod time code
1             package Klonk::Routes 0.01;
2 1     1   512 use Klonk::pragma;
  1         2  
  1         9  
3 1     1   518 use Carp qw(croak);
  1         1  
  1         56  
4 1     1   774 use HTML::Blitz ();
  1         117475  
  1         37  
5 1     1   13 use Klonk::Env ();
  1         41  
  1         766  
6              
7             our %routes;
8              
9 0 0   0     fun _parse_route($pattern) {
  0 0          
  0            
  0            
10 0           my @segments;
11 0           while () {
12 0 0         $pattern =~ m{\G ( / [^`*]*+ (?: `[`*] [^`*]*+ )*+ ) }xgc
13             or croak "Malformed route pattern (must start with '/'): '$pattern'";
14 0           my $raw = $1;
15 0           $raw =~ s/`([`*])/$1/g;
16 0           push @segments, $raw;
17 0 0         if ($pattern =~ m{\G \z}x) {
18 0           last;
19             }
20 0 0         if ($pattern =~ m{\G `}x) {
21 0           croak "Malformed route pattern ('`' must be followed by '`' or '*'): '$pattern'";
22             }
23 0 0         if ($pattern =~ m{\G (?<= / ) \*\* \z}xgc) {
24 0           push @segments, undef;
25 0           last;
26             }
27 0 0         if ($pattern =~ m{\G (?<= / ) \* }xgc) {
28 0 0         if ($pattern =~ m{\G \z}x) {
29 0           push @segments, '';
30 0           last;
31             }
32 0 0         $pattern =~ m{\G / }x
33             or croak "Malformed route pattern ('*' must be followed by '/'): '$pattern'";
34             } else {
35 0           croak "Malformed route pattern ('*' must be preceded by '/') :'$pattern'";
36             }
37             }
38             \@segments
39 0           }
40              
41             my $dispatch_info;
42              
43 0 0 0 0 1   fun mkroute($pattern, %handlers) {
  0 0          
  0            
  0            
44 0           my (undef, $filename, $line) = caller;
45 0           my $location = [$filename, $line];
46              
47 0           $dispatch_info = undef;
48 0   0       my $def = $routes{$pattern} //= do {
49 0           my $chunks = _parse_route $pattern;
50 0 0         my $weight = join('', map chr(defined $_ ? 1 + length : 0), @$chunks) . "\x{7fff_ffff}";
51             #say STDERR sprintf "weight(%s): %vd", $pattern, $weight;
52             +{
53 0           chunks => $chunks,
54             weight => $weight,
55             resource => {},
56             }
57             };
58              
59 0           for my $pmethod (sort keys %handlers) {
60 0           my $method = uc $pmethod;
61 0           my $resource = $def->{resource};
62 0 0         exists $resource->{$method}
63             and croak "Redefinition of $method $pattern (previously defined at $resource->{$method}{location}[0] line $resource->{$method}{location}[1])";
64             $resource->{$method} = {
65             location => $location,
66 0           handler => $handlers{$pmethod},
67             };
68             }
69             }
70              
71             my $redirect_template = do {
72             my $blitz = HTML::Blitz->new(
73             { dummy_marker_re => qr/\bXXX\b/ },
74             [ 'a[href=URL]' =>
75             [ 'set_attribute_var', href => 'url' ],
76             [ 'replace_inner_var', 'url' ],
77             ],
78             [ 'title, h1' =>
79             [ 'replace_inner_text', "308 Permanent Redirect" ]
80             ],
81             );
82              
83             my $template = $blitz->apply_to_html(
84             '(inline_redirect)',
85             'XXX

XXX

See XXX

'
86             );
87              
88             $template->compile_to_sub
89             };
90              
91 0 0   0     fun _routes_prepare() {
  0            
92 0           my $gen = 'a';
93 0           my %mapping;
94             my $regex = join '|',
95             map {
96 0           my @chunks = @{$_->[2]};
  0            
97 0 0         my $rest = defined $chunks[-1] ? 0 : (pop @chunks, 1);
98 0           my $re = join '([^/]*+)', map quotemeta, @chunks;
99 0 0         $re .= '(.*+)' if $rest;
100 0           $re .= "(*:$gen)";
101 0           $mapping{$gen++} = $_->[0];
102 0           $re
103             }
104 0 0         sort { $b->[1] cmp $a->[1] || $a->[0] cmp $b->[0] }
105 0           map [$_, @{$routes{$_}}{'weight', 'chunks'}],
  0            
106             keys %routes;
107 0 0         $regex = '(?!)' if $regex eq '';
108             #say STDERR ">>> $regex";
109             #use re 'debugcolor';
110             [
111 0           qr/\A(?|$regex)\z/s,
112             \%mapping,
113             ]
114             }
115              
116             my %text_types = (
117             'css' => 'text/css',
118             'csv' => 'text/csv',
119             'html' => 'text/html',
120             'js' => 'text/javascript',
121             'json' => 'application/json',
122             'jsonld' => 'application/ld+json',
123             'text' => 'text/plain',
124             );
125              
126             my %bin_types = (
127             'bin' => 'application/octet-stream',
128             'jpeg' => 'image/jpeg',
129             'png' => 'image/png',
130             'webp' => 'image/webp',
131             );
132              
133 0 0   0     fun _postprocess($ret) {
  0 0          
  0            
  0            
134 0           my $status = 200;
135 0 0         if ($ret->[0] =~ /\A\d{3}\z/a) {
136 0           $status = shift @$ret;
137             }
138 0           my ($itype, $body, $headers) = @$ret;
139              
140 0           for my $spec (
141             [\%text_types, 1],
142             [\%bin_types, 0],
143             ) {
144 0           my ($type_map, $encode_p) = @$spec;
145 0 0         if (my $type = $type_map->{$itype}) {
146 0 0         if ($encode_p) {
147 0 0         utf8::encode $body unless ref $body;
148 0           $type .= '; charset=utf-8';
149             }
150 0 0 0       my $length = ref $body
151             ? -s $body || undef
152             : length $body;
153             return [
154             $status,
155             [
156             'content-type' => $type,
157             defined $length
158             ? ('content-length' => $length)
159             : (),
160             map {
161 0           my $k = $_;
162 0           my $v = $headers->{$k};
163 0 0         map +($k => $_), ref($v) eq 'ARRAY' ? @$v : $v
164             }
165 0 0 0       keys %{$headers // {}}
  0 0          
166             ],
167             ref $body ? $body : [ $body ]
168             ];
169             }
170             }
171            
172 0           die "Unknown content type: $itype";
173             }
174              
175             my $booted;
176             my @init;
177              
178 0 0   0 1   fun on_init($fun) {
  0 0          
  0            
  0            
179 0 0         croak "Can't call on_init() after boot()" if $booted;
180 0           push @init, $fun;
181             }
182              
183 0 0   0 0   fun dispatch($env) {
  0 0          
  0            
  0            
184 0           my $kenv = Klonk::Env->new($env);
185 0           my $req_path = $env->{PATH_INFO};
186 0           my $req_method = $env->{REQUEST_METHOD};
187              
188 0   0       $dispatch_info //= _routes_prepare;
189 0           local our $REGMARK;
190 0 0         if (my @captures = $req_path =~ /$dispatch_info->[0]/) {
191 0           my $pattern = $dispatch_info->[1]{$REGMARK};
192 0           my $meta = $routes{$pattern};
193 0           splice @captures, $#{$meta->{chunks}};
  0            
194 0           my $resource = $meta->{resource};
195 0 0         if (my $info = $resource->{$req_method}) {
196 0           my $handler = $info->{handler};
197 0           return _postprocess $handler->($kenv, @captures);
198             }
199              
200 0 0 0       if ($req_method eq 'HEAD' && (my $info = $resource->{GET})) {
201 0           my $ret = _postprocess $info->{handler}($kenv, @captures);
202 0           return [ 204, $ret->[1], [] ];
203             }
204              
205 0           my $allowed_methods = join ', ', sort keys %$resource;
206 0 0         if ($req_method eq 'OPTIONS') {
207 0           return [ 204, [ 'allow' => $allowed_methods ], [] ];
208             }
209              
210 0           return _postprocess [
211             405,
212             'html',
213             "405 Method Not Allowed

405 Method Not Allowed

",
214             { allow => $allowed_methods }
215             ];
216             }
217              
218 0 0 0       if ($req_path !~ m{/\z} && "$req_path/" =~ /$dispatch_info->[0]/) {
219 0           my $uri = $env->{REQUEST_URI};
220 0           $uri =~ s{(?=[?#])|\z}{/};
221 0           return _postprocess [
222             308,
223             'html',
224             $redirect_template->({ url => $uri }),
225             {
226             'location' => $uri,
227             },
228             ];
229             }
230              
231 0           return _postprocess [
232             404,
233             'html',
234             "404 Not Found

404 Not Found

",
235             ];
236             }
237              
238 0 0   0 1   fun boot() {
  0            
239 0           $booted = 1;
240 0           my @fun = splice @init;
241 0           for my $fun (@fun) {
242 0           $fun->();
243             }
244              
245 0           \&dispatch
246             }
247              
248             1
249             __END__