Repository: falconry/falcon
Pull request #1082 (2017-07-07 00:27:15)

feat(routing): Add UUIDConverter

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

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

```diff
@@          Coverage Diff           @@
##           master   #1082   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2343    2350    +7     
  Branches      331     331           
======================================
+ Hits         2343    2350    +7
```


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

------

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

kgriffs: @ueg1990 Not a bad idea. If nothing else, it will provide a self-documenting template for writing custom converters.
ueg1990: It shouldn't really affect performance right?
ueg1990: Since this issue is merged, can I open another issue and send a pull request?
kgriffs: @ueg1990 I don't think you need to create a new issue in this case.
ueg1990: ok, I will send a pull request :smile:  (i get to keep my streak going of atleast one contritbution to Falcon per year :stuck_out_tongue: )

Patch:
diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py
index e6934cb..16e3b21 100644
--- a/falcon/routing/converters.py
+++ b/falcon/routing/converters.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 from datetime import datetime
+import uuid
 
 
 # PERF(kgriffs): Avoid an extra namespace lookup when using this function
@@ -69,7 +70,7 @@ class DateTimeConverter(object):
     """Converts a field value to a datetime.
 
     Keyword Args:
-        format_string (str): String used to parse the param value
+        format_string (str): String used to parse the field value
             into a datetime. Any format recognized by strptime() is
             supported (default ``'%Y-%m-%dT%H:%M:%SZ'``).
     """
@@ -86,7 +87,23 @@ class DateTimeConverter(object):
             return None
 
 
+class UUIDConverter(object):
+    """Converts a field value to a uuid.UUID.
+
+    In order to be converted, the field value must consist of a
+    string of 32 hexadecimal digits, as defined in RFC 4122, Section 3.
+    Note, however, that hyphens and the URN prefix are optional.
+    """
+
+    def convert(self, fragment):
+        try:
+            return uuid.UUID(fragment)
+        except ValueError:
+            return None
+
+
 BUILTIN = (
     ('int', IntConverter),
     ('dt', DateTimeConverter),
+    ('uuid', UUIDConverter),
 )


Test patch:
diff --git a/tests/test_uri_converters.py b/tests/test_uri_converters.py
index 536512a..a4e25ce 100644
--- a/tests/test_uri_converters.py
+++ b/tests/test_uri_converters.py
@@ -1,11 +1,17 @@
 from datetime import datetime
 import string
+import uuid
 
 import pytest
 
 from falcon.routing import converters
 
 
