Repository: falconry/falcon

Issue:
Expose routing metadata
Per a [discussion](http://librelist.com/browser//falcon/2015/7/28/getting-the-url-template-when-servicing-a-request/) on the mailing list, it can be useful to introspect the URI template that is associated with a given resource. One way to implement this would be to check for an attribute and fill it in as part of the `add_route` logic. For example:

``` python
class Thing(object):
    def __init__(self):
        self.uri_templates = []

    def on_get(self, req, resp):
        pass

# ...

def add_route(self, uri_template, resource):

    # ...

    if hasattr(resource, 'uri_templates'):
        resource.uri_templates.append(uri_template)

    # ...
```

We could also do this:

``` python
    if hasattr(resource, 'uri_templates'):
        resource.uri_templates.append(uri_template)
    else:
        resource.uri_templates = [uri_template]
```

But that would break in the case that `Thing` defined `__slots__`.

Another option would be to store the URI template in the routing tree and then set an attribute on each Request instance:

``` python
def __call__(self, env, start_response):

    # ...

    responder, params, resource, uri_template = self._get_responder(req)

    req = self._request_type(env, options=self.req_options, 
                             uri_template=uri_template)

    # ...
```

Either way, this would provide a way for middleware and hooks to key off the URI template to perform additional actions.



Discussion:
Currently the line:

```
responder, params, resource, uri_template = self._get_responder(req)
```

has been moved to inside the try-catch. what about something like this:
responder, params, resource, uri_template = self._get_responder(req)
setattr(req, 'uri_template', uri_template)

Thinking about this, it may be helpful to do both. Tooling may wish to enumerate the resources, examining each for the routes associated with it. And people have asked for a way to know what route was matched for a particular request (see also #581).

We should try to do this in a non-breaking way first, if it isn't too ugly.


Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -189,7 +189,7 @@ def __call__(self, env, start_response):  # noqa: C901
                 # next-hop child resource. In that case, the object
                 # being asked to dispatch to its child will raise an
                 # HTTP exception signalling the problem, e.g. a 404.
-                responder, params, resource = self._get_responder(req)
+                responder, params, resource, req.uri_template = self._get_responder(req)
             except Exception as ex:
                 if not self._handle_exception(ex, req, resp, params):
                     raise
@@ -508,11 +508,18 @@ def _get_responder(self, req):
 
         path = req.path
         method = req.method
+        uri_template = None
 
         route = self._router.find(path)
 
         if route is not None:
-            resource, method_map, params = route
+            try:
+                resource, method_map, params, uri_template = route
+            except ValueError:
+                # NOTE(kgriffs): Older routers may not return the
+                # template. But for performance reasons they should at
+                # least return None if they don't support it.
+                resource, method_map, params = route
         else:
             # NOTE(kgriffs): Older routers may indicate that no route
             # was found by returning (None, None, None). Therefore, we
@@ -538,7 +545,7 @@ def _get_responder(self, req):
             else:
                 responder = falcon.responders.path_not_found
 
-        return (responder, params, resource)
+        return (responder, params, resource, uri_template)
 
     def _compose_status_response(self, req, resp, http_status):
         """Composes a response for the given HTTPStatus instance."""
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -68,6 +68,8 @@ class Request(object):
     Args:
         env (dict): A WSGI environment dict passed in from the server. See
             also PEP-3333.
+
+    Keyword Arguments
         options (dict): Set of global options passed from the API handler.
 
     Attributes:
@@ -140,7 +142,6 @@ class Request(object):
                 opposed to a class), the function is called like a method of
                 the current Request instance. Therefore the first argument is
                 the Request instance itself (self).
-
         uri (str): The fully-qualified URI for the request.
         url (str): alias for `uri`.
         relative_uri (str): The path + query string portion of the full URI.
@@ -148,6 +149,13 @@ class Request(object):
             string).
         query_string (str): Query string portion of the request URL, without
             the preceding '?' character.
