Repository: nylas/nylas-python

Issue:
Ensure draft versioning works
Make sure that draft versions are updated correctly and automatically when creating and updating drafts.

Comments:

Patch:
diff --git a/nylas/client/client.py b/nylas/client/client.py
index ea9f2b4..d67109c 100644
--- a/nylas/client/client.py
+++ b/nylas/client/client.py
@@ -13,64 +13,61 @@ from nylas.client.restful_models import (
     Account, APIAccount, SingletonAccount, Folder,
     Label, Draft
 )
-from nylas.client.errors import (
-    APIClientError, ConnectionError, NotAuthorizedError,
-    InvalidRequestError, NotFoundError, MethodNotSupportedError,
-    ServerError, ServiceUnavailableError, ConflictError,
-    SendingQuotaExceededError, ServerTimeoutError, MessageRejectedError
-)
+from nylas.client.errors import APIClientError, ConnectionError, STATUS_MAP
+try:
+    from json import JSONDecodeError
+except ImportError:
+    JSONDecodeError = ValueError
 
 DEBUG = environ.get('NYLAS_CLIENT_DEBUG')
 API_SERVER = "https://api.nylas.com"
 
 
 def _validate(response):
-    status_code_to_exc = {400: InvalidRequestError,
-                          401: NotAuthorizedError,
-                          402: MessageRejectedError,
-                          403: NotAuthorizedError,
-                          404: NotFoundError,
-                          405: MethodNotSupportedError,
-                          409: ConflictError,
-                          429: SendingQuotaExceededError,
-                          500: ServerError,
-                          503: ServiceUnavailableError,
-                          504: ServerTimeoutError}
-    request = response.request
-    url = request.url
-    status_code = response.status_code
-    data = request.body
-
     if DEBUG:  # pragma: no cover
-        print("{} {} ({}) => {}: {}".format(request.method, url, data,
-                                            status_code, response.text))
-
-    try:
-        data = json.loads(data) if data else None
-    except (ValueError, TypeError):
-        pass
-
-    if status_code == 200:
+        print("{method} {url} ({body}) => {status}: {text}".format(
+            method=response.request.method,
+            url=response.request.url,
+            data=response.request.body,
+            status=response.status_code,
+            text=response.text,
+        ))
+
+    if response.ok:
         return response
-    elif status_code in status_code_to_exc:
-        cls = status_code_to_exc[status_code]
-        try:
-            response = json.loads(response.text)
-            kwargs = dict(url=url, status_code=status_code,
-                          data=data)
 
-            for key in ['message', 'server_error']:
-                if key in response:
-                    kwargs[key] = response[key]
+    # The rest of this function is logic for raising the correct exception
+    # from the `nylas.client.errors` module. In the future, it may be worth changing
+    # this function to just call `response.raise_for_status()`.
+    # http://docs.python-requests.org/en/master/api/#requests.Response.raise_for_status
 
+    try:
+        data = response.json()
+        json_content = True
+    except JSONDecodeError:
+        data = response.content
+        json_content = False
+
+    kwargs = {
+        "url": response.request.url,
+        "status_code": response.status_code,
+        "data": data,
+    }
+
+    if response.status_code in STATUS_MAP:
+        cls = STATUS_MAP[response.status_code]
+        if json_content:
+            if "message" in data:
+                kwargs["message"] = data["message"]
+            if "server_error" in data:
+                kwargs["server_error"] = data["server_error"]
+            raise cls(**kwargs)
+        else:
+            kwargs["message"] = "Malformed"
             raise cls(**kwargs)
-
-        except (ValueError, TypeError):
-            raise cls(url=url, status_code=status_code,
-                      data=data, message="Malformed")
     else:
-        raise APIClientError(url=url, status_code=status_code,
-                             data=data, message="Unknown status code.")
+        kwargs["message"] = "Unknown status code."
+        raise APIClientError(**kwargs)
 
 
 def nylas_excepted(func):
diff --git a/nylas/client/errors.py b/nylas/client/errors.py
index d564154..29dc3f0 100644
--- a/nylas/client/errors.py
+++ b/nylas/client/errors.py
@@ -68,3 +68,18 @@ class ServerTimeoutError(APIClientError):
 
 class FileUploadError(APIClientError):
     pass
