File Coverage

blib/lib/Path/Router.pm
Criterion Covered Total %
statement 166 173 95.9
branch 49 58 84.4
condition 22 23 95.6
subroutine 21 21 100.0
pod 5 5 100.0
total 263 280 93.9


line stmt bran cond sub pod time code
1             package Path::Router;
2             our $AUTHORITY = 'cpan:STEVAN';
3             $Path::Router::VERSION = '0.15';
4 15     15   117487 use 5.008;
  15         34  
5 15     15   120 use Carp 1.32;
  15         213  
  15         737  
6 15     15   5967 use Eval::Closure 0.13;
  15         23878  
  15         685  
7 15     15   70 use File::Spec::Unix 3.40 ();
  15         210  
  15         253  
8 15     15   6131 use Try::Tiny 0.19;
  15         20489  
  15         803  
9 15     15   6754 use Types::Standard 1.000005 -types;
  15         633458  
  15         133  
10              
11 15     15   44797 use Path::Router::Types;
  15         402  
  15         133  
12 15     15   7698 use Path::Router::Route;
  15         37  
  15         497  
13 15     15   6049 use Path::Router::Route::Match;
  15         26  
  15         434  
14              
15 15     15   76 use Moo 2.000001;
  15         200  
  15         81  
16 15     15   3846 use namespace::clean 0.23;
  15         190  
  15         65  
17             # ABSTRACT: A tool for routing paths
18              
19              
20 15 50   15   5749 use constant 1.24 DEBUG => exists $ENV{PATH_ROUTER_DEBUG} ? $ENV{PATH_ROUTER_DEBUG} : 0;
  15         230  
  15         23395  
