Repository: brettcannon/gidgethub (python)

Issue:
Support the x-www-form-urlencoded content type for events
Webhooks support [2 content types](https://developer.github.com/webhooks/creating/#content-type) while gidgethub only supports one of them. Updating `gidgethub.sansio.Event.from_http()` to support both wouldn't be hard and would be potentially useful.

Pull request description:
Add support for www-form-urlencoded
Closes #30 

Patch:
diff --git a/README.rst b/README.rst
index 5c32ad2..05f97b9 100644
--- a/README.rst
+++ b/README.rst
@@ -58,10 +58,12 @@ cat's name, Gidget, as part of the name. Since "Gidget" somewhat sounds like
 Changelog
 ---------
 
-2.2.1 (in development)
+2.3.0 (in development)
 ''''''''''''''''''''''
 
-N/A
+- Add support for ``application/x-www-form-urlencoded`` webhook event payloads.
+  (This also allows for API calls to return this content type, although GitHub
+  currently does not do that.)
 
 
 2.2.0
diff --git a/docs/sansio.rst b/docs/sansio.rst
index cfead94..7f47ee6 100644
--- a/docs/sansio.rst
+++ b/docs/sansio.rst
@@ -65,10 +65,10 @@ without requiring the use of the :class:`Event` class.
 
       The *headers* mapping is expected to support lowercase keys.
 
-      Since this method assumes the body of the HTTP request is JSON, a check
-      is performed for a ``"content-type"`` header field of
-      ``"application/json"``. If the content type does not match,
-      :exc:`~gidgethub.BadRequest` is raised.
+      Since this method assumes the body of the HTTP request is only of the
+      [content type that GitHub sends](https://developer.github.com/webhooks/creating/#content-type),
+      :exc:`~gidgethub.BadRequest` is raised if the content type is
+      unexpected.
 
       If the appropriate headers are provided for event validation, then
       the *secret* argument is required. Any failure in validation
diff --git a/gidgethub/sansio.py b/gidgethub/sansio.py
index 841b756..be34ddc 100644
--- a/gidgethub/sansio.py
+++ b/gidgethub/sansio.py
@@ -35,6 +35,26 @@ def _parse_content_type(content_type):
         return type_, encoding
 
 
+def _decode_body(content_type, body, *, strict=False):
+    """Decode an HTTP body based on the specified content type.
+
+    If 'strict' is true, then raise ValueError if the content type
+    is not recognized. Otherwise simply returned the body as a decoded
+    string.
+    """
+    type_, encoding = _parse_content_type(content_type)
+    if not len(body) or not content_type:
+        return None
+    decoded_body = body.decode(encoding)
+    if type_ == "application/json":
+        return json.loads(decoded_body)
+    elif type_ == "application/x-www-form-urlencoded":
+        return json.loads(urllib.parse.parse_qs(decoded_body)["payload"][0])
+    elif strict:
+        raise ValueError(f"unrecognized content type: {type_!r}")
+    return decoded_body
+
+
 def validate_event(payload, *, signature, secret):
     """Validate the signature of a webhook event."""
     # https://developer.github.com/webhooks/securing/#validating-payloads-from-github
@@ -80,18 +100,21 @@ class Event:
         (including not providing a secret) will lead to ValidationFailure being
         raised.
         """
-        type_, encoding = _parse_content_type(headers.get("content-type"))
-        if type_ != "application/json":
-            raise BadRequest(http.HTTPStatus(415), "expected a content-type of "
-                                             "'application/json'")
-        elif "x-hub-signature" in headers:
+        if "x-hub-signature" in headers:
                 if secret is None:
                     raise ValidationFailure("secret not provided")
                 validate_event(body, signature=headers["x-hub-signature"],
                                secret=secret)
         elif secret is not None:
             raise ValidationFailure("signature is missing")
-        data = _decode_body(headers["content-type"], body)
+
+        try:
+            data = _decode_body(headers["content-type"], body, strict=True)
+        except (KeyError, ValueError):
+            raise BadRequest(http.HTTPStatus(415),
+                             "expected a content-type of "
+                             "'application/json' or "
+                             "'application/x-www-form-urlencoded'")
         return cls(data, event=headers["x-github-event"],
                    delivery_id=headers["x-github-delivery"])
 
@@ -202,16 +225,6 @@ class RateLimit:
         return cls(limit=limit, remaining=remaining, reset_epoch=reset_epoch)
 
 
-def _decode_body(content_type, body):
-    type_, encoding = _parse_content_type(content_type)
-    if not len(body) or not content_type:
-        return None
-    decoded_body = body.decode(encoding)
-    if type_ == "application/json":
-        return json.loads(decoded_body)
-    return decoded_body
-
-
 _link_re = re.compile(r'\<(?P<uri>[^>]+)\>;\s*'
                       r'(?P<param_type>\w+)="(?P<param_value>\w+)"(,\s*)?')
 
diff --git a/setup.py b/setup.py
index 7a533cc..c4848fa 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ long_description = pathlib.Path("README.rst").read_text("utf-8")
 
 setuptools.setup(
     name="gidgethub",
-    version="2.2.1.dev1",
+    version="2.3.0.dev1",
     description="An async GitHub API library",
     long_description=long_description,
     url="https://gidgethub.readthedocs.io",


Test patch:
diff --git a/gidgethub/test/test_sansio.py b/gidgethub/test/test_sansio.py
index 701a3a5..f1cd787 100644
--- a/gidgethub/test/test_sansio.py
+++ b/gidgethub/test/test_sansio.py
@@ -2,6 +2,7 @@ import datetime
 import http
 import json
 import pathlib
+import urllib.parse
 
 import pytest
 
@@ -59,13 +60,24 @@ class TestEvent:
                            delivery_id=self.headers["x-github-delivery"])
         self.check_event(ins)
 
-    def test_from_http(self):
+    def test_from_http_json(self):
         """Construct an event from complete HTTP information."""
         event = sansio.Event.from_http(self.headers, self.data_bytes,
                                        secret=self.secret)
         self.check_event(event)
 
-    def test_from_http_not_json(self):
+    def test_from_http_urlencoded(self):
+        payload = {"payload": json.dumps(self.data)}
+        body = urllib.parse.urlencode(payload).encode("UTF-8")
+        headers = {
+            "content-type": "application/x-www-form-urlencoded",
+            "x-github-event": "pull_request",
+            "x-github-delivery": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
+        }
+        event = sansio.Event.from_http(headers, body)
+        self.check_event(event)
+
+    def test_from_http_no_content_type(self):
         """Only accept data when content-type is application/json."""
         headers_no_content_type = self.headers.copy()
         del headers_no_content_type["content-type"]
@@ -73,6 +85,16 @@ class TestEvent:
             sansio.Event.from_http(headers_no_content_type, self.data_bytes,
                                    secret=self.secret)
 
+    def test_from_http_unknown_content_type(self):
+        headers = headers = {
+            "content-type": "image/png",
+            "x-github-event": "pull_request",
+            "x-github-delivery": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
+        }
+        with pytest.raises(BadRequest):
+            sansio.Event.from_http(headers, self.data_bytes)
+        pass
+
     def test_from_http_missing_secret(self):
         """Signature but no secret raises ValidationFailure."""
         with pytest.raises(ValidationFailure):