Repository: cherrypy/cherrypy

Issue:
Convert request params based on function annotations.
With the increasing focus on [type hints](https://docs.python.org/3.5/library/typing.html) in Python, having a tool which automatically converted query params (with exception handling) would be a nice feature.

It's common for apps to have boilerplate code for [converting request params](http://stackoverflow.com/questions/32774024/), and returning 400-levels errors on failure.
```
@cherrypy.tools.params(exception=ValueError)
def resource(self, limit: int):
    assert isinstance(limit, int) # a ValueError would have already raised a 400
```


Discussion:
jaraco: This idea is a very cool one. And the implementation is so short and concise, signs of an elegant implementation. I have a couple of comments to make about the specific implementation, but I don't see why this change couldn't be rolled into an upcoming release in short order.

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 20877f63..b06461ad 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -4,6 +4,7 @@
 * Issue #1411: Fix issue where autoreload fails when
   the host interpreter for CherryPy was launched using
   ``python -m``.
+* #1441: Added tool to automatically convert request params.
 
 6.1.0
 -----
diff --git a/cherrypy/_cptools.py b/cherrypy/_cptools.py
index 54c63734..060ca76f 100644
--- a/cherrypy/_cptools.py
+++ b/cherrypy/_cptools.py
@@ -533,5 +533,6 @@ _d.json_in = Tool('before_request_body', jsontools.json_in, priority=30)
 _d.json_out = Tool('before_handler', jsontools.json_out, priority=30)
 _d.auth_basic = Tool('before_handler', auth_basic.basic_auth, priority=1)
 _d.auth_digest = Tool('before_handler', auth_digest.digest_auth, priority=1)
+_d.params = Tool('before_handler', cptools.convert_params)
 
 del _d, cptools, encoding, auth, static
diff --git a/cherrypy/lib/cptools.py b/cherrypy/lib/cptools.py
index fbed0e23..073216e0 100644
--- a/cherrypy/lib/cptools.py
+++ b/cherrypy/lib/cptools.py
@@ -630,3 +630,21 @@ def autovary(ignore=None, debug=False):
         v.sort()
         resp_h['Vary'] = ', '.join(v)
     request.hooks.attach('before_finalize', set_response_header, 95)
+
+
+def convert_params(exception=ValueError, error=400):
+    """Convert request params based on function annotations, with error handling.
+
+    exception
+        Exception class to catch.
+
+    status
+        The HTTP error code to return to the client on failure.
+    """
+    request = cherrypy.serving.request
+    types = request.handler.callable.__annotations__
+    try:
+        for key in set(types).intersection(request.params):
+            request.params[key] = types[key](request.params[key])
+    except exception as exc:
+        raise cherrypy.HTTPError(error, str(exc))


Test patch:
diff --git a/cherrypy/test/test_params.py b/cherrypy/test/test_params.py
new file mode 100644
index 00000000..2d57c279
--- /dev/null
+++ b/cherrypy/test/test_params.py
@@ -0,0 +1,55 @@
+import sys
+import cherrypy
+from cherrypy.test import helper
+
+
+class ParamsTest(helper.CPWebCase):
+    @staticmethod
+    def setup_server():
+        class Root:
+            @cherrypy.expose
+            @cherrypy.tools.params()
+            def resource(self, limit=None, sort=None):
+                return type(limit).__name__
+            # for testing on Py 2
+            resource.__annotations__ = {'limit': int}
+        conf = {'/': {'tools.params.on': True}}
+        cherrypy.tree.mount(Root(), config=conf)
+
+    def test_pass(self):
+        self.getPage('/resource')
+        self.assertStatus(200)
+        self.assertBody('NoneType')
+
+        self.getPage('/resource?limit=0')
+        self.assertStatus(200)
+        self.assertBody('int')
+
+    def test_error(self):
+        self.getPage('/resource?limit=')
+        self.assertStatus(400)
+        self.assertInBody('invalid literal for int')
+
+        cherrypy.config['tools.params.error'] = 422
+        self.getPage('/resource?limit=')
+        self.assertStatus(422)
+        self.assertInBody('invalid literal for int')
+
+        cherrypy.config['tools.params.exception'] = TypeError
+        self.getPage('/resource?limit=')
+        self.assertStatus(500)
+
+    def test_syntax(self):
+        if sys.version_info < (3,):
+            return self.skip("skipped (Python 3 only)")
+        exec("""class Root:
+    @cherrypy.expose
+    @cherrypy.tools.params()
+    def resource(self, limit: int):
+        return type(limit).__name__
+conf = {'/': {'tools.params.on': True}}
+cherrypy.tree.mount(Root(), config=conf)""")
+
+        self.getPage('/resource?limit=0')
+        self.assertStatus(200)
+        self.assertBody('int')