Repository: falconry/falcon

Issue:
Has compile_uri_template been removed?
I can't see it in the code any more.



Discussion:
@dukedougal  Looks like it was removed as part of the new router patch. We could bring it back in falcon.routing.util if it's still needed. What do you think?

I use it because I built my own routing and it's at the core of my functionality. 


Resolving patch:
diff --git a/falcon/routing/__init__.py b/falcon/routing/__init__.py
--- 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
--- 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.
 


Test patch:
diff --git a/tests/test_uri_templates_legacy.py b/tests/test_uri_templates_legacy.py
new file mode 100644
--- /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'})