diff --git a/docs/api/routing.rst b/docs/api/routing.rst index c0ce2d2..f579920 100644 --- a/docs/api/routing.rst +++ b/docs/api/routing.rst @@ -24,14 +24,15 @@ A custom router is any class that implements the following interface: """ def find(self, uri): - """Search for a route that matches the given URI. + """Search for a route that matches the given partial URI. Args: - uri (str): Request URI to match to a route. + uri(str): The requested path to route Returns: - tuple: A 3-member tuple composed of (resource, method_map, params) - or ``None`` if no route is found. + tuple: A 4-member tuple composed of (resource, method_map, + params, uri_template), or ``None`` if no route matches + the requested path """ A custom routing engine may be specified when instantiating diff --git a/falcon/api.py b/falcon/api.py index e1e985a..8d62173 100644 --- a/falcon/api.py +++ b/falcon/api.py @@ -189,7 +189,7 @@ class API(object): # 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 @@ class API(object): 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 @@ class API(object): 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 index b063321..851898c 100644 --- 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 @@ class Request(object): 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 index 395dc72..32becd8 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -43,7 +43,15 @@ class CompiledRouter(object): 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 @@ class CompiledRouter(object): # 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 @@ class CompiledRouter(object): 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 @@ class CompiledRouter(object): 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 diff --git a/tests/test_custom_router.py b/tests/test_custom_router.py index cbe1b48..c425ea8 100644 --- a/tests/test_custom_router.py +++ b/tests/test_custom_router.py @@ -21,14 +21,20 @@ class TestCustomRouter(testing.TestBase): 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 @@ class TestCustomRouter(testing.TestBase): 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 index 9f3c6d5..28ee2e5 100644 --- a/tests/test_default_router.py +++ b/tests/test_default_router.py @@ -24,18 +24,18 @@ class TestRegressionCases(testing.TestBase): 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 @@ class TestRegressionCases(testing.TestBase): 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 @@ class TestComplexRouting(testing.TestBase): 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 @@ class TestComplexRouting(testing.TestBase): 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 @@ class TestComplexRouting(testing.TestBase): ) @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 @@ class TestComplexRouting(testing.TestBase): 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 @@ class TestComplexRouting(testing.TestBase): @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 @@ class TestComplexRouting(testing.TestBase): '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 @@ class TestComplexRouting(testing.TestBase): 'usr0': 'johndoe', 'branch0': 'master', }) + self.assertEqual(uri_template, expected_template)