File Coverage

blib/lib/Mojolicious/Plugin/CanonicalURL.pm
Criterion Covered Total %
statement 180 180 100.0
branch 116 124 93.5
condition 47 53 88.6
subroutine 14 14 100.0
pod 2 2 100.0
total 359 373 96.2


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::CanonicalURL;
2 4     4   461099 use Mojo::Base 'Mojolicious::Plugin';
  4         16  
  4         28  
3 4     4   1266 use Carp ();
  4         7  
  4         64  
4 4     4   18 use Exporter 'import';
  4         6  
  4         95  
5 4     4   17 use Mojo::Util ();
  4         9  
  4         65  
6 4     4   20 use Scalar::Util ();
  4         7  
  4         62  
7 4     4   2155 use Sub::Quote ();
  4         18569  
  4         10181  
8              
9             our $VERSION = '0.05';
10              
11             our @EXPORT_OK = qw(remove_trailing_slashes);
12              
13             sub register {
14 308     308 1 20671201 my (undef, $app, $config) = @_;
15              
16             my (
17 308         1305 $should_canonicalize_request_config,
18             $should_not_canonicalize_request_config,
19             $inline_code,
20             $end_with_slash,
21             $canonicalize_before_render,
22             %captures
23             ) = _parse_and_validate_config($config);
24              
25 259         647 my $sub_string = '';
26 259         1269 my ($path_declared, $path_with_no_slashes_at_the_end_declared);
27 259 100       730 if (defined $should_canonicalize_request_config) {
28 109         492 ($path_declared, $path_with_no_slashes_at_the_end_declared, $sub_string) = _create_should_canonicalize_request_sub_string(
29             config => $should_canonicalize_request_config,
30             captures => \%captures,
31             sub_string => $sub_string,
32             should_canonicalize_request => 1,
33             path_declared => $path_declared,
34             path_with_no_slashes_at_the_end_declared => $path_with_no_slashes_at_the_end_declared,
35             );
36             }
37 259 100       666 if (defined $should_not_canonicalize_request_config) {
38 104         441 ($path_declared, $path_with_no_slashes_at_the_end_declared, $sub_string) = _create_should_canonicalize_request_sub_string(
39             config => $should_not_canonicalize_request_config,
40             captures => \%captures,
41             sub_string => $sub_string,
42             should_canonicalize_request => undef,
43             path_declared => $path_declared,
44             path_with_no_slashes_at_the_end_declared => $path_with_no_slashes_at_the_end_declared,
45             );
46             }
47 259 100       640 $sub_string .= $inline_code if $inline_code;
48              
49 259 100       800 $sub_string .= 'my $_mpcu_path = $c->req->url->path->to_string;' unless $path_declared;
50 259 100       590 if ($end_with_slash) {
51 10         38 $sub_string .= q{
52             my $_mpcu_path_length = length($_mpcu_path);
53             return $next->() if $_mpcu_path_length != 0 and rindex($_mpcu_path, '/') == $_mpcu_path_length - 1 and ($_mpcu_path_length < 2 or rindex($_mpcu_path, '//') != $_mpcu_path_length - 2);
54              
55             while (rindex($_mpcu_path, '/') == length($_mpcu_path) - 1) {
56             substr $_mpcu_path, -1, 1, '';
57             }
58              
59             my $url = $c->req->url->clone;
60             $url->path($_mpcu_path)->path->trailing_slash(1);
61              
62             $c->res->code(301);
63             $c->redirect_to($url);
64             };
65             } else {
66 249         686 $sub_string .= q{
67             return $next->() if $_mpcu_path eq '/' or rindex($_mpcu_path, '/') != length($_mpcu_path) - 1 or $_mpcu_path eq '';
68              
69             while (rindex($_mpcu_path, '/') == length($_mpcu_path) - 1) {
70             substr $_mpcu_path, -1, 1, '';
71             }
72              
73             $c->res->code(301);
74             $c->redirect_to($c->req->url->clone->path($_mpcu_path));
75             };
76             }
77              
78             # Potentially flaky for a minor speed improvment. Could just assign $next and $c above to @_.
79             # Or could use Mojo::Template, but that would be awkward writing perl code.
80 259         2050 $sub_string =~ s/\$next\b/\$_[0]/g;
81 259         2571 $sub_string =~ s/\$c\b/\$_[1]/g;
82              
83 259         1549 $app->hook(around_action => _quote_sub($sub_string, \%captures));
84              
85 259 100       190803 if ($canonicalize_before_render) {
86             # replace return $next->() with return
87 91         949 $sub_string =~ s/return\s+\$_\[0\]->\(\)/return/g;
88              
89             # replace $_[1] with $_[0] since $c is now the first argument
90 91         546 $sub_string =~ s/\$_\[1\]/\$_[0]/gm;
91              
92             # we could set a stash variable if we failed to canonicalize in
93             # around_action, but the performance hit isn't big
94 91         256 $sub_string = "return if \$_[0]->res->is_redirect;$sub_string";
95 91         267 $app->hook(before_render => _quote_sub($sub_string, \%captures));
96             }
97             }
98              
99             sub _quote_sub {
100 350     350   962 my ($sub_string, $captures) = @_;
101 350         5216 return Sub::Quote::quote_sub $sub_string, $captures, {no_install => 1, no_defer => 1};
102             }
103              
104             sub _parse_and_validate_config {
105 308     308   700 my ($config) = @_;
106              
107             my (
108 308         1092 $should_canonicalize_request,
109             $should_not_canonicalize_request,
110             $inline_code,
111             $end_with_slash,
112             $canonicalize_before_render
113             );
114 308         0 my %captures;
115 308 50       1018 if (defined $config) {
116 308 50 33     2614 Carp::confess 'config must be a hash reference, but was ' . Scalar::Util::reftype $config
117             if not defined Scalar::Util::reftype $config
118             or Scalar::Util::reftype $config ne 'HASH';
119              
120 308 100       908 if (%$config) {
121 303         490 my $captures_allowed;
122 303 100       1038 if (exists $config->{should_canonicalize_request}) {
123             ($should_canonicalize_request, $captures_allowed) =
124 131         443 _validate_should_canonicalize_request_config(delete $config->{should_canonicalize_request}, 1);
125             }
126 283 100       1192 if (exists $config->{should_not_canonicalize_request}) {
127             ($should_not_canonicalize_request, $captures_allowed) = _validate_should_canonicalize_request_config(
128             delete $config->{should_not_canonicalize_request},
129             undef,
130 125         443 );
131             }
132              
133 263 100       786 if (exists $config->{inline_code}) {
134 17         42 $inline_code = delete $config->{inline_code};
135 17 100 100     150 Carp::confess 'inline_code must be a true scalar value'
136             unless not defined Scalar::Util::reftype $inline_code and $inline_code;
137 13         27 $captures_allowed = 1;
138             }
139              
140 259 100       802 if (exists $config->{canonicalize_before_render}) {
141             Carp::confess 'canonicalize_before_render must be a scalar value'
142 184 100       609 if defined Scalar::Util::reftype $config->{canonicalize_before_render};
143 183         406 $canonicalize_before_render = delete $config->{canonicalize_before_render};
144             }
145              
146 258 100 100     895 if ($captures_allowed and exists $config->{captures}) {
147 31         92 %captures = %{delete $config->{captures}};
  31         118  
148 31 100       92 Carp::confess 'captures cannot be empty' unless %captures;
149             }
150              
151             Carp::confess
152             'captures only applies when inline_code is set or a scalar reference is passed to should_canonicalize_request or should_not_canonicalize_request'
153 257 100       1132 if exists $config->{captures};
154              
155 255 100       633 if (exists $config->{end_with_slash}) {
156 20         112 $end_with_slash = delete $config->{end_with_slash};
157 20 50       82 Carp::confess 'end_with_slash must be a scalar value' if defined Scalar::Util::reftype $end_with_slash;
158             }
159              
160 255 100       880 Carp::confess 'unknown keys passed in config: ' . Mojo::Util::dumper $config if keys %$config;
161             }
162             }
163              
164             return (
165 259         1213 $should_canonicalize_request,
166             $should_not_canonicalize_request,
167             $inline_code,
168             $end_with_slash,
169             $canonicalize_before_render,
170             %captures
171             );
172             }
173              
174             sub _validate_should_canonicalize_request_config {
175 256     256   738 my ($config, $should_canonicalize_request) = @_;
176              
177 256         454 my $captures_allowed;
178 256         715 my $config_name = _get_should_canonicalize_request_config_name($should_canonicalize_request);
179 256   100     988 my $config_reftype = Scalar::Util::reftype $config || '';
180             Carp::confess
181             "$config_name must be a scalar that evaluates to true and starts with a '/', a REGEXP, a SCALAR, a subroutine, an array reference, or a hash reference"
182             unless $config
183             and ((not $config_reftype and index($config, '/') == 0)
184 256 100 100     1777 or grep { $config_reftype eq $_ } qw/ARRAY HASH REGEXP SCALAR CODE/);
      100        
185              
186 248 100 66     2567 if (defined $config_reftype and $config_reftype eq 'SCALAR') {
    100 66        
    100 66        
187 39         79 $captures_allowed = 1;
188             } elsif (defined $config_reftype and $config_reftype eq 'ARRAY') {
189 91 100       286 Carp::confess "array passed to $config_name must not be empty" unless @$config;
190              
191 89         240 for (@$config) {
192 154 100       407 Carp::confess "elements of $config_name must be a true value" unless $_;
193              
194 148         326 my $reftype = Scalar::Util::reftype $_;
195 148 100 100     1034 Carp::confess
      100        
      100        
      100        
196             "elements of $config_name must have a reftype of undef (scalar), CODE, HASH, REGEXP, or SCALAR but was '$reftype'"
197             unless not defined $reftype
198             or $reftype eq 'CODE'
199             or $reftype eq 'HASH'
200             or $reftype eq 'REGEXP'
201             or $reftype eq 'SCALAR';
202 146 100 100     514 Carp::confess "elements of $config_name must begin with a '/' when they are scalar"
203             if not defined $reftype and index($_, '/') != 0;
204              
205 144 100 100     484 if (defined $reftype and $reftype eq 'SCALAR') {
206 24         51 $captures_allowed = 1;
207             }
208              
209 144 100 100     795 if (defined $reftype and $reftype eq 'HASH') {
210 37         103 _validate_starts_with_hash($config_name, $_);
211             }
212             }
213             } elsif (defined $config_reftype and $config_reftype eq 'HASH') {
214 42         152 _validate_starts_with_hash($config_name, $config);
215             }
216              
217 216         664 return ($config, $captures_allowed);
218             }
219              
220             sub _validate_starts_with_hash {
221 79     79   332 my ($config_name, $hash) = @_;
222 79         296 my %copy = %$hash;
223 79 100       386 Carp::confess "must provide key 'starts_with' to hash in $config_name" unless exists $copy{starts_with};
224 75 100       265 Carp::confess 'value for starts_with must not be undef' unless defined $copy{starts_with};
225             Carp::confess 'value for starts_with must be a scalar'
226 71 100       349 unless not defined Scalar::Util::reftype $copy{starts_with};
227             Carp::confess q{value for starts_with must begin with a '/'}
228 67 100       306 unless index(delete $copy{starts_with}, '/') == 0;
229 63 100       275 Carp::confess "unknown keys/values passed in hash inside of $config_name: " . Mojo::Util::dumper \%copy
230             if %copy
231             }
232              
233             sub _create_should_canonicalize_request_sub_string {
234 213     213   1377 my %args = @_;
235             my ($config, $captures, $sub_string, $should_canonicalize_request, $path_declared, $path_with_no_slashes_at_the_end_declared) =
236 213         521 @{{@_}}{qw/config captures sub_string should_canonicalize_request path_declared path_with_no_slashes_at_the_end_declared/};
  213         1387  
237 213         635 my $path_with_no_slashes_at_the_end_declared_code = q{
238             my $_mpcu_path_with_no_slashes_at_the_end = $_mpcu_path;
239             while (rindex($_mpcu_path_with_no_slashes_at_the_end, '/') == length($_mpcu_path_with_no_slashes_at_the_end) - 1) {
240             substr $_mpcu_path_with_no_slashes_at_the_end, -1, 1, '';
241             }
242             };
243              
244 213         465 my $config_name = _get_should_canonicalize_request_config_name($should_canonicalize_request);
245 213         598 my $config_variable_name = "\$$config_name";
246 213 100       453 my $if_or_unless = $should_canonicalize_request ? 'unless' : 'if';
247 213         544 my $reftype = Scalar::Util::reftype $config;
248 213 100       1143 if (not defined $reftype) {
    100          
    100          
    100          
    100          
249 23         103 $config =~ s#/+\z##m;
250 23         108 $captures->{$config_variable_name} = \$config;
251              
252 23 50       75 unless ($path_with_no_slashes_at_the_end_declared) {
253 23 50       76 unless ($path_declared) {
254 23         60 $sub_string .= 'my $_mpcu_path = $c->req->url->path->to_string;';
255 23         44 $path_declared = 1;
256             }
257              
258 23         72 $sub_string .= $path_with_no_slashes_at_the_end_declared_code;
259 23         52 $path_with_no_slashes_at_the_end_declared = 1;
260             }
261 23         97 $sub_string .= "return \$next->() $if_or_unless \$_mpcu_path_with_no_slashes_at_the_end eq $config_variable_name;";
262             } elsif ($reftype eq 'REGEXP') {
263 18 100       57 unless ($path_declared) {
264 12         35 $sub_string .= 'my $_mpcu_path = $c->req->url->path->to_string;';
265 12         25 $path_declared = 1;
266             }
267              
268 18         53 $captures->{$config_variable_name} = \$config;
269 18         72 $sub_string .= "return \$next->() $if_or_unless \$_mpcu_path =~ $config_variable_name;";
270             } elsif ($reftype eq 'SCALAR') {
271 39         73 my $code = $$config;
272 39 100       221 $code = "return \$next->() $if_or_unless $code" if $code !~ /\A\s*return/;
273 39 100       154 $code .= ';' unless $code =~ /;\s*\z/;
274              
275 39 50       207 Carp::confess 'code must contain return $next->()' unless $code =~ /return\s+\$next->\(\)/;
276              
277 39         104 $sub_string .= $code;
278             } elsif ($reftype eq 'CODE') {
279 32         96 $captures->{$config_variable_name} = \$config;
280 32         134 $sub_string .= qq{
281             local \$_ = \$c;
282             return \$next->() $if_or_unless $config_variable_name->();
283             };
284             } elsif ($reftype eq 'HASH') {
285 32 50       110 unless ($path_declared) {
286 32         83 $sub_string .= 'my $_mpcu_path = $c->req->url->path->to_string;';
287 32         66 $path_declared = 1;
288             }
289              
290 32         79 my $starts_with = $config->{starts_with};
291 32         97 $captures->{$config_variable_name} = \$starts_with;
292 32         126 $sub_string .= qq{return \$next->() $if_or_unless index(\$_mpcu_path, $config_variable_name) == 0;}
293             } else {
294 69 100 66     174 if (grep { not defined Scalar::Util::reftype $_ } @$config and not $path_with_no_slashes_at_the_end_declared) {
  134         579  
295 32 50       108 unless ($path_declared) {
296 32         86 $sub_string .= 'my $_mpcu_path = $c->req->url->path->to_string;';
297 32         69 $path_declared = 1;
298             }
299              
300 32         110 $sub_string .= $path_with_no_slashes_at_the_end_declared_code;
301 32         64 $path_with_no_slashes_at_the_end_declared = 1;
302             }
303              
304 69 100       189 unless ($path_declared) {
305 37         99 for my $config_item (@$config) {
306 47         109 my $reftype = Scalar::Util::reftype $config_item;
307 47 100 100     210 if ($reftype eq 'REGEXP' or $reftype eq 'HASH') {
308 11         28 $sub_string .= 'my $_mpcu_path = $c->req->url->path->to_string;';
309 11         37 $path_declared = 1;
310 11         27 last;
311             }
312             }
313             }
314              
315 69 100       223 $sub_string .= 'return $next->() unless' if $should_canonicalize_request;
316 69         317 for my $index (0 .. $#$config) {
317 134         353 my $reftype_of_item = Scalar::Util::reftype $config->[$index];
318 134         202 my $condition;
319              
320 134 100       545 if (not defined $reftype_of_item) {
    100          
    100          
    100          
321 32         142 $config->[$index] =~ s#/+\z##;
322 32         129 my $var_name = '$_mpcu_' . $config_name . "_eq_$index";
323 32         71 my $value = $config->[$index];
324 32         117 $captures->{$var_name} = \$value;
325              
326 32         135 $condition = "\$_mpcu_path_with_no_slashes_at_the_end eq $var_name";
327             } elsif ($reftype_of_item eq 'CODE') {
328 12         43 my $var_name = '$_mpcu_' . $config_name . "_code_$index";
329 12         32 my $value = $config->[$index];
330 12         38 $captures->{$var_name} = \$value;
331 12         45 $condition .= "do { local \$_ = \$c; $var_name->(); }";
332             } elsif ($reftype_of_item eq 'HASH') {
333 27         94 my $var_name = '$_mpcu_' . $config_name . "_starts_with_$index";
334 27         65 my $value = $config->[$index]{starts_with};
335 27         72 $captures->{$var_name} = \$value;
336 27         75 $condition = "index(\$_mpcu_path, $var_name) == 0";
337             } elsif ($reftype_of_item eq 'REGEXP') {
338 39         133 my $var_name = '$_mpcu_' . $config_name . "_regexp_$index";
339 39         85 my $value = $config->[$index];
340 39         198 $captures->{$var_name} = \$value;
341 39         98 $condition = "\$_mpcu_path =~ $var_name";
342             } else {
343 24         42 $condition = ${$config->[$index]};
  24         60  
344             }
345              
346 134 100       262 if ($should_canonicalize_request) {
347 72 100       201 $sub_string .= " ||" if $index != 0;
348 72         195 $sub_string .= " $condition";
349             } else {
350 62         184 $sub_string .= "return \$next->() if $condition;";
351             }
352             }
353              
354 69 100       210 $sub_string .= ';' if $should_canonicalize_request;
355             }
356              
357 213         867 return ($path_declared, $path_with_no_slashes_at_the_end_declared, $sub_string);
358             }
359              
360             sub _get_should_canonicalize_request_config_name {
361 469 100   469   1304 return shift() ? 'should_canonicalize_request' : 'should_not_canonicalize_request';
362             }
363              
364             sub remove_trailing_slashes {
365 16     16 1 148977 my ($path) = "$_[0]"; # turn possible Mojo::Path into string.
366              
367 16         1041 while (rindex($path, '/') == length($path) - 1) {
368 14         43 substr $path, -1, 1, '';
369             }
370              
371 16         186 return $path;
372             }
373              
374             1;
375             __END__