Repository: falconry/falcon

Issue:
Add support for filters in route templates
This is a request I've heard from the community, and that was previously mentioned in one of my comments on #161.  It would be nice to if we could support this within the path and for query string params (assuming we implement the latter). We can take inspiration from [bottle](http://bottlepy.org/docs/dev/tutorial.html#dynamic-routes). Default filters might include:
- [x] `int` (already implemented)
- [x] `float` deferred to https://github.com/falconry/falcon/issues/2022
- [ ] `path` prototype PR: https://github.com/falconry/falcon/pull/1945
- [x] `re` deferred to https://github.com/falconry/falcon/issues/857
- [ ] `in` will be deferred to another issue if needed

~~Also, we need to consider how this might be applies to query string parameters as well (which would require defining such parameters in the URI template)~~ :arrow_left: tracked separately in https://github.com/falconry/falcon/issues/838
Routing all paths to one handler function
Is it possible to route all paths that start with something like “/api” to same handler function?

Such as:

```
/api/foo
/api/bar
/api/foo/bar
/api/bar/baz/this/that 
```

All should be handled with one function and I should be able to get the full path after /api.

This feature is very handy and I used it often at Node.js Express framework. Now I am looking for ways to accomplish the same thing with Falcon.

More info can be found here; It defines the feature as "white-listed “global” functionality."
http://expressjs.com/api.html#app.all



Discussion:
I wouldn't mind looking into this!

@kgriffs I was glancing through the suggested default filters list again and realized I'm not sure what "in" is :P 

@lindseybrockman  I honestly don't remember about "in", so let's just leave it out for now. However, we might want to add "uuid" since that is a fairly common way to specify resource IDs (in addition to "int").

I've split out the query string work into a separate issue (see also #838).

Here's what I was thinking regarding the contract for these filters...

Assume a sample template: 

``` py
'/things/{thing_id:some_filter:optional_filter_config}'
```

And a sample request path:

``` py
'/things/dd8644b9-98d3-4532-b759-57e8427d3f04/arbitrary/stuff'
```

Each filter would be defined as a callable that does the following:
1. Receives the following arguments:
   - A tuple of path segments starting with the current segment and ending with the last segment in the requested path
     - For simple conversion filters and such, they will only use the first item and ignore the rest.
     - Other filters that want to match/consume the rest of the path will make use of all segments.
   - Filter config string (default `None`)
     - Arbitrary string passed verbatim from how it is specified in the template.
     - Must escape '}' character since that is normally used to signify the end of the field definition in the template.
2. Returns a tuple consisting of the following, in the given order:
   - `True` if the filter accepted or matched the segment in the request path, `False` otherwise. If `False`, a single-element tuple may be returned, or the remaining members of the tuple must be `None`. 
     - E.g., given a "re" filter, it must match the expression. Given an "int" filter, the segment must be parsable as an `int`.
   - `True` if the filter consumed the entire remaining path (all remaining segments, starting with the current one). `False` if the filter only consumed the first segment.
   - The value to be assigned to the field name.
     - E.g., given a "re" filter, the match object or groups could be returned. Given an "int" filter, the value of the segment converted to an `int` would be returned. Other filters might simply pass through the string for the segment as-is.

To be consistent with the way non-filtered fields work, we may have to allow for templates such as:

``` py
'/foo/bar.{name:filter}.png'
```

But if that ends up being complicated, we might want to break it out into a separate issue.

What do you think? Does it make sense to do it this way?

@jmvrbanac Also curious to get your thoughts on this...

Something else to think about: What is a good way to allow a filter to short-circuit a request with a specific error? For example, when parsing a UUID, if it is invalid, the filter may want to raise an instance of `HTTPBadRequest` instead of just returning `False` to indicate the segment wasn't matched/accepted, which would presumably result in a 404.

@kgriffs ah, yes, I'll be sure to take that into account! I believe custom filter registration was also suggested.. I was planning on going ahead with that as well, unless that has also been broken out into a separate issue (like #838)