+_TEST_UUID = uuid.uuid4()
+_TEST_UUID_STR = str(_TEST_UUID)
+_TEST_UUID_STR_SANS_HYPHENS = _TEST_UUID_STR.replace('-', '')
+
+
 @pytest.mark.parametrize('fragment, num_digits, min, max, expected', [
     ('123', None, None, None, 123),
     ('01', None, None, None, 1),
@@ -60,8 +66,9 @@ def test_int_converter_invalid_config(num_digits):
     ('07-03-17 ', '%m-%d-%y ', datetime(2017, 7, 3)),
     ('2017-07-03T14:30:01Z', '%Y-%m-%dT%H:%M:%SZ', datetime(2017, 7, 3, 14, 30, 1)),
     ('2017-07-03T14:30:01', '%Y-%m-%dT%H:%M:%S', datetime(2017, 7, 3, 14, 30, 1)),
-    ('2017-07-03T14:30:01', '%Y-%m-%dT%H:%M:%SZ', None),
     ('2017_19', '%Y_%H', datetime(2017, 1, 1, 19, 0)),
+
+    ('2017-07-03T14:30:01', '%Y-%m-%dT%H:%M:%SZ', None),
     ('07-03-17 ', '%m-%d-%y', None),
     (' 07-03-17', '%m-%d-%y', None),
     ('07 -03-17', '%m-%d-%y', None),
@@ -74,3 +81,23 @@ def test_datetime_converter(fragment, format_string, expected):
 def test_datetime_converter_default_format():
     c = converters.DateTimeConverter()
     assert c.convert('2017-07-03T14:30:01Z') == datetime(2017, 7, 3, 14, 30, 1)
+
+
+@pytest.mark.parametrize('fragment, expected', [
+    (_TEST_UUID_STR, _TEST_UUID),
+    (_TEST_UUID_STR.replace('-', '', 1), _TEST_UUID),
+    (_TEST_UUID_STR_SANS_HYPHENS, _TEST_UUID),
+    ('urn:uuid:' + _TEST_UUID_STR, _TEST_UUID),
+    ('urn:uuid:' + _TEST_UUID_STR_SANS_HYPHENS, _TEST_UUID),
+
+    (' ', None),
+    (_TEST_UUID_STR + ' ', None),
+    (' ' + _TEST_UUID_STR, None),
+    (_TEST_UUID_STR[:-1], None),
+    (_TEST_UUID_STR[0], None),
+    (_TEST_UUID_STR[:-1] + 'g', None),
+    (_TEST_UUID_STR.replace('-', '_'), None),
+])
+def test_uuid_converter(fragment, expected):
+    c = converters.UUIDConverter()
+    assert c.convert(fragment) == expected
diff --git a/tests/test_uri_templates.py b/tests/test_uri_templates.py
index e330898..70ac510 100644
--- a/tests/test_uri_templates.py
+++ b/tests/test_uri_templates.py
@@ -6,6 +6,7 @@ path via simulate_get(), vs. probing the router directly.
 """
 
 from datetime import datetime
+import uuid
 
 import pytest
 import six
@@ -14,6 +15,11 @@ import falcon
 from falcon import testing
 
 
+_TEST_UUID = uuid.uuid4()
+_TEST_UUID_STR = str(_TEST_UUID)
+_TEST_UUID_STR_SANS_HYPHENS = _TEST_UUID_STR.replace('-', '')
+
+
 class IDResource(object):
     def __init__(self):
         self.id = None
@@ -203,6 +209,54 @@ def test_datetime_converter(client, resource, uri_template, path, dt_expected):
         assert resource.captured_kwargs['timestamp'] == dt_expected
 
 
+@pytest.mark.parametrize('uri_template, path, uuid_expected', [
+    (
+        '/widgets/{widget_id:uuid}',
+        '/widgets/' + _TEST_UUID_STR,
+        _TEST_UUID
+    ),
+    (
+        '/widgets/{widget_id:uuid}/orders',
+        '/widgets/' + _TEST_UUID_STR_SANS_HYPHENS + '/orders',
+        _TEST_UUID
+    ),
+    (
+        '/widgets/{widget_id:uuid}/orders',
+        '/widgets/' + _TEST_UUID_STR_SANS_HYPHENS[:-1] + '/orders',
+        None
+    ),
+])
+def test_uuid_converter(client, resource, uri_template, path, uuid_expected):
+    client.app.add_route(uri_template, resource)
+
+    result = client.simulate_get(path)
+
+    if uuid_expected is None:
+        assert result.status_code == 404
+        assert not resource.called
+    else:
+        assert result.status_code == 200
+        assert resource.called
+        assert resource.captured_kwargs['widget_id'] == uuid_expected
+
+
+def test_uuid_converter_complex_segment(client, resource):
+    client.app.add_route('/pages/{first:uuid}...{last:uuid}', resource)
+
+    first_uuid = uuid.uuid4()
+    last_uuid = uuid.uuid4()
+
+    result = client.simulate_get('/pages/{0}...{1}'.format(
+        first_uuid,
+        last_uuid
+    ))
+
+    assert result.status_code == 200
+    assert resource.called
+    assert resource.captured_kwargs['first'] == first_uuid
+    assert resource.captured_kwargs['last'] == last_uuid
+
+
 def test_converter_custom(client, resource):
     class SpamConverter(object):
         def convert(self, fragment):