Repository: StackStorm/st2

Issue:
Webhooks do not take array data as trigger.body
I have a webhook:
```
curl -X POST https://127.0.0.1/api/v1/webhooks/my/webhook?st2-api-key=apikey -H "Content-Type: application/json" --data '[{"data":"val"}]' -k
```

And I get this error:
```
TypeError: type object argument after ** must be a mapping, not list (_exception_data={},_exception_class='TypeError',_exception_message='type object argument after ** must be a mapping, not list')
2018-01-22 20:03:20,288 140714391321168 INFO logging [-] fcc8f336-09c3-4989-a29b-caeb398f1e8c - 500 46 8.433ms (content_length=46,request_id='fcc8f336-09c3-4989-a29b-caeb398f1e8c',runtime=8.433,remote_addr='127.0.0.1',status=500,method='POST',path='/v1/webhooks/my/webhook')
```

..I'm going to assume this means that a service cannot send list data to a webhook, is that correct?
If so, this is new for 2.5 as it worked in 2.4.  We have services that send lists..


Discussion:
Thanks for reporting this.

I believe the issue is related to some frameworks not supporting arrays as root objects because of potential issues (https://haacked.com/archive/2009/06/25/json-hijacking.aspx/), but this only applies to responses and not requests such as in this case, so I think it's reasonable to make a change and also support arrays for ingress.
@humblearner I checked the code and as far as I know, this is not regression - it seems like that was always the case.

Resolving patch:
diff --git a/st2api/st2api/controllers/v1/webhooks.py b/st2api/st2api/controllers/v1/webhooks.py
--- a/st2api/st2api/controllers/v1/webhooks.py
+++ b/st2api/st2api/controllers/v1/webhooks.py
@@ -30,6 +30,7 @@
 from st2common.transport.reactor import TriggerDispatcher
 from st2common.router import abort
 from st2common.router import Response
+from st2common.util.jsonify import get_json_type_for_python_value
 
 http_client = six.moves.http_client
 
@@ -114,8 +115,8 @@ def get_one(self, name, requester_user):
         # For demonstration purpose return 1st
         return triggers[0]
 
-    def post(self, hook, body, headers, requester_user):
-        body = vars(body)
+    def post(self, hook, webhook_body_api, headers, requester_user):
+        body = webhook_body_api.data
 
         permission_type = PermissionType.WEBHOOK_SEND
         rbac_utils.assert_user_has_resource_db_permission(user_db=requester_user,
@@ -128,6 +129,12 @@ def post(self, hook, body, headers, requester_user):
                                                    hook=hook)
 
         if hook == 'st2' or hook == 'st2/':
+            # When using st2 or system webhook, body needs to always be a dict
+            if not isinstance(body, dict):
+                type_string = get_json_type_for_python_value(body)
+                msg = ('Webhook body needs to be an object, got: %s' % (type_string))
+                raise ValueError(msg)
+
             trigger = body.get('trigger', None)
             payload = body.get('payload', None)
 
diff --git a/st2common/st2common/models/api/webhook.py b/st2common/st2common/models/api/webhook.py
new file mode 100644
--- /dev/null
+++ b/st2common/st2common/models/api/webhook.py
@@ -0,0 +1,34 @@
+# Licensed to the StackStorm, Inc ('StackStorm') under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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
+#
+#     http://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 st2common.models.api.base import BaseAPI
+
+__all___ = [
+    'WebhookBodyAPI'
+]
+
+
+class WebhookBodyAPI(BaseAPI):
+    schema = {
+        'type': 'object',
+        'properties': {
+            # Holds actual webhook body
+            'data': {
+                'type': ['object', 'array'],
+                'required': True
+            }
+        },
+        'additionalProperties': False
+    }
diff --git a/st2common/st2common/router.py b/st2common/st2common/router.py
--- a/st2common/st2common/router.py
+++ b/st2common/st2common/router.py
@@ -387,7 +387,17 @@ def __init__(self, **entries):
                             schema = resolved
 
                     if 'x-api-model' in schema:
+                        input_type = schema.get('type', [])
                         Model = op_resolver(schema['x-api-model'])
+
+                        if input_type and not isinstance(input_type, (list, tuple)):
+                            input_type = [input_type]
+
+                        # root attribute is not an object, we need to use wrapper attribute to
+                        # make it work with **kwarg expansion
+                        if input_type and 'array' in input_type:
+                            data = {'data': data}
+
                         instance = self._get_model_instance(model_cls=Model, data=data)
 
                         # Call validate on the API model - note we should eventually move all


Test patch:
diff --git a/st2api/tests/unit/controllers/v1/test_webhooks.py b/st2api/tests/unit/controllers/v1/test_webhooks.py
--- a/st2api/tests/unit/controllers/v1/test_webhooks.py
+++ b/st2api/tests/unit/controllers/v1/test_webhooks.py
@@ -181,10 +181,23 @@ def test_unsupported_content_type(self):
         self.assertTrue('Failed to parse request body' in post_resp)
         self.assertTrue('Unsupported Content-Type' in post_resp)
 
-    def test_custom_webhook_invalid_input_type(self):
-        post_resp = self.__do_post('sample', [{'foo': 'bar'}], expect_errors=True)
-        self.assertTrue('Input body needs to be an object, got: array' in post_resp)
+    @mock.patch.object(TriggerInstancePublisher, 'publish_trigger', mock.MagicMock(
+        return_value=True))
+    @mock.patch.object(WebhooksController, '_is_valid_hook', mock.MagicMock(
+        return_value=True))
+    @mock.patch.object(HooksHolder, 'get_triggers_for_hook', mock.MagicMock(
+        return_value=[DUMMY_TRIGGER]))
+    @mock.patch('st2common.transport.reactor.TriggerDispatcher.dispatch')
+    def test_custom_webhook_array_input_type(self, _):
+        post_resp = self.__do_post('sample', [{'foo': 'bar'}])
+        self.assertEqual(post_resp.status_int, http_client.ACCEPTED)
+        self.assertEqual(post_resp.json, [{'foo': 'bar'}])
+
+    def test_array_webhook_array_input_type_not_valid(self):
+        post_resp = self.__do_post('st2', [{'foo': 'bar'}], expect_errors=True)
         self.assertEqual(post_resp.status_int, http_client.BAD_REQUEST)
+        self.assertEqual(post_resp.json['faultstring'],
+                         'Webhook body needs to be an object, got: array')
 
     def test_leading_trailing_slashes(self):
         # Ideally the test should setup fixtures in DB. However, the triggerwatcher