Repository: falconry/falcon
Pull request #1223 (2018-02-24 00:33:47)

feat(routing): Add support for adding multiple routes for the same resource
Implementation of #584

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=h1) Report
> Merging [#1223](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/800396f0a8d9d13889386522c4bee3e4661d547f?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1223/graphs/tree.svg?src=pr&token=zOtkfNQ3h6&width=650&height=150)](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1223   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2484    2491    +7     
  Branches      364     366    +2     
======================================
+ Hits         2484    2491    +7
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/routing/util.py](https://codecov.io/gh/falconry/falcon/pull/1223/diff?src=pr&el=tree#diff-ZmFsY29uL3JvdXRpbmcvdXRpbC5weQ==) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=footer). Last update [800396f...fdfd56c](https://codecov.io/gh/falconry/falcon/pull/1223?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

santeyio: Made requested changes -- thanks so much for the input @kgriffs, really appreciate it!
santeyio: Alrighty, thanks again for your input @kgriffs and @vytas7 ! Much appreciated. Made the changes suggested, let me know if you catch anything else. 

Patch:
diff --git a/falcon/api.py b/falcon/api.py
index f299ab7..cf6ed43 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -327,6 +327,18 @@ class API(object):
                 corresponding request handlers, and Falcon will do the right
                 thing.
 
+        Keyword Args:
+            suffix (str): Optional responder name suffix for this route. If
+                a suffix is provided, Falcon will map GET requests to
+                `on_get_{suffix}()`, POST requests to `on_post_{suffix}()`,
+                etc. In this way, multiple closely-related routes can be
+                mapped to the same resource. For example, a single resource
+                class can use suffixed responders to distinguish requests
+                for a single item vs. a collection of those same items.
+                Another class might use a suffixed responder to handle
+                a shortlink route in addition to the regular route for the
+                resource.
+
         Note:
             Any additional args and kwargs not defined above are passed
             through to the underlying router's ``add_route()`` method. The
@@ -347,7 +359,14 @@ class API(object):
         if '//' in uri_template:
             raise ValueError("uri_template may not contain '//'")
 
-        method_map = routing.map_http_methods(resource)
+        # NOTE(santeyio): This is a not very nice way to catch the suffix
+        # keyword. In python 3 it can be specified explicitly in the function
+        # definition, e.g.
+        # `add_route(self, uri_template, resource, *args, suffix=None, **kwargs)`
+        # but python 2 won't accept args like this.
+        suffix = kwargs.pop('suffix', None)
+
+        method_map = routing.map_http_methods(resource, suffix=suffix)
         routing.set_default_responders(method_map)
         self._router.add_route(uri_template, method_map, resource, *args,
                                **kwargs)
diff --git a/falcon/routing/util.py b/falcon/routing/util.py
index 56cbf72..c40c05b 100644
--- a/falcon/routing/util.py
+++ b/falcon/routing/util.py
@@ -21,6 +21,12 @@ import six
 from falcon import COMBINED_METHODS, responders
 
 
+class SuffixedMethodNotFoundError(Exception):
+    def __init__(self, message):
+        super(SuffixedMethodNotFoundError, self).__init__(message)
+        self.message = message
+
+
 # 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.
@@ -82,7 +88,7 @@ def compile_uri_template(template):
 
 
 def create_http_method_map(resource):  # pragma: nocover
-    """Maps HTTP methods (e.g., 'GET', 'POST') to methods of a resource object.
+    """Maps HTTP methods (e.g., GET, POST) to methods of a resource object.
 
     Warning:
         This method is deprecated and will be removed in a future release.
@@ -130,8 +136,8 @@ def create_http_method_map(resource):  # pragma: nocover
     return method_map
 
 
-def map_http_methods(resource):
-    """Maps HTTP methods (e.g., 'GET', 'POST') to methods of a resource object.
+def map_http_methods(resource, suffix=None):
+    """Maps HTTP methods (e.g., GET, POST) to methods of a resource object.
 
     Args:
         resource: An object with *responder* methods, following the naming
@@ -140,6 +146,12 @@ def map_http_methods(resource):
             should define ``on_get(self, req, resp)`` and
             ``on_post(self, req, resp)``.
 
+    Keyword Args:
+        suffix (str): Optional responder name suffix for this route. If
+            a suffix is provided, Falcon will map GET requests to
+            `on_get_{suffix}()`, POST requests to `on_post_{suffix}()`,
+            etc.
+
     Returns:
         dict: A mapping of HTTP methods to explicitly defined resource responders.
 
@@ -149,7 +161,11 @@ def map_http_methods(resource):
 
     for method in COMBINED_METHODS:
         try:
-            responder = getattr(resource, 'on_' + method.lower())
+            responder_name = 'on_' + method.lower()
+            if suffix:
+                responder_name += '_' + suffix
+
+            responder = getattr(resource, responder_name)
         except AttributeError:
             # resource does not implement this method
             pass
@@ -158,6 +174,10 @@ def map_http_methods(resource):
             if callable(responder):
                 method_map[method] = responder
 
+    # If suffix is specified and doesn't map to any methods, raise an error
+    if suffix and not method_map:
+        raise SuffixedMethodNotFoundError('No responders found for the specified suffix')
+
     return method_map
 
 


Test patch:
diff --git a/tests/test_uri_templates.py b/tests/test_uri_templates.py
index 9b97fcd..95c8c4e 100644
--- a/tests/test_uri_templates.py
+++ b/tests/test_uri_templates.py
@@ -13,6 +13,7 @@ import six
 
 import falcon
 from falcon import testing
+from falcon.routing.util import SuffixedMethodNotFoundError
 
 
 _TEST_UUID = uuid.uuid4()
@@ -80,6 +81,40 @@ class FileDetailsResource(object):
         self.called = True
 
 
+class ResourceWithSuffixRoutes(object):
+    def __init__(self):
+        self.get_called = False
+        self.post_called = False
+        self.put_called = False
+
+    def on_get(self, req, resp, collection_id, item_id):
+        self.collection_id = collection_id
+        self.item_id = item_id
+        self.get_called = True
+
+    def on_post(self, req, resp, collection_id, item_id):
+        self.collection_id = collection_id
+        self.item_id = item_id
+        self.post_called = True
+
+    def on_put(self, req, resp, collection_id, item_id):
+        self.collection_id = collection_id
+        self.item_id = item_id
+        self.put_called = True
+
+    def on_get_collection(self, req, resp, collection_id):
+        self.collection_id = collection_id
+        self.get_called = True
+
+    def on_post_collection(self, req, resp, collection_id):
+        self.collection_id = collection_id
+        self.post_called = True
+
+    def on_put_collection(self, req, resp, collection_id):
+        self.collection_id = collection_id
+        self.put_called = True
+
+
 @pytest.fixture
 def resource():
     return testing.SimpleTestResource()
@@ -399,3 +434,39 @@ def test_same_level_complex_var(client, reverse):
     assert details_resource.called
     assert details_resource.file_id == file_id_2
     assert details_resource.ext == ext
+
+
+def test_adding_suffix_routes(client):
+    resource_with_suffix_routes = ResourceWithSuffixRoutes()
+    client.app.add_route(
+        '/collections/{collection_id}/items/{item_id}', resource_with_suffix_routes)
+    client.app.add_route(
+        '/collections/{collection_id}/items', resource_with_suffix_routes, suffix='collection')
+    # GET
+    client.simulate_get('/collections/123/items/456')
+    assert resource_with_suffix_routes.collection_id == '123'
+    assert resource_with_suffix_routes.item_id == '456'
+    assert resource_with_suffix_routes.get_called
+    client.simulate_get('/collections/foo/items')
+    assert resource_with_suffix_routes.collection_id == 'foo'
+    # POST
+    client.simulate_post('/collections/foo234/items/foo456')
+    assert resource_with_suffix_routes.collection_id == 'foo234'
+    assert resource_with_suffix_routes.item_id == 'foo456'
+    assert resource_with_suffix_routes.post_called
+    client.simulate_post('/collections/foo123/items')
+    assert resource_with_suffix_routes.collection_id == 'foo123'
+    # PUT
+    client.simulate_put('/collections/foo345/items/foo567')
+    assert resource_with_suffix_routes.collection_id == 'foo345'
+    assert resource_with_suffix_routes.item_id == 'foo567'
+    assert resource_with_suffix_routes.put_called
+    client.simulate_put('/collections/foo321/items')
+    assert resource_with_suffix_routes.collection_id == 'foo321'
+
+
+def test_custom_error_on_suffix_route_not_found(client):
+    resource_with_suffix_routes = ResourceWithSuffixRoutes()
+    with pytest.raises(SuffixedMethodNotFoundError):
+        client.app.add_route(
+            '/collections/{collection_id}/items', resource_with_suffix_routes, suffix='bad-alt')