Repository: falconry/falcon

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

Discussion:

Patch:
diff --git a/doc/api/routing.rst b/doc/api/routing.rst
index b76ba4f..c0ce2d2 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/request.py b/falcon/request.py
index 74915a0..b16e154 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -599,7 +599,7 @@ class Request(object):
             if not required:
                 return None
 
-            raise HTTPMissingHeader(name)
+            raise HTTPMissingParam(name)
 
     def get_header_as_datetime(self, header, required=False):
         """Return an HTTP header with HTTP-Date values as a datetime.
diff --git a/falcon/routing/__init__.py b/falcon/routing/__init__.py
index 6498a47..9ced8d6 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 40625af..e4f258e 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.
 


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 654eee9..f2a689a 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -183,14 +183,9 @@ class TestHeaders(testing.TestBase):
     def test_required_header(self):
         self.simulate_request(self.test_route)
 
-        try:
-            self.resource.req.get_header('X-Not-Found', required=True)
-            self.fail('falcon.HTTPMissingHeader not raised')
-        except falcon.HTTPMissingHeader as ex:
-            self.assertIsInstance(ex, falcon.HTTPBadRequest)
-            self.assertEqual(ex.title, 'Missing header value')
-            expected_desc = 'The X-Not-Found header is required.'
-            self.assertEqual(ex.description, expected_desc)
+        self.assertRaises(falcon.HTTPBadRequest,
+                          self.resource.req.get_header, 'X-Not-Found',
+                          required=True)
 
     def test_no_body_on_100(self):
         self.resource = StatusTestResource(falcon.HTTP_100)
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index d6adbf8..893afda 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -94,7 +94,6 @@ class _TestQueryParams(testing.TestBase):
             getattr(req, method_name)('marker', required=True)
             self.fail('falcon.HTTPMissingParam not raised')
         except falcon.HTTPMissingParam as ex:
-            self.assertIsInstance(ex, falcon.HTTPBadRequest)
             self.assertEqual(ex.title, 'Missing query parameter')
             expected_desc = 'The "marker" query parameter is required.'
             self.assertEqual(ex.description, expected_desc)
diff --git a/tests/test_uri_templates_legacy.py b/tests/test_uri_templates_legacy.py
new file mode 100644
index 0000000..538f4be
--- /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'})