From 1644ca5641a595419b5e2e2a3023d6a89d4390db Mon Sep 17 00:00:00 2001 From: Kurt Griffiths Date: Mon, 4 May 2015 19:09:29 -0500 Subject: [PATCH] fix(routing): Restore compile_uri_template Restore the compile_uri_template function to the routing module to ensure backwards compatibility with custom routing engines that rely on it. Fixes #532 --- doc/api/routing.rst | 2 +- falcon/routing/__init__.py | 1 + falcon/routing/util.py | 62 +++++++++++++++++++ tests/test_uri_templates_legacy.py | 96 ++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/test_uri_templates_legacy.py diff --git a/doc/api/routing.rst b/doc/api/routing.rst index b76ba4f2e..c0ce2d249 100644 --- a/doc/api/routing.rst +++ b/doc/api/routing.rst @@ -43,4 +43,4 @@ A custom routing engine may be specified when instantiating api = API(router=fancy) .. automodule:: falcon.routing - :members: create_http_method_map, CompiledRouter + :members: create_http_method_map, compile_uri_template, CompiledRouter diff --git a/falcon/routing/__init__.py b/falcon/routing/__init__.py index 6498a477a..9ced8d6cb 100644 --- a/falcon/routing/__init__.py +++ b/falcon/routing/__init__.py @@ -14,6 +14,7 @@ from falcon.routing.compiled import CompiledRouter from falcon.routing.util import create_http_method_map # NOQA +from falcon.routing.util import compile_uri_template # NOQA DefaultRouter = CompiledRouter diff --git a/falcon/routing/util.py b/falcon/routing/util.py index 40625afa1..e4f258e52 100644 --- a/falcon/routing/util.py +++ b/falcon/routing/util.py @@ -12,10 +12,72 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + +import six + from falcon import HTTP_METHODS, responders from falcon.hooks import _wrap_with_hooks +# NOTE(kgriffs): Published method; take care to avoid breaking changes. +def compile_uri_template(template): + """Compile the given URI template string into a pattern matcher. + + This function can be used to construct custom routing engines that + iterate through a list of possible routes, attempting to match + an incoming request against each route's compiled regular expression. + + Each field is converted to a named group, so that when a match + is found, the fields can be easily extracted using + :py:meth:`re.MatchObject.groupdict`. + + This function does not support the more flexible templating + syntax used in the default router. Only simple paths with bracketed + field expressions are recognized. For example:: + + / + /books + /books/{isbn} + /books/{isbn}/characters + /books/{isbn}/characters/{name} + + Also, note that if the template contains a trailing slash character, + it will be stripped in order to normalize the routing logic. + + Args: + template(str): The template to compile. Note that field names are + restricted to ASCII a-z, A-Z, and the underscore character. + + Returns: + tuple: (template_field_names, template_regex) + """ + + if not isinstance(template, six.string_types): + raise TypeError('uri_template is not a string') + + if not template.startswith('/'): + raise ValueError("uri_template must start with '/'") + + if '//' in template: + raise ValueError("uri_template may not contain '//'") + + if template != '/' and template.endswith('/'): + template = template[:-1] + + expression_pattern = r'{([a-zA-Z][a-zA-Z_]*)}' + + # Get a list of field names + fields = set(re.findall(expression_pattern, template)) + + # Convert Level 1 var patterns to equivalent named regex groups + escaped = re.sub(r'[\.\(\)\[\]\?\*\+\^\|]', r'\\\g<0>', template) + pattern = re.sub(expression_pattern, r'(?P<\1>[^/]+)', escaped) + pattern = r'\A' + pattern + r'\Z' + + return fields, re.compile(pattern, re.IGNORECASE) + + def create_http_method_map(resource, before, after): """Maps HTTP methods (e.g., 'GET', 'POST') to methods of a resource object. diff --git a/tests/test_uri_templates_legacy.py b/tests/test_uri_templates_legacy.py new file mode 100644 index 000000000..538f4be7d --- /dev/null +++ b/tests/test_uri_templates_legacy.py @@ -0,0 +1,96 @@ +import ddt + +import falcon +from falcon import routing +import falcon.testing as testing + + +@ddt.ddt +class TestUriTemplates(testing.TestBase): + + def test_string_type_required(self): + self.assertRaises(TypeError, routing.compile_uri_template, 42) + self.assertRaises(TypeError, routing.compile_uri_template, falcon.API) + + def test_template_must_start_with_slash(self): + self.assertRaises(ValueError, routing.compile_uri_template, 'this') + self.assertRaises(ValueError, routing.compile_uri_template, 'this/that') + + def test_template_may_not_contain_double_slash(self): + self.assertRaises(ValueError, routing.compile_uri_template, '//') + self.assertRaises(ValueError, routing.compile_uri_template, 'a//') + self.assertRaises(ValueError, routing.compile_uri_template, '//b') + self.assertRaises(ValueError, routing.compile_uri_template, 'a//b') + self.assertRaises(ValueError, routing.compile_uri_template, 'a/b//') + self.assertRaises(ValueError, routing.compile_uri_template, 'a/b//c') + + def test_root(self): + fields, pattern = routing.compile_uri_template('/') + self.assertFalse(fields) + self.assertFalse(pattern.match('/x')) + + result = pattern.match('/') + self.assertTrue(result) + self.assertFalse(result.groupdict()) + + @ddt.data('/hello', '/hello/world', '/hi/there/how/are/you') + def test_no_fields(self, path): + fields, pattern = routing.compile_uri_template(path) + self.assertFalse(fields) + self.assertFalse(pattern.match(path[:-1])) + + result = pattern.match(path) + self.assertTrue(result) + self.assertFalse(result.groupdict()) + + def test_one_field(self): + fields, pattern = routing.compile_uri_template('/{name}') + self.assertEqual(fields, set(['name'])) + + result = pattern.match('/Kelsier') + self.assertTrue(result) + self.assertEqual(result.groupdict(), {'name': 'Kelsier'}) + + fields, pattern = routing.compile_uri_template('/character/{name}') + self.assertEqual(fields, set(['name'])) + + result = pattern.match('/character/Kelsier') + self.assertTrue(result) + self.assertEqual(result.groupdict(), {'name': 'Kelsier'}) + + fields, pattern = routing.compile_uri_template('/character/{name}/profile') + self.assertEqual(fields, set(['name'])) + + self.assertFalse(pattern.match('/character')) + self.assertFalse(pattern.match('/character/Kelsier')) + self.assertFalse(pattern.match('/character/Kelsier/')) + + result = pattern.match('/character/Kelsier/profile') + self.assertTrue(result) + self.assertEqual(result.groupdict(), {'name': 'Kelsier'}) + + @ddt.data('', '/') + def test_two_fields(self, postfix): + path = '/book/{id}/characters/{name}' + postfix + fields, pattern = routing.compile_uri_template(path) + self.assertEqual(fields, set(['name', 'id'])) + + result = pattern.match('/book/0765350386/characters/Vin') + self.assertTrue(result) + self.assertEqual(result.groupdict(), {'name': 'Vin', 'id': '0765350386'}) + + def test_three_fields(self): + fields, pattern = routing.compile_uri_template('/{a}/{b}/x/{c}') + self.assertEqual(fields, set('abc')) + + result = pattern.match('/one/2/x/3') + self.assertTrue(result) + self.assertEqual(result.groupdict(), {'a': 'one', 'b': '2', 'c': '3'}) + + def test_malformed_field(self): + fields, pattern = routing.compile_uri_template('/{a}/{1b}/x/{c}') + self.assertEqual(fields, set('ac')) + + result = pattern.match('/one/{1b}/x/3') + self.assertTrue(result) + self.assertEqual(result.groupdict(), {'a': 'one', 'c': '3'})