+        uri_template (str): The template for the route that was matched for
+            this request. May be ``None`` if the request has not yet been
+            routed, as would be the case for `process_request()` middleware
+            methods. May also be ``None`` if your app uses a custom routing
+            engine and the engine does not provide the URI template when
+            resolving a route.
+
         user_agent (str): Value of the User-Agent header, or ``None`` if the
             header is missing.
         accept (str): Value of the Accept header, or '*/*' if the header is
@@ -244,6 +252,7 @@ class Request(object):
         '_cookies',
         '_cached_access_route',
         '__dict__',
+        'uri_template',
     )
 
     # Child classes may override this
@@ -259,6 +268,8 @@ def __init__(self, env, options=None):
         self.stream = env['wsgi.input']
         self.method = env['REQUEST_METHOD']
 
+        self.uri_template = None
+
         # Normalize path
         path = env['PATH_INFO']
         if path:
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -43,7 +43,15 @@ def __init__(self):
         self._return_values = None
 
     def add_route(self, uri_template, method_map, resource):
-        """Adds a route between URI path template and resource."""
+        """Adds a route between a URI path template and a resource.
+
+        Args:
+            uri_template (str): A URI template to use for the route
+            method_map (dict): A mapping of HTTP methods (e.g., 'GET',
+                'POST') to methods of a resource object.
+            resource (object): The resource instance to associate with
+                the URI template.
+        """
 
         if re.search('\s', uri_template):
             raise ValueError('URI templates may not include whitespace.')
@@ -67,6 +75,7 @@ def insert(nodes, path_index=0):
                         # NOTE(kgriffs): Override previous node
                         node.method_map = method_map
                         node.resource = resource
+                        node.uri_template = uri_template
                     else:
                         insert(node.children, path_index)
 
@@ -85,6 +94,7 @@ def insert(nodes, path_index=0):
             if path_index == len(path) - 1:
                 new_node.method_map = method_map
                 new_node.resource = resource
+                new_node.uri_template = uri_template
             else:
                 insert(new_node.children, path_index + 1)
 
@@ -92,13 +102,23 @@ def insert(nodes, path_index=0):
         self._find = self._compile()
 
     def find(self, uri):
-        """Finds resource and method map for a URI, or returns None."""
+        """Search for a route that matches the given partial URI.
+
+        Args:
+            uri(str): The requested path to route
+
+        Returns:
+            tuple: A 4-member tuple composed of (resource, method_map,
+                params, uri_template), or ``None`` if no route matches
+                the requested path
+        """
+
         path = uri.lstrip('/').split('/')
         params = {}
         node = self._find(path, self._return_values, self._expressions, params)
 
         if node is not None:
-            return node.resource, node.method_map, params
+            return node.resource, node.method_map, params, node.uri_template
         else:
             return None
 
@@ -234,12 +254,14 @@ class CompiledRouterNode(object):
 
     _regex_vars = re.compile('{([-_a-zA-Z0-9]+)}')
 
-    def __init__(self, raw_segment, method_map=None, resource=None):
+    def __init__(self, raw_segment,
+                 method_map=None, resource=None, uri_template=None):
         self.children = []
 
         self.raw_segment = raw_segment
         self.method_map = method_map
         self.resource = resource
+        self.uri_template = uri_template
 
         self.is_var = False
         self.is_complex = False


Test patch:
diff --git a/tests/test_custom_router.py b/tests/test_custom_router.py
--- a/tests/test_custom_router.py
+++ b/tests/test_custom_router.py
@@ -21,14 +21,20 @@ def add_route(self, uri_template, *args, **kwargs):
     def test_custom_router_find_should_be_used(self):
 
         def resource(req, resp, **kwargs):
-            resp.body = '{"status": "ok"}'
+            resp.body = '{{"uri_template": "{0}"}}'.format(req.uri_template)
 
         class CustomRouter(object):
             def __init__(self):
                 self.reached_backwards_compat = False
 
             def find(self, uri):
