Repository: marshmallow-code/webargs

Issue:
Support delimited list parameters
Currently, webargs supports list parameters in query strings using repeated parameters: `foo=bar&foo=baz`. But it's also a common pattern to encode lists in query strings using delimited values: `foo=bar,baz`, `foo=bar|baz`, etc. This is described using the `collectionFormat` in swagger: https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#fixed-fields-8. It would be helpful to expose control over the formatting of list values to support both repeated and delimited parameters.

Here are a few thoughts about interfaces:
- Use a subclass of `List`, like `DelimitedList`, that splits and joins on a delimiter on de/serializing.
- Add an optional `delimiter` argument to `List` that has the same behavior as above.
- Add an option to `Schema.Meta` that controls de/serialization of lists:

``` python
class ArgSchema(Schema):
    class Meta:
        list_delimiter = ','
```

Not sure if I like any of these. What do you think @sloria?



Discussion:
The 1st option seems best. `List` is not meant to deserialize from strings, so that rules out 2. Option 3 doesn't seem ideal because you wouldn't be able to use the feature with dictionary argmaps.

Agreed with everything you said, but the first option strikes me as a little underpowered. In most use cases, all list parameters for a given resource will be encoded in the same way (all repeated parameters, or all delimited parameters), so I still like a higher-level option--what about an argument passed to `parse` and `use_(kw)args`? That said, I still think the lower-level option is independently useful, so I'm down to take a pass at it.

By the way, I don't particularly like delimited query parameters, but they seem to be pretty commonly used, and are even recommended by JSON-API, which is why I'd like to add support.

Good point. I want to avoid adding a custom `Schema` class in webargs for the time being. Would it be enough if the delimiter option were configurable at the Field class level _and_ as a constructor argument?


Resolving patch:
diff --git a/webargs/core.py b/webargs/core.py
--- a/webargs/core.py
+++ b/webargs/core.py
@@ -67,7 +67,7 @@ class Meta(object):
 
 def is_multiple(field):
     """Return whether or not `field` handles repeated/multi-value arguments."""
