Repository: falconry/falcon

Issue:
Why content-length header required on get request?
I use ember.js as my frontend framework and when I do a get through ember-data the content-length header is not set resulting in a 'HTTPInvalidHeader' on the falcon side.

Why this header is mandatory? 

Here you can see my curls, 1 with the header and 1 without.

https://gist.github.com/DriesS/77047d9a269b8ebceb24



Discussion:

Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -236,10 +236,8 @@ def __init__(self, env, options=None):
 
         # NOTE(kgriffs): Wrap wsgi.input if needed to make read() more robust,
         # normalizing semantics between, e.g., gunicorn and wsgiref.
-        if isinstance(self.stream, NativeStream):  # pragma: nocover
-            # NOTE(kgriffs): coverage can't detect that this *is* actually
-            # covered since the test that does so uses multiprocessing.
-            self.stream = helpers.Body(self.stream, self.content_length)
+        if isinstance(self.stream, NativeStream):
+            self._wrap_stream()
 
         # PERF(kgriffs): Technically, we should spend a few more
         # cycles and parse the content type for real, but
@@ -291,6 +289,15 @@ def content_length(self):
         except KeyError:
             return None
 
+        # NOTE(kgriffs): Normalize an empty value to behave as if
+        # the header were not included; wsgiref, at least, inserts
+        # an empty CONTENT_LENGTH value if the request does not
+        # set the header. Gunicorn and uWSGI do not do this, but
+        # others might if they are trying to match wsgiref's
+        # behavior too closely.
+        if not value:
+            return None
+
         try:
             value_as_int = int(value)
         except ValueError:
@@ -847,36 +854,48 @@ def log_error(self, message):  # pragma: no cover
     # Helpers
     # ------------------------------------------------------------------------
 
-    def _parse_form_urlencoded(self):
-            # NOTE(kgriffs): This assumes self.stream has been patched
-            # above in the case of wsgiref, so that self.content_length
-            # is not needed. Normally we just avoid accessing
-            # self.content_length, because it is a little expensive
-            # to call. We could cache self.content_length, but the
-            # overhead to do that won't usually be helpful, since
-            # content length will only ever be read once per
-            # request in most cases.
-            body = self.stream.read()
-
-            # NOTE(kgriffs): According to http://goo.gl/6rlcux the
-            # body should be US-ASCII. Enforcing this also helps
-            # catch malicious input.
-            try:
-                body = body.decode('ascii')
-            except UnicodeDecodeError:
-                body = None
-                self.log_error('Non-ASCII characters found in form body '
-                               'with Content-Type of '
-                               'application/x-www-form-urlencoded. Body '
-                               'will be ignored.')
-
-            if body:
-                extra_params = uri.parse_query_string(
-                    uri.decode(body),
-                    keep_blank_qs_values=self.options.keep_blank_qs_values,
-                )
+    def _wrap_stream(self):
+        try:
+            # NOTE(kgriffs): We can only add the wrapper if the
+            # content-length header was provided.
+            if self.content_length is not None:
+                self.stream = helpers.Body(self.stream, self.content_length)
+
+        except HTTPInvalidHeader:
+            # NOTE(kgriffs): The content-length header was specified,
+            # but it had an invalid value.
+            pass
 
-                self._params.update(extra_params)
+    def _parse_form_urlencoded(self):
+        # NOTE(kgriffs): This assumes self.stream has been patched
+        # above in the case of wsgiref, so that self.content_length
+        # is not needed. Normally we just avoid accessing
+        # self.content_length, because it is a little expensive
+        # to call. We could cache self.content_length, but the
+        # overhead to do that won't usually be helpful, since
+        # content length will only ever be read once per
+        # request in most cases.
+        body = self.stream.read()
+
+        # NOTE(kgriffs): According to http://goo.gl/6rlcux the
+        # body should be US-ASCII. Enforcing this also helps
+        # catch malicious input.
+        try:
+            body = body.decode('ascii')
+        except UnicodeDecodeError:
+            body = None
+            self.log_error('Non-ASCII characters found in form body '
+                           'with Content-Type of '
+                           'application/x-www-form-urlencoded. Body '
+                           'will be ignored.')
+
+        if body:
+            extra_params = uri.parse_query_string(
+                uri.decode(body),
+                keep_blank_qs_values=self.options.keep_blank_qs_values,
+            )
+
+            self._params.update(extra_params)
 
 
 class RequestOptions(object):


