Repository: boto/botocore

Issue:
JSON protocol parser does not handle missing output shape
When a operation does not define an output shape, we still try to parse the JSON body, which will fail because we call `json.loads('')`.  This can be seen on any JSON service that has no output shape defined.



Discussion:

Resolving patch:
diff --git a/botocore/parsers.py b/botocore/parsers.py
--- a/botocore/parsers.py
+++ b/botocore/parsers.py
@@ -450,9 +450,11 @@ def _do_parse(self, response, shape):
         # The json.loads() gives us the primitive JSON types,
         # but we need to traverse the parsed JSON data to convert
         # to richer types (blobs, timestamps, etc.
-        body = response['body'].decode(self.DEFAULT_ENCODING)
-        original_parsed = json.loads(body)
-        parsed = self._parse_shape(shape, original_parsed)
+        parsed = {}
+        if shape is not None:
+            body = response['body'].decode(self.DEFAULT_ENCODING)
+            original_parsed = json.loads(body)
+            parsed = self._parse_shape(shape, original_parsed)
         self._inject_response_metadata(parsed, response['headers'])
         return parsed
 


Test patch:
diff --git a/tests/integration/test_cognito_identity.py b/tests/integration/test_cognito_identity.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_cognito_identity.py
@@ -0,0 +1,32 @@
+# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+#
+# http://aws.amazon.com/apache2.0/
+#
+# or in the "license" file accompanying this file. This file 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.
+from tests import unittest
+import random
+
+import botocore.session
+
+
+class TestCognitoIdentity(unittest.TestCase):
+    def setUp(self):
+        self.session = botocore.session.get_session()
+        self.client = self.session.create_client('cognito-identity', 'us-east-1')
+
+    def test_can_create_and_delete_identity_pool(self):
+        pool_name = 'botocoretest%s' % random.randint(1, 100000)
+        response = self.client.create_identity_pool(
+            IdentityPoolName=pool_name, AllowUnauthenticatedIdentities=True)
+        self.client.delete_identity_pool(IdentityPoolId=response['IdentityPoolId'])
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/unit/test_parsers.py b/tests/unit/test_parsers.py
--- a/tests/unit/test_parsers.py
+++ b/tests/unit/test_parsers.py
@@ -154,15 +154,6 @@ def test_response_metadata_on_normal_request(self):
             parsed, {'Str': 'mystring',
                      'ResponseMetadata': {'RequestId': 'request-id'}})
 
-    def test_response_metadata_on_empty_rest_response(self):
-        headers = {'x-amzn-requestid': 'request-id'}
-        parser = parsers.RestJSONParser()
-        parsed = parser.parse(
-            {'body': b'', 'headers': headers, 'status_code': 200}, None)
-        self.assertEqual(
-            parsed,
-            {'ResponseMetadata': {'RequestId': 'request-id'}})
-
     def test_response_metadata_on_rest_response(self):
         parser = parsers.RestJSONParser()
         response = b'{"Str": "mystring"}'
@@ -187,16 +178,6 @@ def test_response_metadata_on_rest_response(self):
             parsed, {'Str': 'mystring',
                      'ResponseMetadata': {'RequestId': 'request-id'}})
 
-    def test_response_metadata_on_empty_rest_xml_response(self):
-        # This is the format used by cloudfront, route53.
-        headers = {'x-amzn-requestid': 'request-id'}
-        parser = parsers.RestXMLParser()
-        parsed = parser.parse(
-            {'body': b'', 'headers': headers, 'status_code': 200}, None)
-        self.assertEqual(
-            parsed,
-            {'ResponseMetadata': {'RequestId': 'request-id'}})
-
     def test_response_metadata_from_s3_response(self):
         # Even though s3 is a rest-xml service, it's response metadata
         # is slightly different.  It has two request ids, both come from
@@ -284,3 +265,59 @@ def test_can_parse_float_timestamps(self):
              'headers': [],
              'status_code': 200}, output_shape)
         self.assertEqual(parsed, expected_parsed)
+
+
+class TestHandlesNoOutputShape(unittest.TestCase):
+    """Verify that each protocol handles no output shape properly."""
+
+    def test_empty_rest_json_response(self):
+        headers = {'x-amzn-requestid': 'request-id'}
+        parser = parsers.RestJSONParser()
+        output_shape = None
+        parsed = parser.parse(
+            {'body': b'', 'headers': headers, 'status_code': 200},
+            output_shape)
+        self.assertEqual(
+            parsed,
+            {'ResponseMetadata': {'RequestId': 'request-id'}})
+
+    def test_empty_rest_xml_response(self):
+        # This is the format used by cloudfront, route53.
+        headers = {'x-amzn-requestid': 'request-id'}
+        parser = parsers.RestXMLParser()
+        output_shape = None
+        parsed = parser.parse(
+            {'body': b'', 'headers': headers, 'status_code': 200},
+            None)
+        self.assertEqual(
+            parsed,
+            {'ResponseMetadata': {'RequestId': 'request-id'}})
+
+    def test_empty_query_response(self):
+        body = (
+            b'<DeleteTagsResponse xmlns="http://autoscaling.amazonaws.com/">'
+            b'  <ResponseMetadata>'
+            b'    <RequestId>request-id</RequestId>'
+            b'  </ResponseMetadata>'
+            b'</DeleteTagsResponse>'
+        )
+        parser = parsers.QueryParser()
+        output_shape = None
+        parsed = parser.parse(
+            {'body': body, 'headers': {}, 'status_code': 200},
+            None)
+        self.assertEqual(
+            parsed,
+            {'ResponseMetadata': {'RequestId': 'request-id'}})
+
+    def test_empty_json_response(self):
+        headers = {'x-amzn-requestid': 'request-id'}
+        # Output shape of None represents no output shape in the model.
+        output_shape = None
+        parser = parsers.JSONParser()
+        parsed = parser.parse(
+            {'body': b'', 'headers': headers, 'status_code': 200},
+            output_shape)
+        self.assertEqual(
+            parsed,
+            {'ResponseMetadata': {'RequestId': 'request-id'}})