Repository: falconry/falcon

Issue:
Add a get_param_as_uuid method to `falcon.Request`
It would be great to be able to get a parameter and to cast it to an UUID, raising a HTTPBadRequest otherwise.


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

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

```diff
@@          Coverage Diff           @@
##           master   #1174   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          37      37           
  Lines        2419    2437   +18     
  Branches      350     354    +4     
======================================
+ Hits         2419    2437   +18
```


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

------

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


Patch:
diff --git a/falcon/request.py b/falcon/request.py
index b1f92e1..5bc5d41 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -26,7 +26,8 @@ except AttributeError:
     import io
     NativeStream = io.BufferedReader
 
-from wsgiref.validate import InputWrapper  # NOQA: I202
+from uuid import UUID  # NOQA: I202
+from wsgiref.validate import InputWrapper
 
 import mimeparse
 import six
@@ -1273,6 +1274,70 @@ class Request(object):
 
         raise errors.HTTPMissingParam(name)
 
+    def get_param_as_uuid(self, name, required=False, store=None):
+        """Return the value of a query string parameter as an UUID.
+
+        The value to convert must conform to the standard UUID string
+        representation per RFC 4122. For example, the following
+        strings are all valid::
+
+            # Lowercase
+            '64be949b-3433-4d36-a4a8-9f19d352fee8'
+
+            # Uppercase
+            'BE71ECAA-F719-4D42-87FD-32613C2EEB60'
+
+            # Mixed
+            '81c8155C-D6de-443B-9495-39Fa8FB239b5'
+
+        Args:
+            name (str): Parameter name, case-sensitive (e.g., 'id').
+
+        Keyword Args:
+            required (bool): Set to ``True`` to raise
+                ``HTTPBadRequest`` instead of returning ``None`` when the
+                parameter is not found or is not a UUID (default
+                ``False``).
+            store (dict): A ``dict``-like object in which to place
+                the value of the param, but only if the param is found
+                (default ``None``).
+
+        Returns:
+            UUID: The value of the param if it is found and can be converted to
+            a ``UUID``. If the param is not found, returns ``None``, unless
+            `required` is ``True``.
+
+        Raises
+            HTTPBadRequest: The param was not found in the request, even though
+                it was required to be there, or it was found but could not
+                be converted to a ``UUID``.
+        """
+
+        params = self._params
+
+        # PERF: Use if..in since it is a good all-around performer; we don't
+        #       know how likely params are to be specified by clients.
+        if name in params:
+            val = params[name]
+            if isinstance(val, list):
+                val = val[-1]
+
+            try:
+                val = UUID(val)
+            except ValueError:
+                msg = 'The value must be a UUID string.'
+                raise errors.HTTPInvalidParam(msg, name)
+
+            if store is not None:
+                store[name] = val
+
+            return val
+
+        if not required:
+            return None
+
+        raise errors.HTTPMissingParam(name)
+
     def get_param_as_bool(self, name, required=False, store=None,
                           blank_as_true=False):
         """Return the value of a query string parameter as a boolean


Test patch:
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index e90567d..3485773 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -1,4 +1,5 @@
 from datetime import date, datetime
+from uuid import UUID
 
 try:
     import ujson as json
@@ -72,7 +73,7 @@ class TestQueryParams(object):
 
     def test_none(self, simulate_request, client, resource):
         query_string = ''
-        client.app.add_route('/', resource)
+        client.app.add_route('/', resource)  # TODO: DRY up this setup logic
         simulate_request(client=client, path='/', query_string=query_string)
 
         req = resource.captured_req
@@ -213,6 +214,7 @@ class TestQueryParams(object):
     @pytest.mark.parametrize('method_name', [
         'get_param',
         'get_param_as_int',
+        'get_param_as_uuid',
         'get_param_as_bool',
         'get_param_as_list',
     ])
@@ -307,6 +309,33 @@ class TestQueryParams(object):
         with pytest.raises(falcon.HTTPBadRequest):
             req.get_param_as_int('pos', min=0, max=10)
 
+    def test_uuid(self, simulate_request, client, resource):
+        client.app.add_route('/', resource)
+        query_string = ('marker1=8d76b7b3-d0dd-46ca-ad6e-3989dcd66959&'
+                        'marker2=64be949b-3433-4d36-a4a8-9f19d352fee8&'
+                        'marker2=8D76B7B3-d0dd-46ca-ad6e-3989DCD66959&'
+                        'short=4be949b-3433-4d36-a4a8-9f19d352fee8')
+        simulate_request(client=client, path='/', query_string=query_string)
+
+        req = resource.captured_req
+
+        expected_uuid = UUID('8d76b7b3-d0dd-46ca-ad6e-3989dcd66959')
+        assert req.get_param_as_uuid('marker1') == expected_uuid
+        assert req.get_param_as_uuid('marker2') == expected_uuid
+        assert req.get_param_as_uuid('marker3') is None
+        assert req.get_param_as_uuid('marker3', required=False) is None
+
+        with pytest.raises(falcon.HTTPBadRequest):
+            req.get_param_as_uuid('short')
+
+        store = {}
+        with pytest.raises(falcon.HTTPBadRequest):
+            req.get_param_as_uuid('marker3', required=True, store=store)
+
+        assert not store
+        assert req.get_param_as_uuid('marker1', store=store)
+        assert store['marker1'] == expected_uuid
+
     def test_boolean(self, simulate_request, client, resource):
         client.app.add_route('/', resource)
         query_string = ('echo=true&doit=false&bogus=bar&bogus2=foo&'