Repository: duosecurity/duo_client_python

Issue:
metadata parse error in add_user_phone
When trying to add an existing phone to an existing user, e.g. with:

```python
import duo_client
admin_api = duo_client.Admin(
    ikey="xxx",
    skey="xxx",
    host="api-xxx.duosecurity.com"
)
admin_api.add_user_phone(user_id="userxxx", phone_id="phonexxx")
```

I am running into this error:
```
Traceback (most recent call last):
  File "/home/test/./duo_test.py", line 8, in <module>
    admin_api.add_user_phone(user_id="userxxx", phone_id="phonexxx")
  File "/home/applications-home/python/default_venv_3/lib/python3.9/site-packages/duo_client/admin.py", line 1010, in add_user_phone
    return self.json_api_call('POST', path, params)
  File "/home/applications-home/python/default_venv_3/lib/python3.9/site-packages/duo_client/client.py", line 479, in json_api_call
    return self.parse_json_response(response, data)
  File "/home/applications-home/python/default_venv_3/lib/python3.9/site-packages/duo_client/client.py", line 545, in parse_json_response
    (response, metadata) = self.parse_json_response_and_metadata(response, data)
  File "/home/applications-home/python/default_venv_3/lib/python3.9/site-packages/duo_client/client.py", line 589, in parse_json_response_and_metadata
    metadata = response.get('metadata', {})
AttributeError: 'str' object has no attribute 'get'
```

I believe the issue is that according to the API [docs](https://duo.com/docs/adminapi#associate-phone-with-user) the response to this call should be:

```
{
  "stat": "OK",
  "response": ""
}
```

`parse_json_response_and_metadata` tries to [parse](https://github.com/duosecurity/duo_client_python/blob/cb2eb878247012c699b8e5d88db11fc96fcea3f2/duo_client/client.py#L583) this response with:

```
data = json.loads(data)
if data['stat'] != 'OK':
    raise_error('Received error response: %s' % data)
response = data['response']
metadata = data.get('metadata', {})
if not metadata and not isinstance(response, list):
    metadata = response.get('metadata', {})
```

Since there is no `metadata` key at the top level it tries to pull the `metadata` key from within `response`, but that is an empty string in this case, so the `.get` method fails.

I'm not sure what your preferred approach to fixing this would be, obviously this would work:

```
if not metadata and not isinstance(response, list) and response:
    metadata = response.get('metadata', {})
else:
    metadata = {}
```

But maybe that would cause other issues?

Discussion:

Patch:
diff --git a/duo_client/client.py b/duo_client/client.py
index d959c62..72abd9b 100644
--- a/duo_client/client.py
+++ b/duo_client/client.py
@@ -585,7 +585,7 @@ class Client(object):
                 raise_error('Received error response: %s' % data)
             response = data['response']
             metadata = data.get('metadata', {})
-            if not metadata and not isinstance(response, list):
+            if not metadata and isinstance(response, dict):
                 metadata = response.get('metadata', {})
 
             return (response, metadata)


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 92e37bb..28d030c 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -628,6 +628,38 @@ class TestParseJsonResponseAndMetadata(unittest.TestCase):
         self.assertEqual(e.exception.reason, api_res.reason)
         self.assertEqual(e.exception.data, response)
 
+    def test_response_is_a_string(self):
+        """
+        Some API requests return just a string in their response.
+        We want to make sure we handle those correctly
+        """
+        api_res = self.APIResponse(200, 'Fake reason')
+        response = {
+            'response': "just a string",
+            'stat': 'OK'
+        }
+        try:
+            self.client.parse_json_response_and_metadata(api_res, json.dumps(response))
+        except Exception:
+            self.fail("parsing raised exception for string response")
+
+    def test_response_is_a_list(self):
+        """
+        Some API requests return just a list in their response. with
+        metadata included at the top level.
+        We want to make sure we handle those correctly
+        """
+        api_res = self.APIResponse(200, "Fake reason")
+        expected_metadata = { "offset": 4 }
+        expected_response = ["multiple", "elements"]
+        response = {
+            "response": expected_response,
+            "metadata": expected_metadata,
+            "stat": "OK"
+        }
+        response, metadata = self.client.parse_json_response_and_metadata(api_res, json.dumps(response))
+        self.assertEqual(metadata, expected_metadata)
+
     def test_response_stat_isnot_OK(self):
         api_res = self.APIResponse(200, 'Fake reason')