Repository: nylas/nylas-python

Issue:
Handle HTTP success status codes beyond 200
There are [many HTTP status codes that indicate different kinds of successful responses](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success). "200 OK" is the most widely used, but many RESTful APIs use "201 Created" in response to creating resources, and "204 No Content" in response to deleting resources.

This project is hardcoded to only accept the 200 status code as success, which means that if the API ever returns a different, non-200 status code to indicate a successful response, the SDK will throw an exception. This project should work with success status codes beyond 200.

When using the `requests` library, you can use [`response.ok`](http://docs.python-requests.org/en/master/api/#requests.Response.ok) to determine if the response was successful. You can also use [`response.raise_for_status()`](http://docs.python-requests.org/en/master/api/#requests.Response.raise_for_status) to raise an exception if necessary, rather than trying to determine if the request was successful and raising an exception if it wasn't.

Comments:

Patch:
diff --git a/nylas/client/client.py b/nylas/client/client.py
index 91de992..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):
@@ -90,7 +87,7 @@ class APIClient(json.JSONEncoder):
                  app_secret=environ.get('NYLAS_APP_SECRET'),
                  access_token=environ.get('NYLAS_ACCESS_TOKEN'),
                  api_server=API_SERVER):
-        if "://" not in api_server:
+        if not api_server.startswith("https://"):
             raise Exception("When overriding the Nylas API server address, you"
                             " must include https://")
         self.api_server = api_server
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 3bdd6ef..af96ba9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -33,7 +33,7 @@ def message_body():
 
 @pytest.fixture
 def api_url():
-    return 'http://localhost:2222'
+    return 'https://localhost:2222'
 
 
 @pytest.fixture
diff --git a/tests/test_client.py b/tests/test_client.py
index 2adce17..f7c2f97 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -21,8 +21,8 @@ def urls_equal(url1, url2):
 
 def test_custom_client():
     # Can specify API server
-    custom = APIClient(api_server="http://example.com")
-    assert custom.api_server == "http://example.com"
+    custom = APIClient(api_server="https://example.com")
+    assert custom.api_server == "https://example.com"
     # Must be a valid URL
     with pytest.raises(Exception) as exc:
         APIClient(api_server="invalid")
@@ -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