Repository: box/box-python-sdk
Pull request #324 (2018-08-20 20:17:46)

Webhooks v2

boxcla: Hi @carycheng, thanks for the pull request. Before we can merge it, we need you to sign our Contributor License Agreement. You can do so electronically here: http://opensource.box.com/cla

Once you have signed, just add a comment to this pull request saying, "CLA signed". Thanks!

Patch:
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index b304231..177887e 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -511,6 +511,88 @@ def get_groups(self, name=None, limit=None, offset=None, fields=None):
             return_full_pages=False,
         )
 
+    def webhook(self, webhook_id):
+        """
+        Initialize a :class:`Webhook` object, whose box id is webhook_id.
+
+        :param webhook_id:
+            The box ID of the :class: `Webhook` object.
+        :type webhook_id:
+            `unicode`
+        :return:
+            A :class:`Webhook` object with the given entry ID.
+        :rtype:
+            :class:`Webhook`
+        """
+        return self.translator.get('webhook')(session=self._session, object_id=webhook_id)
+
+    def create_webhook(self, target, triggers, address):
+        """
+        Create a webhook on the given file.
+
+        :param target:
+            Either a :class:`File` or :class:`Folder` to assign a webhook to.
+        :type target:
+            :class:`File` or :class`Folder`
+        :param triggers:
+            Event types that trigger notifications for the target.
+        :type triggers:
+            `list` of `unicode`
+        :param address:
+            The url to send the notification to.
+        :type address:
+            `unicode`
+        :return:
+            A :class:`Webhook` object with the given entry ID.
+        :rtype:
+            :class:`Webhook`
+        """
+        url = self.get_url('webhooks')
+        webhook_attributes = {
+            'target': {
+                'type': target.object_type,
+                'id': target.object_id,
+            },
+            'triggers': triggers,
+            'address': address,
+        }
+        box_response = self._session.post(url, data=json.dumps(webhook_attributes))
+        response = box_response.json()
+        return self.translator.translate(response['type'])(
+            session=self._session,
+            object_id=response['id'],
+            response_object=response,
+        )
+
+    def get_webhooks(self, limit=None, marker=None, fields=None):
+        """
+        Get all webhooks in an enterprise.
+
+        :param limit:
+            The maximum number of entries to return.
+        :type limit:
+            `int` or None
+        :param marker:
+            The position marker at which to begin the response.
+        :type marker:
+            `unicode` or None
+        :param fields:
+            List of fields to request on the file or folder which the `RecentItem` references.
+        :type fields:
+            `Iterable` of `unicode`
+        :returns:
+            An iterator of the entries in the webhook
+        :rtype:
+            :class:`BoxObjectCollection`
+        """
+        return MarkerBasedObjectCollection(
+            session=self._session,
+            url=self.get_url('webhooks'),
+            limit=limit,
+            marker=marker,
+            fields=fields,
+        )
+
     @api_call
     def create_group(
             self,
diff --git a/boxsdk/object/__init__.py b/boxsdk/object/__init__.py
index ebdcd6c..a58ca02 100644
--- a/boxsdk/object/__init__.py
+++ b/boxsdk/object/__init__.py
@@ -27,6 +27,7 @@
     'task',
     'task_assignment',
     'user',
+    'webhook',
     'watermark',
     'web_link',
 ]))
