Repository: marshmallow-code/apispec
Pull request #496 (2019-09-06 18:36:04)

Extract SchemaResolver from MarshmallowPlugin
Extracts logic for resolving schemas in OpenAPI objects and enhannces documentation.

Might have gone a little bit overboard on the documentation, but I thought it would helpful to provide concrete examples of how to construct dictionaries for conversion.


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 92218b2..18cb18c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,20 +6,21 @@ Changelog
 
 Features:
 
-- Add support for generating user-defined OpenAPI properties for custom field
-  classes via an ``add_attribute_function`` method (:pr:`478`).
-- [apispec.ext.marshmallow]: *Backwards-incompatible* ``fields.Raw`` and ``fields.Field`` are
-  now represented by OpenAPI `Any Type <https://swagger.io/docs/specification/data-models/data-types/#any>`_
-  (:pr:`495`).
-- [apispec.ext.marshmallow]: *Backwards-incompatible*: The ``schema_name_resolver`` function now receives
+- *Backwards-incompatible* Marshmallow ``fields.Raw`` and ``fields.Field`` are
+  now represented by OpenAPI `Any Type <https://swagger.io/docs/specification/data-models/data-types/#any>`_.
+  (:pr:`495`)
+- Add support for generating user defined OpenAPI properties for custom field
+  classes via an ``add_attribute_function`` method. (:pr:`478`)
+- *Backwards-incompatible*: The ``schema_name_resolver`` function now receives
   a ``Schema`` class, a ``Schema`` instance or a string that resolves to a
   ``Schema`` class. This allows a custom resolver to generate different names
-  depending on schema modifiers used in a ``Schema`` instance (:pr:`476`).
+  depending on schema modifiers used in a ``Schema`` instance. (:pr:`476`)
 
 Bug fixes:
 
-- [apispec.ext.marshmallow]: With marshmallow 3, the default value of a field in the
-  documentation is the serialized value of the ``missing`` attribute, not ``missing`` itself (:pr:`490`).
+- MarshmallowPlugin: With marshmallow 3, the default value of a field in the
+  documentation is the dump of the ``missing`` attribute, not ``missing``
+  itself (:pr:`490`).
 
 Other changes:
 
diff --git a/docs/api_ext.rst b/docs/api_ext.rst
index 58ed806..5710c53 100644
--- a/docs/api_ext.rst
+++ b/docs/api_ext.rst
@@ -7,6 +7,12 @@ apispec.ext.marshmallow
 .. automodule:: apispec.ext.marshmallow
     :members:
 
+apispec.ext.marshmallow.schema_resolver
++++++++++++++++++++++++++++++++++++++++
+
+.. automodule:: apispec.ext.marshmallow.schema_resolver
+    :members:
+
 apispec.ext.marshmallow.openapi
 +++++++++++++++++++++++++++++++
 
diff --git a/src/apispec/ext/marshmallow/__init__.py b/src/apispec/ext/marshmallow/__init__.py
index df2d3c6..be619f7 100644
--- a/src/apispec/ext/marshmallow/__init__.py
+++ b/src/apispec/ext/marshmallow/__init__.py
@@ -73,6 +73,7 @@ import warnings
 from apispec import BasePlugin
 from .common import resolve_schema_instance, make_schema_key, resolve_schema_cls
 from .openapi import OpenAPIConverter
+from .schema_resolver import SchemaResolver
 
 
 def resolver(schema):