-                if uri == '/test':
+                if uri == '/test/42':
+                    return resource, {'GET': resource}, {}, '/test/{id}'
+
+                if uri == '/test/42/no-uri-template':
+                    return resource, {'GET': resource}, {}, None
+
+                if uri == '/test/42/uri-template/backwards-compat':
                     return resource, {'GET': resource}, {}
 
                 if uri == '/404/backwards-compat':
@@ -39,8 +45,14 @@ def find(self, uri):
 
         router = CustomRouter()
         self.api = falcon.API(router=router)
-        body = self.simulate_request('/test')
-        self.assertEqual(body, [b'{"status": "ok"}'])
+        body = self.simulate_request('/test/42')
+        self.assertEqual(body, [b'{"uri_template": "/test/{id}"}'])
+
+        body = self.simulate_request('/test/42/no-uri-template')
+        self.assertEqual(body, [b'{"uri_template": "None"}'])
+
+        body = self.simulate_request('/test/42/uri-template/backwards-compat')
+        self.assertEqual(body, [b'{"uri_template": "None"}'])
 
         for uri in ('/404', '/404/backwards-compat'):
             body = self.simulate_request(uri)
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
@@ -24,18 +24,18 @@ def before(self):
     def test_versioned_url(self):
         self.router.add_route('/{version}/messages', {}, ResourceWithId(2))
 
-        resource, method_map, params = self.router.find('/v2/messages')
+        resource, __, __, __ = self.router.find('/v2/messages')
         self.assertEqual(resource.resource_id, 2)
 
         self.router.add_route('/v2', {}, ResourceWithId(1))
 
-        resource, method_map, params = self.router.find('/v2')
+        resource, __, __, __ = self.router.find('/v2')
         self.assertEqual(resource.resource_id, 1)
 
-        resource, method_map, params = self.router.find('/v2/messages')
+        resource, __, __, __ = self.router.find('/v2/messages')
         self.assertEqual(resource.resource_id, 2)
 
-        resource, method_map, params = self.router.find('/v1/messages')
+        resource, __, __, __ = self.router.find('/v1/messages')
         self.assertEqual(resource.resource_id, 2)
 
         route = self.router.find('/v1')
@@ -47,10 +47,10 @@ def test_recipes(self):
         self.router.add_route(
             '/recipes/baking', {}, ResourceWithId(2))
 
-        resource, method_map, params = self.router.find('/recipes/baking/4242')
+        resource, __, __, __ = self.router.find('/recipes/baking/4242')
         self.assertEqual(resource.resource_id, 1)
 
-        resource, method_map, params = self.router.find('/recipes/baking')
+        resource, __, __, __ = self.router.find('/recipes/baking')
         self.assertEqual(resource.resource_id, 2)
 
         route = self.router.find('/recipes/grilling')
@@ -165,20 +165,20 @@ def test_dump(self):
     def test_override(self):
         self.router.add_route('/emojis/signs/0', {}, ResourceWithId(-1))
 
-        resource, method_map, params = self.router.find('/emojis/signs/0')
+        resource, __, __, __ = self.router.find('/emojis/signs/0')
         self.assertEqual(resource.resource_id, -1)
 
     def test_literal_segment(self):
-        resource, method_map, params = self.router.find('/emojis/signs/0')
+        resource, __, __, __ = self.router.find('/emojis/signs/0')
         self.assertEqual(resource.resource_id, 12)
 
-        resource, method_map, params = self.router.find('/emojis/signs/1')
+        resource, __, __, __ = self.router.find('/emojis/signs/1')
         self.assertEqual(resource.resource_id, 13)
 
-        resource, method_map, params = self.router.find('/emojis/signs/42')
+        resource, __, __, __ = self.router.find('/emojis/signs/42')
         self.assertEqual(resource.resource_id, 14)
 