@kgriffs one more follow up regarding the return value of a filter indicating that it consumed all of the remaining segments: I just want to confirm that this means falcon would only support filters that consume either the first segment _or_ the entire rest of path (presumably after it's been re-built with `/` delimiters). Only verifying because I checked the functionality in bottle, and it looks like templates like the following silly example are supported: `'/static/<static_path:path>/extra/segments/that/will/not/match'`, where the `static_path` param being passed to the controller will not contain the "/extra/segments/that/will/not/match" bit of the template.

@lindseybrockman I think custom filter registration would be good to do now, since that would help encourage a clean design. Regarding consuming any number of path segments, we could support it, but it may be something we put off for now since it would probably involve significantly more work in the router. But if we want to plan for that in the future, we could have filters return a number and just state that currently only `1` and `len(path_segments)` are supported. 

What do you think?

@kgriffs I agree that putting off consuming only certain path segments can be done at a later time 👍 I'll look into returning numbers and see if that would be another possible way of having them fail fast instead of having the tree return `None` and then 404.

@lindseybrockman Just following up to see if you were still working on this? If you don't have the bandwidth, no worries, just let me know and we can reassign. Thanks!

BTW, this could be a good way to score some points for https://hacktoberfest.digitalocean.com 🎃 

@kgriffs woah jeeze, I really put this on the backburner 😖 It looks like the last bit of functionality I need to implement is filters on complex nodes! I'll dive back in this weekend and let you know if I need to hand the work off to someone else! 

@kgriffs alrighty, working on some testing and clean up to prep for a PR. The last remaining issue was handling filters on complex nodes. You mentioned above that we may want to break it out into a separate issue. At first I thought I'd be able to tackle it, but it's been a little more time consuming than I thought it would be. Worst case scenario, I'll submit the PR with_out_ support for that and work on adding it as a feature separately.

@kgriffs unfortunately I haven't had much time to dedicate to this feature. I will continue working on it if I get a chance, but I'm more than fine handing off the remainder of the work to a new assignee. 

@lindseybrockman Thanks for everything you've done so far! Would you like to put up a WIP PR and then we can see if anyone else wants to take it and run with it?

@kgriffs said that I should comment on my use case here, to make sure it's covered in any subsequent fixes.

Given URI:

```
/images/a,6;b,45;text,hello/http://www.test.com/photos/dcm_04334.jpg
```

I'd like to `add_route()` a route that matches the above like:

```
/images/{params}/{source}
```

such that `{params}` contains `a,6;b,45;text,hello` and `{source}` contains `http://www.test.com/photos/dcm_04334.jpg`.

I think a regular expression match is needed for the last bit?

<hr>

**EDIT**: `app.add_sink()` does give me what I need, however.

@lindseybrockman just checking in to see if you are still working on this, or if you have some WIP code you would like to share for another contributor to pick up?
@kgriffs just opened a WIP PR for another user to grab! https://github.com/falconry/falcon/pull/976
Thanks!
Added initial support in 1.3. For the next release we still need to add `re` and `path` converters. These will need to use a different interface that allows for consuming more than one path segment. Also, these types of converters will be required to consume one or more complete path segments (no partial segments).
FWIW, I've heard from a couple people now who are looking for a way to accept forward slashes in a path segment. Most recent query from the gitter channel:

> Hi, how can I handle backslashes within a route Path? In Flask you can do: @app.route('/thing1/thing2/<path:param1>', methods=['DELETE'])
Wondering how to accomplish with Falcon? Thanks!
To expand on the above, the variable "param1" would look something like: "abc:def:ghi:a/798dfldfndk897:9odjlfndl"

I would also like to comment my use-case here:

I am building a github like url strategy, where the last part of the url is a path to a blob or tree.

`app.add_route('/repo/{owner}/{repo}/tree/{branch}/{path}', treeObject)`

would match the url /repo/nikic/PHP-Parser/tree/master/lib/PhpParser/Lexer
with the **path** param being **lib/PhpParser/Lexer**

`app.add_route('/repo/{owner}/{repo}/blob/{branch}/{path}', blobObject)`

would match the url /repo/nikic/PHP-Parser/blob/master/lib/PhpParser/Lexer/Emulative.php
with the **path** param being lib/PhpParser/Lexer/Emulative.php

Edit:
@vytas7 recommended using sink at this moment.
```app.add_sink(treeHandler, prefix='/repo/(?P<owner>.*?)/(?P<repo>.*?)/tree/(?P<branch>.*?)/(?P<path>.*)')```
You can always write your own routing function (though it will be slow in comparison).

You can get partially there by adding a parameter, but this won't include paths which have extra '/'.

```
app.add_route('/things/{rest_of_url}', things)
```

Someone else might know more.

Unfortunately slash "/" is the delimiter used by the compiled routing code, thus it can not be contained by any URL parameter. @hpincket's example will not work, I guess.

Right, my example will only work if there are no slashes.

On Wed, Nov 4, 2015 at 2:02 PM, Philip Tzou notifications@github.com
wrote:

> ## Unfortunately slash "/" is the delimiter used by the compiled routing code, it can not be contained by any URL parameter. @hpincket's example will not work, I guess.
> 
> Reply to this email directly or view it on GitHub:
> https://github.com/falconry/falcon/issues/648#issuecomment-153831557

Falcon has a few features that can help with this. Global logic (such as auth, rate limiting, etc.) can be implemented in Falcon via [middleware components](https://falcon.readthedocs.org/en/stable/api/middleware.html) or [hooks](https://falcon.readthedocs.org/en/stable/api/hooks.html). [Sinks](https://falcon.readthedocs.org/en/stable/api/api.html#falcon.API.add_sink) can capture all requests with a specific prefix.

If none of these fit the bill, I'm sure we can work out a plan to get what you need into the framework.

I suppose we can introduce some new syntax to URI templates. For example something like `'{path|re:/.+/}'`, which can be interpreted to `"(?P<path>.+)"`. This approach doesn't add new type of node, it compatible with the complex var node which has already been interpreted as regexp.

Another approach (may be more efficient and non-break) is add a new parameter "match_type" to `add_route`. Which is default to "EXCEPT_SLASH" which is the current behaviour. But we can add other types like "ALL" to extend the behaviour. So the usage will basically like this:

``` python
app.add_route('/things/{rest_of_url}', things, match_type=ALL)  # match the remaining part include slashes
app.add_route('/things/{just_this}', things, match_type=EXCEPT_SLASH)  # match any chars except slash
app.add_route('/things2/{just_this}', things)  # match any chars except slash, the default behaviour
```

How do you think @kgriffs?

The regex approach may prove more flexible (afford more functionality). However, before moving forward I'd like to see some concrete examples of what types of use cases such a feature would serve, that isn't already covered by using a sink. Just to make sure we design this in the right way.

I ran into this issue and what surprises me is that slashes pose problems in parameters even when URL encoded.

```
api.add_route('/v1/things/{param}', things)
```

```
GET /v1/things/forward-slash     # works
GET /v1/things/forward/slash     # does not work
GET /v1/things/forward%2Fslash   # does also not work
```

Is this expected behaviour?

Hi, my use-case is to create microservice for CORS proxy like this `GET /api/v1/proxy/http://example.com/some/path/to/file.jpeg?width=300&heigth=100`. IMHO this is quite common case. Currently I am able to do this with *sink*.

EDIT: This was not good idea. Now we are using URL `GET /api/v1/proxy?url=http%3A%2F%2Fexample.com%2Fsome%2Fpath%2Fto%2Ffile.jpeg%3Fwidth%3D300%26heigth%3D100` because if proxied URL has some encoded characters they are decoded in endpoint and the URL is broken. This way the URL is always correctly decoded in endpoint.
same issue here. slash was url encoded
I use a sink and then extract the rest part with the follow code:
```python
parts = req.relative_uri.split('/my_sink_path/')
url = parts[1]
```
But this is ugly!

Also I need deal with a # symbol but this part is totally lost in the Request object.

@Dotrox: There's no way you can capture the text after # symbol. It won't be submitted to the server by any browsers.
Really need this feature.
Voting for this feature as well
Any updates? Really wanted this feature as well
Any updates to this?
I am looking for the best way to handle this case. Any updates here?
Hi everyone,
as @kgriffs pointed out, Falcon now already has a few features (including the new ones in 2.0) which may or may not fit the bill in this case.
To reiterate, it would be good to understand where we stand right now, and what is not covered by Falcon 2.0 (or is covered but perceived too "ugly" / too much extra work to do).

Although probably not exactly what was requested in this issue, if the amount of routes is finite and known in advance, the [route suffix](https://falcon.readthedocs.io/en/stable/api/api.html#falcon.API.add_route) feature may be helpful to route all these paths to the same resource with suffixed methods.

If we just need to handle all paths to one function as the issue is originally formulated, could a sink work? I was thinking along the lines:
```python
import re

import falcon


def one_handler_function(req, resp, path):
    resp.media = {
        'message': 'Just demonstrating a possible approach to #648',
        'path': path,
    }


api = falcon.API()
api.add_sink(one_handler_function, re.compile(r'/api(?P<path>/.*)'))
```

That seems to do the job (but probably I'm missing important details):
```
GET /api/hello/world?q=1 HTTP/1.1

{
    "message": "Just demonstrating a possible approach to #648",
    "path": "/hello/world"
}
```

Sinks are somewhat unwieldy in the sense that one needs to handle `req.method` explicitly, but OTOH that perfectly fits the bill of _one handler function_.

FWIW, we have an issue to improve documentation for the sink feature: https://github.com/falconry/falcon/issues/1538 .
Regarding forward slashes in the path woes, there are admittedly some unresolved glitches on that front. See also related routing discussions here: https://github.com/falconry/falcon/issues/423

Furthermore, depending on what this _one handler function_ does, simple cases may be handled by responding directly from middleware by signalling the [`Response.complete`](https://falcon.readthedocs.io/en/stable/api/request_and_response.html#falcon.Response.complete) flag. While I would primarily recommend sinks for routing paths to one function, the `Response.complete` approach may prove advantageous if you also have some of these paths clashing with existing routes that you still may want to handle separately (and that would take precedence over sinks). The new feature itself is meant to cover cases like intelligent caching of responses etc.

Last but not the least, do not hesitate to share and discuss ideas on our [Gitter channels](https://falconframework.org/#sectionCommunity).
Routing improvements is admittedly one of the areas we are getting many requests from the community.

Resolving patch:
diff --git a/falcon/routing/__init__.py b/falcon/routing/__init__.py
--- a/falcon/routing/__init__.py
+++ b/falcon/routing/__init__.py
@@ -24,6 +24,7 @@
 from falcon.routing.converters import BaseConverter
 from falcon.routing.converters import DateTimeConverter
 from falcon.routing.converters import IntConverter
+from falcon.routing.converters import PathConverter
 from falcon.routing.converters import UUIDConverter
 from falcon.routing.static import StaticRoute
 from falcon.routing.static import StaticRouteAsync
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -146,7 +146,7 @@ class can use suffixed responders to distinguish requests
 
         return map_http_methods(resource, suffix=kwargs.get('suffix', None))
 
-    def add_route(self, uri_template, resource, **kwargs):
+    def add_route(self, uri_template, resource, **kwargs):  # noqa: C901
         """Add a route between a URI path template and a resource.
 
         This method may be overridden to customize how a route is added.
@@ -199,7 +199,7 @@ class can use suffixed responders to distinguish requests
         # NOTE(kgriffs): Fields may have whitespace in them, so sub
         # those before checking the rest of the URI template.
         if re.search(r'\s', _FIELD_PATTERN.sub('{FIELD}', uri_template)):
-            raise ValueError('URI templates may not include whitespace.')
+            raise UnacceptableRouteError('URI templates may not include whitespace.')
 
         path = uri_template.lstrip('/').split('/')
 
@@ -207,6 +207,19 @@ class can use suffixed responders to distinguish requests
         for segment in path:
             self._validate_template_segment(segment, used_names)
 
+        def find_cmp_converter(node):
+            value = [
+                (field, converter)
+                for field, converter, _ in node.var_converter_map
+                if converters._consumes_multiple_segments(
+                    self._converter_map[converter]
+                )
+            ]
+            if value:
+                return value[0]
+            else:
+                return None
+
         def insert(nodes, path_index=0):
             for node in nodes:
                 segment = path[path_index]
@@ -218,12 +231,17 @@ def insert(nodes, path_index=0):
                         node.resource = resource
                         node.uri_template = uri_template
                     else:
+                        cpc = find_cmp_converter(node)
+                        if cpc:
+                            raise UnacceptableRouteError(
+                                _NO_CHILDREN_ERR.format(uri_template, *cpc)
+                            )
                         insert(node.children, path_index)
 
                     return
 
                 if node.conflicts_with(segment):
-                    raise ValueError(
+                    raise UnacceptableRouteError(
                         'The URI template for this route is inconsistent or conflicts '
                         "with another route's template. This is usually caused by "
                         'configuring a field converter differently for the same field '
@@ -236,12 +254,27 @@ def insert(nodes, path_index=0):
             # exist and needs to be created. This builds a new branch of the
             # routing tree recursively until it reaches the new node leaf.
             new_node = CompiledRouterNode(path[path_index])
+            if new_node.is_complex:
+                cpc = find_cmp_converter(new_node)
+                if cpc:
+                    raise UnacceptableRouteError(
+                        'Cannot use converter "{1}" of variable "{0}" in a template '
+                        'that includes other characters or variables.'.format(*cpc)
+                    )
             nodes.append(new_node)
             if path_index == len(path) - 1:
                 new_node.method_map = method_map
                 new_node.resource = resource
                 new_node.uri_template = uri_template
             else:
+                cpc = find_cmp_converter(new_node)
+                if cpc:
+                    # NOTE(caselit): assume success and remove the node if it's not
+                    # supported to avoid leaving the router in a broken state.
+                    nodes.remove(new_node)
+                    raise UnacceptableRouteError(
+                        _NO_CHILDREN_ERR.format(uri_template, *cpc)
+                    )
                 insert(new_node.children, path_index + 1)
 
         insert(self._roots)
@@ -349,7 +382,7 @@ def _validate_template_segment(self, segment, used_names):
                     'Field names must be valid identifiers ("{0}" is not valid)'
                 )
                 msg = msg_template.format(name)
-                raise ValueError(msg)
+                raise UnacceptableRouteError(msg)
 
             if name in used_names:
                 msg_template = (
@@ -357,28 +390,28 @@ def _validate_template_segment(self, segment, used_names):
                     '("{0}" was used more than once)'
                 )
                 msg = msg_template.format(name)
-                raise ValueError(msg)
+                raise UnacceptableRouteError(msg)
 
             used_names.add(name)
 
             if field.group('cname_sep') == ':':
                 msg = 'Missing converter for field "{0}"'.format(name)
-                raise ValueError(msg)
+                raise UnacceptableRouteError(msg)
 
             name = field.group('cname')
             if name:
                 if name not in self._converter_map:
                     msg = 'Unknown converter: "{0}"'.format(name)
-                    raise ValueError(msg)
+                    raise UnacceptableRouteError(msg)
                 try:
                     self._instantiate_converter(
                         self._converter_map[name], field.group('argstr')
                     )
                 except Exception as e:
                     msg = 'Cannot instantiate converter "{}"'.format(name)
-                    raise ValueError(msg) from e
+                    raise UnacceptableRouteError(msg) from e
 
-    def _generate_ast(
+    def _generate_ast(  # noqa: C901
         self,
         nodes: list,
         parent,
@@ -428,12 +461,14 @@ def _generate_ast(
         original_params_stack = params_stack.copy()
         for node in nodes:
             params_stack = original_params_stack.copy()
+            consume_multiple_segments = False
             if node.is_var:
                 if node.is_complex:
                     # NOTE(richardolsson): Complex nodes are nodes which
                     # contain anything more than a single literal or variable,
                     # and they need to be checked using a pre-compiled regular
                     # expression.
+                    assert node.var_pattern
                     pattern_idx = len(patterns)
                     patterns.append(node.var_pattern)
 
@@ -462,8 +497,6 @@ def _generate_ast(
                     if node.var_converter_map:
                         assert len(node.var_converter_map) == 1
 
-                        parent.append_child(_CxSetFragmentFromPath(level))
-
                         field_name = node.var_name
                         __, converter_name, converter_argstr = node.var_converter_map[0]
                         converter_class = self._converter_map[converter_name]
@@ -473,6 +506,11 @@ def _generate_ast(
                         )
                         converter_idx = len(self._converters)
                         self._converters.append(converter_obj)
+                        if converters._consumes_multiple_segments(converter_obj):
+                            consume_multiple_segments = True
+                            parent.append_child(_CxSetFragmentFromRemainingPaths(level))
+                        else:
+                            parent.append_child(_CxSetFragmentFromPath(level))
 
                         construct = _CxIfConverterField(
                             len(params_stack) + 1, converter_idx
@@ -513,6 +551,8 @@ def _generate_ast(
                 resource_idx = len(return_values)
                 return_values.append(node)
 
+            assert not (consume_multiple_segments and node.children)
+
             self._generate_ast(
                 node.children,
                 parent,
@@ -527,17 +567,22 @@ def _generate_ast(
                 if fast_return:
                     parent.append_child(_CxReturnNone())
             else:
-                # NOTE(kgriffs): Make sure that we have consumed all of
-                # the segments for the requested route; otherwise we could
-                # mistakenly match "/foo/23/bar" against "/foo/{id}".
-                construct = _CxIfPathLength('==', level + 1)
-                for params in params_stack:
-                    construct.append_child(params)
-                construct.append_child(_CxReturnValue(resource_idx))
-                parent.append_child(construct)
+                if consume_multiple_segments:
+                    for params in params_stack:
+                        parent.append_child(params)
+                    parent.append_child(_CxReturnValue(resource_idx))
+                else:
+                    # NOTE(kgriffs): Make sure that we have consumed all of
+                    # the segments for the requested route; otherwise we could
+                    # mistakenly match "/foo/23/bar" against "/foo/{id}".
+                    construct = _CxIfPathLength('==', level + 1)
+                    for params in params_stack:
+                        construct.append_child(params)
+                    construct.append_child(_CxReturnValue(resource_idx))
+                    parent.append_child(construct)
 
-                if fast_return:
-                    parent.append_child(_CxReturnNone())
+                    if fast_return:
+                        parent.append_child(_CxReturnNone())
 
             parent = outer_parent
 
@@ -553,6 +598,7 @@ def _generate_conversion_ast(
         # a series of nested "if" constructs.
         for field_name, converter_name, converter_argstr in node.var_converter_map:
             converter_class = self._converter_map[converter_name]
+            assert not converters._consumes_multiple_segments(converter_class)
 
             converter_obj = self._instantiate_converter(
                 converter_class, converter_argstr
@@ -644,6 +690,17 @@ def _compile_and_find(self, path, _return_values, _patterns, _converters, params
         )
 
 
+_NO_CHILDREN_ERR = (
+    'Cannot add route with template "{0}". Field name "{1}" '
+    'uses the converter "{2}" that will consume all the path, '
+    'making it impossible to match this route.'
+)
+
+
+class UnacceptableRouteError(ValueError):
+    """Error raised by ``add_error`` when a route is not acceptable."""
+
+
 class CompiledRouterNode:
     """Represents a single URI segment in a URI."""
 
@@ -800,24 +857,6 @@ def conflicts_with(self, segment):
 class ConverterDict(UserDict):
     """A dict-like class for storing field converters."""
 
-    def update(self, other):
-        try:
-            # NOTE(kgriffs): If it is a mapping type, it should
-            # implement keys().
-            names = other.keys()
-        except AttributeError:
-            # NOTE(kgriffs): Not a mapping type, so assume it is an
-            # iterable of 2-item iterables. But we need to make it
-            # re-iterable if it is a generator, for when we pass
-            # it on to the parent's update().
-            other = list(other)
-            names = [n for n, __ in other]
-
-        for n in names:
-            self._validate(n)
-
-        UserDict.update(self, other)
-
     def __setitem__(self, name, converter):
         self._validate(name)
         UserDict.__setitem__(self, name, converter)
@@ -868,10 +907,17 @@ class CompiledRouterOptions:
     __slots__ = ('converters',)
 
     def __init__(self):
-        self.converters = ConverterDict(
-            (name, converter) for name, converter in converters.BUILTIN
+        object.__setattr__(
+            self,
+            'converters',
+            ConverterDict((name, converter) for name, converter in converters.BUILTIN),
         )
 
+    def __setattr__(self, name, value) -> None:
+        if name == 'converters':
+            raise AttributeError('Cannot set "converters", please update it in place.')
+        super().__setattr__(name, value)
+
 
 # --------------------------------------------------------------------
 # AST Constructs
@@ -1001,6 +1047,17 @@ def src(self, indentation):
         )
 
 
+class _CxSetFragmentFromRemainingPaths:
+    def __init__(self, segment_idx):
+        self._segment_idx = segment_idx
+
+    def src(self, indentation):
+        return '{0}fragment = path[{1}:]'.format(
+            _TAB_STR * indentation,
+            self._segment_idx,
+        )
+
+
 class _CxVariableFromPatternMatch:
     def __init__(self, unique_idx):
         self._unique_idx = unique_idx
diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py
--- a/falcon/routing/converters.py
+++ b/falcon/routing/converters.py
@@ -31,12 +31,23 @@
 class BaseConverter(metaclass=abc.ABCMeta):
     """Abstract base class for URI template field converters."""
 
+    CONSUME_MULTIPLE_SEGMENTS = False
+    """When set to ``True`` it indicates that this converter will consume
+    multiple URL path segments. Currently a converter with
+    ``CONSUME_MULTIPLE_SEGMENTS=True`` must be at the end of the URL template
+    effectively meaning that it will consume all of the remaining URL path
+    segments.
+    """
+
     @abc.abstractmethod  # pragma: no cover
     def convert(self, value):
         """Convert a URI template field value to another format or type.
 
         Args:
-            value (str): Original string to convert.
+            value (str or List[str]): Original string to convert.
+                If ``CONSUME_MULTIPLE_SEGMENTS=True`` this value is a
+                list of strings containing the path segments matched by
+                the converter.
 
         Returns:
             object: Converted field value, or ``None`` if the field
@@ -44,6 +55,10 @@ def convert(self, value):
         """
 
 
+def _consumes_multiple_segments(converter):
+    return getattr(converter, 'CONSUME_MULTIPLE_SEGMENTS', False)
+
+
 class IntConverter(BaseConverter):
     """Converts a field value to an int.
 
@@ -132,8 +147,34 @@ def convert(self, value):
             return None
 
 
+class PathConverter(BaseConverter):
+    """Field converted used to match the rest of the path.
+
+    This field converter matches the remainder of the URL path,
+    returning it as a string.
+
+    This converter is currently supported only when used at the
+    end of the URL template.
+
+    The classic routing rules of falcon apply also to this converter:
+    considering the template ``'/foo/bar/{matched_path:path}'``, the path
+    ``'/foo/bar'`` will *not* match the route; ``'/foo/bar/'`` will
+    match, producing ``matched_path=''``, when
+    :attr:`~falcon.RequestOptions.strip_url_path_trailing_slash` is ``False``
+    (the default), while it will *not* match when that option is ``True``.
+
+    (See also: :ref:`trailing_slash_in_path`)
+    """
+
+    CONSUME_MULTIPLE_SEGMENTS = True
+
+    def convert(self, value):
+        return '/'.join(value)
+
+
 BUILTIN = (
     ('int', IntConverter),
     ('dt', DateTimeConverter),
     ('uuid', UUIDConverter),
+    ('path', PathConverter),
 )


Test patch:
diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py
--- a/tests/asgi/test_ws.py
+++ b/tests/asgi/test_ws.py
@@ -1,6 +1,7 @@
 import asyncio
 from collections import deque
 import os
+import sys
 
 import cbor2
 import pytest
@@ -339,12 +340,13 @@ async def on_websocket(self, req, ws):
                             #   the case of a closed connection while waiting on
                             #   more data.
                             recv_task = falcon.create_task(ws.receive_data())
-                            await asyncio.sleep(
-                                0
-                            )  # Ensure recv_task() has a chance to get ahead
-                            await asyncio.wait(
-                                [recv_task, falcon.create_task(ws.close(4099))]
-                            )
+                            # Ensure recv_task() has a chance to get ahead
+                            await asyncio.sleep(0)
+                            ws_close = ws.close(4099)
+                            if sys.version_info >= (3, 7):
+                                # using create_task on py3.6 causes this to hang
+                                ws_close = asyncio.create_task(ws_close)
+                            await asyncio.wait([recv_task, ws_close])
 
                         self.data_received.set()
 
diff --git a/tests/test_compiled_router.py b/tests/test_compiled_router.py
--- a/tests/test_compiled_router.py
+++ b/tests/test_compiled_router.py
@@ -4,7 +4,7 @@
 
 import pytest
 
-from falcon.routing import CompiledRouter
+from falcon.routing import CompiledRouter, CompiledRouterOptions
 
 
 def test_find_src(monkeypatch):
@@ -114,3 +114,26 @@ def on_get(self, req, res):
 
     def on_get_other(self, req, res):
         pass
+
+
+def test_cannot_replace_compiled():
+    opt = CompiledRouterOptions()
+    with pytest.raises(AttributeError, match='Cannot set'):
+        opt.converters = {}
+    with pytest.raises(AttributeError, match='object has no attribute'):
+        opt.other = 123
+
+
+def test_converter_not_subclass():
+    class X:
+        def convert(self, v):
+            return v
+
+    router = CompiledRouter()
+    router.options.converters['x'] = X
+
+    router.add_route('/foo/{bar:x}', MockResource())
+    res = router.find('/foo/bar')
+    assert res is not None
+    assert res[2] == {'bar': 'bar'}
+    assert router.find('/foo/bar/bar') is None
diff --git a/tests/test_default_router.py b/tests/test_default_router.py
--- a/tests/test_default_router.py
+++ b/tests/test_default_router.py
@@ -727,3 +727,96 @@ def test_params_in_non_taken_branches(param_router, route, expected, num):
 
     assert resource.resource_id == num
     assert params == expected
+
+
+# capture path
+
+
+def test_capture_path_no_children():
+    router = DefaultRouter()
+    router.add_route('/foo/{bar:path}', ResourceWithId(1))
+    res = router.finder_src
+    with pytest.raises(
+        ValueError,
+        match='Cannot add route with template "/foo/{bar:path}/child". '
+        'Field name "bar" uses the converter "path"',
+    ):
+        router.add_route('/foo/{bar:path}/child', ResourceWithId(1))
+    with pytest.raises(
+        ValueError,
+        match='Cannot add route with template "/{bar:path}/child". '
+        'Field name "bar" uses the converter "path"',
+    ):
+        router.add_route('/{bar:path}/child', ResourceWithId(1))
+    assert res == router.finder_src
+
+
+@pytest.mark.parametrize(
+    'template',
+    (
+        '/foo/{bar:path}-x',
+        '/foo/x-{bar:path}',
+        '/foo/{x}-{bar:path}',
+        '/foo/{bar:path}-{x}',
+    ),
+)
+def test_capture_path_complex(template):
+    router = DefaultRouter()
+    with pytest.raises(
+        ValueError, match='Cannot use converter "path" of variable "bar" in a template '
+    ):
+        router.add_route(template, ResourceWithId(1))
+
+
+@pytest.fixture
+def capture_path_router():
+    router = DefaultRouter()
+
+    router.add_route('/foo/bar/baz', ResourceWithId(1))
+    router.add_route('/foo/{bar:path}', ResourceWithId(2))
+    router.add_route('/foo/bar/{foo:path}', ResourceWithId(3))
+    router.add_route('/{baz:path}', ResourceWithId(4))
+    router.add_route('/x/{v1:int}/{v2}/{other:path}', ResourceWithId(5))
+    router.add_route('/y/{v1:int}/{v2:int}/{other:path}', ResourceWithId(6))
+    return router
+
+
+@pytest.mark.parametrize(
+    'route, expected, num',
+    (
+        ('/foo/bar/baz', {}, 1),
+        ('/foo/some/path/here', {'bar': 'some/path/here'}, 2),
+        ('/foo/bar/bar', {'foo': 'bar'}, 3),
+        (
+            '/foo/bar/bar-1/2/3/4/5/5/6/7/8/98/9/0/-/9/',
+            {'foo': 'bar-1/2/3/4/5/5/6/7/8/98/9/0/-/9/'},
+            3,
+        ),
+        ('/x/1/2/3', {'v1': 1, 'v2': '2', 'other': '3'}, 5),
+        ('/x/1/2/3/4/5/6', {'v1': 1, 'v2': '2', 'other': '3/4/5/6'}, 5),
+        ('/upload/youtube/auth/token', {'baz': 'upload/youtube/auth/token'}, 4),
+        ('/x/y/o.o/w', {'baz': 'x/y/o.o/w'}, 4),
+        ('/foo', {'baz': 'foo'}, 4),
+        ('/foo/', {'bar': ''}, 2),
+        ('/foo/bar', {'bar': 'bar'}, 2),
+        ('/foo/bar/', {'foo': ''}, 3),
+        ('/foo/bar/baz/other', {'foo': 'baz/other'}, 3),
+        ('/y/1/2/3', {'v1': 1, 'v2': 2, 'other': '3'}, 6),
+        ('/y/1/a/3', {'baz': 'y/1/a/3'}, 4),
+    ),
+)
+def test_capture_path(capture_path_router, route, expected, num):
+    resource, __, params, __ = capture_path_router.find(route)
+
+    assert resource.resource_id == num
+    assert params == expected
+
+
+def test_capture_path_no_match():
+    router = DefaultRouter()
+
+    router.add_route('/foo/bar/baz', ResourceWithId(1))
+    router.add_route('/foo/{bar:path}', ResourceWithId(2))
+    router.add_route('/foo/bar/{foo:path}', ResourceWithId(3))
+
+    assert router.find('/foo') is None