From b4707f05f7a023c68037be142c0437415aa3cb94 Mon Sep 17 00:00:00 2001 From: Sawyer X Date: Mon, 10 Oct 2016 23:24:20 +0200 Subject: Named routes: You can now give names to the routes: get NAME, PATH, sub {...}; Or the lesser-known one: get NAME, PATH, OPTIONS_HASHREF, sub {...}; The PATH, as before, can be a string (matching our spec) or a full regular expression. If you do not give a name, a globally incrementing number will be used. I'm not sure that's the best idea but it's a unique identified that, if leaks, does not say which App this is part of. This should lend in the future to allow `uri_for` (or a different DSL keyword) to use the name of a route instead of the path: End result should look something like this: # MyApp.pm get 'view_product', '/view/product/:id' => sub {...}; # in template: <% uri_for( 'view_product', { id => 4 } ); %> We're getting there... --- lib/Dancer2/Core/DSL.pm | 21 ++++++++--- lib/Dancer2/Core/Route.pm | 8 ++++ t/named_routes.t | 93 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 t/named_routes.t diff --git a/lib/Dancer2/Core/DSL.pm b/lib/Dancer2/Core/DSL.pm index 9d203a6c..13d6d437 100644 --- a/lib/Dancer2/Core/DSL.pm +++ b/lib/Dancer2/Core/DSL.pm @@ -241,11 +241,22 @@ sub _normalize_route { my $methods = shift; my %args; - # Options are optional, deduce their presence from arg length. - # @_ = ( REGEXP, OPTIONS, CODE ) - # or - # @_ = ( REGEXP, CODE ) - @args{qw/regexp options code/} = @_ == 3 ? @_ : ( $_[0], {}, $_[1] ); + # Options are optional, try to deduce their presence from arg length. + if ( @_ == 4 ) { + # @_ = ( NAME, REGEXP, OPTIONS, CODE ) + @args{qw} = @_; + } elsif ( @_ == 2 ) { + # @_ = ( REGEXP, CODE ) + @args{qw} = ( $_[0], {}, $_[1] ); + } elsif ( @_ == 3 ) { + # @_ = ( REGEXP, OPTIONS, CODE ) + # @_ = ( NAME, REGEXP, CODE ) + if (ref $_[1] eq 'HASH') { + @args{qw} = @_; + } else { + @args{qw} = @_; + } + } return map $app->add_route( %args, method => $_ ), @{$methods}; } diff --git a/lib/Dancer2/Core/Route.pm b/lib/Dancer2/Core/Route.pm index a9383482..8d21daf7 100644 --- a/lib/Dancer2/Core/Route.pm +++ b/lib/Dancer2/Core/Route.pm @@ -12,6 +12,14 @@ use Type::Registry; our ( $REQUEST, $RESPONSE, $RESPONDER, $WRITER, $ERROR_HANDLER ); +my $count = 0; + +has name => ( + is => 'ro', + isa => Str, + default => sub { $count++ }, +); + has method => ( is => 'ro', isa => Dancer2Method, diff --git a/t/named_routes.t b/t/named_routes.t new file mode 100644 index 00000000..4cd86b15 --- /dev/null +++ b/t/named_routes.t @@ -0,0 +1,93 @@ +use strict; +use warnings; +use Test::More 'tests' => 5; +use Plack::Test; +use HTTP::Request::Common; + +{ + package MyApp; + + use Dancer2; + + # Name, Regexp, Code + get 'view_static', '/view' => sub { + 'View Static'; + }; + + get 'view_regex', qr{^/view_r$} => sub { + 'View Regex' + }; + + # Name, Regexp, Options, Code + get 'base_static', '/' => { 'user_agent' => 'XX' }, sub { + 'Base Static'; + }; + + # Name, Regexp, Options, Code + get 'base_regex', qr{^/r$} => { 'user_agent' => 'XX' }, sub { + 'Base Regex'; + }; + + get '/ignore1' => sub {1}; + get '/ignore2' => sub {1}; + get '/ignore3' => sub {1}; +} + +my $test = Plack::Test->create( MyApp->to_app ); + +subtest 'Named route' => sub { + plan 'tests' => 2; + + my $response = $test->request( GET '/view' ); + ok( $response->is_success, 'Successfully reached /view' ); + is( $response->content, 'View Static', 'Static route with name' ); +}; + +subtest 'Named regex route' => sub { + plan 'tests' => 2; + + my $response = $test->request( GET '/view_r' ); + ok( $response->is_success, 'Successfully reached /view_r' ); + is( $response->content, 'View Regex', 'Regex route with name' ); +}; + +subtest 'Named route with options' => sub { + plan 'tests' => 2; + + my $response = $test->request( GET '/', 'User-Agent' => 'XX' ); + ok( $response->is_success, 'Successfully reached /' ); + is($response->content, 'Base Static', + 'Static route with name and options'); +}; + +subtest 'Named regex route with options' => sub { + plan 'tests' => 2; + + my $response = $test->request( GET '/r', 'User-Agent' => 'XX' ); + ok( $response->is_success, 'Successfully reached /r' ); + is($response->content, 'Base Regex', 'Regex route with name and options'); +}; + +subtest 'Route objects' => sub { + plan 'tests' => 3; + + my @apps = @{ Dancer2::runner->apps }; + is( scalar @apps, 1, 'Only one app exists' ); + + my @routes = @{ $apps[0]->routes->{'get'} }; + is( scalar @routes, 7, 'Five routes registered' ); + + is_deeply( + [ map $_->name, @routes ], + [ + 'view_static', + 'view_regex', + 'base_static', + 'base_regex', + 0, # /ignore1 (0) + HEAD (Plack::Middleware::Head) (1) + 2, # /ignore2 (2) + HEAD (Plack::Middleware::Head) (3) + 4, # /ignore3 (4) + HEAD (Plack::Middleware::Head) (5) + ], + 'All the right route names', + ); +}; -- cgit v1.2.3 From 1211dbf5e19ffbe46026ddf983c444ce1f5c0b6d Mon Sep 17 00:00:00 2001 From: Sawyer X Date: Wed, 14 Dec 2016 16:06:35 +0100 Subject: Introduce new DSL keyword: uri_for_route: This new DSL provides a uri_for()-style resolution, but uses named routes for this. get 'view_product' => '/view/:product/:id' => sub {...}; get 'scary' => '/*/:foo/**' => sub {...}; # somewhere else in your App my $uri = uri_for_route( 'view_product' => { 'product' => 'phone', 'id' => 'K2V3', }); # $uri = /view/phone/K2V3 $uri = uri_for_route( 'view_product', { 'foo' => 'bar', 'splat' => [ 'baz', ['quux'] ], }, { 'id' => 4 }, ); # /baz/bar/quux?id=4 * This works on any non-HEAD method (GET, POST, PATCH, PUT, DELETE, and if you create your own). * Splat and Megasplat are supported. Mixing it with named params is also supported. * Query parameters are supported. * HTML escaping is supported. * `request.uri_for_route()` in templates is also supported. * Lots of testing. * Documentation updated. --- lib/Dancer2/Core/App.pm | 77 ++++++++++++- lib/Dancer2/Core/DSL.pm | 7 ++ lib/Dancer2/Core/Request.pm | 19 +++- lib/Dancer2/Core/Route.pm | 6 +- lib/Dancer2/Manual.pod | 7 ++ lib/Dancer2/Manual/Keywords.pod | 136 ++++++++++++++++++++++ t/dsl/uri_for.t | 48 ++++++++ t/dsl/uri_for_route.t | 246 ++++++++++++++++++++++++++++++++++++++++ t/named_routes.t | 21 ++-- t/uri_for.t | 30 ----- 10 files changed, 549 insertions(+), 48 deletions(-) create mode 100644 t/dsl/uri_for.t create mode 100644 t/dsl/uri_for_route.t delete mode 100644 t/uri_for.t diff --git a/lib/Dancer2/Core/App.pm b/lib/Dancer2/Core/App.pm index b18527fa..fb447c8e 100644 --- a/lib/Dancer2/Core/App.pm +++ b/lib/Dancer2/Core/App.pm @@ -4,13 +4,14 @@ package Dancer2::Core::App; use Moo; use Carp qw; use Scalar::Util 'blessed'; +use List::Util (); use Module::Runtime 'is_module_name'; use Safe::Isa; use Sub::Quote; use File::Spec; use Module::Runtime qw< require_module use_module >; use List::Util (); -use Ref::Util qw< is_ref is_globref is_scalarref >; +use Ref::Util qw< is_ref is_arrayref is_globref is_scalarref is_regexpref >; use Plack::App::File; use Plack::Middleware::FixMissingBodyInRedirect; @@ -608,6 +609,12 @@ has routes => ( }, ); +has 'route_names' => ( + 'is' => 'rw', + 'isa' => HashRef, + 'default' => sub { {} }, +); + # add_hook will add the hook to the first "hook candidate" it finds that support # it. If none, then it will try to add the hook to the current application. around add_hook => sub { @@ -1244,9 +1251,16 @@ sub add_route { ); my $method = $route->method; - push @{ $self->routes->{$method} }, $route; + if ( $method ne 'head' && $route->has_name() ) { + my $name = $route->name; + $self->route_names->{$name} + and die "Route with this name ($name) already exists"; + + $self->route_names->{$name} = $route; + } + return $route; } @@ -1597,11 +1611,13 @@ DISPATCH: sub build_request { my ( $self, $env ) = @_; + Scalar::Util::weaken( my $weak_self = $self ); # If we have an app, send the serialization engine my $request = Dancer2::Core::Request->new( env => $env, is_behind_proxy => $self->settings->{'behind_proxy'} || 0, + uri_for_route => sub { shift; $weak_self->uri_for_route(@_) }, $self->has_serializer_engine ? ( serializer => $self->serializer_engine ) @@ -1694,6 +1710,63 @@ sub response_not_found { return $response; } +sub uri_for_route { + my ( $self, $route_name, $route_params, $query_params, $dont_escape ) = @_; + my $route = $self->route_names->{$route_name} + or die "Cannot find route named '$route_name'"; + + my $string = $route->spec_route; + is_regexpref($string) + and die "uri_for_route() does not support regexp route paths"; + + # Convert splat only to the general purpose structure + if ( is_arrayref($route_params) ) { + $route_params = { 'splat' => $route_params }; + } + + # The regexes are taken and altered from: + # Dancer2::Core::Route::_build_regexp_from_string. + + # Replace :foo with arg (route parameters) + # Not a fan of all this regex play to handle typed parameter -- SX + my @params = $string =~ m{:([^/.\?]+)}xmsg; + + foreach my $param (@params) { + $param =~ s{^([^\[]+).*}{$1}xms; + my $value = $route_params->{$param} + or die "Route $route_name uses the parameter '${param}', which was not provided"; + + $string =~ s!\Q:$param\E(\[[^\]]+\])?!$value!xmsg; + } + + # TODO: Can we cut this down by replacing on the spot? + # I think that will be tricky because we first need all **, then * + + $string =~ s!\Q**\E!(?#megasplat)!g; + $string =~ s!\*!(?#splat)!g; + + # TODO: Can we cut this down? + my @token_or_splat = + $string =~ /\(\?#((?:mega)?splat)\)/g; + + my $splat_params = $route_params->{'splat'}; + if ($splat_params && @token_or_splat) { + $#{$splat_params} == $#token_or_splat + or die 'Mismatch in amount of splat args and splat elements'; + + for ( my $i = 0; $i < @{$splat_params}; $i++ ) { + if ( is_arrayref($splat_params->[$i]) ){ + my $megasplat = join '/', @{ $splat_params->[$i] }; + $string =~ s{\Q(?#megasplat)\E}{$megasplat}; + } else { + $string =~ s{\Q(?#splat)\E}{$splat_params->[$i]}; + } + } + } + + return $self->request->uri_for( $string, $query_params, $dont_escape ); +} + 1; __END__ diff --git a/lib/Dancer2/Core/DSL.pm b/lib/Dancer2/Core/DSL.pm index 13d6d437..d286e1b5 100644 --- a/lib/Dancer2/Core/DSL.pm +++ b/lib/Dancer2/Core/DSL.pm @@ -122,6 +122,7 @@ sub dsl_keywords { true => { is_global => 1 }, upload => { is_global => 0 }, uri_for => { is_global => 0 }, + uri_for_route => { is_global => 0 }, var => { is_global => 0 }, vars => { is_global => 0 }, warning => { is_global => 1 }, @@ -244,13 +245,17 @@ sub _normalize_route { # Options are optional, try to deduce their presence from arg length. if ( @_ == 4 ) { # @_ = ( NAME, REGEXP, OPTIONS, CODE ) + # get 'foo', '/foo', { 'user_agent' => '...' }, sub {...} @args{qw} = @_; } elsif ( @_ == 2 ) { # @_ = ( REGEXP, CODE ) + # get '/foo', sub {...} @args{qw} = ( $_[0], {}, $_[1] ); } elsif ( @_ == 3 ) { # @_ = ( REGEXP, OPTIONS, CODE ) + # get '/foo', { 'user_agent' => '...', sub {...} # @_ = ( NAME, REGEXP, CODE ) + # get 'foo', '/foo',sub {...} if (ref $_[1] eq 'HASH') { @args{qw} = @_; } else { @@ -425,6 +430,8 @@ sub captures { $Dancer2::Core::Route::REQUEST->captures } sub uri_for { shift; $Dancer2::Core::Route::REQUEST->uri_for(@_); } +sub uri_for_route { shift->app->uri_for_route(@_); } + sub splat { $Dancer2::Core::Route::REQUEST->splat } sub params { shift; $Dancer2::Core::Route::REQUEST->params(@_); } diff --git a/lib/Dancer2/Core/Request.pm b/lib/Dancer2/Core/Request.pm index 1b817117..0e0b9a05 100644 --- a/lib/Dancer2/Core/Request.pm +++ b/lib/Dancer2/Core/Request.pm @@ -11,7 +11,7 @@ use URI; use URI::Escape; use Safe::Isa; use Hash::MultiValue; -use Ref::Util qw< is_ref is_arrayref is_hashref >; +use Ref::Util qw< is_ref is_arrayref is_hashref is_coderef >; use Dancer2::Core::Types; use Dancer2::Core::Request::Upload; @@ -77,6 +77,7 @@ sub new { $self->{'id'} = ++$_id; $self->{'vars'} = {}; $self->{'is_behind_proxy'} = !!$opts{'is_behind_proxy'}; + $self->{'uri_for_route'} = $opts{'uri_for_route'}; $opts{'body_params'} and $self->{'_body_params'} = $opts{'body_params'}; @@ -316,6 +317,15 @@ sub uri_for { : ${ $uri->canonical }; } +sub uri_for_route { + my ( $self, @args ) = @_; + + is_coderef( $self->{'uri_for_route'} ) + or die 'uri_for_route called on a request instance without it'; + + return $self->{'uri_for_route'}->(@_); +} + sub params { my ( $self, $source ) = @_; @@ -1090,6 +1100,13 @@ You get the following behavior: C returns a L object (which can stringify to the value). +=method uri_for_route(route_name, route_params, query_params, escape) + +Constructs a URI from the base and the path of the specified route name. + +Read more about it in the C document under +C. + =method user Return remote user if defined. diff --git a/lib/Dancer2/Core/Route.pm b/lib/Dancer2/Core/Route.pm index 8d21daf7..caec9dbb 100644 --- a/lib/Dancer2/Core/Route.pm +++ b/lib/Dancer2/Core/Route.pm @@ -15,9 +15,9 @@ our ( $REQUEST, $RESPONSE, $RESPONDER, $WRITER, $ERROR_HANDLER ); my $count = 0; has name => ( - is => 'ro', - isa => Str, - default => sub { $count++ }, + is => 'ro', + isa => Str, + predicate => 'has_name', ); has method => ( diff --git a/lib/Dancer2/Manual.pod b/lib/Dancer2/Manual.pod index d0244e24..bbf9217c 100644 --- a/lib/Dancer2/Manual.pod +++ b/lib/Dancer2/Manual.pod @@ -213,6 +213,13 @@ coderef to execute, which returns the response. The above route specifies that, for GET requests to C, the code block provided should be executed. + +You can also provide routes with a name: + + get 'hi_to' => '/hello/:name' => sub {...}; + +See C on how this can be used. + =head3 Retrieving request parameters The L, diff --git a/lib/Dancer2/Manual/Keywords.pod b/lib/Dancer2/Manual/Keywords.pod index bd1c9a92..ebdf0262 100644 --- a/lib/Dancer2/Manual/Keywords.pod +++ b/lib/Dancer2/Manual/Keywords.pod @@ -200,6 +200,12 @@ Defines a route for HTTP B requests to the given URL: del '/resource' => sub { ... }; +You can also provide the route with a name: + + del 'rec' => '/resource' => sub { ... }; + +See C on how this can be used. + =head2 delayed Stream a response asynchronously. For more information, please see @@ -349,6 +355,14 @@ Defines a route for HTTP B requests to the given path: Note that a route to match B requests is automatically created as well. +You can also provide the route with a name: + + get 'index' => '/' => sub { + return "Hello world"; + } + +See C on how this can be used. + =head2 halt Sets a response object with the content given. @@ -516,6 +530,12 @@ Defines a route for HTTP B requests to the given URL: intended to work as a "partial-PUT", transferring just the changes; please see L for further details.) +You can also provide the route with a name: + + patch 'rec' => '/resource' => sub { ... }; + +See C on how this can be used. + =head2 path Concatenates multiple paths together, without worrying about the underlying @@ -534,6 +554,14 @@ Defines a route for HTTP B requests to the given URL: return "Hello world"; } +You can also provide the route with a name: + + post 'index' => '/' => sub { + return "Hello world"; + } + +See C on how this can be used. + =head2 prefix Defines a prefix for each route handler, like this: @@ -625,6 +653,12 @@ Defines a route for HTTP B requests to the given URL: put '/resource' => sub { ... }; +You can also provide the route with a name: + + put 'rec' => '/resource' => sub { ... }; + +See C on how this can be used. + =head2 query_parameters Returns a L object from the request parameters. @@ -1146,6 +1180,108 @@ URL encoding via a third parameter: uri_for('/path', { foo => 'qux%3Dquo' }, 1); # would return http://localhost:5000/path?foo=qux%3Dquo +=head2 uri_for_route + +An enhanced version of C that utilizes their names. + + get 'view_entry' => '/entry/view/:id' => sub {...}; + +Now that the route has a name we can use C to +create a URI for it: + + my $path = uri_for_route( + 'view_entry', + { 'id' => 3 }, + { 'foo' => 'bar' }, + ); + + # (assuming it's run on a local server in HTTP port 5000) + # $path = 'http://localhost:5000/entry/view/3?foo=bar' + +This works for every HTTP method, except C (which is +effectively a C). + +There are multiple arguments options: + +=over 4 + +=item * Route parameters + +The first argument controls the route parameters: + + get 'test' => '/:foo/:bar' => sub {1}; + # ... + $path = uri_for_route( 'test', { 'foo' => 'hello', 'bar' => 'world' } ); + # $path = http://localhost:5000/hello/world + +=item * Splat route parameters + +If you provide an arrayref instead of hashref, it will assume on +these being splat and megasplat args: + + get 'test' => '/*/*/**' => sub {1}; + # ... + $path = uri_for_route( + 'test', + [ 'hello', 'world', [ 'myhello', 'myworld' ], + ); + # $path = http://localhost:5000/hello/world/myhello/myworld + +=item * Mixed route parameters + +If you have a route that includes both, the plat and megasplat +arguments need to be under the C key: + + patch 'test' => '/*/:id/*/:foo/*' => sub {1}; + # ... + $path = uri_for_route( + 'test', + { + 'id' => 4, + 'foo ' => 'bar', + 'splat' => [ 'hello', 'world' ], + } + ); + # $path = http://localhost:5000/hello/4/world/bar + +=item * Query parameters + +If you want to create a path the query parameters, use the +second argument: + + get 'index' => '/:foo' => sub {1}; + get 'update_form' => '/update' => sub {1}; + + # ... + + $path = uri_for_route( + 'index', + { 'foo' => 'bar' }, + { 'id' => 1 }, + ); + # $path = http://localhost:5000/bar?id=1 + + $path = uri_for_route( 'update_form', {}, { 'id' => 2 } ); + # $path = http://localhost:5000/update?id=2 + +(Technically, only C requests should include query parameters, but +C does not enforce this.) + +=item * Escaping + +The final parameter determines whether the URI will be URI-escaped: + + get 'show_entry' => '/view/:str_id' => sub {1}; + # ... + $path = uri_for_route( 'show_entry' => { 'str_id' => '!£%^@' }, {}, 1 ); + # $path = http://localhost/view/!@%C3%82%C2%A3$% + +This is useful when your ID is not HTML-safe and might include HTML +tags and Javascript code or include characters that interfere with the +URI request string (like a forward slash). + +=back + =head2 var Provides an accessor for variables shared between hooks and route diff --git a/t/dsl/uri_for.t b/t/dsl/uri_for.t new file mode 100644 index 00000000..7712ce5c --- /dev/null +++ b/t/dsl/uri_for.t @@ -0,0 +1,48 @@ +use strict; +use warnings; +use Test::More 'tests' => 2; +use Plack::Test; +use Plack::Builder; +use HTTP::Request::Common; + +{ + package App; + use Dancer2; + get '/' => sub { return uri_for('/foo'); }; +} + +{ + package MountedApp; + use Dancer2; + get '/' => sub { return uri_for('/bar'); }; +} + +my $prefix = 'http://localhost'; + +subtest 'Non-mounted app' => sub { + my $app = Plack::Test->create( App->to_app ); + my $res; + + $res = $app->request( GET "$prefix/" ); + ok( $res->is_success, 'Successful request' ); + is( $res->content, "$prefix/foo", 'Correct regular path' ); +}; + +subtest 'Mounted app' => sub { + my $app = Plack::Test->create( + builder { + mount '/mount' => MountedApp->to_app; + mount '/' => App->to_app; + } + ); + + my $res; + + $res = $app->request( GET "$prefix/" ); + ok( $res->is_success, 'Successful request' ); + is( $res->content, "$prefix/foo", 'Correct mounted regular path' ); + + $res = $app->request( GET "$prefix/mount" ); + ok( $res->is_success, 'Successful request' ); + is($res->content, "$prefix/mount/bar", 'Correct mounted regular path'); +}; diff --git a/t/dsl/uri_for_route.t b/t/dsl/uri_for_route.t new file mode 100644 index 00000000..c327b884 --- /dev/null +++ b/t/dsl/uri_for_route.t @@ -0,0 +1,246 @@ +use strict; +use warnings; +use Test::More 'tests' => 3; +use Plack::Test; +use Plack::Builder; +use HTTP::Request::Common; +use JSON::MaybeXS; + +{ + package App; + use Dancer2; + our $tested; + + # Static with route params + # Static with code + # Static with options and code + get 'view_entry_static1' => '/view1/:id' => sub {1}; + get 'view_entry_static2' => '/view2/:id' => { 'user_agent' => 'UA/1.0' }, sub {1}; + + # static with typed route param + get 'view_user' => '/:prefix/user/:username[Str]' => sub {1}; + + # splat / megasplat + get 'view_entry_splat' => '/viewsplat/*/*/**' => sub {1}; + + # Mixed with splat/megasplat + # Different method + patch 'view_entry_mixed' => '/view_mixed/*/**/:id' => sub {1}; + + # Regexp - fails + get 'view_entry_regexp1' => qr{/rview1/[0-9]+} => sub {1}; + + post '/uri_for_route' => sub { + my $params = JSON::MaybeXS::decode_json( request->content ); + return uri_for_route( + $params->{'route_name'}, + $params->{'route_params'}, + $params->{'query_params'} // {}, + !!$params->{'dont_escape'}, + ); + }; + + get '/fail_uri_for_route' => sub { + my $failed = 0; + eval { + uri_for_route('vvv'); + 1; + } or do { + ::like( + $@, + qr/\QCannot find route named 'vvv'\E/xms, + 'Cannot retrieve nonexistent route', + ); + + $failed++; + }; + + return $failed; + }; + + get '/fail_uri_for_route_splat_args' => sub { + my $failed = 0; + eval { + uri_for_route( + 'view_entry_splat', + ['foo'], + ); + + 1; + } or do { + ::like( + $@, + qr/\QMismatch in amount of splat args and splat elements\E/xms, + 'Cannot handle mismatched splat args and elements', + ); + + $failed++; + }; + + return $failed; + }; + + get '/fail_uri_for_route_leftovers' => sub { + my $failed = 0; + eval { + uri_for_route('view_entry_static1'); + 1; + } or do { + my $msg = 'Route view_entry_static1 uses the parameter \'id\', ' + . 'which was not provided'; + + ::like( + $@, + qr/\Q$msg\E/xms, + 'Cannot handle leftover route parameters', + ); + + $failed++; + }; + + return $failed; + }; + + # Error defining two routes with the same name, regardless of method + eval { + get 'view_entry_splat' => '/' => sub {1}; + 1; + } or do { + ::like( + $@, + qr/\QRoute with this name (view_entry_splat) already exists\E/xms, + 'Cannot register two routes with same name', + ); + + $tested = 1; + }; +} + +sub test_app { + my ( $app, $mount_path ) = @_; + + my $prefix = 'http://localhost'; + $mount_path + and $prefix .= $mount_path; + + my ( $path, $res ); + + # Test static paths + foreach my $idx ( 1 .. 2 ) { + $res = $app->request( + POST( + "$prefix/uri_for_route", + 'Content' => JSON::MaybeXS::encode_json({ + 'route_name' => "view_entry_static$idx", + 'route_params' => { 'id' => $idx }, + 'query_params' => { 'foo' => $idx }, + }), + ) + ); + + $path = "$prefix/view$idx/$idx?foo=$idx"; + ok( $res->is_success, 'Successful request' ); + is( $res->content, $path, "Correct path: $path" ); + } + + # Test splat + megasplat + $res = $app->request( + POST( + "$prefix/uri_for_route", + 'Content' => JSON::MaybeXS::encode_json({ + 'route_name' => 'view_entry_splat', + 'route_params' => [ 'foo', 'bar', [ 'baz', 'quux' ] ], + 'query_params' => { 'id' => 'di' }, + }), + ) + ); + + $path = "$prefix/viewsplat/foo/bar/baz/quux?id=di"; + ok( $res->is_success, 'Successful request' ); + is( $res->content, $path, "Correct path: $path" ); + + # Test mixed + $res = $app->request( + POST( + "$prefix/uri_for_route", + 'Content' => JSON::MaybeXS::encode_json( + { 'route_name' => 'view_entry_mixed', + 'route_params' => { + 'id' => 'di', + 'splat' => ['foo', ['bar', 'baz']] + }, + 'query_params' => {'foo' => 'bar'}, + } + ), + ) + ); + + $path = "$prefix/view_mixed/foo/bar/baz/di?foo=bar"; + ok( $res->is_success, 'Successful request' ); + is( $res->content, $path, "Correct path: $path" ); + + # Test escaping + $res = $app->request( + POST( + "$prefix/uri_for_route", + 'Content' => JSON::MaybeXS::encode_json({ + 'route_name' => 'view_entry_static1', + 'route_params' => { 'id' => '!@£$%' }, + }), + ) + ); + + $path = "$prefix/view1/!@%C3%82%C2%A3\$%"; + ok( $res->is_success, 'Successful request' ); + is( $res->content, $path, "Correct path: $path" ); + + # Test nonexistent route name + $res = $app->request( GET "$prefix/fail_uri_for_route" ); + ok( $res->is_success, 'Successful request' ); + is( $res->content, '1', 'Successfully tested nonexistent failure mode' ); + + # Test splat + megasplat (incorrect amount) + $res = $app->request( GET "$prefix/fail_uri_for_route_splat_args" ); + ok( $res->is_success, 'Successful request' ); + is( $res->content, '1', 'Successfully tested mismatch splat args/elements failure mode' ); + + # Test mixed with not all filled (named args left) + $res = $app->request( GET "$prefix/fail_uri_for_route_leftovers" ); + ok( $res->is_success, 'Successful request' ); + is( $res->content, '1', 'Successfully tested leftover args failure mode' ); + + # Static with typed route parameters + $res = $app->request( + POST( + "$prefix/uri_for_route", + 'Content' => JSON::MaybeXS::encode_json({ + 'route_name' => 'view_user', + 'route_params' => { 'prefix' => 'foo', 'username' => 'sawyer' }, + 'query_params' => { 'foo' => 1 }, + }), + ) + ); + + $path = "$prefix/foo/user/sawyer?foo=1"; + ok( $res->is_success, 'Successful request' ); + is( $res->content, $path, "Correct path for typed route param: $path" ); +} + +subtest 'Non-mounted app' => sub { + my $app = Plack::Test->create( App->to_app ); + test_app($app); + ok( $App::tested, 'Check for duplicate route names done successfully' ); +}; + +subtest 'Mounted app' => sub { + my $app = Plack::Test->create( + builder { + mount '/mount' => App->to_app; + mount '/' => sub { + return { Plack::Response->new(200, [], ['OK'] ) } + }, + } + ); + + test_app( $app, '/mount' ); +}; diff --git a/t/named_routes.t b/t/named_routes.t index 4cd86b15..4e933ad1 100644 --- a/t/named_routes.t +++ b/t/named_routes.t @@ -24,7 +24,7 @@ use HTTP::Request::Common; }; # Name, Regexp, Options, Code - get 'base_regex', qr{^/r$} => { 'user_agent' => 'XX' }, sub { + get 'base_regex', qr{^/r$}, {}, sub { 'Base Regex'; }; @@ -35,7 +35,7 @@ use HTTP::Request::Common; my $test = Plack::Test->create( MyApp->to_app ); -subtest 'Named route' => sub { +subtest 'Named static route' => sub { plan 'tests' => 2; my $response = $test->request( GET '/view' ); @@ -51,7 +51,7 @@ subtest 'Named regex route' => sub { is( $response->content, 'View Regex', 'Regex route with name' ); }; -subtest 'Named route with options' => sub { +subtest 'Named static route with options' => sub { plan 'tests' => 2; my $response = $test->request( GET '/', 'User-Agent' => 'XX' ); @@ -74,19 +74,16 @@ subtest 'Route objects' => sub { my @apps = @{ Dancer2::runner->apps }; is( scalar @apps, 1, 'Only one app exists' ); - my @routes = @{ $apps[0]->routes->{'get'} }; - is( scalar @routes, 7, 'Five routes registered' ); + my %routes = %{ $apps[0]->route_names() }; + is( scalar keys %routes, 4, 'Four named routes registered' ); is_deeply( - [ map $_->name, @routes ], + [ sort keys %routes ], [ - 'view_static', - 'view_regex', - 'base_static', 'base_regex', - 0, # /ignore1 (0) + HEAD (Plack::Middleware::Head) (1) - 2, # /ignore2 (2) + HEAD (Plack::Middleware::Head) (3) - 4, # /ignore3 (4) + HEAD (Plack::Middleware::Head) (5) + 'base_static', + 'view_regex', + 'view_static', ], 'All the right route names', ); diff --git a/t/uri_for.t b/t/uri_for.t deleted file mode 100644 index 7eff3478..00000000 --- a/t/uri_for.t +++ /dev/null @@ -1,30 +0,0 @@ -use strict; -use warnings; -use Test::More import => ['!pass']; -use Plack::Test; -use HTTP::Request::Common; -use Ref::Util qw; - -{ - package App; - use Dancer2; - get '/foo' => sub { - return uri_for('/foo'); - }; -} - -my $app = App->to_app; -ok( is_coderef($app), 'Got app' ); - -test_psgi $app, sub { - my $cb = shift; - - is( $cb->( GET '/foo' )->code, 200, '/foo code okay' ); - is( - $cb->( GET '/foo' )->content, - 'http://localhost/foo', - 'uri_for works as expected', - ); -}; - -done_testing; -- cgit v1.2.3 From 1f702dad7e79e851e00aa98196af42fb3df6f961 Mon Sep 17 00:00:00 2001 From: Sawyer X Date: Sun, 12 Nov 2023 19:52:48 +0100 Subject: A set of fixes, thanks to Chris White (@cxw42) --- lib/Dancer2/Core/App.pm | 1 - lib/Dancer2/Core/DSL.pm | 8 ++++---- lib/Dancer2/Core/Route.pm | 2 -- lib/Dancer2/Manual.pod | 2 +- lib/Dancer2/Manual/Keywords.pod | 28 ++++++++++++++++++++++------ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lib/Dancer2/Core/App.pm b/lib/Dancer2/Core/App.pm index fb447c8e..947824fb 100644 --- a/lib/Dancer2/Core/App.pm +++ b/lib/Dancer2/Core/App.pm @@ -10,7 +10,6 @@ use Safe::Isa; use Sub::Quote; use File::Spec; use Module::Runtime qw< require_module use_module >; -use List::Util (); use Ref::Util qw< is_ref is_arrayref is_globref is_scalarref is_regexpref >; use Plack::App::File; diff --git a/lib/Dancer2/Core/DSL.pm b/lib/Dancer2/Core/DSL.pm index d286e1b5..dce01ba8 100644 --- a/lib/Dancer2/Core/DSL.pm +++ b/lib/Dancer2/Core/DSL.pm @@ -5,7 +5,7 @@ package Dancer2::Core::DSL; use Moo; use Carp; use Module::Runtime 'require_module'; -use Ref::Util qw< is_arrayref >; +use Ref::Util qw< is_arrayref is_hashref >; use Dancer2::Core::Hook; use Dancer2::FileUtils; use Dancer2::Core::Response::Delayed; @@ -250,13 +250,13 @@ sub _normalize_route { } elsif ( @_ == 2 ) { # @_ = ( REGEXP, CODE ) # get '/foo', sub {...} - @args{qw} = ( $_[0], {}, $_[1] ); + @args{qw} = @_; } elsif ( @_ == 3 ) { # @_ = ( REGEXP, OPTIONS, CODE ) - # get '/foo', { 'user_agent' => '...', sub {...} + # get '/foo', { 'user_agent' => '...' }, sub {...} # @_ = ( NAME, REGEXP, CODE ) # get 'foo', '/foo',sub {...} - if (ref $_[1] eq 'HASH') { + if ( is_hashref( $_[1] ) ) { @args{qw} = @_; } else { @args{qw} = @_; diff --git a/lib/Dancer2/Core/Route.pm b/lib/Dancer2/Core/Route.pm index caec9dbb..48f0159b 100644 --- a/lib/Dancer2/Core/Route.pm +++ b/lib/Dancer2/Core/Route.pm @@ -12,8 +12,6 @@ use Type::Registry; our ( $REQUEST, $RESPONSE, $RESPONDER, $WRITER, $ERROR_HANDLER ); -my $count = 0; - has name => ( is => 'ro', isa => Str, diff --git a/lib/Dancer2/Manual.pod b/lib/Dancer2/Manual.pod index bbf9217c..42b09525 100644 --- a/lib/Dancer2/Manual.pod +++ b/lib/Dancer2/Manual.pod @@ -218,7 +218,7 @@ You can also provide routes with a name: get 'hi_to' => '/hello/:name' => sub {...}; -See C on how this can be used. +See L on how this can be used. =head3 Retrieving request parameters diff --git a/lib/Dancer2/Manual/Keywords.pod b/lib/Dancer2/Manual/Keywords.pod index ebdf0262..0587cfd4 100644 --- a/lib/Dancer2/Manual/Keywords.pod +++ b/lib/Dancer2/Manual/Keywords.pod @@ -1182,7 +1182,7 @@ URL encoding via a third parameter: =head2 uri_for_route -An enhanced version of C that utilizes their names. +An enhanced version of C that utilizes routes' names. get 'view_entry' => '/entry/view/:id' => sub {...}; @@ -1229,7 +1229,7 @@ these being splat and megasplat args: =item * Mixed route parameters -If you have a route that includes both, the plat and megasplat +If you have a route that includes both, the splat and megasplat arguments need to be under the C key: patch 'test' => '/*/:id/*/:foo/*' => sub {1}; @@ -1267,19 +1267,35 @@ second argument: (Technically, only C requests should include query parameters, but C does not enforce this.) -=item * Escaping +=item * Disable URI escaping -The final parameter determines whether the URI will be URI-escaped: +The final parameter determines whether the URI will be URI-escaped or not: get 'show_entry' => '/view/:str_id' => sub {1}; # ... - $path = uri_for_route( 'show_entry' => { 'str_id' => '!£%^@' }, {}, 1 ); - # $path = http://localhost/view/!@%C3%82%C2%A3$% + $path = uri_for_route( + 'show_entry', + { 'str_id' => '...' }, + {}, + ); + # $path = http://localhost/view/%3Cjavascript%3E... This is useful when your ID is not HTML-safe and might include HTML tags and Javascript code or include characters that interfere with the URI request string (like a forward slash). +This is on by default, but you can disable it by setting this flag: + + get 'show_entry' => '/view/:str_id' => sub {1}; + # ... + $path = uri_for_route( + 'show_entry', + { 'str_id' => '...' }, + {}, + 1, + ); + # $path = http://localhost/view/... + =back =head2 var -- cgit v1.2.3 From 01ec74ca01f90100dd8008181d08538281f05a5d Mon Sep 17 00:00:00 2001 From: Sawyer X Date: Fri, 24 Nov 2023 12:08:43 +0100 Subject: Document in template --- lib/Dancer2/Manual/Keywords.pod | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/Dancer2/Manual/Keywords.pod b/lib/Dancer2/Manual/Keywords.pod index 0587cfd4..b9f0305d 100644 --- a/lib/Dancer2/Manual/Keywords.pod +++ b/lib/Dancer2/Manual/Keywords.pod @@ -1201,6 +1201,11 @@ create a URI for it: This works for every HTTP method, except C (which is effectively a C). +It can also be used in templates: + + + [% request.uri_for_route( 'my_route_name', { 'foo' => 'bar' }, { 'id' => 4 } ) %] + There are multiple arguments options: =over 4 -- cgit v1.2.3 From 8aa7e1ecf0403212951baf3110440d17f1cd86eb Mon Sep 17 00:00:00 2001 From: "Jason A. Crome" Date: Mon, 11 Dec 2023 20:22:44 -0500 Subject: reflect changes --- Changes | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Changes b/Changes index 8955940f..cca9b4a9 100644 --- a/Changes +++ b/Changes @@ -1,5 +1,20 @@ {{$NEXT}} + [ BUG FIXES ] + * None + + [ ENHANCEMENTS ] + * GH #33: Named routes; add uri_for_route keyword (Sawyer X) + + [ DOCUMENTATION ] + * None + + [ DEPRECATED ] + * None + + [ MISC ] + * None + 1.0.0 2023-10-09 10:10:10-04:00 America/New_York [ BUG FIXES ] -- cgit v1.2.3 From 4bcfd04ae11165b8c5b3011929b1a0b872fdc9f9 Mon Sep 17 00:00:00 2001 From: "Jason A. Crome" Date: Mon, 11 Dec 2023 20:28:48 -0500 Subject: v1.1.0 [ BUG FIXES ] * None [ ENHANCEMENTS ] * GH #33: Named routes; add uri_for_route keyword (Sawyer X) [ DOCUMENTATION ] * None [ DEPRECATED ] * None [ MISC ] * None --- Changes | 2 ++ README.md | 2 +- dist.ini | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Changes b/Changes index cca9b4a9..18147ce4 100644 --- a/Changes +++ b/Changes @@ -1,5 +1,7 @@ {{$NEXT}} +1.1.0 2023-12-11 20:28:26-05:00 America/New_York + [ BUG FIXES ] * None diff --git a/README.md b/README.md index 26949108..e94fd689 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Dancer2 - Lightweight yet powerful web application framework # VERSION -version 1.0.0 +version 1.1.0 # DESCRIPTION diff --git a/dist.ini b/dist.ini index 6ac2c6fe..c7d7c781 100644 --- a/dist.ini +++ b/dist.ini @@ -5,7 +5,7 @@ copyright_holder = Alexis Sukrieh copyright_year = 2023 main_module = lib/Dancer2.pm -version = 1.0.0 +version = 1.1.0 [NextRelease] filename = Changes -- cgit v1.2.3