+
+
+STATUS_MAP = {
+    400: InvalidRequestError,
+    401: NotAuthorizedError,
+    402: MessageRejectedError,
+    403: NotAuthorizedError,
+    404: NotFoundError,
+    405: MethodNotSupportedError,
+    409: ConflictError,
+    429: SendingQuotaExceededError,
+    500: ServerError,
+    503: ServiceUnavailableError,
+    504: ServerTimeoutError,
+}


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index af96ba9..e8b3efc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -560,10 +560,13 @@ def mock_draft_saved_response(mocked_responses, api_url):
             }
         ],
         "unread": False,
-        "version": 0
+        "version": 0,
     }
 
-    def request_callback(request):
+    def create_callback(_request):
+        return (200, {}, json.dumps(draft_json))
+
+    def update_callback(request):
         try:
             payload = json.loads(request.body)
         except ValueError:
@@ -574,20 +577,21 @@ def mock_draft_saved_response(mocked_responses, api_url):
         }
         updated_draft_json = copy.copy(draft_json)
         updated_draft_json.update(stripped_payload)
+        updated_draft_json["version"] += 1
         return (200, {}, json.dumps(updated_draft_json))
 
     mocked_responses.add_callback(
         responses.POST,
         api_url + '/drafts/',
         content_type='application/json',
-        callback=request_callback,
+        callback=create_callback,
     )
 
     mocked_responses.add_callback(
         responses.PUT,
         api_url + '/drafts/2h111aefv8pzwzfykrn7hercj',
         content_type='application/json',
-        callback=request_callback,
+        callback=update_callback,
     )
 
 
@@ -638,7 +642,6 @@ def mock_draft_sent_response(mocked_responses, api_url):
     def callback(request):
         payload = json.loads(request.body)
         assert payload['draft_id'] == '2h111aefv8pzwzfykrn7hercj'
-        assert payload['version'] == 0
         return values.pop()
 
     mocked_responses.add_callback(
diff --git a/tests/test_client.py b/tests/test_client.py
index 2e7a75b..f7c2f97 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -185,3 +185,47 @@ def test_call_resource_method(mocked_responses, api_client, api_url):
     )
     assert isinstance(contact, Contact)
     assert len(mocked_responses.calls) == 1
+
+
+def test_201_response(mocked_responses, api_client, api_url):
+    contact_data = {
+        "id": 1,
+        "name": "first",
+        "email": "first@example.com",
+    }
+    mocked_responses.add(
+        responses.POST,
+        api_url + "/contacts/",
+        content_type='application/json',
+        status=201,  # This HTTP status still indicates success,
+                     # even though it's not 200.
+        body=json.dumps(contact_data),
+    )
+    contact = api_client.contacts.create()
+    contact.save()
+    assert len(mocked_responses.calls) == 1
+
+
+def test_301_response(mocked_responses, api_client, api_url):
+    contact_data = {
+        "id": 1,
+        "name": "first",
+        "email": "first@example.com",
+    }
+    mocked_responses.add(
+        responses.GET,
+        api_url + "/contacts/first",
+        status=301,
+        headers={"Location": api_url + "/contacts/1"}
+    )
+    mocked_responses.add(
+        responses.GET,
+        api_url + "/contacts/1",
+        content_type='application/json',
+        status=200,
+        body=json.dumps(contact_data),
+    )
+    contact = api_client.contacts.find("first")
+    assert contact["id"] == 1
+    assert contact["name"] == "first"
+    assert len(mocked_responses.calls) == 2
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
index f8747c3..bfd84d7 100644
--- a/tests/test_drafts.py
+++ b/tests/test_drafts.py
@@ -62,3 +62,15 @@ def test_delete_draft(api_client):
     draft.save()
     # ... and delete it for real.
     draft.delete()
+
+
+@pytest.mark.usefixtures("mock_draft_saved_response")
+def test_draft_version(api_client):
+    draft = api_client.drafts.create()
+    assert 'version' not in draft
+    draft.save()
+    assert draft['version'] == 0
+    draft.update()
+    assert draft['version'] == 1
+    draft.update()
+    assert draft['version'] == 2