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.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=h1) Report
> Merging [#81](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=desc) into [master](https://codecov.io/gh/nylas/nylas-python/commit/6a9b1e5c0774b2a198496daedb980b5bbc091d30?src=pr&el=desc) will **increase** coverage by `0.02%`.
> The diff coverage is `92%`.

[![Impacted file tree graph](https://codecov.io/gh/nylas/nylas-python/pull/81/graphs/tree.svg?height=150&width=650&token=GFzivPuPDA&src=pr)](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #81      +/-   ##
==========================================
+ Coverage   95.16%   95.18%   +0.02%     
==========================================
  Files           6        6              
  Lines         641      644       +3     
==========================================
+ Hits          610      613       +3     
  Misses         31       31
```


| [Impacted Files](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [nylas/client/errors.py](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=tree#diff-bnlsYXMvY2xpZW50L2Vycm9ycy5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [nylas/client/client.py](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=tree#diff-bnlsYXMvY2xpZW50L2NsaWVudC5weQ==) | `96.56% <91.66%> (+0.03%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=footer). Last update [6a9b1e5...cdfef49](https://codecov.io/gh/nylas/nylas-python/pull/81?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/examples-new/webhooks/README.md b/examples-new/webhooks/README.md
deleted file mode 100644
index ac9483a..0000000
--- a/examples-new/webhooks/README.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Example: Webhooks
-
-This is an example project that demonstrates how to use
-[the webhooks feature on Nylas](https://docs.nylas.com/reference#webhooks).
-When you run the app and set up a webhook with Nylas, it will print out
-some information every time you receive a webhook notification from Nylas.
-
-In order to successfully run this example, you need to do the following things:
-
-## Install and run redis
-
-[Redis](https://redis.io/) is an in-memory data store. This example uses it
-as a message broker for the Celery task queue. You'll need to have it running
-on your local computer in order to use the task queue.
-
-If you're using macOS, you can install redis from [Homebrew](https://brew.sh/),
-like this:
-
-```
-brew install redis
-brew services start redis
-```
-
-If you're unable to install and run redis, you can still run this example
-without the task queue -- keep reading.
-
-## Get a client ID & client secret from Nylas
-
-To do this, make a [Nylas Developer](https://developer.nylas.com/) account.
-You should see your client ID and client secret on the dashboard,
-once you've logged in on the
-[Nylas Developer](https://developer.nylas.com/) website.
-
-## Update the `config.json` File
-
-Open the `config.json` file in this directory, and replace the example
-client ID and client secret with the real values that you got from the Nylas
-Developer dashboard. You'll also need to replace the example secret key with
-any random string of letters and numbers: a keyboard mash will do.
-
-The config file also has two options related to Celery, which you probably
-don't need to modify. `CELERY_BROKER_URL` should point to your running redis
-server: if you've got it running on your local computer, you're all set.
-However, if you haven't managed to get redis running on your computer, you
-can change `CELERY_TASK_ALWAYS_EAGER` to `true`. This will disable the task
-queue, and cause all Celery tasks to be run immediately rather than queuing
-them for later.
-
-## Set Up HTTPS
-
-Nylas requires that all webhooks be delivered to the secure HTTPS endpoints,
-rather than insecure HTTP endpoints. There are several ways
-to set up HTTPS on your computer, but perhaps the simplest is to use
-[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel
-from the ngrok website to your computer. Install it from the website, and
-then run the following command:
-
-```
-ngrok http 5000
-```
-
-Notice that ngrok will show you two "forwarding" URLs, which may look something
-like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash
-subdomain will be different for you.) You'll be using the second URL, which
-starts with `https`.
-
-## Install the Dependencies
-
-This project depends on a few third-party Python modules.
-These dependencies are listed in the `requirements.txt` file in this directory.
-To install them, use the `pip` tool, like this:
-
-```
-pip install -r requirements.txt
-```
-
-## Run the Celery worker (if you're using redis)
-
-The Celery worker will continuously check the task queue to see if there are
-any new tasks to be run, and it will run any tasks that it finds. Without at
-least one worker running, tasks on the task queue will sit there unfinished
-forever. To run a celery worker, pass the `--worker` argument to the `server.py`
-script, like this:
-
-```
-python server.py --worker
-```
-
-Note that if you're not using redis, you don't need to run a Celery worker,
-because the tasks will be run immediately rather than put on the task queue.
-
-## Run the Example
-
-While the Celery worker is running, open a new terminal window and run the
-Flask web server, like this:
-
-```
-python server.py
-```
-
-You should see the ngrok URL in the console, and the web server will start
-on port 5000.
-
-## Set the Nylas Callback URL
-
-Now that your webhook is all set up and running, you need to tell
-Nylas about it. On the [Nylas Developer](https://developer.nylas.com) console,
-click on the "Webhooks" tab on the left side, then click the "Add Webhook"
-button.
-Paste your HTTPS URL into text field, and add `/webhook`
-after it. For example, if your HTTPS URL is `https://ad172180.ngrok.io`, then
-you would put `https://ad172180.ngrok.io/webhook` into the text field.
-
-Then click the "Create Webhook" button to save.
-
-## Trigger events and see webhook notifications!
-
-Send an email on an account that's connected to Nylas. In a minute or two,
-you'll get a webhook notification with information about the event that just
-happened!
-
-If you're using redis, you should see the information about the event in the
-terminal window where your Celery worker is running. If you're not using
-redis, you should see the information about the event in the terminal window
-where your Flask web server is running.
diff --git a/examples-new/webhooks/config.json b/examples-new/webhooks/config.json
deleted file mode 100644
index 04e9a64..0000000
--- a/examples-new/webhooks/config.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "SECRET_KEY": "replace me with a random string",
-  "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas",
-  "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas",
-  "CELERY_BROKER_URL": "redis://localhost",
-  "CELERY_TASK_ALWAYS_EAGER": false
-}
diff --git a/examples-new/webhooks/requirements.txt b/examples-new/webhooks/requirements.txt
deleted file mode 100644
index 38e9017..0000000
--- a/examples-new/webhooks/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-Flask>=0.11
-celery[redis]>=4.0.0
-requests
diff --git a/examples-new/webhooks/server.py b/examples-new/webhooks/server.py
deleted file mode 100755
index 9d4b77d..0000000
--- a/examples-new/webhooks/server.py
+++ /dev/null
@@ -1,195 +0,0 @@
-#!/usr/bin/env python
-
-# Imports from the Python standard library
-from __future__ import print_function
-import os
-import sys
-import datetime
-import textwrap
-import hmac
-import hashlib
-
-# Imports from third-party modules that this project depends on
-try:
-    import requests
-    from flask import Flask, request, render_template
-    from werkzeug.contrib.fixers import ProxyFix
-    from celery import Celery
-except ImportError:
-    message = textwrap.dedent("""
-        You need to install the dependencies for this project.
-        To do so, run this command:
-
-            pip install -r requirements.txt
-    """)
-    print(message, file=sys.stderr)
-    sys.exit(1)
-
-# This example uses Flask, a micro web framework written in Python.
-# For more information, check out the documentation: http://flask.pocoo.org
-# Create a Flask app, and load the configuration file.
-app = Flask(__name__)
-app.config.from_json('config.json')
-
-# Check for dummy configuration values.
-# If you are building your own application based on this example,
-# you can remove this check from your code.
-cfg_needs_replacing = [
-    key for key, value in app.config.items()
-    if isinstance(value, str) and value.startswith("replace me")
-]
-if cfg_needs_replacing:
-    message = textwrap.dedent("""
-        This example will only work if you replace the fake configuration
-        values in `config.json` with real configuration values.
-        The following config values need to be replaced:
-        {keys}
-        Consult the README.md file in this directory for more information.
-    """).format(keys=", ".join(cfg_needs_replacing))
-    print(message, file=sys.stderr)
-    sys.exit(1)
-
-# Teach Flask how to find out that it's behind an ngrok proxy
-app.wsgi_app = ProxyFix(app.wsgi_app)
-
-# This example also uses Celery, a task queue framework written in Python.
-# For more information, check out the documentation: http://docs.celeryproject.org
-# Create a Celery instance, and load its configuration from Flask.
-celery = Celery(app.import_name)
-celery.config_from_object(app.config, namespace='CELERY')
-
-
-@app.route('/webhook', methods=['GET', 'POST'])
-def webhook():
-    """
-    When the Flask server gets a request at the `/webhook` URL, it will run
-    this function. Most of the time, that request will be a genuine webhook
-    notification from Nylas. However, it's possible that the request could
-    be a fake notification from someone else, trying to fool our app. This
-    function needs to verify that the webhook is genuine!
-    """
-    # When you first tell Nylas about your webhook, it will test that webhook
-    # URL with a GET request to make sure that it responds correctly.
-    # We just need to return the `challenge` parameter to indicate that this
-    # is a valid webhook URL.
-    if request.method == "GET" and "challenge" in request.args:
-        print(" * Nylas connected to the webhook!")
-        return request.args["challenge"]
-
-    # Alright, this is a POST request, which means it's a webhook notification.
-    # The question is, is it genuine or fake? Check the signature to find out.
-    is_genuine = verify_signature(
-        message=request.data,
-        key=app.config["NYLAS_OAUTH_CLIENT_SECRET"].encode('utf8'),
-        signature=request.headers.get('X-Nylas-Signature'),
-    )
-    if not is_genuine:
-        return "Signature verification failed!", 401
-
-    # Alright, we have a genuine webhook notification from Nylas!
-    # Let's find out what it says...
-    data = request.get_json()
-    for delta in data["deltas"]:
-        # Processing the data might take awhile, or it might fail.
-        # As a result, instead of processing it right now, we'll push a task
-        # onto the Celery task queue, to handle it later. That way,
-        # we've got the data saved, and we can return a response to the
-        # Nylas webhook notification right now.
-        process_delta.delay(delta)
-
-    # Now that all the `process_delta` tasks have been queued, we can
-    # return an HTTP response to Nylas, to let them know that we processed
-    # the webhook notification successfully.
-    return "Deltas have been queued", 200
-
-
-def verify_signature(message, key, signature):
-    """
-    This function will verify the authenticity of a digital signature.
-    For security purposes, Nylas includes a digital signature in the headers
-    of every webhook notification, so that clients can verify that the
-    webhook request came from Nylas and no one else. The signing key
-    is your OAuth client secret, which only you and Nylas know.
-    """
-    digest = hmac.new(key, msg=message, digestmod=hashlib.sha256).hexdigest()
-    return digest == signature
-
-
-@celery.task
-def process_delta(delta):
-    """
-    This is the part of the code where you would process the information
-    from the webhook notification. Each delta is one change that happened,
-    and might require fetching message IDs, updating your database,
-    and so on.
-
-    However, because this is just an example project, we'll just print
-    out information about the notification, so you can see what
-    information is being sent.
-    """
-    kwargs = {
-        "type": delta["type"],
-        "date": datetime.datetime.fromtimestamp(delta["date"]),
-        "object_id": delta["object_data"]["id"],
-    }
-    print(" * {type} at {date} with ID {object_id}".format(**kwargs))
-
-
-@app.route("/")
-def index():
-    """
-    This makes sure that when you visit the root of the website,
-    you get a webpage rather than a 404 error.
-    """
-    return render_template("index.html", ngrok_url=ngrok_url())
-
-
-def ngrok_url():
-    """
-    If ngrok is running, it exposes an API on port 4040. We can use that
-    to figure out what URL it has assigned, and suggest that to the user.
-    https://ngrok.com/docs#list-tunnels
-    """
-    try:
-        ngrok_resp = requests.get("http://localhost:4040/api/tunnels")
-    except requests.ConnectionError:
-        # I guess ngrok isn't running.
-        return None
-    ngrok_data = ngrok_resp.json()
-    secure_urls = [
-        tunnel['public_url'] for tunnel in ngrok_data['tunnels']
-        if tunnel['proto'] == 'https'
-    ]
-    return secure_urls[0]
-
-
-# When this file is executed, this block of code will run.
-if __name__ == "__main__":
-    if len(sys.argv) > 1 and sys.argv[1] == "--worker":
-        # Run the celery worker, *instead* of running the Flask web server.
-        celery.worker_main(sys.argv[1:])
-        sys.exit()
-
-    # If we get here, we're going to try to run the Flask web server.
-    url = ngrok_url()
-    if not url:
-        print(
-            "Looks like ngrok isn't running! Start it by running "
-            "`ngrok http 5000` in a different terminal window, "
-            "and then try running this example again.",
-            file=sys.stderr,
-        )
-        sys.exit(1)
-
-    print(" * Webhook URL: {url}/webhook".format(url=url))
-
-    if app.config.get("CELERY_TASK_ALWAYS_EAGER"):
-        print(" * Celery tasks will be run synchronously. No worker needed.")
-    elif len(celery.control.inspect().stats().keys()) < 2:
-        print(
-            " * You need to run at least one Celery worker, otherwise "
-            "the webhook notifications will never be processed.\n"
-            "   To do so, run `{arg0} --worker` in a different "
-            "terminal window.".format(arg0=sys.argv[0])
-        )
-    app.run()
diff --git a/examples-new/webhooks/templates/base.html b/examples-new/webhooks/templates/base.html
deleted file mode 100644
index 3d6ac8a..0000000
--- a/examples-new/webhooks/templates/base.html
+++ /dev/null
@@ -1,14 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <title>Nylas Webhook Example</title>
-    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
-  </head>
-  <body>
-    <div class="container">
-      <h1>Nylas Webhook Example</h1>
-      {% block body %}{% endblock %}
-    </div>
-  </body>
-</html>
diff --git a/examples-new/webhooks/templates/index.html b/examples-new/webhooks/templates/index.html
deleted file mode 100644
index 5069360..0000000
--- a/examples-new/webhooks/templates/index.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html" %}
-{% block body %}
-<p>This example doesn't have anything to see in the browser. Set up your
-   webhook on the
-   <a href="https://developer.nylas.com">Nylas Developer console</a>,
-   and then watch your terminal to see the webhook notifications come in.
-</p>
-
-<p>Your webhook URL is:
-  <code>{{ ngrok_url }}{{ url_for("webhook") }}</code>
-</p>
-
-<p>Once you've received at least one webhook notification from Nylas,
-   you might want to check out the
-   <a href="http://localhost:4040/">ngrok web interface</a>.
-   That will allow you to see more information about the webhook notification,
-   and replay it for testing purposes if you want.
-</p>
-{% endblock %}
diff --git a/nylas/client/client.py b/nylas/client/client.py
index 91de992..4674bcd 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/test_client.py b/tests/test_client.py
index 2adce17..53669e7 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