Skip to content

Commit

Permalink
Merge pull request #778 from marshmallow-code/prop2param
Browse files Browse the repository at this point in the history
Add field parameter functions
  • Loading branch information
lafrech committed Oct 4, 2022
2 parents f32a1c6 + 632f790 commit be3dd41
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 26 deletions.
5 changes: 3 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
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"
Expand Down
26 changes: 26 additions & 0 deletions docs/using_plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------

Expand Down
74 changes: 63 additions & 11 deletions src/apispec/ext/marshmallow/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

from __future__ import annotations
import typing

import marshmallow
from marshmallow.utils import is_collection
Expand Down Expand Up @@ -59,9 +60,37 @@ def __init__(
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.
Expand Down Expand Up @@ -150,34 +179,57 @@ def schema2parameters(

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>`.
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject
"""
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):
Expand Down
57 changes: 44 additions & 13 deletions tests/test_ext_marshmallow_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,20 +207,36 @@ class NotASchema:


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)
Expand Down Expand Up @@ -252,6 +268,21 @@ class UserSchema(Schema):
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):
Expand Down

0 comments on commit be3dd41

Please sign in to comment.