-    return isinstance(field, ma.fields.List)
+    return isinstance(field, ma.fields.List) and not hasattr(field, 'delimiter')
 
 def get_value(d, name, field):
     """Get a value from a dictionary. Handles ``MultiDict`` types when
diff --git a/webargs/djangoparser.py b/webargs/djangoparser.py
--- a/webargs/djangoparser.py
+++ b/webargs/djangoparser.py
@@ -63,7 +63,7 @@ def parse_headers(self, req, name, field):
 
     def parse_files(self, req, name, field):
         """Pull a file from the request."""
-        return core.get_value(req.FILES, name, core.is_multiple(field))
+        return core.get_value(req.FILES, name, field)
 
     def get_request_from_view_args(self, args, kwargs):
         # The first argument is either `self` or `request`
diff --git a/webargs/fields.py b/webargs/fields.py
--- a/webargs/fields.py
+++ b/webargs/fields.py
@@ -36,3 +36,33 @@ def __init__(self, nested, *args, **kwargs):
         if isinstance(nested, dict):
             nested = argmap2schema(nested)
         super(Nested, self).__init__(nested, *args, **kwargs)
+
+class DelimitedList(ma.fields.List):
+    """Same as `marshmallow.fields.List`, except can load from either a list or
+    a delimited string (e.g. "foo,bar,baz").
+
+    :param Field cls_or_instance: A field class or instance.
+    :param str delimiter: Delimiter between values.
+    :param bool as_string: Dump values to string.
+    """
+
+    delimiter = ','
+
+    def __init__(self, cls_or_instance, delimiter=None, as_string=False, **kwargs):
+        self.delimiter = delimiter or self.delimiter
+        self.as_string = as_string
+        super(DelimitedList, self).__init__(cls_or_instance, **kwargs)
+
+    def _serialize(self, value, attr, obj):
+        ret = super(DelimitedList, self)._serialize(value, attr, obj)
+        if self.as_string:
+            return self.delimiter.join(format(each) for each in value)
+        return ret
+
+    def _deserialize(self, value, attr, data):
+        ret = (
+            value
+            if ma.utils.is_iterable_but_not_string(value)
+            else value.split(self.delimiter)
+        )
+        return super(DelimitedList, self)._deserialize(ret, attr, data)


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -16,22 +16,17 @@
     missing,
     ValidationError,
 )
-from webargs.core import (
-    Parser,
-    get_value,
-    is_multiple,
-    argmap2schema,
-)
+from webargs.core import Parser, get_value, argmap2schema
 
 
 class MockRequestParser(Parser):
     """A minimal parser implementation that parses mock requests."""
 
     def parse_json(self, web_request, name, arg):
-        return get_value(web_request.json, name, is_multiple(arg))
+        return get_value(web_request.json, name, arg)
 
     def parse_cookies(self, web_request, name, arg):
-        return get_value(web_request.cookies, name, is_multiple(arg))
+        return get_value(web_request.cookies, name, arg)
 
 
 @pytest.fixture
@@ -546,6 +541,44 @@ def viewfunc(username, password):
         return {'username': username, 'password': password}
     assert viewfunc() == {'username': 'foo', 'password': missing}
 
+def test_delimited_list_default_delimiter(web_request, parser):
+    web_request.json = {'ids': '1,2,3'}
+    schema_cls = argmap2schema({'ids': fields.DelimitedList(fields.Int())})
+    schema = schema_cls()
+
+    parsed = parser.parse(schema, web_request)
+    assert parsed['ids'] == [1, 2, 3]
+
+    dumped = schema.dump(parsed).data
+    assert dumped['ids'] == [1, 2, 3]
+
+def test_delimited_list_as_string(web_request, parser):
+    web_request.json = {'ids': '1,2,3'}
+    schema_cls = argmap2schema({'ids': fields.DelimitedList(fields.Int(), as_string=True)})
+    schema = schema_cls()
+
+    parsed = parser.parse(schema, web_request)
+    assert parsed['ids'] == [1, 2, 3]
+
+    dumped = schema.dump(parsed).data
+    assert dumped['ids'] == '1,2,3'
+
+def test_delimited_list_custom_delimiter(web_request, parser):
+    web_request.json = {'ids': '1|2|3'}
+    schema_cls = argmap2schema({'ids': fields.DelimitedList(fields.Int(), delimiter='|')})
+    schema = schema_cls()
+
+    parsed = parser.parse(schema, web_request)
+    assert parsed['ids'] == [1, 2, 3]
+
+def test_delimited_list_load_list(web_request, parser):
+    web_request.json = {'ids': [1, 2, 3]}
+    schema_cls = argmap2schema({'ids': fields.DelimitedList(fields.Int())})
+    schema = schema_cls()
+
+    parsed = parser.parse(schema, web_request)
+    assert parsed['ids'] == [1, 2, 3]
+
 def test_missing_list_argument_not_in_parsed_result(web_request, parser):
     # arg missing in request
     web_request.json = {}
diff --git a/tests/test_flaskparser.py b/tests/test_flaskparser.py
--- a/tests/test_flaskparser.py
+++ b/tests/test_flaskparser.py
@@ -390,6 +390,12 @@ def test_parse_multiple(context, testapp):
         assert args['name'] == ['steve', 'Loria']
         assert args['nums'] == [4, 2]
 
+def test_parse_multiple_delimited(testapp):
+    args = {'values': fields.DelimitedList(fields.Int())}
+    with testapp.test_request_context('/?values=1,2,3'):
+        res = parser.parse(args)
+        assert res['values'] == [1, 2, 3]
+
 @mock.patch('webargs.flaskparser.abort')
 def test_multiple_required_arg(mock_abort, testapp):
     multargs = {'name': fields.List(fields.Field(), required=True)}