@@ -102,6 +103,7 @@ class MarshmallowPlugin(BasePlugin):
     """
 
     Converter = OpenAPIConverter
+    Resolver = SchemaResolver
 
     def __init__(self, schema_name_resolver=None):
         super().__init__()
@@ -109,6 +111,7 @@ class MarshmallowPlugin(BasePlugin):
         self.spec = None
         self.openapi_version = None
         self.converter = None
+        self.resolver = None
 
     def init_spec(self, spec):
         super().init_spec(spec)
@@ -119,55 +122,9 @@ class MarshmallowPlugin(BasePlugin):
             schema_name_resolver=self.schema_name_resolver,
             spec=spec,
         )
-
-    def resolve_parameters(self, parameters):
-        resolved = []
-        for parameter in parameters:
-            if (
-                isinstance(parameter, dict)
-                and not isinstance(parameter.get("schema", {}), dict)
-                and "in" in parameter
-            ):
-                schema_instance = resolve_schema_instance(parameter.pop("schema"))
-                resolved += self.converter.schema2parameters(
-                    schema_instance, default_in=parameter.pop("in"), **parameter
-                )
-            else:
-                self.resolve_schema(parameter)
-                resolved.append(parameter)
-        return resolved
-
-    def resolve_schema_in_request_body(self, request_body):
-        """Function to resolve a schema in a requestBody object - modifies then
-        response dict to convert Marshmallow Schema object or class into dict
-        """
-        content = request_body["content"]
-        for content_type in content:
-            schema = content[content_type]["schema"]
-            content[content_type]["schema"] = self.converter.resolve_schema_dict(schema)
-
-    def resolve_schema(self, data):
-        """Function to resolve a schema in a parameter or response - modifies the
-        corresponding dict to convert Marshmallow Schema object or class into dict
-
-        :param APISpec spec: `APISpec` containing refs.
-        :param dict|str data: either a parameter or response dictionary that may
-            contain a schema, or a reference provided as string
-        """
-        if not isinstance(data, dict):
-            return
-
-        # OAS 2 component or OAS 3 header
-        if "schema" in data:
-            data["schema"] = self.converter.resolve_schema_dict(data["schema"])
-        # OAS 3 component except header
-        if self.openapi_version.major >= 3:
-            if "content" in data:
-                for content in data["content"].values():
-                    if "schema" in content:
-                        content["schema"] = self.converter.resolve_schema_dict(
-                            content["schema"]
-                        )
+        self.resolver = self.Resolver(
+            openapi_version=spec.openapi_version, converter=self.converter
+        )
 
     def map_to_openapi_type(self, *args):
         """Decorator to set mapping for custom fields.
@@ -217,7 +174,7 @@ class MarshmallowPlugin(BasePlugin):
             Schema class or instance.
         """
         # In OpenAPIv3, this only works when using the complex form using "content"
-        self.resolve_schema(parameter)
+        self.resolver.resolve_schema(parameter)
         return parameter
 
     def response_helper(self, response, **kwargs):
@@ -227,10 +184,7 @@ class MarshmallowPlugin(BasePlugin):
         :param dict parameter: response fields. May contain a marshmallow
             Schema class or instance.
         """
-        self.resolve_schema(response)
-        if "headers" in response:
-            for header in response["headers"].values():
-                self.resolve_schema(header)
+        self.resolver.resolve_response(response)
         return response
 
     def operation_helper(self, operations, **kwargs):
@@ -238,17 +192,14 @@ class MarshmallowPlugin(BasePlugin):
             if not isinstance(operation, dict):
                 continue
             if "parameters" in operation:
