Repository: sanic-org/sanic

Issue:
request.form uses urllib.parse_qs which does not persist blank values by default when requests submit application/x-www-form-urlencoded data
**Describe the bug**
This seems to be an acknowledged limitation of parse_qs, but I'm raising as a bug as this introduces undesired behaviour rather than adding new features.

While `request.get_args` and and `request.get_query_args` accept the `keep_blank_values` argument in order to persist blanks, `request.form` does not provide an interface to persist blank values.

My assertion is that a framework should not remove data simply because it doesn't have a value.

Relevant anecdote: Twilio webhooks submit a POST request using `application/x-www-form-urlencoded` encoded body. The body includes all values including some potentially blank/empty values.

When the request is processed by Sanic, it is decoded using `urllib.parse_qs` and does not have a mechanism to allow blank elements to persist. This results in missing elements from the request when accessing the variables from `request.form`.

In my case, the elements from the submission are relevant to generate a signature for request validation (see https://www.twilio.com/docs/usage/security). The signature is constructed using SHA1 hash of the submitted request key/value pairs and missing pairs generates a different signature to that expected.

In this instance, I have been receiving requests which include two elements that, depending on the hook, may or may not have a value.

**Expected behavior**
All variables, irrespective of existence of a value should be passed to the application.


**Environment (please complete the following information):**
 - Version v22.3.0


Discussion:
@pobk Thanks for the heads up. I agree that we should not be dropping blank values as the parameter in and of itself is a value that may be necessary.

Just popping here to say that changing its behaviour now is probably a breaking change.

A different approach to avoid sudden breaking changes could be adding a method like `get_query_args` but for `get_form_args`, to which users can pass arguments for the `parse_qs` method.

If we want to change the behaviour of the `.form` attribute, we could also add a warning when accessing the `.form` attribute warning users that from a certain version we will no longer omit blank values by default, although in that case we would probably want to do the same with any other attributes (e.g.: query strings) for consistence. 
This can be done as a non-breaking change where the behavior defaults to what it is currently, which should be fine. Already working on it, just need some tests before I submit a PR.

Resolving patch:
diff --git a/sanic/request.py b/sanic/request.py
--- a/sanic/request.py
+++ b/sanic/request.py
@@ -152,8 +152,8 @@ def __init__(
         self.parsed_accept: Optional[AcceptContainer] = None
         self.parsed_credentials: Optional[Credentials] = None
         self.parsed_json = None
-        self.parsed_form = None
-        self.parsed_files = None
+        self.parsed_form: Optional[RequestParameters] = None
+        self.parsed_files: Optional[RequestParameters] = None
         self.parsed_token: Optional[str] = None
         self.parsed_args: DefaultDict[
             Tuple[bool, bool, str, str], RequestParameters
@@ -426,28 +426,40 @@ def credentials(self) -> Optional[Credentials]:
                 pass
         return self.parsed_credentials
 
+    def get_form(
+        self, keep_blank_values: bool = False
+    ) -> Optional[RequestParameters]:
+        self.parsed_form = RequestParameters()
+        self.parsed_files = RequestParameters()
+        content_type = self.headers.getone(
+            "content-type", DEFAULT_HTTP_CONTENT_TYPE
+        )
+        content_type, parameters = parse_content_header(content_type)
+        try:
+            if content_type == "application/x-www-form-urlencoded":
+                self.parsed_form = RequestParameters(
+                    parse_qs(
+                        self.body.decode("utf-8"),
+                        keep_blank_values=keep_blank_values,
+                    )
+                )
+            elif content_type == "multipart/form-data":
+                # TODO: Stream this instead of reading to/from memory
+                boundary = parameters["boundary"].encode(  # type: ignore
+                    "utf-8"
+                )  # type: ignore
+                self.parsed_form, self.parsed_files = parse_multipart_form(
+                    self.body, boundary
+                )
+        except Exception:
+            error_logger.exception("Failed when parsing form")
+
+        return self.parsed_form
+
     @property
     def form(self):
         if self.parsed_form is None:
-            self.parsed_form = RequestParameters()
-            self.parsed_files = RequestParameters()
-            content_type = self.headers.getone(
-                "content-type", DEFAULT_HTTP_CONTENT_TYPE
-            )
-            content_type, parameters = parse_content_header(content_type)
-            try:
-                if content_type == "application/x-www-form-urlencoded":
-                    self.parsed_form = RequestParameters(
-                        parse_qs(self.body.decode("utf-8"))
-                    )
-                elif content_type == "multipart/form-data":
-                    # TODO: Stream this instead of reading to/from memory
-                    boundary = parameters["boundary"].encode("utf-8")
-                    self.parsed_form, self.parsed_files = parse_multipart_form(
-                        self.body, boundary
-                    )
-            except Exception:
-                error_logger.exception("Failed when parsing form")
+            self.get_form()
 
         return self.parsed_form
 


Test patch:
diff --git a/tests/test_requests.py b/tests/test_requests.py
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -1016,6 +1016,72 @@ async def handler(request):
     assert request.form.get("test") == "OK"  # For request.parsed_form
 
 
+def test_post_form_urlencoded_keep_blanks(app):
+    @app.route("/", methods=["POST"])
+    async def handler(request):
+        request.get_form(keep_blank_values=True)
+        return text("OK")
+
+    payload = "test="
+    headers = {"content-type": "application/x-www-form-urlencoded"}
+
+    request, response = app.test_client.post(
+        "/", data=payload, headers=headers
+    )
+
+    assert request.form.get("test") == ""
+    assert request.form.get("test") == ""  # For request.parsed_form
+
+
+@pytest.mark.asyncio
+async def test_post_form_urlencoded_keep_blanks_asgi(app):
+    @app.route("/", methods=["POST"])
+    async def handler(request):
+        request.get_form(keep_blank_values=True)
+        return text("OK")
+
+    payload = "test="
+    headers = {"content-type": "application/x-www-form-urlencoded"}
+
+    request, response = await app.asgi_client.post(
+        "/", data=payload, headers=headers
+    )
+
+    assert request.form.get("test") == ""
+    assert request.form.get("test") == ""  # For request.parsed_form
+
+
+
+def test_post_form_urlencoded_drop_blanks(app):
+    @app.route("/", methods=["POST"])
+    async def handler(request):
+        return text("OK")
+
+    payload = "test="
+    headers = {"content-type": "application/x-www-form-urlencoded"}
+
+    request, response = app.test_client.post(
+        "/", data=payload, headers=headers
+    )
+
+    assert "test" not in request.form.keys()
+
+@pytest.mark.asyncio
+async def test_post_form_urlencoded_drop_blanks_asgi(app):
+    @app.route("/", methods=["POST"])
+    async def handler(request):
+        return text("OK")
+
+    payload = "test="
+    headers = {"content-type": "application/x-www-form-urlencoded"}
+
+    request, response = await app.asgi_client.post(
+        "/", data=payload, headers=headers
+    )
+
+    assert "test" not in request.form.keys()
+
+
 @pytest.mark.parametrize(
     "payload",
     [