Repository: falconry/falcon

Issue:
Add test for hyphens and other non-arg-friendly chars in URI template field names
Currently we only check up-front in `add_route()` for field names that start with a digit or contain whitespace. This can lead to cryptic errors later on when requests are routed to responders.

Discussion:
codecov-io: ## [Current coverage][cc-pull] is **100%**
> Merging [#843][cc-pull] into [master][cc-base-branch] will not change coverage

```diff
@@           master   #843   diff @@
====================================
  Files          29     29          
  Lines        1813   1817     +4   
  Methods         0      0          
  Messages        0      0          
  Branches      305    306     +1   
====================================
+ Hits         1813   1817     +4   
  Misses          0      0          
  Partials        0      0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last updated by [0f64e94...130da74][cc-compare]
[cc-base-branch]: https://codecov.io/gh/falconry/falcon/branch/master?src=pr
[cc-compare]: https://codecov.io/gh/falconry/falcon/compare/0f64e94a5ab49b58b9efa8462fe8c0369428243d...130da74f772ad772b74f74190cc85510333eefef?src=pr
[cc-pull]: https://codecov.io/gh/falconry/falcon/pull/843?src=pr

Patch:
diff --git a/falcon/api.py b/falcon/api.py
index 3b5539b..557de77 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -40,17 +40,17 @@ class API(object):
 
                 class ExampleComponent(object):
                     def process_request(self, req, resp):
-                        \"""Process the request before routing it.
+                        \"\"\"Process the request before routing it.
 
                         Args:
                             req: Request object that will eventually be
                                 routed to an on_* responder method.
                             resp: Response object that will be routed to
                                 the on_* responder.
-                        \"""
+                        \"\"\"
 
                     def process_resource(self, req, resp, resource, params):
-                        \"""Process the request and resource *after* routing.
+                        \"\"\"Process the request and resource *after* routing.
 
                         Note:
                             This method is only called when the request matches
@@ -69,10 +69,10 @@ class API(object):
                                 template fields, that will be passed to the
                                 resource's responder method as keyword
                                 arguments.
-                        \"""
+                        \"\"\"
 
                     def process_response(self, req, resp, resource)
-                        \"""Post-processing of the response (after routing).
+                        \"\"\"Post-processing of the response (after routing).
 
                         Args:
                             req: Request object.
@@ -80,7 +80,7 @@ class API(object):
                             resource: Resource object to which the request was
                                 routed. May be None if no route was found
                                 for the request.
-                        \"""
+                        \"\"\"
 
             See also :ref:`Middleware <middleware>`.
 
@@ -249,6 +249,10 @@ class API(object):
     def add_route(self, uri_template, resource, *args, **kwargs):
         """Associates a templatized URI path with a resource.
 
+        Note:
+            The following information describes the behavior of
+            Falcon's default router.
+
         A resource is an instance of a class that defines various
         "responder" methods, one for each HTTP method the resource
         allows. Responder names start with `on_` and are named according to
@@ -272,6 +276,10 @@ class API(object):
         field names defined in the template. A field expression consists
         of a bracketed field name.
 
+        Note:
+            Since field names correspond to argument names in responder
+            methods, they must be valid Python identifiers.
+
         For example, given the following template::
 
             /user/{name}
@@ -281,8 +289,8 @@ class API(object):
             def on_put(self, req, resp, name):
                 pass
 
-        Individual path segments may contain one or more field expressions.
-        For example::
+        Individual path segments may contain one or more field
+        expressions::
 
             /repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}
 
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
index 057cf6e..f4e3058 100644
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import keyword
 import re
 
 
@@ -41,14 +42,18 @@ class CompiledRouter(object):
 
     def add_route(self, uri_template, method_map, resource):
         """Adds a route between URI path template and resource."""
-        # Can't start with a number, since these eventually get passed as
-        # args to on_* responders
-        if re.search('{\d', uri_template):
-            raise ValueError('Field names may not start with a digit.')
 
         if re.search('\s', uri_template):
             raise ValueError('URI templates may not include whitespace.')
 
+        # NOTE(kgriffs): Ensure fields are valid Python identifiers,
+        # since they will be passed as kwargs to responders.
+        fields = re.findall('{([^}]*)}', uri_template)
+        for field in fields:
+            is_identifier = re.match('[A-Za-z_][A-Za-z0-9_]+$', field)
+            if not is_identifier or field in keyword.kwlist:
+                raise ValueError('Field names must be valid identifiers.')
+
         path = uri_template.strip('/').split('/')
 
         def insert(nodes, path_index=0):


Test patch:
diff --git a/tests/test_default_router.py b/tests/test_default_router.py
index dec8a8e..9f3c6d5 100644
--- a/tests/test_default_router.py
+++ b/tests/test_default_router.py
@@ -137,13 +137,28 @@ class TestComplexRouting(testing.TestBase):
         )
 
     @ddt.data(
-        '/repos/{org}/{repo}/compare/{simple-vs-complex}',
+        '/repos/{org}/{repo}/compare/{simple_vs_complex}',
         '/repos/{complex}.{vs}.{simple}',
         '/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}/full',
     )
     def test_non_collision(self, template):
         self.router.add_route(template, {}, ResourceWithId(-1))
 
+    @ddt.data(
+        '/{}',
+        '/{9v}',
+        '/{@kgriffs}',
+        '/repos/{simple-thing}/etc',
+        '/repos/{or g}/{repo}/compare/{thing}',
+        '/repos/{org}/{repo}/compare/{}',
+        '/repos/{complex}.{}.{thing}',
+        '/repos/{complex}.{9v}.{thing}/etc',
+    )
+    def test_invalid_field_name(self, template):
+        self.assertRaises(
+            ValueError,
+            self.router.add_route, template, {}, ResourceWithId(-1))
+
     def test_dump(self):
         print(self.router._src)