Repository: falconry/falcon
Pull request #1522 (2019-05-06 18:05:53)

feat(issue-1503) Support API users defining and using custom HTTP methods
* adds support for an environment variable, FALCON_CUSTOM_HTTP_METHODS, which if defined is a comma-delimited string with custom HTTP methods that the app should support.
* add tests, which simulate this using the other method that works, which is to add these method names to the falcon.constants.COMBINED_METHODS.
* add documentation for using either method at the end of docs/api/routing

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

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

```diff
@@          Coverage Diff           @@
##           master   #1522   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2561    2563    +2     
  Branches      391     392    +1     
======================================
+ Hits         2561    2563    +2
```


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

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1522?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/1522?src=pr&el=footer). Last update [ac0e33b...fd11f69](https://codecov.io/gh/falconry/falcon/pull/1522?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

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

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

```diff
@@          Coverage Diff           @@
##           master   #1522   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2561    2563    +2     
  Branches      391     392    +1     
======================================
+ Hits         2561    2563    +2
```


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

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1522?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/1522?src=pr&el=footer). Last update [ac0e33b...fd11f69](https://codecov.io/gh/falconry/falcon/pull/1522?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/api/routing.rst b/docs/api/routing.rst
index d90b5d9..31159e7 100644
--- a/docs/api/routing.rst
+++ b/docs/api/routing.rst
@@ -312,3 +312,30 @@ be used by custom routing engines.
 .. autofunction:: falcon.routing.set_default_responders
 
 .. autofunction:: falcon.routing.compile_uri_template
+
+
+Custom HTTP Methods
+-------------------
+
+Some applications need to support non-standard HTTP methods, such as FOO or BAR, 
+in addition to the standard HTTP methods like GET and PUT. To support custom
+HTTP methods, use one of the following methods:
+
+- define the FALCON_CUSTOM_HTTP_METHODS environment variable as a comma-delimited
+  list of custom methods, and make sure that environment variable is active in 
+  your API app's environment. For example::
+
+    $ export FALCON_CUSTOM_HTTP_METHODS=FOO,BAR
+
+- in your app setup, override the value of ``falcon.constants.COMBINED_METHODS``
+  to include your custom methods. For example::
+
+    import falcon.constants
+    falcon.constants += ['FOO', 'BAR']
+
+Once you have notified the framework that it should support these custom methods,
+you can define request methods like any other HTTP method, such as::
+
+    def on_foo(self, req, resp):
+        ...
+
diff --git a/falcon/constants.py b/falcon/constants.py
index 3f64e36..4ba0f40 100644
--- a/falcon/constants.py
+++ b/falcon/constants.py
@@ -1,5 +1,7 @@
+import os
+
 # RFC 7231, 5789 methods
-HTTP_METHODS = (
+HTTP_METHODS = [
     'CONNECT',
     'DELETE',
     'GET',
@@ -9,20 +11,27 @@ HTTP_METHODS = (
     'POST',
     'PUT',
     'TRACE',
-)
+]
 
 # RFC 3253 methods
-WEBDAV_METHODS = (
+WEBDAV_METHODS = [
     'CHECKIN',
     'CHECKOUT',
     'REPORT',
     'UNCHECKIN',
     'UPDATE',
     'VERSION-CONTROL',
-)
+]
 
-COMBINED_METHODS = HTTP_METHODS + WEBDAV_METHODS
+# if FALCON_CUSTOM_HTTP_METHODS is defined, treat it as a comma-
+# delimited string of additional supported methods in this env.
+FALCON_CUSTOM_HTTP_METHODS = [
+    method for method in
+    (os.environ.get('FALCON_CUSTOM_HTTP_METHODS') or '').split(',')
+    if method != ''
+]
 
+COMBINED_METHODS = HTTP_METHODS + WEBDAV_METHODS + FALCON_CUSTOM_HTTP_METHODS
 
 # NOTE(kgriffs): According to RFC 7159, most JSON parsers assume
 # UTF-8 and so it is the recommended default charset going forward,


Test patch:
diff --git a/tests/test_http_custom_method_routing.py b/tests/test_http_custom_method_routing.py
new file mode 100644
index 0000000..1a6f9ba
--- /dev/null
+++ b/tests/test_http_custom_method_routing.py
@@ -0,0 +1,68 @@
+import pytest
+
+import falcon
+from falcon import testing
+import falcon.constants
+from falcon.routing.util import map_http_methods
+
+FALCON_CUSTOM_HTTP_METHODS = ['FOO', 'BAR']
+
+
+@pytest.fixture
+def resource_things():
+    return ThingsResource()
+
+
+@pytest.fixture
+def client():
+    app = falcon.API()
+    app.add_route('/things', ThingsResource())
+    return testing.TestClient(app)
+
+
+class ThingsResource:
+    def __init__(self):
+        self.called = False
+
+        # Test non-callable attribute
+        self.on_patch = {}
+
+    # Field names ordered differently than in uri template
+    def on_foo(self, req, resp):
+        self.called = True
+        self.req, self.resp = req, resp
+        resp.status = falcon.HTTP_204
+
+
+def setup_module(module):
+    falcon.constants.COMBINED_METHODS += FALCON_CUSTOM_HTTP_METHODS
+
+
+def teardown_module(module):
+    for method in FALCON_CUSTOM_HTTP_METHODS:
+        index = falcon.constants.COMBINED_METHODS.index(method)
+        falcon.constants.COMBINED_METHODS.pop(index)
+
+
+def test_map_http_methods(resource_things, monkeypatch):
+    method_map = map_http_methods(resource_things)
+    assert 'FOO' in method_map
+    assert 'BAR' not in method_map
+
+
+class TestHttpMethodRouting:
+
+    def test_foo(self, client, resource_things):
+        """FOO is a supported method, so returns HTTP_204"""
+        client.app.add_route('/things', resource_things)
+        response = client.simulate_request(path='/things', method='FOO')
+        assert 'FOO' in falcon.constants.COMBINED_METHODS
+        assert response.status == falcon.HTTP_204
+        assert resource_things.called
+
+    def test_bar(self, client, resource_things):
+        """BAR is not supported by ResourceThing"""
+        client.app.add_route('/things', resource_things)
+        response = client.simulate_request(path='/things', method='BAR')
+        assert 'BAR' in falcon.constants.COMBINED_METHODS
+        assert response.status == falcon.HTTP_405