-                operation["parameters"] = self.resolve_parameters(
+                operation["parameters"] = self.resolver.resolve_parameters(
                     operation["parameters"]
                 )
             if self.openapi_version.major >= 3:
                 if "requestBody" in operation:
-                    self.resolve_schema_in_request_body(operation["requestBody"])
+                    self.resolver.resolve_schema(operation["requestBody"])
             for response in operation.get("responses", {}).values():
-                self.resolve_schema(response)
-                if "headers" in response:
-                    for header in response["headers"].values():
-                        self.resolve_schema(header)
+                self.resolver.resolve_response(response)
 
     def warn_if_schema_already_in_spec(self, schema_key):
         """Method to warn the user if the schema has already been added to the
diff --git a/src/apispec/ext/marshmallow/openapi.py b/src/apispec/ext/marshmallow/openapi.py
index 6d157fe..492f04d 100644
--- a/src/apispec/ext/marshmallow/openapi.py
+++ b/src/apispec/ext/marshmallow/openapi.py
@@ -120,7 +120,7 @@ class OpenAPIConverter(FieldConverterMixin):
         """
         openapi_default_in = __location_map__.get(default_in, default_in)
         if self.openapi_version.major < 3 and openapi_default_in == "body":
-            prop = self.resolve_schema_dict(schema)
+            prop = self.resolve_nested_schema(schema)
 
             param = {
                 "in": openapi_default_in,
@@ -314,16 +314,3 @@ class OpenAPIConverter(FieldConverterMixin):
         if getattr(schema, "many", False):
             return {"type": "array", "items": ref_schema}
         return ref_schema
-
-    def resolve_schema_dict(self, schema):
-        if isinstance(schema, dict):
-            if schema.get("type") == "array" and "items" in schema:
-                schema["items"] = self.resolve_schema_dict(schema["items"])
-            if schema.get("type") == "object" and "properties" in schema:
-                schema["properties"] = {
-                    k: self.resolve_schema_dict(v)
-                    for k, v in schema["properties"].items()
-                }
-            return schema
-
-        return self.resolve_nested_schema(schema)
diff --git a/src/apispec/ext/marshmallow/schema_resolver.py b/src/apispec/ext/marshmallow/schema_resolver.py
new file mode 100644
index 0000000..b1faeb7
--- /dev/null
+++ b/src/apispec/ext/marshmallow/schema_resolver.py
@@ -0,0 +1,218 @@
+from .common import resolve_schema_instance
+
+
+class SchemaResolver:
+    """Resolve marshmallow Schemas in OpenAPI components and translate to OpenAPI
+    `schema objects
+    <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schema-object>`_,
+    `parameter objects
+    <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameter-object>`_
+    or `reference objects
+    <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#reference-object>`_.
+    """
+
+    def __init__(self, openapi_version, converter):
+        self.openapi_version = openapi_version
+        self.converter = converter
+
+    def resolve_parameters(self, parameters):
+        """Resolve marshmallow Schemas in a list of OpenAPI `Parameter Objects
+        <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameter-object>`_.
+        Each parameter object that contains a Schema will be translated into
+        one or more Parameter Objects.
+
+        If the value of a `schema` key is marshmallow Schema class, instance or
+        a string that resolves to a Schema Class each field in the Schema will
+        be expanded as a separate Parameter Object.
+
+        Example: ::
+
+            #Input
+            class UserSchema(Schema):
+                name = fields.String()
+                id = fields.Int()
+
+            [
+                {"in": "query", "schema": "UserSchema"}
+            ]
+
+            #Output
+            [
+                {"in": "query", "name": "id", "required": False, "schema": {"type": "integer", "format": "int32"}},
+                {"in": "query", "name": "name", "required": False, "schema": {"type": "string"}}
+            ]
+
+        If the Parameter Object contains a `content` key a single Parameter
+        Object is returned with the Schema translated into a Schema Object or
+        Reference Object.
+
+        Example: ::
+
+            #Input
+            [{"in": "query", "name": "pet", "content":{"application/json": {"schema": "PetSchema"}} }]
+
+            #Output
+            [
+                {
+                    "in": "query",
+                    "name": "pet",
+                    "content": {
+                        "application/json": {
+                            "schema": {"$ref": "#/components/schemas/Pet"}
+                        }
+                    }
+                }
+            ]
+
+
+        :param list parameters: the list of OpenAPI parameter objects to resolve.
+        """
+        resolved = []
+        for parameter in parameters:
+            if (
+                isinstance(parameter, dict)
+                and not isinstance(parameter.get("schema", {}), dict)
+                and "in" in parameter
+            ):
+                schema_instance = resolve_schema_instance(parameter.pop("schema"))
+                resolved += self.converter.schema2parameters(
+                    schema_instance, default_in=parameter.pop("in"), **parameter
+                )
+            else:
+                self.resolve_schema(parameter)
+                resolved.append(parameter)
+        return resolved
+
+    def resolve_response(self, response):
+        """Resolve marshmallow Schemas in OpenAPI `Response Objects
+        <https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#responseObject>`_.
+        Schemas may appear in either a Media Type Object or a Header Object.
+
+        Example: ::
+
+            #Input
+            {
+                "content": {"application/json": {"schema": "PetSchema"}},
+                "description": "successful operation",
+                "headers": {"PetHeader": {"schema": "PetHeaderSchema"}},
+            }
+
+            #Output
+            {
+                "content": {
+                    "application/json":{"schema": {"$ref": "#/components/schemas/Pet"}}
+                },
+                "description": "successful operation",
+                "headers": {
+                    "PetHeader": {"schema": {"$ref": "#/components/schemas/PetHeader"}}
+                },
+            }
+
+        :param dict response: the response object to resolve.
+        """
+        self.resolve_schema(response)
+        if "headers" in response:
+            for header in response["headers"].values():
+                self.resolve_schema(header)
+
+    def resolve_schema(self, data):
+        """Resolve marshmallow Schemas in an OpenAPI component or header -
+        modifies the input dictionary to translate marshmallow Schemas to OpenAPI
+        Schema Objects or Reference Objects.
+
+        OpenAPIv3 Components: ::
+
+            #Input
+            {
+                "description": "user to add to the system",
+                "content": {
+                    "application/json": {
+                        "schema": "UserSchema"
+                    }
+                }
+            }
+
+            #Output
+            {
+                "description": "user to add to the system",
+                "content": {
+                    "application/json": {
+                        "schema": {
+                            "$ref": "#/components/schemas/User"
+                        }
+                    }
+                }
+            }
+
+        :param dict|str data: either a parameter or response dictionary that may
+            contain a schema, or a reference provided as string
+        """
+        if not isinstance(data, dict):
+            return
+
+        # OAS 2 component or OAS 3 header
+        if "schema" in data:
+            data["schema"] = self.resolve_schema_dict(data["schema"])
+        # OAS 3 component except header
+        if self.openapi_version.major >= 3:
+            if "content" in data:
+                for content in data["content"].values():
+                    if "schema" in content:
+                        content["schema"] = self.resolve_schema_dict(content["schema"])
+
+    def resolve_schema_dict(self, schema):
+        """Resolve a marshmallow Schema class, object, or a string that resolves
+        to a Schema class or an OpenAPI Schema Object containing one of the above
+        to an OpenAPI Schema Object or Reference Object.
+
+        If the input is a marshmallow Schema class, object or a string that resolves
+        to a Schema class the Schema will be translated to an OpenAPI Schema Object
+        or Reference Object.
+
+        Example: ::
+
+            #Input
+            "PetSchema"
+
+            #Output
+            {"$ref": "#/components/schemas/Pet"}
+
+        If the input is a dictionary representation of an OpenAPI Schema Object
+        recursively search for a marshmallow Schemas to resolve. For `"type": "array"`,
+        marshmallow Schemas may appear as the value of the `items` key. For
+        `"type": "object"` Marshmalow Schemas may appear as values in the `properties`
+        dictionary.
+
+        Examples: ::
+
+            #Input
+            {"type": "array", "items": "PetSchema"}
+
+            #Output
+            {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}
+
+            #Input
+            {"type": "object", "properties": {"pet": "PetSchcema", "user": "UserSchema"}}
+
+            #Output
+            {
+                "type": "object",
+                "properties": {
+                    "pet": {"$ref": "#/components/schemas/Pet"},
+                    "user": {"$ref": "#/components/schemas/User"}
+                }
+            }
+
+        :param string|Schema|dict schema: the schema to resolve.
+        """
+        if isinstance(schema, dict):
+            if schema.get("type") == "array" and "items" in schema:
+                schema["items"] = self.resolve_schema_dict(schema["items"])
+            if schema.get("type") == "object" and "properties" in schema:
+                schema["properties"] = {
+                    k: self.resolve_schema_dict(v)
+                    for k, v in schema["properties"].items()
+                }
+            return schema
+
+        return self.converter.resolve_nested_schema(schema)


Test patch:
diff --git a/tests/test_ext_marshmallow.py b/tests/test_ext_marshmallow.py
index 4305ffc..a5a91bd 100644
--- a/tests/test_ext_marshmallow.py
+++ b/tests/test_ext_marshmallow.py
@@ -477,7 +477,9 @@ class TestOperationHelper:
             name = parameter["name"]
             assert description == PetSchema.description[name]
         post = p["post"]
-        post_schema = spec_fixture.openapi.resolve_schema_dict(PetSchema)
+        post_schema = spec_fixture.marshmallow_plugin.resolver.resolve_schema_dict(
+            PetSchema
+        )
         assert (
             post["requestBody"]["content"]["application/json"]["schema"] == post_schema
         )