-        resource, method_map, params = self.router.find('/emojis/signs/42/small')
+        resource, __, __, __ = self.router.find('/emojis/signs/42/small')
         self.assertEqual(resource.resource_id, 14.1)
 
         route = self.router.find('/emojis/signs/1/small')
@@ -204,18 +204,18 @@ def test_malformed_pattern(self):
         self.assertIs(route, None)
 
     def test_literal(self):
-        resource, method_map, params = self.router.find('/user/memberships')
+        resource, __, __, __ = self.router.find('/user/memberships')
         self.assertEqual(resource.resource_id, 8)
 
     def test_variable(self):
-        resource, method_map, params = self.router.find('/teams/42')
+        resource, __, params, __ = self.router.find('/teams/42')
         self.assertEqual(resource.resource_id, 6)
         self.assertEqual(params, {'id': '42'})
 
-        resource, method_map, params = self.router.find('/emojis/signs/stop')
+        __, __, params, __ = self.router.find('/emojis/signs/stop')
         self.assertEqual(params, {'id': 'stop'})
 
-        resource, method_map, params = self.router.find('/gists/42/raw')
+        __, __, params, __ = self.router.find('/gists/42/raw')
         self.assertEqual(params, {'id': '42'})
 
     @ddt.data(
@@ -232,7 +232,7 @@ def test_variable(self):
     )
     @ddt.unpack
     def test_literal_vs_variable(self, path, expected_id):
-        resource, method_map, params = self.router.find(path)
+        resource, __, __, __ = self.router.find(path)
         self.assertEqual(resource.resource_id, expected_id)
 
     @ddt.data(
@@ -271,12 +271,12 @@ def test_subsegment_not_found(self):
         self.assertIs(route, None)
 
     def test_multivar(self):
-        resource, method_map, params = self.router.find(
+        resource, __, params, __ = self.router.find(
             '/repos/racker/falcon/commits')
         self.assertEqual(resource.resource_id, 4)
         self.assertEqual(params, {'org': 'racker', 'repo': 'falcon'})
 
-        resource, method_map, params = self.router.find(
+        resource, __, params, __ = self.router.find(
             '/repos/racker/falcon/compare/all')
         self.assertEqual(resource.resource_id, 11)
         self.assertEqual(params, {'org': 'racker', 'repo': 'falcon'})
@@ -285,7 +285,7 @@ def test_multivar(self):
     @ddt.unpack
     def test_complex(self, url_postfix, resource_id):
         uri = '/repos/racker/falcon/compare/johndoe:master...janedoe:dev'
-        resource, method_map, params = self.router.find(uri + url_postfix)
+        resource, __, params, __ = self.router.find(uri + url_postfix)
 
         self.assertEqual(resource.resource_id, resource_id)
         self.assertEqual(params, {
@@ -297,11 +297,14 @@ def test_complex(self, url_postfix, resource_id):
             'branch1': 'dev',
         })
 
-    @ddt.data(('', 16), ('/full', 17))
+    @ddt.data(
+        ('', 16, '/repos/{org}/{repo}/compare/{usr0}:{branch0}'),
+        ('/full', 17, '/repos/{org}/{repo}/compare/{usr0}:{branch0}/full')
+    )
     @ddt.unpack
-    def test_complex_alt(self, url_postfix, resource_id):
-        uri = '/repos/falconry/falcon/compare/johndoe:master'
-        resource, method_map, params = self.router.find(uri + url_postfix)
+    def test_complex_alt(self, url_postfix, resource_id, expected_template):
+        uri = '/repos/falconry/falcon/compare/johndoe:master' + url_postfix
+        resource, __, params, uri_template = self.router.find(uri)
 
         self.assertEqual(resource.resource_id, resource_id)
         self.assertEqual(params, {
@@ -310,3 +313,4 @@ def test_complex_alt(self, url_postfix, resource_id):
             'usr0': 'johndoe',
             'branch0': 'master',
         })
+        self.assertEqual(uri_template, expected_template)