Repository: falconry/falcon

Issue:
Falcon should encode response's body before starting the response
Falcon does not encode response body before getting its len which might result in an issue for unicode bodies with special characters. Such bodies, when encoded, might increase their size and that'd result in a truncated response.

For example:

```
  >>> len(u"\u6c49\u5b57")
  2
  >>> len(u"\u6c49\u5b57".encode("utf-8"))
  6
```



Discussion:

Resolving patch:
diff --git a/falcon/api_helpers.py b/falcon/api_helpers.py
--- a/falcon/api_helpers.py
+++ b/falcon/api_helpers.py
@@ -19,8 +19,6 @@
 import re
 from functools import wraps
 
-import six
-
 from falcon import responders, HTTP_METHODS
 import falcon.status_codes as status
 
@@ -77,10 +75,10 @@ def set_content_length(resp):
 
     content_length = 0
 
-    if resp.body is not None:
+    if resp.body_encoded is not None:
         # Since body is assumed to be a byte string (str in Python 2, bytes in
         # Python 3), figure out the length using standard functions.
-        content_length = len(resp.body)
+        content_length = len(resp.body_encoded)
     elif resp.data is not None:
         content_length = len(resp.data)
     elif resp.stream is not None:
@@ -111,13 +109,10 @@ def get_body(resp):
 
     """
 
-    body = resp.body
+    body = resp.body_encoded
 
     if body is not None:
-        if isinstance(body, six.text_type):
-            return [body.encode('utf-8')]
-        else:
-            return [body]
+        return [body]
 
     elif resp.data is not None:
         return [resp.data]
diff --git a/falcon/response.py b/falcon/response.py
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -16,6 +16,8 @@
 
 """
 
+import six
+
 from falcon.response_helpers import header_property, format_range
 from falcon.util import dt_to_http, percent_escape
 
@@ -36,7 +38,8 @@ class Response(object):
     """
 
     __slots__ = (
-        'body',  # Stuff
+        '_body',  # Stuff
+        '_body_encoded',  # Stuff
         'data',
         '_headers',
         'status',
@@ -55,11 +58,50 @@ def __init__(self):
         self.status = '200 OK'
         self._headers = {}
 
-        self.body = None
+        self._body = None
+        self._body_encoded = None
         self.data = None
         self.stream = None
         self.stream_len = None
 
+    def _get_body(self):
+        """Returns the body as-is."""
+        return self._body
+
+    def _set_body(self, value):
+        """Sets the body and clears the encoded cache."""
+        self._body = value
+        self._body_encoded = None
+
+    # NOTE(flaper87): Lets use a property
+    # for the body in case its content was
+    # encoded and then modified.
+    body = property(_get_body, _set_body)
+
+    @property
+    def body_encoded(self):
+        """Encode the body and return it
+
+        This property will encode `_body` and
+        cache the result in the `_body_encoded`
+        attribute.
+        """
+        # NOTE(flaper87): Notice this property
+        # is not thread-safe. If body is modified
+        # before this property returns, we might
+        # end up returning None.
+        body = self._body
+        if body and self._body_encoded is None:
+
+            # NOTE(flaper87): Assume it is an
+            # encoded str, then check and encode
+            # if it isn't.
+            self._body_encoded = body
+            if isinstance(body, six.text_type):
+                self._body_encoded = body.encode('utf-8')
+
+        return self._body_encoded
+
     def set_header(self, name, value):
         """Set a header for this response to a given value.
 


Test patch:
diff --git a/falcon/tests/test_after_hooks.py b/falcon/tests/test_after_hooks.py
--- a/falcon/tests/test_after_hooks.py
+++ b/falcon/tests/test_after_hooks.py
@@ -85,7 +85,7 @@ def test_global_hook(self):
         self.api.add_route(self.test_route, zoo_resource)
 
         self.simulate_request(self.test_route)
