Repository: falconry/falcon

Issue:
perf(Request): Short-circuit path encode/decode when possible
See if we can quickly determine if the following is required for a given path and short-circuit it when it is not (from `Request.__init__`):

```py
# PEP 3333 specifies that PATH_INFO variable are always
# "bytes tunneled as latin-1" and must be encoded back
path = path.encode('latin1').decode('utf-8', 'replace')
```

See also this section from `falcon/testing/helpers.py`:

```py
# NOTE(kgriffs): The decoded path may contain UTF-8 characters.
# But according to the WSGI spec, no strings can contain chars
# outside ISO-8859-1. Therefore, to reconcile the URI
# encoding standard that allows UTF-8 with the WSGI spec
# that does not, WSGI servers tunnel the string via
# ISO-8859-1. falcon.testing.create_environ() mimics this
# behavior, e.g.:
#
#   tunnelled_path = path.encode('utf-8').decode('iso-8859-1')
#
# falcon.Request does the following to reverse the process:
#
#   path = tunnelled_path.encode('iso-8859-1').decode('utf-8', 'replace')
#
path = path.encode('utf-8').decode('iso-8859-1')
```

Comments:

Patch:
diff --git a/docs/_newsfragments/1492.newandimproved.rst b/docs/_newsfragments/1492.newandimproved.rst
new file mode 100644
index 0000000..86860b5
--- /dev/null
+++ b/docs/_newsfragments/1492.newandimproved.rst
@@ -0,0 +1,2 @@
+WSGI path decoding in :class:`falcon.Request` was optimized, and is now
+significantly faster than in Falcon 2.0.
diff --git a/falcon/cyutil/misc.pyx b/falcon/cyutil/misc.pyx
new file mode 100644
index 0000000..095dddd
--- /dev/null
+++ b/falcon/cyutil/misc.pyx
@@ -0,0 +1,40 @@
+# Copyright 2020 by Vytautas Liuolia.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+def isascii(unicode string not None):
+    """Return ``True`` if all characters in the string are ASCII.
+
+    ASCII characters have code points in the range U+0000-U+007F.
+
+    Note:
+        On Python 3.7+, this function is just aliased to ``str.isascii``.
+
+    This is a Cython fallback for older CPython versions. For longer strings,
+    it is slightly less performant than the built-in ``str.isascii``.
+
+    Args:
+        string (str): A string to test.
+
+    Returns:
+        ``True`` if all characters are ASCII, ``False`` otherwise.
+    """
+
+    cdef Py_UCS4 ch
+
+    for ch in string:
+        if ch > 0x007F:
+            return False
+
+    return True
diff --git a/falcon/request.py b/falcon/request.py
index 827aaaa..76c5da3 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -24,6 +24,7 @@ from falcon.forwarded import Forwarded  # NOQA
 from falcon.media import Handlers
 from falcon.util import json
 from falcon.util import structures
+from falcon.util.misc import isascii
 from falcon.util.uri import parse_host, parse_query_string
 from falcon.vendor import mimeparse
 
@@ -464,8 +465,11 @@ class Request:
         # ISO-8859-1, e.g.:
         #
         #   tunnelled_path = path.encode('utf-8').decode('iso-8859-1')
-        #
-        path = path.encode('iso-8859-1').decode('utf-8', 'replace')
+
+        # perf(vytas): Only decode the tunnelled path in case it is not ASCII.
+        #   For ASCII-strings, the below decoding chain is a no-op.
+        if not isascii(path):
+            path = path.encode('iso-8859-1').decode('utf-8', 'replace')
 
         if (self.options.strip_url_path_trailing_slash and
                 len(path) != 1 and path.endswith('/')):
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
index 4eea6d6..2287e93 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -36,6 +36,11 @@ import warnings
 
 from falcon import status_codes
 
+try:
+    from falcon.cyutil.misc import isascii as _cy_isascii
+except ImportError:
+    _cy_isascii = None
+
 __all__ = (
     'is_python_func',
     'deprecated',
@@ -508,3 +513,31 @@ def deprecated_args(*, allowed_positional, is_method=True):
         return wraps
 
     return deprecated_args
+
+
+def _isascii(string):
+    """Return ``True`` if all characters in the string are ASCII.
+
+    ASCII characters have code points in the range U+0000-U+007F.
+
+    Note:
+        On Python 3.7+, this function is just aliased to ``str.isascii``.
+
+    This is a pure-Python fallback for older CPython (where Cython is
+    unavailable) and PyPy versions.
+
+    Args:
+        string (str): A string to test.
+
+    Returns:
+        ``True`` if all characters are ASCII, ``False`` otherwise.
+    """
+
+    try:
+        string.encode('ascii')
+        return True
+    except ValueError:
+        return False
+
+
+isascii = getattr(str, 'isascii', _cy_isascii or _isascii)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d393a09..c611c2c 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -498,6 +498,21 @@ class TestFalconUtils:
         with pytest.raises(ValueError):
             misc.secure_filename('')
 
+    @pytest.mark.parametrize('string,expected_ascii', [
+        ('', True),
+        ('/', True),
+        ('/api', True),
+        ('/data/items/something?query=apples%20and%20oranges', True),
+        ('/food?item=ð\x9f\x8d\x94', False),
+        ('\x00\x00\x7F\x00\x00\x7F\x00', True),
+        ('\x00\x00\x7F\x00\x00\x80\x00', False),
+    ])
+    def test_misc_isascii(self, string, expected_ascii):
+        if expected_ascii:
+            assert misc.isascii(string)
+        else:
+            assert not misc.isascii(string)
+
 
 @pytest.mark.parametrize(
     'protocol,method',