File Coverage

blib/lib/Path/Router.pm
Criterion Covered Total %
statement 168 175 96.0
branch 49 58 84.4
condition 23 23 100.0
subroutine 22 22 100.0
pod 5 5 100.0
total 267 283 94.3


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