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.

* `request.uri_for_route()` in templates is also supported.

* Lots of testing.

* Documentation updated.
  • Loading branch information
xsawyerx committed Nov 12, 2023
1 parent b4707f0 commit 1211dbf
Show file tree
Hide file tree
Showing 10 changed files with 549 additions and 48 deletions.
77 changes: 75 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 @@ -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 )
Expand Down Expand Up @@ -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__
Expand Down
7 changes: 7 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,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(@_); }
Expand Down
19 changes: 18 additions & 1 deletion lib/Dancer2/Core/Request.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'};
Expand Down Expand Up @@ -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 ) = @_;

Expand Down Expand Up @@ -1090,6 +1100,13 @@ You get the following behavior:
C<uri_for> returns a L<URI> 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<Dancer2::Manual::Keywords> document under
C<uri_for_route>.
=method user
Return remote user if defined.
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
Loading

0 comments on commit 1211dbf

Please sign in to comment.