Test patch:
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -96,7 +96,7 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1',
     # NOTE(kgriffs): nocover since this branch will never be
     # taken in Python3. However, the branch is tested under Py2,
     # in test_utils.TestFalconTesting.test_unicode_path_in_create_environ
-    if six.PY2 and isinstance(path, unicode):  # pragma: nocover
+    if six.PY2 and isinstance(path, six.text_type):  # pragma: nocover
         path = path.encode('utf-8')
 
     scheme = scheme.lower()
diff --git a/tests/bare_bones.py b/tests/bare_bones.py
new file mode 100644
--- /dev/null
+++ b/tests/bare_bones.py
@@ -0,0 +1,20 @@
+import falcon
+
+
+class Things(object):
+    def on_get(self, req, resp):
+        pass
+
+
+api = application = falcon.API()
+api.add_route('/', Things())
+
+
+if __name__ == '__main__':
+    # import eventlet.wsgi
+    # import eventlet
+    # eventlet.wsgi.server(eventlet.listen(('localhost', 8000)), application)
+
+    from wsgiref.simple_server import make_server
+    server = make_server('localhost', 8000, application)
+    server.serve_forever()
diff --git a/tests/test_req_vars.py b/tests/test_req_vars.py
--- a/tests/test_req_vars.py
+++ b/tests/test_req_vars.py
@@ -445,8 +445,7 @@ def test_content_length(self):
 
         headers = {'content-length': ''}
         req = Request(testing.create_environ(headers=headers))
-        self.assertRaises(falcon.HTTPInvalidHeader,
-                          lambda: req.content_length)
+        self.assertEqual(req.content_length, None)
 
     def test_bogus_content_length_nan(self):
         headers = {'content-length': 'fuzzy-bunnies'}
diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py
--- a/tests/test_wsgi.py
+++ b/tests/test_wsgi.py
@@ -1,5 +1,9 @@
 import sys
+import threading
+import time
 
+import requests
+from six.moves import http_client
 import testtools
 from testtools.matchers import Equals, MatchesRegex
 
@@ -17,6 +21,22 @@ def _is_iterable(thing):
         return False
 
 
+def _run_server():
+    class Things(object):
+        def on_get(self, req, resp):
+            pass
+
+        def on_put(self, req, resp):
+            pass
+
+    api = application = falcon.API()
+    api.add_route('/', Things())
+
+    from wsgiref.simple_server import make_server
+    server = make_server('localhost', 9803, application)
+    server.serve_forever()
+
+
 class TestWsgi(testtools.TestCase):
 
     def test_srmock(self):
@@ -55,3 +75,31 @@ def test_pep3333(self):
             self.assertThat(len(header), Equals(2))
             self.assertTrue(isinstance(header[0], str))
             self.assertTrue(isinstance(header[1], str))
+
+    def test_wsgiref(self):
+        thread = threading.Thread(target=_run_server)
+        thread.daemon = True
+        thread.start()
+
+        # Wait a moment for the thread to start up
+        time.sleep(0.1)
+
+        resp = requests.get('http://localhost:9803')
+        self.assertEqual(resp.status_code, 200)
+
+        # NOTE(kgriffs): This will cause a different path to
+        # be taken in req._wrap_stream. Have to use httplib
+        # to prevent the invalid header value from being
+        # forced to "0".
+        conn = http_client.HTTPConnection('localhost', 9803)
+        headers = {'Content-Length': 'invalid'}
+        conn.request('PUT', '/', headers=headers)
+        resp = conn.getresponse()
+        self.assertEqual(resp.status, 200)
+
+        headers = {'Content-Length': '0'}
+        resp = requests.put('http://localhost:9803', headers=headers)
+        self.assertEqual(resp.status_code, 200)
+
+        resp = requests.post('http://localhost:9803')
+        self.assertEqual(resp.status_code, 405)