Repository: marshmallow-code/flask-smorest

Issue:
Unexpected behaviour for webargs.fields.DelimitedList
Taking the following schema:
```python
from marshmallow import Schema
from webargs import fields

class MyScghema(Schema):
    a = fields.String()
    b = fields.String()

    sort_by = fields.DelimitedList(fields.String())
```

According to [Webargs](https://webargs.readthedocs.io/en/latest/quickstart.html#parsing-lists-in-query-strings) and [webargs/issues/406](https://github.com/marshmallow-code/webargs/issues/406), I would expect the OpenAPI specification to design something like this:
```bash
curl 'http://localhost:5000/benchmarks?sort_by=a,b'
```

However, when I use the swagger interface, it produces something like this:
```bash
curl 'http://localhost:5000/benchmarks?sort_by=a&sort_by=b'
```

To use a correct OpenAPI serialization I would recommend:
[Swagger, serialization](https://swagger.io/docs/specification/serialization/) -> Query Parameters (style='form' + explode=false)







Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code) Report
Base: **99.87**% // Head: **99.62**% // Decreases project coverage by **`-0.25%`** :warning:
> Coverage data is based on head [(`b977308`)](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code) compared to base [(`710e1f5`)](https://codecov.io/gh/marshmallow-code/flask-smorest/commit/710e1f5bfa9dacdba3e0f561f9ec64edb71bfe78?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code).
> Patch coverage: 80.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #409      +/-   ##
==========================================
- Coverage   99.87%   99.62%   -0.26%     
==========================================
  Files          13       13              
  Lines         813      804       -9     
  Branches      153      155       +2     
==========================================
- Hits          812      801      -11     
- Misses          0        1       +1     
- Partials        1        2       +1     
```


| [Impacted Files](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code) | Coverage Δ | |
|---|---|---|
| [flask\_smorest/spec/\_\_init\_\_.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9zcGVjL19faW5pdF9fLnB5) | `98.63% <80.00%> (-1.37%)` | :arrow_down: |
| [flask\_smorest/spec/plugins.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9zcGVjL3BsdWdpbnMucHk=) | `98.70% <0.00%> (-0.02%)` | :arrow_down: |
| [flask\_smorest/etag.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9ldGFnLnB5) | `100.00% <0.00%> (ø)` | |
| [flask\_smorest/response.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9yZXNwb25zZS5weQ==) | `100.00% <0.00%> (ø)` | |
| [flask\_smorest/arguments.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9hcmd1bWVudHMucHk=) | `100.00% <0.00%> (ø)` | |
| [flask\_smorest/blueprint.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9ibHVlcHJpbnQucHk=) | `100.00% <0.00%> (ø)` | |
| [flask\_smorest/pagination.py](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code#diff-Zmxhc2tfc21vcmVzdC9wYWdpbmF0aW9uLnB5) | `100.00% <0.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/marshmallow-code/flask-smorest/pull/409?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marshmallow-code).


Patch:
diff --git a/flask_smorest/spec/__init__.py b/flask_smorest/spec/__init__.py
index cb43dbf..76e8351 100644
--- a/flask_smorest/spec/__init__.py
+++ b/flask_smorest/spec/__init__.py
@@ -7,6 +7,7 @@ from flask import current_app
 import click
 import apispec
 from apispec.ext.marshmallow import MarshmallowPlugin
+from webargs.fields import DelimitedList
 
 try:  # pragma: no cover
     import yaml
@@ -28,6 +29,18 @@ def _add_leading_slash(string):
     return string if string.startswith("/") else "/" + string
 
 
+def delimited_list2param(self, field, **kwargs):
+    """apispec parameter attribute function documenting DelimitedList field"""
+    ret = {}
+    if isinstance(field, DelimitedList):
+        if self.openapi_version.major < 3:
+            ret["collectionFormat"] = "csv"
+        else:
+            ret["explode"] = False
+            ret["style"] = "form"
+    return ret
+
+
 class DocBlueprintMixin:
     """Extend Api to serve the spec in a dedicated blueprint."""
 
@@ -217,6 +230,8 @@ class APISpecMixin(DocBlueprintMixin):
             self._register_converter(*args)
         # Register Upload field properties function
         self.ma_plugin.converter.add_attribute_function(uploadfield2properties)
+        # Register DelimitedList field parameter attribute function
+        self.ma_plugin.converter.add_parameter_attribute_function(delimited_list2param)
 
         # Lazy register default responses
         self._register_responses()


Test patch:
diff --git a/tests/test_spec.py b/tests/test_spec.py
index 0f0a8cc..03ea05c 100644
--- a/tests/test_spec.py
+++ b/tests/test_spec.py
@@ -5,6 +5,8 @@ from unittest import mock
 
 import pytest
 import yaml
+from webargs.fields import DelimitedList
+import marshmallow as ma
 
 from flask_smorest import Api, Blueprint
 from flask_smorest import etag as fs_etag
@@ -34,7 +36,7 @@ class TestAPISpec:
             assert "consumes" not in spec
 
     @pytest.mark.parametrize("openapi_version", ["2.0", "3.0.2"])
-    def test_api_lazy_registers_error_responses(self, app, openapi_version):
+    def test_apispec_lazy_registers_error_responses(self, app, openapi_version):
         """Test error responses are registered"""
         app.config["OPENAPI_VERSION"] = openapi_version
         api = Api(app)
@@ -81,7 +83,7 @@ class TestAPISpec:
                     }
 
     @pytest.mark.parametrize("openapi_version", ["2.0", "3.0.2"])
