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)







Comments:

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"""