diff --git a/boxsdk/object/webhook.py b/boxsdk/object/webhook.py
new file mode 100644
index 0000000..c1b9fb6
--- /dev/null
+++ b/boxsdk/object/webhook.py
@@ -0,0 +1,10 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+from .base_object import BaseObject
+
+
+class Webhook(BaseObject):
+    """Represents a Box Webhook."""
+
+    _item_type = 'webhook'
diff --git a/docs/webhook.md b/docs/webhook.md
new file mode 100644
index 0000000..7463bce
--- /dev/null
+++ b/docs/webhook.md
@@ -0,0 +1,70 @@
+Webhooks
+========
+
+Webhooks enable you to attach event triggers to Box files and folders. Event triggers monitor events on Box objects and notify your application when they occur. A webhook notifies your application by sending HTTP requests to a URL of your choosing.
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+
+- [Get Information about Webhook](#get-information-about-webhook)
+- [List all Webhooks](#list-all-webhooks)
+- [Create Webhook](#create-webhook)
+- [Delete Webhook](#delete-webhook)
+- [Update Webhook](#update-webhook)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+Get Information about Webhook
+-----------------------------
+
+To retrieve information about a webhook, use `webhook.get(fields=None)`
+
+```python
+webhook_info = client.webhook('1234').get()
+```
+
+List all Webhooks
+-----------------
+
+To retrieve an iterable of all webhooks in the enterprise, use `client.get_webhooks(limit=None, marker=None, fields=None)`
+
+```python
+webhooks = client.get_webhooks()
+for webhook in webhooks:
+    # Do something
+```
+
+Create Webhook
+--------------
+
+To create a webhook on a specified target, use `client.create_webhook(target, triggers, address)`
+
+You can create a webhook on either a `file` or a `folder`. For a full list of triggers, see [here](https://developer.box.com/v2.0/reference#webhooks-v2)
+
+```python
+folder = client.folder('1111')
+created_webhook = client.create_webhook(folder, ['FILE.UPLOADED', 'FILE.PREVIEWED'], 'https://example.com')
+```
+
+Delete Webhook
+--------------
+
+To delete a webhook, use `webhook.delete()`
+
+```python
+client.webhook('1234').delete()
+```
+
+Update Webhook
+--------------
+
+To update a webhook, use `webhook.update_info(data)`
+
+```python
+update_object = {
+    triggers: ['FILE.COPIED'],
+    address: 'https://newexample.com',
+}
+updated_webhook = client.webhook('1234').update_info(update_object)
+```


Test patch:
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index b9c7af1..3735297 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -27,6 +27,7 @@
 from boxsdk.object.retention_policy import RetentionPolicy
 from boxsdk.object.file_version_retention import FileVersionRetention
 from boxsdk.object.legal_hold_policy import LegalHoldPolicy
+from boxsdk.object.webhook import Webhook
 from boxsdk.pagination.marker_based_object_collection import MarkerBasedObjectCollection
 
 
@@ -65,6 +66,34 @@ def folder_id():
     return '1022'
 
 
+@pytest.fixture()
+def test_folder(mock_box_session, mock_object_id):
+    return Folder(mock_box_session, mock_object_id)
+
+
+@pytest.fixture()
+def test_webhook(mock_box_session, mock_object_id):
+    return Webhook(mock_box_session, mock_object_id)
+
+
+@pytest.fixture(scope='function')
+def mock_file_response(mock_object_id, make_mock_box_request):
+    # pylint:disable=redefined-outer-name
+    mock_box_response, _ = make_mock_box_request(
+        response={'type': 'file', 'id': mock_object_id},
+    )
+    return mock_box_response
+
+
+@pytest.fixture(scope='function')
+def mock_folder_response(mock_object_id, make_mock_box_request):
+    # pylint:disable=redefined-outer-name
+    mock_box_response, _ = make_mock_box_request(
+        response={'type': 'folder', 'id': mock_object_id},
+    )
+    return mock_box_response
+
+
 @pytest.fixture(scope='module')
 def marker_id():
     return 'marker_1'
@@ -207,6 +236,35 @@ def create_user_response():
     return mock_network_response
 
 
+@pytest.fixture(params=('file', 'folder'))
+def test_item_and_response(mock_file, test_folder, mock_file_response, mock_folder_response, request):
+    if request.param == 'file':
+        return mock_file, mock_file_response
+    return test_folder, mock_folder_response
+
+
+@pytest.fixture()
+def create_webhook_response(test_item_and_response, test_webhook):
+    # pylint:disable=redefined-outer-name
+    test_item, _ = test_item_and_response
+    mock_network_response = Mock(DefaultNetworkResponse)
+    mock_network_response.json.return_value = {
+        'type': test_webhook.object_type,
+        'id': test_webhook.object_id,
+        'target': {
+            'type': test_item.object_type,
+            'id': test_item.object_id,
+        },
+        'created_at': '2016-05-09T17:41:27-07:00',
+        'address': 'https://test.com',
+        'triggers': [
+            'FILE.UPLOADED',
+            'FOLDER.CREATED',
+        ],
+    }
+    return mock_network_response
+
+
 @pytest.fixture(scope='module', params=('file', 'folder'))
 def shared_item_response(request):
     # pylint:disable=redefined-outer-name
@@ -526,6 +584,71 @@ def test_create_enterprise_user_returns_the_correct_user_object(mock_client, moc
     assert new_user.name == test_user_name
 
 
+def test_webhook_initializer(mock_client):
+    expected_id = '1234'
+    webhook = mock_client.webhook(expected_id)
+    assert isinstance(webhook, Webhook)
+    assert webhook.object_id == expected_id
+
+
+def test_create_webhook_returns_the_correct_policy_object(
+        test_item_and_response,
+        test_webhook,
+        mock_client,
+        mock_box_session,
+        create_webhook_response,
+):
+    # pylint:disable=redefined-outer-name
+    test_item, _ = test_item_and_response
+    expected_url = "{0}/webhooks".format(API.BASE_API_URL)
+    expected_body = {
+        'target': {
+            'type': test_item.object_type,
+            'id': test_item.object_id,
+        },
+        'triggers': ['FILE.UPLOADED', 'FOLDER.CREATED'],
+        'address': 'https://test.com',
+    }
+    value = json.dumps(expected_body)
+    mock_box_session.post.return_value = create_webhook_response
+    new_webhook = mock_client.create_webhook(test_item, ['FILE.UPLOADED', 'FOLDER.CREATED'], 'https://test.com')
+    mock_box_session.post.assert_called_once_with(
+        expected_url,
+        data=value,
+    )
+    assert isinstance(new_webhook, Webhook)
+    assert new_webhook.id == test_webhook.object_id
+    assert new_webhook.type == test_webhook.object_type
+    assert new_webhook.target['type'] == test_item.object_type
+    assert new_webhook.target['id'] == test_item.object_id
+    assert new_webhook.triggers == ['FILE.UPLOADED', 'FOLDER.CREATED']
+    assert new_webhook.address == 'https://test.com'
+
+
+def test_get_webhooks(mock_client, mock_box_session):
+    expected_url = "{0}/webhooks".format(API.BASE_API_URL)
+    webhook_body = {
+        'type': 'webhook',
+        'id': '12345',
+        'target': {
+            'type': 'folder',
+            'id': '11111',
+        },
+    }
+    mock_box_session.get.return_value.json.return_value = {
+        'limit': 100,
+        'entries': [webhook_body],
+    }
+    webhooks = mock_client.get_webhooks()
+    webhook = webhooks.next()
+    mock_box_session.get.assert_called_once_with(expected_url, params={})
+    assert isinstance(webhook, Webhook)
+    assert webhook.object_id == webhook_body['id']
+    assert webhook.object_type == webhook_body['type']
+    assert webhook.target['id'] == webhook_body['target']['id']
+    assert webhook.target['type'] == webhook_body['target']['type']
+
+
 def test_create_retention_policy(mock_client, mock_box_session, mock_user_list):
     policy_name = 'Test Retention Policy'
     policy_type = 'finite'
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index e2f1259..201685d 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -22,6 +22,7 @@
 from boxsdk.object.retention_policy import RetentionPolicy
 from boxsdk.object.retention_policy_assignment import RetentionPolicyAssignment
 from boxsdk.object.search import Search
+from boxsdk.object.webhook import Webhook
 from boxsdk.object.task import Task
 from boxsdk.object.task_assignment import TaskAssignment
 from boxsdk.object.web_link import WebLink
@@ -141,6 +142,11 @@ def test_search(mock_box_session):
     return Search(mock_box_session)
 
 
+@pytest.fixture()
+def test_webhook(mock_box_session, mock_object_id):
+    return Webhook(mock_box_session, mock_object_id)
+
+
 @pytest.fixture()
 def test_task(mock_box_session, mock_object_id):
     return Task(mock_box_session, mock_object_id)
diff --git a/test/unit/object/test_webhook.py b/test/unit/object/test_webhook.py
new file mode 100644
index 0000000..43be8dd
--- /dev/null
+++ b/test/unit/object/test_webhook.py
@@ -0,0 +1,70 @@
+# coding: utf-8
+from __future__ import unicode_literals
+import json
+import pytest
+
+from mock import Mock
+from boxsdk.config import API
+from boxsdk.network.default_network import DefaultNetworkResponse
+from boxsdk.object.webhook import Webhook
+
+
+@pytest.fixture(scope='module')
+def delete_webhook_response():
+    # pylint:disable=redefined-outer-name
+    mock_network_response = Mock(DefaultNetworkResponse)
+    mock_network_response.ok = True
+    return mock_network_response
+
+
+def test_delete_webhook_return_the_correct_response(
+        test_webhook,
+        mock_box_session,
+        delete_webhook_response,
+):
+    # pylint:disable=redefined-outer-name
+    mock_box_session.delete.return_value = delete_webhook_response
+    response = test_webhook.delete()
+    expected_url = '{0}/webhooks/{1}'.format(API.BASE_API_URL, test_webhook.object_id)
+    mock_box_session.delete.assert_called_once_with(expected_url, params={}, expect_json_response=False, headers=None)
+    assert response is True
+
+
+def test_get(test_webhook, mock_box_session):
+    expected_url = '{0}/webhooks/{1}'.format(API.BASE_API_URL, test_webhook.object_id)
+    mock_box_session.get.return_value.json.return_value = {
+        'type': test_webhook.object_type,
+        'id': test_webhook.object_id,
+        'created_at': '2016-05-04T18:51:45-07:00',
+        'address': 'https://example.com',
+        'triggers': ['FILE.UPLOADED'],
+    }
+    webhook = test_webhook.get()
+    mock_box_session.get.assert_called_once_with(expected_url, headers=None, params=None)
+    assert isinstance(webhook, Webhook)
+    assert webhook.object_type == test_webhook.object_type
+    assert webhook.object_id == test_webhook.object_id
+    assert webhook.created_at == '2016-05-04T18:51:45-07:00'
+    assert webhook.address == 'https://example.com'
+    assert webhook.triggers == ['FILE.UPLOADED']
+
+
+def test_update(test_webhook, mock_box_session):
+    expected_url = '{0}/webhooks/{1}'.format(API.BASE_API_URL, test_webhook.object_id)
+    mock_box_session.put.return_value.json.return_value = {
+        'type': test_webhook.object_type,
+        'id': test_webhook.object_id,
+        'address': 'https://testnotification.com',
+        'triggers': ['FILE.DOWNLOADED']
+    }
+    data = {
+        'address': 'https://testnotification.com',
+        'triggers': ['FILE.DOWNLOADED'],
+    }
+    webhook = test_webhook.update_info(data)
+    mock_box_session.put.assert_called_once_with(expected_url, data=json.dumps(data), headers=None, params=None)
+    assert isinstance(webhook, Webhook)
+    assert webhook.type == test_webhook.object_type
+    assert webhook.id == test_webhook.object_id
+    assert webhook.address == 'https://testnotification.com'
+    assert webhook.triggers == ['FILE.DOWNLOADED']