-        self.assertEqual('fluffy', zoo_resource.resp.body)
+        self.assertEqual(b'fluffy', zoo_resource.resp.body_encoded)
 
     def test_multiple_global_hook(self):
         self.api = falcon.API(after=[fluffiness, cuteness])
@@ -94,29 +94,28 @@ def test_multiple_global_hook(self):
         self.api.add_route(self.test_route, zoo_resource)
 
         self.simulate_request(self.test_route)
-        self.assertEqual('fluffy and cute', zoo_resource.resp.body)
+        self.assertEqual(b'fluffy and cute', zoo_resource.resp.body_encoded)
 
     def test_output_validator(self):
         self.simulate_request(self.test_route)
         self.assertEqual(falcon.HTTP_723, self.srmock.status)
-        self.assertEqual(None, self.resource.resp.body)
+        self.assertEqual(None, self.resource.resp.body_encoded)
 
     def test_serializer(self):
         self.simulate_request(self.test_route, method='PUT')
 
-        actual_body = self.resource.resp.body
-        self.assertEqual('{"animal": "falcon"}', actual_body)
+        actual_body = self.resource.resp.body_encoded
+        self.assertEqual(b'{"animal": "falcon"}', actual_body)
 
     def test_wrapped_resource(self):
-        expected = 'fluffy and cute'
+        expected = b'fluffy and cute'
 
         self.simulate_request('/wrapped')
         self.assertEqual(falcon.HTTP_200, self.srmock.status)
-        self.assertEqual(expected, self.wrapped_resource.resp.body)
+        self.assertEqual(expected, self.wrapped_resource.resp.body_encoded)
 
         self.simulate_request('/wrapped', method='HEAD')
         self.assertEqual(falcon.HTTP_200, self.srmock.status)
-        self.assertEqual(expected, self.wrapped_resource.resp.body)
 
         self.simulate_request('/wrapped', method='POST')
         self.assertEqual(falcon.HTTP_405, self.srmock.status)
diff --git a/falcon/tests/test_hello.py b/falcon/tests/test_hello.py
--- a/falcon/tests/test_hello.py
+++ b/falcon/tests/test_hello.py
@@ -97,11 +97,11 @@ def test_body(self):
         resp = self.resource.resp
 
         content_length = int(self.srmock.headers_dict['Content-Length'])
-        self.assertEquals(content_length, len(self.resource.sample_unicode))
+        self.assertEquals(content_length, len(self.resource.sample_utf8))
 
         self.assertEquals(self.srmock.status, self.resource.sample_status)
         self.assertEquals(resp.status, self.resource.sample_status)
-        self.assertEquals(resp.body, self.resource.sample_unicode)
+        self.assertEquals(resp.body_encoded, self.resource.sample_utf8)
         self.assertEquals(body, [self.resource.sample_utf8])
 
     def test_body_bytes(self):
@@ -113,7 +113,7 @@ def test_body_bytes(self):
 
         self.assertEquals(self.srmock.status, self.resource.sample_status)
         self.assertEquals(resp.status, self.resource.sample_status)
-        self.assertEquals(resp.body, self.resource.sample_utf8)
+        self.assertEquals(resp.body_encoded, self.resource.sample_utf8)
         self.assertEquals(body, [self.resource.sample_utf8])
 
     def test_data(self):
diff --git a/falcon/tests/test_response_body.py b/falcon/tests/test_response_body.py
new file mode 100644
--- /dev/null
+++ b/falcon/tests/test_response_body.py
@@ -0,0 +1,17 @@
+
+import falcon
+import falcon.testing as testing
+
+
+class TestResponseBody(testing.TestBase):
+
+    def test_append_body(self):
+        text = "Hello beautiful world! "
+        resp = falcon.Response()
+        resp.body = ""
+
+        for token in text.split():
+            resp.body += token
+            resp.body += " "
+
+        self.assertEquals(resp.body, text)