-    def test_api_lazy_registers_etag_headers(self, app, openapi_version):
+    def test_apispec_lazy_registers_etag_headers(self, app, openapi_version):
         """Test etag headers are registered"""
         app.config["OPENAPI_VERSION"] = openapi_version
         api = Api(app)
@@ -126,7 +128,7 @@ class TestAPISpec:
         assert parameters["IF_NONE_MATCH"] == fs_etag.IF_NONE_MATCH_HEADER
         assert parameters["IF_MATCH"] == fs_etag.IF_MATCH_HEADER
 
-    def test_api_lazy_registers_pagination_header(self, app):
+    def test_apispec_lazy_registers_pagination_header(self, app):
         """Test pagination header is registered"""
         api = Api(app)
 
@@ -155,6 +157,39 @@ class TestAPISpec:
             "schema": {"$ref": "#/components/schemas/PaginationMetadata"},
         }
 
+    @pytest.mark.parametrize("openapi_version", ("2.0", "3.0.2"))
+    def test_apispec_delimited_list_documentation(self, app, openapi_version):
+        """Test DelimitedList if correctly documented"""
+        app.config["OPENAPI_VERSION"] = openapi_version
+        api = Api(app)
+
+        blp = Blueprint("test", "test", url_prefix="/test")
+
+        class ListInputsSchema(ma.Schema):
+            inputs = DelimitedList(ma.fields.Integer)
+
+        @blp.route("/")
+        @blp.arguments(ListInputsSchema, location="query")
+        def test(args):
+            # Also test DelimitedList behaves as expected
+            assert args == {"inputs": [1, 2, 3]}
+
+        api.register_blueprint(blp)
+        spec = api.spec.to_dict()
+        parameters = spec["paths"]["/test/"]["get"]["parameters"]
+        param = next(p for p in parameters if p["name"] == "inputs")
+        if openapi_version == "2.0":
+            assert param["type"] == "array"
+            assert param["items"] == {"type": "integer"}
+            assert param["collectionFormat"] == "csv"
+        else:
+            assert param["schema"] == {"type": "array", "items": {"type": "integer"}}
+            assert param["explode"] is False
+            assert param["style"] == "form"
+
+        client = app.test_client()
+        client.get("/test/", query_string={"inputs": "1,2,3"})
+
 
 class TestAPISpecServeDocs:
     """Test APISpec class doc-serving features"""