Repository: aio-libs/aiohttp

Issue:
How to pass array to form data (multipart/form-data)
I need to call API using `multipart/form-data` with list of strings and list of objects

```
data = aiohttp.FormData()
data.add_field("emails", candidate["email"], content_type="multipart/form-data")
data.add_field("phones[]", [{"value": phone}], content_type="multipart/form-data")
```

If I'm not mistaken FormData is designed to support urlencoded params or bytes for files only. Passing anything other than bytes results in `ClientRequestError: Can not write request body`. Is there a better way that passing everything as files?

What is more the 

```
data.add_field("emails[]", bytes(candidate["email"], "ascii"), content_type="multipart/form-data")
```

results in `emails%5B%5D` and form data doesn't allow bytes as names

```
        elif isinstance(value, (bytes, bytearray, memoryview)):
            if filename is None and content_transfer_encoding is None:
                filename = name

        type_options = multidict.MultiDict({'name': name})
        if filename is not None and not isinstance(filename, str):
            raise TypeError('filename must be an instance of str. '
                            'Got: %s' % filename)
```



Discussion:
Yes, field names and values are intended to be a text.
I really have no idea how to encode data like `[{"value": phone}]`. IFAIK there is no any standard for cases like this.


Resolving patch:
diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py
--- a/aiohttp/helpers.py
+++ b/aiohttp/helpers.py
@@ -85,11 +85,12 @@ class FormData:
     """Helper class for multipart/form-data and
     application/x-www-form-urlencoded body generation."""
 
-    def __init__(self, fields=()):
+    def __init__(self, fields=(), quote_fields=True):
         from . import multipart
         self._writer = multipart.MultipartWriter('form-data')
         self._fields = []
         self._is_multipart = False
+        self._quote_fields = quote_fields
 
         if isinstance(fields, dict):
             fields = list(fields.items())
@@ -181,7 +182,9 @@ def _gen_form_data(self, *args, **kwargs):
         for dispparams, headers, value in self._fields:
             part = self._writer.append(value, headers)
             if dispparams:
-                part.set_content_disposition('form-data', **dispparams)
+                part.set_content_disposition(
+                    'form-data', quote_fields=self._quote_fields, **dispparams
+                )
                 # FIXME cgi.FieldStorage doesn't likes body parts with
                 # Content-Length which were sent via chunked transfer encoding
                 part.headers.pop(hdrs.CONTENT_LENGTH, None)
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
--- a/aiohttp/multipart.py
+++ b/aiohttp/multipart.py
@@ -851,7 +851,7 @@ def _apply_content_transfer_encoding(self, stream):
             raise RuntimeError('unknown content transfer encoding: {}'
                                ''.format(encoding))
 
-    def set_content_disposition(self, disptype, **params):
+    def set_content_disposition(self, disptype, quote_fields=True, **params):
         """Sets ``Content-Disposition`` header.
 
         :param str disptype: Disposition type: inline, attachment, form-data.
@@ -868,7 +868,7 @@ def set_content_disposition(self, disptype, **params):
                 if not key or not (TOKEN > set(key)):
                     raise ValueError('bad content disposition parameter'
                                      ' {!r}={!r}'.format(key, val))
-                qval = quote(val, '')
+                qval = quote(val, '') if quote_fields else val
                 lparams.append((key, '"%s"' % qval))
                 if key == 'filename':
                     lparams.append(('filename*', "utf-8''" + qval))


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -131,6 +131,20 @@ def test_invalid_formdata_content_transfer_encoding():
                            content_transfer_encoding=invalid_val)
 
 
+def test_formdata_field_name_is_quoted():
+    form = helpers.FormData()
+    form.add_field("emails[]", "xxx@x.co", content_type="multipart/form-data")
+    res = b"".join(form("ascii"))
+    assert b'name="emails%5B%5D"' in res
+
+
+def test_formdata_field_name_is_not_quoted():
+    form = helpers.FormData(quote_fields=False)
+    form.add_field("emails[]", "xxx@x.co", content_type="multipart/form-data")
+    res = b"".join(form("ascii"))
+    assert b'name="emails[]"' in res
+
+
 def test_access_logger_format():
     log_format = '%T {%{SPAM}e} "%{ETag}o" %X {X} %%P'
     mock_logger = mock.Mock()