Skip to content

Commit

Permalink
Introduce new DSL keyword: uri_for_route:
Browse files Browse the repository at this point in the history
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

* Lots of testing.

* Documentation updated.

This is not yet supported in the template itself.
  • Loading branch information
xsawyerx committed Nov 10, 2023
1 parent b4707f0 commit 959b72f
Show file tree
Hide file tree
Showing 9 changed files with 530 additions and 47 deletions.
75 changes: 73 additions & 2 deletions lib/Dancer2/Core/App.pm
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ package Dancer2::Core::App;
use Moo;
use Carp qw<croak carp>;
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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1694,6 +1708,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 );
}

1;

__END__
Expand Down
8 changes: 8 additions & 0 deletions lib/Dancer2/Core/DSL.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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<name regexp options code>} = @_;
} elsif ( @_ == 2 ) {
# @_ = ( REGEXP, CODE )
# get '/foo', sub {...}
@args{qw<regexp options code>} = ( $_[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<regexp options code>} = @_;
} else {
Expand Down Expand Up @@ -425,6 +430,9 @@ sub captures { $Dancer2::Core::Route::REQUEST->captures }

sub uri_for { shift; $Dancer2::Core::Route::REQUEST->uri_for(@_); }

# Should this really be in App or should it go in the request?
sub uri_for_route { shift->app->uri_for_route(@_); }

sub splat { $Dancer2::Core::Route::REQUEST->splat }

sub params { shift; $Dancer2::Core::Route::REQUEST->params(@_); }
Expand Down
6 changes: 3 additions & 3 deletions lib/Dancer2/Core/Route.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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 => (
Expand Down
7 changes: 7 additions & 0 deletions lib/Dancer2/Manual.pod
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ coderef to execute, which returns the response.
The above route specifies that, for GET requests to C</hello/...>, the code
block provided should be executed.


You can also provide routes with a name:

get 'hi_to' => '/hello/:name' => sub {...};

See C<uri_for_route> on how this can be used.

=head3 Retrieving request parameters

The L<query_parameters|Dancer2::Manual/query_parameters>,
Expand Down
136 changes: 136 additions & 0 deletions lib/Dancer2/Manual/Keywords.pod
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ Defines a route for HTTP B<DELETE> requests to the given URL:

del '/resource' => sub { ... };

You can also provide the route with a name:

del 'rec' => '/resource' => sub { ... };

See C<uri_for_route> on how this can be used.

=head2 delayed

Stream a response asynchronously. For more information, please see
Expand Down Expand Up @@ -349,6 +355,14 @@ Defines a route for HTTP B<GET> requests to the given path:

Note that a route to match B<HEAD> requests is automatically created as well.

You can also provide the route with a name:

get 'index' => '/' => sub {
return "Hello world";
}

See C<uri_for_route> on how this can be used.

=head2 halt

Sets a response object with the content given.
Expand Down Expand Up @@ -516,6 +530,12 @@ Defines a route for HTTP B<PATCH> requests to the given URL:
intended to work as a "partial-PUT", transferring just the changes; please
see L<RFC5789|http://tools.ietf.org/html/rfc5789> for further details.)

You can also provide the route with a name:

patch 'rec' => '/resource' => sub { ... };

See C<uri_for_route> on how this can be used.

=head2 path

Concatenates multiple paths together, without worrying about the underlying
Expand All @@ -534,6 +554,14 @@ Defines a route for HTTP B<POST> 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<uri_for_route> on how this can be used.

=head2 prefix

Defines a prefix for each route handler, like this:
Expand Down Expand Up @@ -625,6 +653,12 @@ Defines a route for HTTP B<PUT> requests to the given URL:

put '/resource' => sub { ... };

You can also provide the route with a name:

put 'rec' => '/resource' => sub { ... };

See C<uri_for_route> on how this can be used.

=head2 query_parameters

Returns a L<Hash::MultiValue> object from the request parameters.
Expand Down Expand Up @@ -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<uri_for> that utilizes their names.

get 'view_entry' => '/entry/view/:id' => sub {...};

Now that the route has a name we can use C<uri_for_route> 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<HEAD> (which is
effectively a C<GET>).

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<splat> 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<GET> requests should include query parameters, but
C<uri_for_route> 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
Expand Down
Loading

0 comments on commit 959b72f

Please sign in to comment.