Repository: marshmallow-code/apispec

Issue:
explode and style are hardcoded in OpenAPIConverter.property2parameter
I've been trying to document webargs's `DelimitedList` (child class of `List`) and I'm stuck because I'd need to override hardcoded stuff.

```py
    def field2parameter(self, field, name, default_in):
        location = field.metadata.get("location", None)
        prop = self.field2property(field)
        return self.property2parameter(
            prop,
            name=name,
            required=field.required,
            multiple=isinstance(field, marshmallow.fields.List),   # <--- List
            location=location,
            default_in=default_in,
        )

    def property2parameter(self, prop, name, required, multiple, location, default_in):
        openapi_default_in = __location_map__.get(default_in, default_in)
        openapi_location = __location_map__.get(location, openapi_default_in)
        ret = {"in": openapi_location, "name": name}

        if openapi_location == "body":
            ret["required"] = False
            ret["name"] = "body"
            ret["schema"] = {"type": "object", "properties": {name: prop}}
            if required:
                ret["schema"]["required"] = [name]
        else:
            ret["required"] = required
            if self.openapi_version.major < 3:
                if multiple:
                    ret["collectionFormat"] = "multi"
                ret.update(prop)
            else:
                if multiple:                    # <--- when List, set explode and style
                    ret["explode"] = True
                    ret["style"] = "form"
                if prop.get("description", None):
                    ret["description"] = prop.pop("description")
                ret["schema"] = prop
        return ret
```

To document `DelimitedList`, we'd need to set `explode` to False. That's assuming we use default `,` as delimiter. Using space or pipe would require to also modify `style`.

To make this more generic, we could remove `multiple` and create an extensible mechanism to allow custom post-processings after `property2parameter`. And add a post-processor for `List` to do what's currently done with `multiple`.

Kinda like what's been done with attribute_functions in `FieldConverter`.

(Maybe we could separate the property2bodyparameter case as right now it does not involve a specific `List` case.)

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7d64b92..85c0224 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,26 +1,26 @@
 repos:
 - repo: https://github.com/asottile/pyupgrade
-  rev: v2.38.0
+  rev: v2.32.0
   hooks:
   - id: pyupgrade
     args: [--py37-plus]
 - repo: https://github.com/python/black
-  rev: 22.8.0
+  rev: 22.3.0
   hooks:
   - id: black
     language_version: python3
 - repo: https://github.com/pycqa/flake8
-  rev: 5.0.4
+  rev: 4.0.1
   hooks:
   - id: flake8
-    additional_dependencies: [flake8-bugbear==22.9.11]
+    additional_dependencies: [flake8-bugbear==22.4.25]
 - repo: https://github.com/asottile/blacken-docs
   rev: v1.12.1
   hooks:
   - id: blacken-docs
     additional_dependencies: [black==22.3.0]
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v0.971
+  rev: v0.950
   hooks:
   - id: mypy
     additional_dependencies: ["marshmallow>=3,<4", "types-PyYAML"]
diff --git a/docs/conf.py b/docs/conf.py
index 368fd52..c0fd839 100755
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,8 +18,9 @@ primary_domain = "py"
 default_role = "py:obj"
 
 intersphinx_mapping = {
-    "python": ("http://python.readthedocs.io/en/latest/", None),
-    "marshmallow": ("http://marshmallow.readthedocs.io/en/latest/", None),
+    "python": ("https://python.readthedocs.io/en/latest/", None),
+    "marshmallow": ("https://marshmallow.readthedocs.io/en/latest/", None),
+    "webargs": ("https://webargs.readthedocs.io/en/latest/", None),
 }
 
 issues_github_path = "marshmallow-code/apispec"
diff --git a/docs/using_plugins.rst b/docs/using_plugins.rst
index 2c794b8..0f02be9 100644
--- a/docs/using_plugins.rst
+++ b/docs/using_plugins.rst
@@ -295,6 +295,32 @@ method. Continuing from the example above:
 The function passed to `add_attribute_function` will be bound to the converter.
 It must accept the converter instance as first positional argument.
 
+In some rare cases, typically with container fields such as fields derived from
+:class:`List <marshmallow.fields.List>`, documenting the parameters using this
+field require some more customization.
+This can be achieved using the `add_parameter_attribute_function
+<apispec.ext.marshmallow.openapi.OpenAPIConverter.add_parameter_attribute_function>`
+method.
+
+For instance, when documenting webargs's
+:class:`DelimitedList <webargs.fields.DelimitedList>` field, one may register
+this function:
+
+.. code-block:: python
+
+    def delimited_list2param(self, field, **kwargs):
+        ret: dict = {}
+        if isinstance(field, DelimitedList):
+            if self.openapi_version.major < 3:
+                ret["collectionFormat"] = "csv"
+            else:
+                ret["explode"] = False
+                ret["style"] = "form"
+        return ret
+
+
+    ma_plugin.converter.add_parameter_attribute_function(delimited_list2param)
+
 Next Steps
 ----------
 
