Repository: line/line-bot-sdk-python
Pull request #300 (2020-10-13 14:37:45)

Support webhook setting APIs
Dear Maintainers,

This PR implemented the following three APIs of webhook settings, which were also mentioned in #298 .

- Get webhook endpoint information
- Set webhook endpoint URL
- Test webhook endpoint

This is my first PR on this repository, tell me if I miss something. Also, if you please, label this PR as `hacktoberfest-accepted` .

louis70109: Hi @CA-Lee,
please add test cases.

Thanks for your contribution.🙂
CA-Lee: Thanks for instantly replying, test cases were added, and CI has passed.
louis70109: Please fix the comments.
CA-Lee: Excuse me, where the comments are...? I didn't see any comment in "files changed" tab.
louis70109: > Excuse me, where the comments are...? I didn't see any comment in "files changed" tab.

@CA-Lee sorry I forgot to push the review comment
CA-Lee: OK, I'm working on it
CA-Lee: All comments are fixed, CI has passed, too. I'll update the README.rst later.
CA-Lee: README.rst was updated, the commit's timestamp is wrong due to my computer problem. Thanks for your patiently read & reply.
CA-Lee: Could you please to label this PR as `hacktoberfest-accepted` ? I'll appreciate it. :)
louis70109: Hi @CA-Lee ,
could you fix the conflict information?

Thanks in advance.
CA-Lee: Done. :)

Patch:
diff --git a/README.rst b/README.rst
index eff72b9..6ca5010 100644
--- a/README.rst
+++ b/README.rst
@@ -610,6 +610,46 @@ https://developers.line.biz/en/reference/messaging-api/#get-bot-info
     print(bot_info.chat_mode)
     print(bot_info.mark_as_read_mode)
 