21              
22             has 'routes' => (
23             is => 'ro',
24             isa => ArrayRef[InstanceOf['Path::Router::Route']],
25             default => sub { [] },
26             );
27              
28             has 'route_class' => (
29             is => 'ro',
30             isa => ClassName,
31             default => 'Path::Router::Route',
32             );
33              
34             has 'inline' => (
35             is => 'rw',
36             isa => Bool,
37             default => 1,
38             trigger => sub { $_[0]->clear_match_code }
39             );
40              
41             has 'match_code' => (
42             is => 'rw',
43             isa => CodeRef,
44             lazy => 1,
45             builder => 1,
46             clearer => 'clear_match_code'
47             );
48              
49             sub _build_match_code {
50 19     19   7019 my $self = shift;
51              
52 19         26 my @code;
53 19         23 my $i = 0;
54 19         24 foreach my $route (@{$self->routes}) {
  19         57  
55 54         119 push @code, $route->generate_match_code($i++);
56             }
57              
58 19         222 return eval_closure(
59             source => [
60             'sub {',
61             '#line ' . __LINE__ . ' "' . __FILE__ . '"',
62             'my $self = shift;',
63             'my $path = shift;',
64             'my $routes = $self->routes;',
65             'my @matches;',
66             @code,
67             '#line ' . __LINE__ . ' "' . __FILE__ . '"',
68             'if (@matches == 0) {',
69             'print STDERR "match failed\n" if Path::Router::DEBUG();',
70             'return;',
71             '}',
72             'elsif (@matches == 1) {',
73             'return $matches[0];',
74             '}',
75             'else {',
76             'return $self->_disambiguate_matches($path, @matches);',
77             '}',
78             '}',
79             ]
80             );
81             }
82              
83             sub add_route {
84 60     60 1 11905 my ($self, $path, %options) = @_;
85 60         66 push @{$self->routes} => $self->route_class->new(
  60         874  
86             path => $path,
87             %options
88             );
89 60         1045 $self->clear_match_code;
90             }
91              
92             sub insert_route {
93 3     3 1 299 my ($self, $path, %options) = @_;
94 3   100     11 my $at = delete $options{at} || 0;
95              
96 3         52 my $route = $self->route_class->new(
97             path => $path,
98             %options
99             );
100 3         17 my $routes = $self->routes;
101              
102 3 100       9 if (! $at) {
    50          
103 1         3 unshift @$routes, $route;
104 2         5 } elsif ($#{$routes} < $at) {
105 2         3 push @$routes, $route;
106             } else {
107 0         0 splice @$routes, $at, 0, $route;
108             }
109 3         51 $self->clear_match_code;
110             }
111              
112             sub include_router {
113 5     5 1 1337 my ($self, $path, $router) = @_;
114              
115 5 100 100     321 ($path eq '' || $path =~ /\/$/)
116             || confess "Path is either empty or does not end with /";
117              
118 2         4 push @{ $self->routes } => map {
119 5         33 $_->clone( path => ($path . $_->path) )
120 2         2 } @{ $router->routes };
  2         4  
121 2         37 $self->clear_match_code;
122             }
123              
124             sub match {
125 138     138 1 8871 my ($self, $url) = @_;
126 138         418 $url = File::Spec::Unix->canonpath($url);
127 138         326 $url =~ s|^/||; # Path::Router specific. remove first /
128              
129 138 100       2171 if ($self->inline) {
130 132         6597 return $self->match_code->($self, $url);
131             } else {
132 6         985 my @parts = split '/' => $url;
133              
134 6         7 my @matches;
135 6         6 for my $route (@{$self->routes}) {
  6         18  
136 14 100       132 my $match = $route->match(\@parts) or next;
137 6         3558 push @matches, $match;
138             }
139 6 50       68 return if @matches == 0;
140 6 50       23 return $matches[0] if @matches == 1;
141 0         0 return $self->_disambiguate_matches($url, @matches);
142             }
143 0         0 return;
144             }
145              
146             sub _disambiguate_matches {
147 6     6   7 my $self = shift;
148 6         8 my ($path, @matches) = @_;
149              
150 6         6 my $min;
151             my @found;
152 6         8 for my $match (@matches) {
153 12         8 my $vars = @{ $match->route->required_variable_component_names };
  12         187  
154 12 100 100     192 if (!defined($min) || $vars < $min) {
    100          
155 8         9 @found = ($match);
156 8         12 $min = $vars;
157             }
158             elsif ($vars == $min) {
159 1         2 push @found, $match;
160             }
161             }
162              
163             confess "Ambiguous match: path $path could match any of "
164 6 100       21 . join(', ', sort map { $_->route->path } @found)
  2         97  
165             if @found > 1;
166              
167 5         32 return $found[0];
168             }
169              
170             sub uri_for {
171 72     72 1 222 my ($self, %orig_url_map) = @_;
172              
173             # anything => undef is useless; ignore it and let the defaults override it
174 72         167 for (keys %orig_url_map) {
175 162 50       287 delete $orig_url_map{$_} unless defined $orig_url_map{$_};
176             }
177              
178 72         79 my @possible;
179 72         64 foreach my $route (@{$self->routes}) {
  72         162  
180 258         549 local $SIG{__DIE__};
181              
182 258         182 my @url;
183             my $url = try {
184              
185 258     258   5561 my %url_map = %orig_url_map;
186              
187 204         1154 my %required = map {( $_ => 1 )}
188 258         257 @{ $route->required_variable_component_names };
  258         4400  
189              
190 60         406 my %optional = map {( $_ => 1 )}
191 258         893 @{ $route->optional_variable_component_names };
  258         3216  
192              
193 258         1224 my %url_defaults;
194              
195 258 50       182 my %match = %{$route->defaults || {}};
  258         942  
196              
197 258         497 for my $component (keys(%required), keys(%optional)) {
198 264 100       458 next unless exists $match{$component};
199 53         91 $url_defaults{$component} = delete $match{$component};
200             }
201             # any remaining keys in %defaults are 'extra' -- they don't appear
202             # in the url, so they need to match exactly rather than being
203             # filled in
204              
205 258         694 %url_map = (%url_defaults, %url_map);
206              
207 258         463 my @keys = keys %url_map;
208              
209 258         164 if (DEBUG) {
210             warn "> Attempting to match ", $route->path, " to (", (join " / " => @keys), ")";
211             }
212             (
213 258 100 100     1327 @keys >= keys(%required) &&
214             @keys <= (keys(%required) + keys(%optional) + keys(%match))
215             ) || die "LENGTH DID NOT MATCH\n";
216              
217 181 100       277 if (my @missing = grep { ! exists $url_map{$_} } keys %required) {
  151         291  
218 54         45 warn "missing: @missing" if DEBUG;
219 54         293 die "MISSING ITEM [@missing]\n";
220             }
221              
222 127 100       151 if (my @extra = grep {
223 279   100     891 ! $required{$_} && ! $optional{$_} && ! $match{$_}
224             } keys %url_map) {
225 10         8 warn "extra: @extra" if DEBUG;
226 10         62 die "EXTRA ITEM [@extra]\n";
227             }
228              
229 117 100       146 if (my @nomatch = grep {
230 179 100       536 exists $url_map{$_} and $url_map{$_} ne $match{$_}
231             } keys %match) {
232 48         32 warn "no match: @nomatch" if DEBUG;
233 48         267 die "NO MATCH [@nomatch]\n";
234             }
235              
236 69         56 for my $component (@{$route->components}) {
  69         1112  
237 139 100       511 if ($route->is_component_variable($component)) {
238 78         49 warn "\t\t... found a variable ($component)" if DEBUG;
239 78         119 my $name = $route->get_component_name($component);
240              
241             push @url => $url_map{$name}
242             unless
243             $route->is_component_optional($component) &&
244             $route->defaults->{$name} &&
245 78 100 66     128 $route->defaults->{$name} eq $url_map{$name};
      100        
246              
247             }
248              
249             else {
250 61         47 warn "\t\t... found a constant ($component)" if DEBUG;
251              
252 61         72 push @url => $component;
253             }
254              
255 139         150 warn "+++ URL so far ... ", (join "/" => @url) if DEBUG;
256             }
257              
258 69         123 return join "/" => grep { defined } @url;
  134         355  
259             }
260             catch {
261 189     189   1150 do {
262             warn join "/" => @url;
263             warn "... ", $_;
264             } if DEBUG;
265              
266 189         355 return;
267 258         1417 };
268              
269 258 100       3702 push @possible, [$route, $url] if defined $url;
270             }
271              
272 72 100       139 return undef unless @possible;
273 66 100       275 return $possible[0][1] if @possible == 1;
274              
275 3         4 my @found;
276             my $min;
277 3         4 for my $possible (@possible) {
278 6         7 my ($route, $url) = @$possible;
279              
280 6         9 my %url_map = %orig_url_map;
281              
282 0         0 my %required = map {( $_ => 1 )}
283 6         6 @{ $route->required_variable_component_names };
  6         84  
284              
285 0         0 my %optional = map {( $_ => 1 )}
286 6         27 @{ $route->optional_variable_component_names };
  6         100  
287              
288 6         23 my %url_defaults;
289              
290 6 50       6 my %match = %{$route->defaults || {}};
  6         20  
291              
292 6         10 for my $component (keys(%required), keys(%optional)) {
293 0 0       0 next unless exists $match{$component};
294 0         0 $url_defaults{$component} = delete $match{$component};
295             }
296             # any remaining keys in %defaults are 'extra' -- they don't appear
297             # in the url, so they need to match exactly rather than being
298             # filled in
299              
300 6         10 %url_map = (%url_defaults, %url_map);
301              
302 6         10 my %wanted = (%required, %optional, %match);
303 6         13 delete $wanted{$_} for keys %url_map;
304              
305 6         4 my $extra = keys %wanted;
306              
307 6 100 100     22 if (!defined($min) || $extra < $min) {
    100          
308 4         5 @found = ($possible);
309 4         9 $min = $extra;
310             }
311             elsif ($extra == $min) {
312 1         3 push @found, $possible;
313             }
314             }
315              
316             confess "Ambiguous path descriptor (specified keys "
317             . join(', ', sort keys(%orig_url_map))
318             . "): could match paths "
319 3 100       12 . join(', ', sort map { $_->path } map { $_->[0] } @found)
  2         144  
  2         3  
320             if @found > 1;
321              
322 2         9 return $found[0][1];
323             }
324              
325             1;
326              
327             __END__
328              
329             =pod
330              
331             =encoding UTF-8
332              
333             =head1 NAME
334              
335             Path::Router - A tool for routing paths
336              
337             =head1 VERSION
338              
339             version 0.15
340              
341             =head1 SYNOPSIS
342              
343             my $router = Path::Router->new;
344              
345             $router->add_route('blog' => (
346             defaults => {
347             controller => 'blog',
348             action => 'index',
349             },
350             # you can provide a fixed "target"
351             # for a match as well, this can be
352             # anything you want it to be ...
353             target => My::App->get_controller('blog')->get_action('index')
354             ));
355              
356             $router->add_route('blog/:year/:month/:day' => (
357             defaults => {
358             controller => 'blog',
359             action => 'show_date',
360             },
361             # validate with ...
362             validations => {
363             # ... raw-Regexp refs
364             year => qr/\d{4}/,
365             # ... custom Moose types you created
366             month => 'NumericMonth',
367             # ... Moose anon-subtypes created inline
368             day => subtype('Int' => where { $_ <= 31 }),
369             }
370             ));
371              
372             $router->add_route('blog/:action/?:id' => (
373             defaults => {
374             controller => 'blog',
375             },
376             validations => {
377             action => qr/\D+/,
378             id => 'Int', # also use plain Moose types too
379             }
380             ));
381              
382             # even include other routers
383             $router->include_router( 'polls/' => $another_router );
384              
385             # ... in your dispatcher
386              
387             # returns a Path::Router::Route::Match object
388             my $match = $router->match('/blog/edit/15');
389              
390             # ... in your code
391              
392             my $uri = $router->uri_for(
393             controller => 'blog',
394             action => 'show_date',
395             year => 2006,
396             month => 10,
397             day => 5,
398             );
399              
400             =head1 DESCRIPTION
401              
402             This module provides a way of deconstructing paths into parameters
403             suitable for dispatching on. It also provides the inverse in that
404             it will take a list of parameters, and construct an appropriate
405             uri for it.
406              
407             =head2 Reversable
408              
409             This module places a high degree of importance on reversability.
410             The value produced by a path match can be passed back in and you
411             will get the same path you originally put in. The result of this
412             is that it removes ambiguity and therefore reduces the number of
413             possible mis-routings.
414              
415             =head2 Verifyable
416              
417             This module also provides additional tools you can use to test
418             and verify the integrity of your router. These include:
419              
420             =over 4
421              
422             =item *
423              
424             An interactive shell in which you can test various paths and see the
425             match it will return, and also test the reversability of that match.
426              
427             =item *
428              
429             A L<Test::Path::Router> module which can be used in your applications
430             test suite to easily verify the integrity of your paths.
431              
432             =back
433              
434             =head1 METHODS
435              
436             =over 4
437              
438             =item B<new>
439              
440             =item B<add_route ($path, ?%options)>
441              
442             Adds a new route to the I<end> of the routes list.
443              
444             =item B<insert_route ($path, %options)>
445              
446             Adds a new route to the routes list. You may specify an C<at> parameter, which would
447             indicate the position where you want to insert your newly created route. The C<at>
448             parameter is the C<index> position in the list, so it starts at 0.
449              
450             Examples:
451              
452             # You have more than three paths, insert a new route at
453             # the 4th item
454             $router->insert_route($path => (
455             at => 3, %options
456             ));
457              
458             # If you have less items than the index, then it's the same as
459             # as add_route -- it's just appended to the end of the list
460             $router->insert_route($path => (
461             at => 1_000_000, %options
462             ));
463              
464             # If you want to prepend, omit "at", or specify 0
465             $router->insert_Route($path => (
466             at => 0, %options
467             ));
468              
469             =item B<include_router ( $path, $other_router )>
470              
471             These extracts all the route from C<$other_router> and includes them into
472             the invocant router and prepends C<$path> to all their paths.
473              
474             It should be noted that this does B<not> do any kind of redispatch to the
475             C<$other_router>, it actually extracts all the paths from C<$other_router>
476             and inserts them into the invocant router. This means any changes to
477             C<$other_router> after inclusion will not be reflected in the invocant.
478              
479             =item B<routes>
480              
481             =item B<match ($path)>
482              
483             Return a L<Path::Router::Route::Match> object for the first route that matches the
484             given C<$path>, or C<undef> if no routes match.
485              
486             =item B<uri_for (%path_descriptor)>
487              
488             Find the path that, when passed to C<< $router->match >>, would produce the
489             given arguments. Returns the path without any leading C</>. Returns C<undef>
490             if no routes match.
491              
492             =item B<route_class ($classname)>
493              
494             The class to use for routes.
495             Defaults to L<Path::Router::Route>.
496              
497             =item B<meta>
498              
499             =back
500              
501             =head1 DEBUGGING
502              
503             You can turn on the verbose debug logging with the C<PATH_ROUTER_DEBUG>
504             environment variable.
505              
506             =head1 BUGS
507              
508             All complex software has bugs lurking in it, and this module is no
509             exception. If you find a bug please either email me, or add the bug
510             to cpan-RT.
511              
512             =head1 AUTHOR
513              
514             Stevan Little E<lt>stevan@cpan.orgE<gt>
515              
516             =head1 COPYRIGHT AND LICENSE
517              
518             Copyright 2008-2011 Infinity Interactive, Inc.
519              
520             L<http://www.iinteractive.com>
521              
522             This library is free software; you can redistribute it and/or modify
523             it under the same terms as Perl itself.
524              
525             =for Pod::Coverage DEBUG
526              
527             =head1 AUTHOR
528              
529             Stevan Little <stevan@cpan.org>
530              
531             =head1 COPYRIGHT AND LICENSE
532              
533             This software is copyright (c) 2016 by Infinity Interactive.
534              
535             This is free software; you can redistribute it and/or modify it under
536             the same terms as the Perl 5 programming language system itself.
537              
538             =cut