diff --git a/META6.json b/META6.json index 8c59d739..ec2a1956 100644 --- a/META6.json +++ b/META6.json @@ -64,6 +64,8 @@ "Cro::HTTP::ResponseSerializer": "lib/Cro/HTTP/ResponseSerializer.pm6", "Cro::HTTP::ReverseProxy": "lib/Cro/HTTP/ReverseProxy.pm6", "Cro::HTTP::Router": "lib/Cro/HTTP/Router.pm6", + "Cro::HTTP::Router::LinkGenerator": "lib/Cro/HTTP/Router/LinkGenerator.pm6", + "Cro::HTTP::Router::Roles": "lib/Cro/HTTP/Router/Roles.pm6", "Cro::HTTP::Server": "lib/Cro/HTTP/Server.pm6", "Cro::HTTP::Session::IdGenerator": "lib/Cro/HTTP/Session/IdGenerator.pm6", "Cro::HTTP::Session::InMemory": "lib/Cro/HTTP/Session/InMemory.pm6", diff --git a/lib/Cro/HTTP/Router.pm6 b/lib/Cro/HTTP/Router.pm6 index e19c78d0..b0c8939c 100644 --- a/lib/Cro/HTTP/Router.pm6 +++ b/lib/Cro/HTTP/Router.pm6 @@ -10,8 +10,10 @@ use Cro::HTTP::MimeTypes; use Cro::HTTP::PushPromise; use Cro::HTTP::Request; use Cro::HTTP::Response; +use Cro::HTTP::Router::Roles; use Cro::UnhandledErrorReporter; use IO::Path::ChildSecure; +use Cro::HTTP::Router::LinkGenerator; class X::Cro::HTTP::Router::OnlyInRouteBlock is Exception { has Str $.what is required; @@ -32,6 +34,12 @@ class X::Cro::HTTP::Router::NoRequestBodyMatch is Exception { "error; if you're seeing it, you may have an over-general error handling)" } } +class X::Cro::HTTP::Router::DuplicateLinkName is Exception { + has Str $.key is required; + method message() { + "Conflicting link name: $.key" + } +} class X::Cro::HTTP::Router::ConfusedCapture is Exception { has $.body; @@ -45,22 +53,18 @@ class X::Cro::HTTP::Router::ConfusedCapture is Exception { } } -module Cro::HTTP::Router { - role Query {} +package Cro::HTTP::Router { multi trait_mod:(Parameter:D $param, :$query! --> Nil) is export { - $param does Query; + $param does Cro::HTTP::Router::Query; } - role Header {} multi trait_mod:(Parameter:D $param, :$header! --> Nil) is export { - $param does Header; + $param does Cro::HTTP::Router::Header; } - role Cookie {} multi trait_mod:(Parameter:D $param, :$cookie! --> Nil) is export { - $param does Cookie; + $param does Cro::HTTP::Router::Cookie; } - role Auth {} multi trait_mod:(Parameter:D $param, :$auth! --> Nil) is export { - $param does Auth; + $param does Cro::HTTP::Router::Auth; } #| Router plugins register themselves using the C @@ -70,6 +74,12 @@ module Cro::HTTP::Router { has Str $.id is required; } + our $link-plugin is export(:link) = router-plugin-register('link'); + + class RouteBlockLinks { + has %.link-generators; + } + #| A C that consumes HTTP requests and produces HTTP #| responses by routing them according to the routing specification set #| up using the C subroutine other routines. This class itself is @@ -134,16 +144,21 @@ module Cro::HTTP::Router { } } + + my class RouteHandler does Handler { has Str $.method; + has Str $.name; has &.implementation; has Hash[Array, Cro::HTTP::Router::PluginKey] $.plugin-config; has Hash[Array, Cro::HTTP::Router::PluginKey] $.flattened-plugin-config; + has Bool $.from-include; + has Str $.url-prefix is rw = ''; method copy-adding(:@prefix, :@body-parsers!, :@body-serializers!, :@before-matched!, :@after-matched!, :@around!, - Hash[Array, Cro::HTTP::Router::PluginKey] :$plugin-config) { + Hash[Array, Cro::HTTP::Router::PluginKey] :$plugin-config, :$name-prefix, :$from-include!) { self.bless: - :$!method, :&!implementation, + :$!method, :&!implementation, |(name => ($name-prefix ?? "$name-prefix." !! '') ~ $!name with $!name), :prefix[flat @prefix, @!prefix], :body-parsers[flat @!body-parsers, @body-parsers], :body-serializers[flat @!body-serializers, @body-serializers], @@ -151,7 +166,8 @@ module Cro::HTTP::Router { :after-matched[flat @!after-matched, @after-matched], :around[flat @!around, @around], :$!plugin-config, - :flattened-plugin-config(merge-plugin-config($plugin-config, $!flattened-plugin-config // $!plugin-config)) + :flattened-plugin-config(merge-plugin-config($plugin-config, $!flattened-plugin-config // $!plugin-config)), + :$from-include, :$!url-prefix } sub merge-plugin-config($outer, $inner) { @@ -280,6 +296,7 @@ module Cro::HTTP::Router { } } + has Str $.name; has Handler @.handlers; has Cro::BodyParser @.body-parsers; has Cro::BodySerializer @.body-serializers; @@ -332,7 +349,7 @@ module Cro::HTTP::Router { $status = 400; last; } - elsif $param ~~ Auth || $param.type ~~ Cro::HTTP::Auth { + elsif $param ~~ Cro::HTTP::Router::Auth || $param.type ~~ Cro::HTTP::Auth { $status = 401; last; } @@ -353,10 +370,10 @@ module Cro::HTTP::Router { } } - method add-handler(Str $method, &implementation --> Nil) { + method add-handler(Str $method, &implementation, Str :$name --> Nil) { @!handlers-to-add.push: { @!handlers.push(RouteHandler.new(:$method, :&implementation, :@!before-matched, :@!after-matched, - :@!around, :%!plugin-config)); + :@!around, :%!plugin-config, :$name, :!from-include)); } } @@ -368,8 +385,8 @@ module Cro::HTTP::Router { @!body-serializers.push($serializer); } - method add-include(@prefix, RouteSet $includee) { - @!includes.push({ :@prefix, :$includee }); + method add-include(@prefix, RouteSet $includee, Str :$name-prefix) { + @!includes.push({ :@prefix, :$includee, :$name-prefix }); } method add-before($middleware) { @@ -417,15 +434,55 @@ module Cro::HTTP::Router { .body-parsers = @!body-parsers; .body-serializers = @!body-serializers; } - for @!includes -> (:@prefix, :$includee) { + self!generate-urls(); + my %urls; + for @!includes -> (:@prefix, :$includee, :$name-prefix) { for $includee.handlers() { - @!handlers.push: .copy-adding(:@prefix, :@!body-parsers, :@!body-serializers, - :@!before-matched, :@!after-matched, :@!around, :%!plugin-config); + my $key = ($name-prefix ?? $name-prefix ~ '.' !! '') ~ ($_.name // ''); + # When checking all included routes in the outer route block for conflicting, + # we omit anonymous ones (if $name-prefix...) + if $name-prefix && $key && (%urls{$key}:exists) { + die X::Cro::HTTP::Router::DuplicateLinkName.new(:$key); + } + %urls{$key} = True; + $_.url-prefix = @prefix.join('/') ~ ($_.url-prefix ?? '/' ~ $_.url-prefix !! ''); + my $outer-handler = .copy-adding(:@prefix, :@!body-parsers, :@!body-serializers, + :@!before-matched, :@!after-matched, :@!around, :%!plugin-config, :$name-prefix, :from-include); + if $outer-handler.name { + my $link-config = $outer-handler.get-innermost-plugin-configs($link-plugin)[0]; + # Url of included route can have a prefix which we did not know about + # at the generation stage, so overwrite it now when we have all the data we need + my $generator = Cro::HTP::Router::LinkGenerator.new(prefix => .url-prefix, signature => .signature); + $link-config.link-generators = %( + |$link-config.link-generators, + $outer-handler.name => $generator, + .name => $generator + ); + } + @!handlers.push: $outer-handler; } } self!generate-route-matcher(); } + method !generate-urls() { + my %urls; + my $prefix = $.name // ""; + $prefix ~= '.' if $prefix; + for @.handlers -> $handler { + if $handler ~~ RouteHandler && $handler.name.defined { + my $key = $prefix ~ $handler.name; + next if $handler.from-include and not $key.contains('.'); + die X::Cro::HTTP::Router::DuplicateLinkName.new(:$key) if %urls{$key}:exists; + my $url-prefix = $handler.url-prefix; + %urls{$key} = Cro::HTP::Router::LinkGenerator.new: + prefix => $url-prefix, + signature => $handler.signature; + } + } + router-plugin-add-config($link-plugin, RouteBlockLinks.new(link-generators => %urls)); + } + method !generate-route-matcher(--> Nil) { my @route-matchers; my @handlers = @!handlers; # This is closed over in the EVAL'd regex @@ -467,7 +524,7 @@ module Cro::HTTP::Router { # and it and compile the check. my $have-auth-param = False; with @positional[0] -> $param { - if $param ~~ Auth || $param.type ~~ Cro::HTTP::Auth { + if $param ~~ Cro::HTTP::Router::Auth || $param.type ~~ Cro::HTTP::Auth { @positional.shift; $have-auth-param = True; $need-sig-bind = True; @@ -571,13 +628,13 @@ module Cro::HTTP::Router { # Turned nameds into unpacks. for @named -> $param { - my $target-name = $param.named_names[0]; + my $target-name = $param.slurpy ?? $param.name !! $param.named_names[0]; my ($exists, $lookup) = do given $param { - when Cookie { + when Cro::HTTP::Router::Cookie { '$req.has-cookie(Q[' ~ $target-name ~ '])', '$req.cookie-value(Q[' ~ $target-name ~ '])' } - when Header { + when Cro::HTTP::Router::Header { '$req.has-header(Q[' ~ $target-name ~ '])', '$req.header(Q[' ~ $target-name ~ '])' } @@ -602,10 +659,10 @@ module Cro::HTTP::Router { } elsif $type =:= Positional { given $param { - when Header { + when Cro::HTTP::Router::Header { push @make-tasks, '%unpacks{Q[' ~ $target-name ~ ']} = $req.headers'; } - when Cookie { + when Cro::HTTP::Router::Cookie { die "Cookies cannot be extracted to List. Maybe you want '%' instead of '@'"; } default { @@ -615,10 +672,10 @@ module Cro::HTTP::Router { } elsif $type =:= Associative { given $param { - when Cookie { + when Cro::HTTP::Router::Cookie { push @make-tasks, '%unpacks{Q[' ~ $target-name ~ ']} = $req.cookie-hash'; } - when Header { + when Cro::HTTP::Router::Header { push @make-tasks, 'my %result;' ~ '$req.headers.map({ %result{$_.name} = $_.value });' @@ -689,14 +746,16 @@ module Cro::HTTP::Router { #| Define a set of routes. Expects to receive a block, which will be evaluated #| to set up the routing definition. - sub route(&route-definition) is export { - my $*CRO-ROUTE-SET = RouteSet.new; + multi route(&route-definition, Str :$name) is export { + my $*CRO-ROUTE-SET = RouteSet.new(:$name); route-definition(); $*CRO-ROUTE-SET.definition-complete(); my @before = $*CRO-ROUTE-SET.before; my @after = $*CRO-ROUTE-SET.after; if @before || @after { - return Cro.compose(|@before, $*CRO-ROUTE-SET, |@after, :for-connection); + return Cro.compose(|@before, $*CRO-ROUTE-SET, |@after, :for-connection) but role { + method route-prefix { $name } + }; } else { $*CRO-ROUTE-SET; } @@ -704,14 +763,14 @@ module Cro::HTTP::Router { #| Add a handler for a HTTP GET request. The signature of the handler will be #| used to determine the routing. - multi get(&handler --> Nil) is export { - $*CRO-ROUTE-SET.add-handler('GET', &handler); + multi sub get(&handler, Str :$name --> Nil) is export { + $*CRO-ROUTE-SET.add-handler('GET', &handler, :$name); } #| Add a handler for a HTTP POST request. The signature of the handler will be #| used to determine the routing. - multi post(&handler --> Nil) is export { - $*CRO-ROUTE-SET.add-handler('POST', &handler); + multi post(&handler, Str :$name --> Nil) is export { + $*CRO-ROUTE-SET.add-handler('POST', &handler, :$name); } #| Add a handler for a HTTP PUT request. The signature of the handler will be @@ -749,17 +808,17 @@ module Cro::HTTP::Router { sub include(*@includees, *%includees --> Nil) is export { for @includees { when RouteSet { - $*CRO-ROUTE-SET.add-include([], $_); + $*CRO-ROUTE-SET.add-include([], $_, name-prefix => $_.name); } when Pair { my ($prefix, $routes) = .kv; if $routes ~~ RouteSet { given $prefix { when Str { - $*CRO-ROUTE-SET.add-include([$prefix], $routes); + $*CRO-ROUTE-SET.add-include([$prefix], $routes, name-prefix => $routes.name); } when Iterable { - $*CRO-ROUTE-SET.add-include($prefix, $routes); + $*CRO-ROUTE-SET.add-include($prefix, $routes, name-prefix => $routes.name); } default { die "An 'include' prefix may be a Str or Iterable, but not " ~ .^name; @@ -779,7 +838,7 @@ module Cro::HTTP::Router { } for %includees.kv -> $prefix, $routes { if $routes ~~ RouteSet { - $*CRO-ROUTE-SET.add-include([$prefix], $routes); + $*CRO-ROUTE-SET.add-include([$prefix], $routes, name-prefix => $routes.name); } else { die "Can only use 'include' with `route` block, not a $routes.^name()"; @@ -1249,7 +1308,13 @@ module Cro::HTTP::Router { #| Add a request handler for the specified HTTP method. This is useful #| when there is no shortcut function available for the HTTP method. - sub http($method, &handler --> Nil) is export { + multi http($name, $method, &handler --> Nil) is export { + $*CRO-ROUTE-SET.add-handler($method, &handler, :$name); + } + + #| Add a request handler for the specified HTTP method. This is useful + #| when there is no shortcut function available for the HTTP method. + multi http($method, &handler --> Nil) is export { $*CRO-ROUTE-SET.add-handler($method, &handler); } @@ -1325,6 +1390,33 @@ module Cro::HTTP::Router { } } + sub rel-link($route-name, *@params, *%params) is export { + with get-link($route-name, 'rel-link') { + return $_.relative(|@params, |%params); + } + ""; + } + + sub abs-link($route-name, *@params, *%params) is export { + with get-link($route-name, 'abs-link') { + return $_.absolute(|@params, |%params); + } + ""; + } + + my sub get-link($route-name, $sub-name) { + my $maker = router-plugin-get-configs($link-plugin); + my @options; + for @$maker -> $links { + with $links.link-generators{$route-name} { + return $_; + } + @options.push: |$links.link-generators.keys; + } + warn "Called the $sub-name subroutine with $route-name but no such route defined, options are: @options.join(', ')"; + Nil; + } + #| Register a router plugin. The provided ID is for debugging purposes. #| Returns a plugin key object which can be used for further interactions #| with the router plugin infrastructure. diff --git a/lib/Cro/HTTP/Router/LinkGenerator.pm6 b/lib/Cro/HTTP/Router/LinkGenerator.pm6 new file mode 100644 index 00000000..ef086eab --- /dev/null +++ b/lib/Cro/HTTP/Router/LinkGenerator.pm6 @@ -0,0 +1,112 @@ +use Cro::Uri :encode-percents; +use Cro::HTTP::Router::Roles; + +class Cro::HTP::Router::LinkGenerator { + has Str $.prefix is required; + has Signature $.signature is required; + has Callable $!generator; + + submethod TWEAK(--> Nil) { + $!generator = signature-to-sub($!signature) + } + + method CALL-ME(|c) { self.absolute(|c) } + method relative(|c) { $!generator(|c) } + method absolute(|c) { '/' ~ ($!prefix ?? $!prefix ~ '/' !! '') ~ $!generator(|c) } + method url(|c) { + my $root-url = $*CRO-ROOT-URL or die 'No CRO-ROOT-URL configured'; + $root-url ~ ($root-url.ends-with('/') ?? '' !! '/') ~ ($!prefix ?? "$!prefix/" !! '') ~ $!generator(|c) + } +} + +sub signature-to-sub(Signature $s) { + sub extract-static-part(Parameter $p) { + if $p.constraint_list == 1 && $p.constraint_list[0] ~~ Str { + return $p.constraint_list[0] + } + } + + my @path-parts; + my @fn-parts; + my $has-slurpy; + my $has-slurpy-named; + my %allowed-named; + my %required-named; + my @default; + my $min-args = 0; + for $s.params.kv -> $i, $param { + next if $param ~~ Cro::HTTP::Router::Header; + next if $param ~~ Cro::HTTP::Router::Cookie; + + if $param.positional { + with extract-static-part $param -> $part { + @path-parts[$i] = $part; + } else { + ++$min-args; + @fn-parts.push: $i; + if $param.optional { + @default.push: $_ with $param.default + } + } + } elsif $param.named { + if $param.slurpy { + $has-slurpy-named = True; + next; + } + + %allowed-named{$param.usage-name} = True; + unless $param.optional { + %required-named{$param.usage-name} = True + } + } elsif $param.slurpy { + $has-slurpy = True; + } + # otherwise it's a Capture, which the router doesn't allow + } + my $allowed-nameds = %allowed-named.keys.Set; + my $required-nameds = %required-named.keys.Set; + + -> *@args, *%nameds { + if @args < $min-args { + die "Not enough arguments"; + } + + my @result = @path-parts; + my @available-default = @default; + for @fn-parts -> $i { + if @args { + @result[$i] = @args.shift; + if @result[$i] ~~ Str { + @result[$i] = encode-percents(@result[$i]); + } + } elsif @available-default { + @result[$i] = @available-default.shift + } + # Otherwise, an optional wasn't filled, leave empty + } + + if @args && !$has-slurpy { + die "Extraneous arguments"; + } + + if !$has-slurpy-named { + my $passed-nameds = %nameds.keys.Set; + my $missing-nameds = $required-nameds (-) $passed-nameds; + my $extra-nameds = $passed-nameds (-) $allowed-nameds; + if $missing-nameds || $extra-nameds { + my @parts = ( + |("Missing named arguments: " ~ $missing-nameds.keys.sort.join(', ') if $missing-nameds), + |("Extraneous named arguments: " ~ $extra-nameds.keys.sort.join(', ') if $extra-nameds) + ); + die @parts.join('. ') ~ '.'; + } + } + + @result.append: @args; + my $result = @result.join: '/'; + if %nameds { + $result ~= '?' ~ %nameds.sort(*.key).map({ encode-percents(.key) ~ "=" ~ encode-percents(.value.Str) }).join('&'); + } + $result + } +} diff --git a/lib/Cro/HTTP/Router/Roles.pm6 b/lib/Cro/HTTP/Router/Roles.pm6 new file mode 100644 index 00000000..00e28dd7 --- /dev/null +++ b/lib/Cro/HTTP/Router/Roles.pm6 @@ -0,0 +1,6 @@ +package Cro::HTTP::Router { + role Query {} + role Header {} + role Cookie {} + role Auth {} +} diff --git a/t/http-router-named-urls.t b/t/http-router-named-urls.t new file mode 100644 index 00000000..341bb358 --- /dev/null +++ b/t/http-router-named-urls.t @@ -0,0 +1,167 @@ +use Cro; +use Cro::HTTP::Request; +use Cro::HTTP::Router :link; +use Cro::HTTP::Router :plugin; +use Cro::HTTP::Router; +use Test; + +sub test-route-urls($app) { + my $source = Supplier.new; + my $responses = $app.transformer($source.Supply).Channel; + $source.emit(Cro::HTTP::Request.new(:method, :target)); + $responses.receive; +} + +test-route-urls route { + get -> { + is abs-link('qs', 'tools', query => 'abc?!'), '/search/tools?query=abc%3F%21', 'Escaped named param'; + is abs-link('segs', 42, 'foo bar.jpg'), '/product/42/docs/foo%20bar.jpg', 'Escaped positional'; + is abs-link('noqs'), '/baz', 'Non-path related parameters were not counted'; + }; + + get :name, -> 'foo', 'bar' { } + get :name, -> 'product', $id, 'docs', $file { } + get :name, -> 'search', $category, :$query {} + get :name, -> 'baz', :$foo! is cookie, :$bar! is header {} +} + +test-route-urls route { + get -> { + is-deeply router-plugin-get-innermost-configs($link-plugin)[0].link-generators, %(), "No named urls"; + }; +} + +test-route-urls route :name, { + get -> { + is-deeply router-plugin-get-innermost-configs($link-plugin)[0].link-generators, %(), "No named urls with a prefix"; + }; +} + +test-route-urls route :name
, { + get :name, -> { + is abs-link('main.home'), '/', 'Basic call of a generator by a qualified name is correct'; + }; +} + +throws-like { + route { + get :name, -> {}; + get :name, -> {}; + } +}, X::Cro::HTTP::Router::DuplicateLinkName, message => "Conflicting link name: home"; + +throws-like { + route :name
, { + get :name, -> {}; + get :name, -> {}; + } +}, X::Cro::HTTP::Router::DuplicateLinkName, message => "Conflicting link name: main.home"; + +test-route-urls route { + get -> { + is abs-link('hello', 'world'), '/hello/world', 'URL is generated correctly'; + throws-like { abs-link('hello') }, Exception, message => "Not enough arguments"; + throws-like { abs-link('hello', 'a', 'b') }, Exception, message => "Extraneous arguments"; + } + + get :name, -> 'hello', $name {}; +} + +test-route-urls route { + get :name, -> :$a, :$b { + is abs-link('hello', :a(1), :b(2)), '/?a=1&b=2'; + is abs-link('hello', :a(1)), '/?a=1'; + is abs-link('hello', :b(2)), '/?b=2'; + throws-like { abs-link('hello', 1) }, Exception, message => "Extraneous arguments"; + throws-like { abs-link('hello', :c(3)) }, Exception, message => "Extraneous named arguments: c."; + throws-like { abs-link('hello', :a(1), :c(3)) }, Exception, message => "Extraneous named arguments: c."; + }; +} + +test-route-urls route { + get -> { + is abs-link('hello', :a(1), :b(2)), '/?a=1&b=2'; + throws-like { abs-link('hello', :a(1)) }, Exception, message => "Missing named arguments: b."; + throws-like { abs-link('hello', :b(2)) }, Exception, message => "Missing named arguments: a."; + throws-like { abs-link('hello', 1) }, Exception, message => "Extraneous arguments"; + throws-like { abs-link('hello', :c(3)) }, Exception, message => "Missing named arguments: a, b. Extraneous named arguments: c."; + throws-like { abs-link('hello', :a(1), :c(3)) }, Exception, message => "Missing named arguments: b. Extraneous named arguments: c."; + } + + get :name, -> :$a!, :$b! {}; +} + +test-route-urls route { + get -> { + is abs-link('css'), '/css'; + is abs-link('css', 'x', 'y', 'z'), '/css/x/y/z'; + } + + get :name, -> 'css', +a { }; +} + +test-route-urls route { + get -> { + is abs-link('css'), '/', 'Splat with no args at all'; + is abs-link('css', 'x', 'y', 'z'), '/x/y/z', 'Splat with no named args'; + is abs-link('css', :a(1), :b(2), :c(3)), '/?a=1&b=2&c=3', 'Splat with no pos args'; + is abs-link('css', 'x', 'y', 'z', :a(1), :b(2), :où('Ÿ')), '/x/y/z?a=1&b=2&o%C3%B9=%C5%B8', 'Splat with both types of args'; + } + + get :name, -> *@a, *%b { }; +} + +{ + lives-ok { + my $app = route { + include route { + get :name, -> {}; + } + include route { + get :name, -> {}; + } + } + }, 'Conflict check is per-route block 1'; +} + +{ + lives-ok { + my $app = route { + get :name, -> {}; + include route { + get :name, -> {}; + get :name, -> {}; + } + include route :name, { + get :name, -> {}; + get :name, -> {}; + } + } + }, 'Conflict check is per-route block 2'; +} + +{ + lives-ok { + my $app = route { + include route :name, { + get :name, -> {}; + } + include route :name, { + get :name, -> {}; + } + } + }, 'Conflict check is by route name'; +} + +throws-like { + my $app = route { + include route :name, { + get :name, -> {}; + } + include route :name, { + get :name, -> {}; + } + } +}, X::Cro::HTTP::Router::DuplicateLinkName, message => "Conflicting link name: foo.home"; + +done-testing;