+set\_webhook\_endpoint(self, webhook_endpoint, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Set the webhook endpoint URL.
+
+https://developers.line.biz/en/reference/messaging-api/#set-webhook-endpoint-url
+
+.. code:: python
+
+    line_bot_api.set_webhook_endpoint(<webhook_endpoint_URL>)
+
+get\_webhook\_endpoint(self, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Get information on a webhook endpoint.
+
+https://developers.line.biz/en/reference/messaging-api/#get-webhook-endpoint-information
+
+.. code:: python
+
+    webhook = line_bot_api.get_webhook_endpoint()
+    print(webhook.endpoint)
+    print(webhook.active)
+
+test\_webhook\_endpoint(self, webhook_endpoint=None, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Check if the configured webhook endpoint can receive a test webhook event.
+
+https://developers.line.biz/en/reference/messaging-api/#test-webhook-endpoint
+
+.. code:: python
+
+    test_result = line_bot_api.test_webhook_endpoint()
+    print(test_result.success)
+    print(test_result.timestamp)
+    print(test_result.status_code)
+    print(test_result.reason)
+    print(test_result.detail)
+
 ※ Error handling
 ^^^^^^^^^^^^^^^^^
 
diff --git a/linebot/api.py b/linebot/api.py
index 7edb8d9..9565c61 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -28,7 +28,7 @@ from .models import (
     MessageDeliveryPushResponse, MessageDeliveryReplyResponse,
     InsightMessageDeliveryResponse, InsightFollowersResponse, InsightDemographicResponse,
     InsightMessageEventResponse, BroadcastResponse, NarrowcastResponse,
-    MessageProgressNarrowcastResponse, BotInfo,
+    MessageProgressNarrowcastResponse, BotInfo, GetWebhookResponse, TestWebhookResponse,
 )
 from .models.responses import Group
 
@@ -1133,6 +1133,7 @@ class LineBotApi(object):
         """Get a bot's basic information.
 
         https://developers.line.biz/en/reference/messaging-api/#get-bot-info
+
         :param timeout: (optional) How long to wait for the server
             to send data before giving up, as a float,
             or a (connect timeout, read timeout) float tuple.
@@ -1147,6 +1148,76 @@ class LineBotApi(object):
 
         return BotInfo.new_from_json_dict(response.json)
 
+    def set_webhook_endpoint(self, webhook_endpoint, timeout=None):
+        """Set the webhook endpoint URL.
+
+        https://developers.line.biz/en/reference/messaging-api/#set-webhook-endpoint-url
+
+        :param str webhook_endpoint: A valid webhook URL to be set.
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: dict
+        :return: Empty dict.
+        """
+        data = {
+            'endpoint': webhook_endpoint
+        }
+
+        response = self._put(
+            '/v2/bot/channel/webhook/endpoint',
+            data=json.dumps(data),
+            timeout=timeout,
+        )
+
+        return response.json
+
+    def get_webhook_endpoint(self, timeout=None):
+        """Get information on a webhook endpoint.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-webhook-endpoint-information
+
+        :rtype: :py:class:`linebot.models.responses.GetWebhookResponse`
+        :return: Webhook information, including `endpoint` for webhook
+            URL and `active` for webhook usage status.
+        """
+        response = self._get(
+            '/v2/bot/channel/webhook/endpoint',
+            timeout=timeout,
+        )
+
+        return GetWebhookResponse.new_from_json_dict(response.json)
+
+    def test_webhook_endpoint(self, webhook_endpoint=None, timeout=None):
+        """Checks if the configured webhook endpoint can receive a test webhook event.
+
+        https://developers.line.biz/en/reference/messaging-api/#test-webhook-endpoint
+
+        :param webhook_endpoint: (optional) Set this parameter to
+            specific the webhook endpoint of the webhook. Default is the webhook
+            endpoint that is already set to the channel.
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.TestWebhookResponse`
+        """
+        data = {}
+
+        if webhook_endpoint is not None:
+            data['endpoint'] = webhook_endpoint
+
+        response = self._post(
+            '/v2/bot/channel/webhook/test',
+            data=json.dumps(data),
+            timeout=timeout,
+        )
+
+        return TestWebhookResponse.new_from_json_dict(response.json)
+
     def _get(self, path, endpoint=None, params=None, headers=None, stream=False, timeout=None):
         url = (endpoint or self.endpoint) + path
 
@@ -1189,6 +1260,20 @@ class LineBotApi(object):
         self.__check_error(response)
         return response
 
+    def _put(self, path, endpoint=None, data=None, headers=None, timeout=None):
+        url = (endpoint or self.endpoint) + path
+
+        if headers is None:
+            headers = {'Content-Type': 'application/json'}
+        headers.update(self.headers)
+
+        response = self.http_client.put(
+            url, headers=headers, data=data, timeout=timeout
+        )
+
+        self.__check_error(response)
+        return response
+
     @staticmethod
     def __check_error(response):
         if 200 <= response.status_code < 300:
diff --git a/linebot/http_client.py b/linebot/http_client.py
index b3e0568..2bfe5c9 100644
--- a/linebot/http_client.py
+++ b/linebot/http_client.py
@@ -92,6 +92,23 @@ class HttpClient(with_metaclass(ABCMeta)):
         """
         raise NotImplementedError
 
+    @abstractmethod
+    def put(self, url, headers=None, data=None, timeout=None):
+        """PUT request.
+
+        :param str url: Request url
+        :param dict headers: (optional) Request headers
+        :param data: (optional) Dictionary, bytes, or file-like object to send in the body
+        :param timeout: (optional), How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is :py:attr:`self.timeout`
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`RequestsHttpResponse`
+        :return: RequestsHttpResponse instance
+        """
+        raise NotImplementedError
+
 
 class RequestsHttpClient(HttpClient):
     """HttpClient implemented by requests."""
@@ -177,6 +194,29 @@ class RequestsHttpClient(HttpClient):
 
         return RequestsHttpResponse(response)
 
+    def put(self, url, headers=None, data=None, timeout=None):
+        """PUT request.
+
+        :param str url: Request url
+        :param dict headers: (optional) Request headers
+        :param data: (optional) Dictionary, bytes, or file-like object to send in the body
+        :param timeout: (optional), How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is :py:attr:`self.timeout`
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`RequestsHttpResponse`
+        :return: RequestsHttpResponse instance
+        """
+        if timeout is None:
+            timeout = self.timeout
+
+        response = requests.put(
+            url, headers=headers, data=data, timeout=timeout
+        )
+
+        return RequestsHttpResponse(response)
+
 
 class HttpResponse(with_metaclass(ABCMeta)):
     """HttpResponse."""
diff --git a/linebot/models/__init__.py b/linebot/models/__init__.py
index ce135c3..cbaf9da 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -152,6 +152,8 @@ from .responses import (  # noqa
     NarrowcastResponse,
     MessageProgressNarrowcastResponse,
     BotInfo,
+    GetWebhookResponse,
+    TestWebhookResponse,
 )
 from .rich_menu import (  # noqa
     RichMenu,
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index aa4613a..edde010 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -520,3 +520,49 @@ class BotInfo(Base):
         self.picture_url = picture_url
         self.chat_mode = chat_mode
         self.mark_as_read_mode = mark_as_read_mode
+
+
+class GetWebhookResponse(Base):
+    """Response of `get_webhook_endpoint()` .
+
+    https://developers.line.biz/en/reference/messaging-api/#get-webhook-endpoint-information
+    """
+
+    def __init__(self, endpoint=None, active=None, **kwargs):
+        """__init__ method.
+
+        :param str endpoint: The webhook endpoint URL.
+        :param bool active: Whether the webhook is in use.
+        :param kwargs:
+        """
+        super(GetWebhookResponse, self).__init__(**kwargs)
+
+        self.endpoint = endpoint
+        self.active = active
+
+
+class TestWebhookResponse(Base):
+    """Response of `test_webhook_endpoint()` .
+
+    https://developers.line.biz/en/reference/messaging-api/#test-webhook-endpoint
+    """
+
+    def __init__(self, success=None, timestamp=None, status_code=None,
+                 reason=None, detail=None, **kwargs):
+        """__init__ method.
+
+        :param bool success: Result of the communication from the LINE platform
+            to the webhook URL.
+        :param str timestamp: Timestamp
+        :param int status_code: The HTTP status code.
+        :param str reason: Reason for the response.
+        :param str detail: Details of the response.
+        :param kwargs:
+        """
+        super(TestWebhookResponse, self).__init__(**kwargs)
+
+        self.success = success
+        self.timestamp = timestamp
+        self.status_code = status_code
+        self.reason = reason
+        self.detail = detail


Test patch:
diff --git a/tests/api/test_get_webhook_endpoint.py b/tests/api/test_get_webhook_endpoint.py
new file mode 100644
index 0000000..ae69be6
--- /dev/null
+++ b/tests/api/test_get_webhook_endpoint.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+
+    @responses.activate
+    def test_get_webhook_endpoint(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/channel/webhook/endpoint',
+            json={
+                "endpoint": "https://example.herokuapp.com/test",
+                "active": True,
+            },
+            status=200
+        )
+
+        webhook = self.tested.get_webhook_endpoint()
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/channel/webhook/endpoint')
+        self.assertEqual(webhook.endpoint, 'https://example.herokuapp.com/test')
+        self.assertEqual(webhook.active, True)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_set_webhook_endpoint.py b/tests/api/test_set_webhook_endpoint.py
new file mode 100644
index 0000000..59574c6
--- /dev/null
+++ b/tests/api/test_set_webhook_endpoint.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+
+    @responses.activate
+    def test_set_webhook_endpoint(self):
+        responses.add(
+            responses.PUT,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/channel/webhook/endpoint',
+            json={},
+            status=200
+        )
+
+        result = self.tested.set_webhook_endpoint('endpoint')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'PUT')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/channel/webhook/endpoint')
+        self.assertEqual(result, {})
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_test_webhook_endpoint.py b/tests/api/test_test_webhook_endpoint.py
new file mode 100644
index 0000000..6bced6c
--- /dev/null
+++ b/tests/api/test_test_webhook_endpoint.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+
+    @responses.activate
+    def test_test_webhook_endpoint_with_endpoint(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/channel/webhook/test',
+            json={
+                "success": True,
+                "timestamp": "2020-09-30T05:38:20.031Z",
+                "statusCode": 200,
+                "reason": "OK",
+                "detail": "200"
+            },
+            status=200
+        )
+
+        result = self.tested.test_webhook_endpoint('endpoint')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/channel/webhook/test')
+        self.assertEqual(result.success, True)
+        self.assertEqual(result.timestamp, '2020-09-30T05:38:20.031Z')
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(result.reason, 'OK')
+        self.assertEqual(result.detail, '200')
+
+
+if __name__ == '__main__':
+    unittest.main()