Repository: StackStorm/st2

Issue:
API returns 400 for some requests due to "bad" Content-type header
It seems that _some_ API endpoints will return a HTTP 400 when presented with a header such as:

```
Content-type: application/json; charset=utf-8
```

but works fine with:

```
Content-type: application/json
```

I ran into this with a POST to the URL `/webhooks/st2`. I'm currently running version `1.4dev-52`



Discussion:
Thanks for the report. Seems fixable :)

Should be a simple enough fix https://github.com/StackStorm/st2/blob/master/st2api/st2api/controllers/v1/webhooks.py#L119

Care to send a PR our way?

Unfortunately, since the problem is no longer standing in my way, I lack the motivation to put together a PR (right now). You're lucky you got the bug report out of me :)

>  You're lucky you got the bug report out of me :)

We certainly appreciate the bug report.

@agaffney Good catch and thanks for the report.

I will add a test case and push a fix later today.


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
@@ -33,6 +33,7 @@
 import st2common.services.triggers as trigger_service
 from st2common.services.triggerwatcher import TriggerWatcher
 from st2common.transport.reactor import TriggerDispatcher
+from st2common.util.http import parse_content_type_header
 from st2common.rbac.types import PermissionType
 from st2common.rbac.decorators import request_user_has_webhook_permission
 
@@ -84,6 +85,7 @@ def post(self, *args, **kwargs):
         # Note: For backward compatibility reasons we default to application/json if content
         # type is not explicitly provided
         content_type = pecan.request.headers.get('Content-Type', 'application/json')
+        content_type = parse_content_type_header(content_type=content_type)[0]
         body = pecan.request.body
 
         try:
diff --git a/st2common/st2common/util/http.py b/st2common/st2common/util/http.py
--- a/st2common/st2common/util/http.py
+++ b/st2common/st2common/util/http.py
@@ -17,8 +17,43 @@
 
 http_client = six.moves.http_client
 
+__all__ = [
+    'HTTP_SUCCESS',
+    'parse_content_type_header'
+]
+
 HTTP_SUCCESS = [http_client.OK, http_client.CREATED, http_client.ACCEPTED,
                 http_client.NON_AUTHORITATIVE_INFORMATION, http_client.NO_CONTENT,
                 http_client.RESET_CONTENT, http_client.PARTIAL_CONTENT,
                 http_client.MULTI_STATUS, http_client.IM_USED,
                 ]
+
+
+def parse_content_type_header(content_type):
+    """
+    Parse and normalize request content type and return a tuple with the content type and the
+    options.
+
+    :rype: ``tuple``
+    """
+    if ';' in content_type:
+        split = content_type.split(';')
+        media = split[0]
+        options = {}
+
+        for pair in split[1:]:
+            split_pair = pair.split('=', 1)
+
+            if len(split_pair) != 2:
+                continue
+
+            key = split_pair[0].strip()
+            value = split_pair[1].strip()
+
+            options[key] = value
+    else:
+        media = content_type
+        options = {}
+
+    result = (media, options)
+    return result


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
@@ -119,6 +119,17 @@ def test_json_request_body(self, dispatch_mock):
         self.assertEqual(dispatch_mock.call_args[1]['payload']['body'], data)
         self.assertEqual(dispatch_mock.call_args[1]['trace_context'].trace_tag, 'tag1')
 
+        # 2. Send JSON using application/json + charset content type
+        data = WEBHOOK_1
+        headers = {'St2-Trace-Tag': 'tag1', 'Content-Type': 'application/json; charset=utf-8'}
+        post_resp = self.__do_post('git', data,
+                                   headers=headers)
+        self.assertEqual(post_resp.status_int, http_client.ACCEPTED)
+        self.assertEqual(dispatch_mock.call_args[1]['payload']['headers']['Content-Type'],
+                        'application/json; charset=utf-8')
+        self.assertEqual(dispatch_mock.call_args[1]['payload']['body'], data)
+        self.assertEqual(dispatch_mock.call_args[1]['trace_context'].trace_tag, 'tag1')
+
         # 3. JSON content type, invalid JSON body
         data = 'invalid'
         headers = {'St2-Trace-Tag': 'tag1', 'Content-Type': 'application/json'}
diff --git a/st2common/tests/unit/test_util_http.py b/st2common/tests/unit/test_util_http.py
new file mode 100644
--- /dev/null
+++ b/st2common/tests/unit/test_util_http.py
@@ -0,0 +1,36 @@
+#
+#     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.
+
+import unittest2
+
+from st2common.util.http import parse_content_type_header
+
+__all__ = [
+    'HTTPUtilTestCase'
+]
+
+
+class HTTPUtilTestCase(unittest2.TestCase):
+    def test_parse_content_type_header(self):
+        values = [
+            'application/json',
+            'foo/bar',
+            'application/json; charset=utf-8',
+            'application/json; charset=utf-8; foo=bar',
+        ]
+        expected_results = [
+            ('application/json', {}),
+            ('foo/bar', {}),
+            ('application/json', {'charset': 'utf-8'}),
+            ('application/json', {'charset': 'utf-8', 'foo': 'bar'})
+        ]
+
+        for value, expected_result in zip(values, expected_results):
+            result = parse_content_type_header(content_type=value)
+            self.assertEqual(result, expected_result)