File Coverage

blib/lib/Path/Router.pm
Criterion Covered Total %
statement 2 4 50.0
branch n/a
condition n/a
subroutine 2 2 100.0
pod n/a
total 4 6 66.6


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