diff --git a/setup.cfg b/setup.cfg
index 6a94be7..986f315 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,9 +2,10 @@
 license_files = LICENSE
 
 [flake8]
+ignore = E203, E266, E501, W503
 max-line-length = 110
 max-complexity = 18
-extend-ignore = E203
+select = B,C,E,F,W,T4,B9
 
 [mypy]
 ignore_missing_imports = true
diff --git a/setup.py b/setup.py
index 49bf24c..d2bcb24 100644
--- a/setup.py
+++ b/setup.py
@@ -4,18 +4,18 @@ from setuptools import setup, find_packages
 EXTRAS_REQUIRE = {
     "marshmallow": ["marshmallow>=3.13.0"],
     "yaml": ["PyYAML>=3.10"],
-    "validation": ["prance[osv]>=0.11", "openapi_spec_validator<0.5"],
+    "validation": ["prance[osv]>=0.11"],
     "lint": [
-        "flake8==5.0.4",
-        "flake8-bugbear==22.9.11",
+        "flake8==4.0.1",
+        "flake8-bugbear==22.7.1",
         "pre-commit~=2.4",
-        "mypy==0.971",
+        "mypy==0.961",
         "types-PyYAML",
     ],
     "docs": [
         "marshmallow>=3.13.0",
         "pyyaml==6.0",
-        "sphinx==5.1.1",
+        "sphinx==5.0.2",
         "sphinx-issues==3.0.1",
         "sphinx-rtd-theme==1.0.0",
     ],
diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py
index c2eaef4..5b6ff13 100644
--- a/src/apispec/ext/marshmallow/field_converter.py
+++ b/src/apispec/ext/marshmallow/field_converter.py
@@ -226,7 +226,7 @@ class FieldConverterMixin:
         else:
             default = field.load_default
             if default is not marshmallow.missing and not callable(default):
-                default = field._serialize(default, None, None)
+                default = field._serialize(default, None, None)  # type:ignore
                 ret["default"] = default
         return ret
 
diff --git a/src/apispec/ext/marshmallow/openapi.py b/src/apispec/ext/marshmallow/openapi.py
index ea76955..94da0b2 100644
--- a/src/apispec/ext/marshmallow/openapi.py
+++ b/src/apispec/ext/marshmallow/openapi.py
@@ -8,6 +8,7 @@ marshmallow :class:`Schemas <marshmallow.Schema>` and :class:`Fields <marshmallo
 """
 
 from __future__ import annotations
+import typing
 
 import marshmallow
 from marshmallow.utils import is_collection
@@ -59,9 +60,37 @@ class OpenAPIConverter(FieldConverterMixin):
         self.schema_name_resolver = schema_name_resolver
         self.spec = spec
         self.init_attribute_functions()
+        self.init_parameter_attribute_functions()
         # Schema references
         self.refs: dict = {}
 
+    def init_parameter_attribute_functions(self) -> None:
+        self.parameter_attribute_functions = [
+            self.field2required,
+            self.list2param,
+        ]
+
+    def add_parameter_attribute_function(self, func) -> None:
+        """Method to add a field parameter function to the list of field
+        parameter functions that will be called on a field to convert it to a
+        field parameter.
+
+        :param func func: the field parameter function to add
+            The attribute function will be bound to the
+            `OpenAPIConverter <apispec.ext.marshmallow.openapi.OpenAPIConverter>`
+            instance.
+            It will be called for each field in a schema with
+            `self <apispec.ext.marshmallow.openapi.OpenAPIConverter>` and a
+            `field <marshmallow.fields.Field>` instance
+            positional arguments and `ret <dict>` keyword argument.
+            May mutate `ret`.
+            User added field parameter functions will be called after all built-in
+            field parameter functions in the order they were added.
+        """
+        bound_func = func.__get__(self)
+        setattr(self, func.__name__, bound_func)
+        self.parameter_attribute_functions.append(bound_func)
+
     def resolve_nested_schema(self, schema):
         """Return the OpenAPI representation of a marshmallow Schema.
 
@@ -150,7 +179,7 @@ class OpenAPIConverter(FieldConverterMixin):
 
     def _field2parameter(
         self, field: marshmallow.fields.Field, *, name: str, location: str
-    ):
+    ) -> dict:
         """Return an OpenAPI parameter as a `dict`, given a marshmallow
         :class:`Field <marshmallow.Field>`.
 
@@ -158,26 +187,49 @@ class OpenAPIConverter(FieldConverterMixin):
         """
         ret: dict = {"in": location, "name": name}
 
+        prop = self.field2property(field)
+        if self.openapi_version.major < 3:
+            ret.update(prop)
+        else:
+            if "description" in prop:
+                ret["description"] = prop.pop("description")
+            ret["schema"] = prop
+
+        for param_attr_func in self.parameter_attribute_functions:
+            ret.update(param_attr_func(field, ret=ret))
+
+        return ret
+
+    def field2required(
+        self, field: marshmallow.fields.Field, **kwargs: typing.Any
+    ) -> dict:
+        """Return the dictionary of OpenAPI parameter attributes for a required field.
+
+        :param Field field: A marshmallow field.
+        :rtype: dict
+        """
+        ret = {}
         partial = getattr(field.parent, "partial", False)
         ret["required"] = field.required and (
             not partial
             or (is_collection(partial) and field.name not in partial)  # type:ignore
         )
+        return ret
 
-        prop = self.field2property(field)
-        multiple = isinstance(field, marshmallow.fields.List)
+    def list2param(self, field: marshmallow.fields.Field, **kwargs: typing.Any) -> dict:
+        """Return a dictionary of parameter properties from
+        :class:`List <marshmallow.fields.List` fields.
 
-        if self.openapi_version.major < 3:
-            if multiple:
+        :param Field field: A marshmallow field.
+        :rtype: dict
+        """
+        ret: dict = {}
+        if isinstance(field, marshmallow.fields.List):
+            if self.openapi_version.major < 3:
                 ret["collectionFormat"] = "multi"
-            ret.update(prop)
-        else:
-            if multiple:
+            else:
                 ret["explode"] = True
                 ret["style"] = "form"
-            if prop.get("description", None):
-                ret["description"] = prop.pop("description")
-            ret["schema"] = prop
         return ret
 
     def schema2jsonschema(self, schema):


Test patch:
diff --git a/tests/test_ext_marshmallow_openapi.py b/tests/test_ext_marshmallow_openapi.py
index 83ea7ad..6ec99ce 100644
--- a/tests/test_ext_marshmallow_openapi.py
+++ b/tests/test_ext_marshmallow_openapi.py
@@ -207,20 +207,36 @@ class TestMarshmallowSchemaToModelDefinition:
 
 
 class TestMarshmallowSchemaToParameters:
-    @pytest.mark.parametrize("ListClass", [fields.List, CustomList])
-    def test_field_multiple(self, ListClass, openapi):
-        field = ListClass(fields.Str)
-        res = openapi._field2parameter(field, name="field", location="query")
-        assert res["in"] == "query"
-        if openapi.openapi_version.major < 3:
-            assert res["type"] == "array"
-            assert res["items"]["type"] == "string"
-            assert res["collectionFormat"] == "multi"
+    def test_custom_properties_for_custom_fields(self, spec_fixture):
+        class DelimitedList(fields.List):
+            """Delimited list field"""
+
+        def delimited_list2param(self, field, **kwargs):
+            ret: dict = {}
+            if isinstance(field, DelimitedList):
+                if self.openapi_version.major < 3:
+                    ret["collectionFormat"] = "csv"
+                else:
+                    ret["explode"] = False
+                    ret["style"] = "form"
+            return ret
+
+        spec_fixture.marshmallow_plugin.converter.add_parameter_attribute_function(
+            delimited_list2param
+        )
+
+        class MySchema(Schema):
+            delimited_list = DelimitedList(fields.Int)
+
+        param = spec_fixture.marshmallow_plugin.converter.schema2parameters(
+            MySchema(), location="query"
+        )[0]
+
+        if spec_fixture.openapi.openapi_version.major < 3:
+            assert param["collectionFormat"] == "csv"
         else:
-            assert res["schema"]["type"] == "array"
-            assert res["schema"]["items"]["type"] == "string"
-            assert res["style"] == "form"
-            assert res["explode"] is True
+            assert param["explode"] is False
+            assert param["style"] == "form"
 
     def test_field_required(self, openapi):
         field = fields.Str(required=True)
@@ -252,6 +268,21 @@ class TestMarshmallowSchemaToParameters:
         param = next(p for p in res_nodump if p["name"] == "partial_field")
         assert param["required"] is False
 
+    @pytest.mark.parametrize("ListClass", [fields.List, CustomList])
+    def test_field_list(self, ListClass, openapi):
+        field = ListClass(fields.Str)
+        res = openapi._field2parameter(field, name="field", location="query")
+        assert res["in"] == "query"
+        if openapi.openapi_version.major < 3:
+            assert res["type"] == "array"
+            assert res["items"]["type"] == "string"
+            assert res["collectionFormat"] == "multi"
+        else:
+            assert res["schema"]["type"] == "array"
+            assert res["schema"]["items"]["type"] == "string"
+            assert res["style"] == "form"
+            assert res["explode"] is True
+
     # json/body is invalid for OpenAPI 3
     @pytest.mark.parametrize("openapi", ("2.0",), indirect=True)
     def test_schema_body(self, openapi):