Repository: marshmallow-code/apispec
Pull request #544 (2020-03-17 12:50:26)

Support schemas in callbacks
Callback definitions are iterated and checked for schemas. This is
implemented by running operation_helper on all events defined in a
callback definition. These events may contain request and response
definitions similar to regular operation definitions.


Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index e10eb08..c6c180e 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -63,5 +63,6 @@ Contributors (chronological)
 - Ashutosh Chaudhary `@codeasashu <https://github.com/codeasashu>`_
 - Fedor Fominykh `@fedorfo <https://github.com/fedorfo>`_
 - Colin Bounouar `@Colin-b <https://github.com/Colin-b>`_
+- Mikko Kortelainen `@kortsi <https://github.com/kortsi>
 - David Bishop `@teancom <https://github.com/teancom>`_
 - Andrea Ghensi `@sanzoghenzo <https://github.com/sanzoghenzo>`_
diff --git a/src/apispec/ext/marshmallow/__init__.py b/src/apispec/ext/marshmallow/__init__.py
index cabffe6..a997604 100644
--- a/src/apispec/ext/marshmallow/__init__.py
+++ b/src/apispec/ext/marshmallow/__init__.py
@@ -187,18 +187,7 @@ class MarshmallowPlugin(BasePlugin):
         return response
 
     def operation_helper(self, operations, **kwargs):
-        for operation in operations.values():
-            if not isinstance(operation, dict):
-                continue
-            if "parameters" in operation:
-                operation["parameters"] = self.resolver.resolve_parameters(
-                    operation["parameters"]
-                )
-            if self.openapi_version.major >= 3:
-                if "requestBody" in operation:
-                    self.resolver.resolve_schema(operation["requestBody"])
-            for response in operation.get("responses", {}).values():
-                self.resolver.resolve_response(response)
+        self.resolver.resolve_operations(operations)
 
     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/schema_resolver.py b/src/apispec/ext/marshmallow/schema_resolver.py
index 07da37b..335b5d7 100644
--- a/src/apispec/ext/marshmallow/schema_resolver.py
+++ b/src/apispec/ext/marshmallow/schema_resolver.py
@@ -15,6 +15,75 @@ class SchemaResolver:
         self.openapi_version = openapi_version
         self.converter = converter
 
+    def resolve_operations(self, operations, **kwargs):
+        """Resolve marshmallow Schemas in a dict mapping operation to OpenApi `Operation Object
+        https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject`_"""
+
+        for operation in operations.values():
+            if not isinstance(operation, dict):
+                continue
+            if "parameters" in operation:
+                operation["parameters"] = self.resolve_parameters(
+                    operation["parameters"]
+                )
+            if self.openapi_version.major >= 3:
+                self.resolve_callback(operation.get("callbacks", {}))
+                if "requestBody" in operation:
+                    self.resolve_schema(operation["requestBody"])
+            for response in operation.get("responses", {}).values():
+                self.resolve_response(response)
+
+    def resolve_callback(self, callbacks):
+        """Resolve marshmallow Schemas in a dict mapping callback name to OpenApi `Callback Object
+        https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#callbackObject`_.
+
+        This is done recursively, so it it is possible to define callbacks in your callbacks.
+
+        Example: ::
+
+            #Input
+            {
+                "userEvent": {
+                    "https://my.example/user-callback": {
+                        "post": {
+                            "requestBody": {
+                                "content": {
+                                    "application/json": {
+                                        "schema": UserSchema
+                                    }
+                                }
+                            }
+                        },
+                    }
+                }
+            }
+
+            #Output
+            {
+                "userEvent": {
+                    "https://my.example/user-callback": {
+                        "post": {
+                            "requestBody": {
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/User"
+                                        }
+                                    }
+                                }
+                            }
+                        },
+                    }
+                }
+            }
+
+
+        """
+        for callback in callbacks.values():
+            if isinstance(callback, dict):
+                for path in callback.values():
+                    self.resolve_operations(path)
+
     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>`_.


Test patch:
diff --git a/tests/test_ext_marshmallow.py b/tests/test_ext_marshmallow.py
index 72d9b92..d931065 100644
--- a/tests/test_ext_marshmallow.py
+++ b/tests/test_ext_marshmallow.py
@@ -327,6 +327,19 @@ def get_nested_schema(schema, field_name):
 
 
 class TestOperationHelper:
+    @pytest.fixture
+    def make_pet_callback_spec(self, spec_fixture):
+        def _make_pet_spec(operations):
+            spec_fixture.spec.path(
+                path="/pet",
+                operations={
+                    "post": {"callbacks": {"petEvent": {"petCallbackUrl": operations}}}
+                },
+            )
+            return spec_fixture
+
+        return _make_pet_spec
+
     @pytest.mark.parametrize(
         "pet_schema",
         (PetSchema, PetSchema(), PetSchema(many=True), "tests.schemas.PetSchema"),
@@ -417,6 +430,56 @@ class TestOperationHelper:
         assert resolved_schema == spec_fixture.openapi.schema2jsonschema(PetSchema)
         assert get["responses"]["200"]["description"] == "successful operation"
 
+    @pytest.mark.parametrize(
+        "pet_schema",
+        (PetSchema, PetSchema(), PetSchema(many=True), "tests.schemas.PetSchema"),
+    )
+    @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True)
+    def test_callback_schema_v3(self, make_pet_callback_spec, pet_schema):
+        spec_fixture = make_pet_callback_spec(
+            {
+                "get": {
+                    "responses": {
+                        "200": {
+                            "content": {"application/json": {"schema": pet_schema}},
+                            "description": "successful operation",
+                            "headers": {"PetHeader": {"schema": pet_schema}},
+                        }
+                    }
+                }
+            }
+        )
+        p = get_paths(spec_fixture.spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        get = c["get"]
+        if isinstance(pet_schema, Schema) and pet_schema.many is True:
+            assert (
+                get["responses"]["200"]["content"]["application/json"]["schema"]["type"]
+                == "array"
+            )
+            schema_reference = get["responses"]["200"]["content"]["application/json"][
+                "schema"
+            ]["items"]
+            assert (
+                get["responses"]["200"]["headers"]["PetHeader"]["schema"]["type"]
+                == "array"
+            )
+            header_reference = get["responses"]["200"]["headers"]["PetHeader"][
+                "schema"
+            ]["items"]
+        else:
+            schema_reference = get["responses"]["200"]["content"]["application/json"][
+                "schema"
+            ]
+            header_reference = get["responses"]["200"]["headers"]["PetHeader"]["schema"]
+
+        assert schema_reference == build_ref(spec_fixture.spec, "schema", "Pet")
+        assert header_reference == build_ref(spec_fixture.spec, "schema", "Pet")
+        assert len(spec_fixture.spec.components._schemas) == 1
+        resolved_schema = spec_fixture.spec.components._schemas["Pet"]
+        assert resolved_schema == spec_fixture.openapi.schema2jsonschema(PetSchema)
+        assert get["responses"]["200"]["description"] == "successful operation"
+
     @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True)
     def test_schema_expand_parameters_v2(self, spec_fixture):
         spec_fixture.spec.path(
@@ -485,6 +548,41 @@ class TestOperationHelper:
         assert post["requestBody"]["description"] == "a pet schema"
         assert post["requestBody"]["required"]
 
+    @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True)
+    def test_callback_schema_expand_parameters_v3(self, make_pet_callback_spec):
+        spec_fixture = make_pet_callback_spec(
+            {
+                "get": {"parameters": [{"in": "query", "schema": PetSchema}]},
+                "post": {
+                    "requestBody": {
+                        "description": "a pet schema",
+                        "required": True,
+                        "content": {"application/json": {"schema": PetSchema}},
+                    }
+                },
+            }
+        )
+        p = get_paths(spec_fixture.spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        get = c["get"]
+        assert get["parameters"] == spec_fixture.openapi.schema2parameters(
+            PetSchema(), location="query"
+        )
+        for parameter in get["parameters"]:
+            description = parameter.get("description", False)
+            assert description
+            name = parameter["name"]
+            assert description == PetSchema.description[name]
+        post = c["post"]
+        post_schema = spec_fixture.marshmallow_plugin.resolver.resolve_schema_dict(
+            PetSchema
+        )
+        assert (
+            post["requestBody"]["content"]["application/json"]["schema"] == post_schema
+        )
+        assert post["requestBody"]["description"] == "a pet schema"
+        assert post["requestBody"]["required"]
+
     @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True)
     def test_schema_uses_ref_if_available_v2(self, spec_fixture):
         spec_fixture.spec.components.schema("Pet", schema=PetSchema)
@@ -514,6 +612,24 @@ class TestOperationHelper:
             "schema"
         ] == build_ref(spec_fixture.spec, "schema", "Pet")
 
+    @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True)
+    def test_callback_schema_uses_ref_if_available_v3(self, make_pet_callback_spec):
+        spec_fixture = make_pet_callback_spec(
+            {
+                "get": {
+                    "responses": {
+                        "200": {"content": {"application/json": {"schema": PetSchema}}}
+                    }
+                }
+            }
+        )
+        p = get_paths(spec_fixture.spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        get = c["get"]
+        assert get["responses"]["200"]["content"]["application/json"][
+            "schema"
+        ] == build_ref(spec_fixture.spec, "schema", "Pet")
+
     def test_schema_uses_ref_if_available_name_resolver_returns_none_v2(self):
         def resolver(schema):
             return None
@@ -606,6 +722,48 @@ class TestOperationHelper:
             in get["responses"]["200"]["content"]["application/json"]["schema"]
         )
 
+    def test_callback_schema_uses_ref_if_available_name_resolver_returns_none_v3(self):
+        def resolver(schema):
+            return None
+
+        spec = APISpec(
+            title="Test auto-reference",
+            version="0.1",
+            openapi_version="3.0.0",
+            plugins=(MarshmallowPlugin(schema_name_resolver=resolver),),
+        )
+        spec.components.schema("Pet", schema=PetSchema)
+        spec.path(
+            path="/pet",
+            operations={
+                "post": {
+                    "callbacks": {
+                        "petEvent": {
+                            "petCallbackUrl": {
+                                "get": {
+                                    "responses": {
+                                        "200": {
+                                            "content": {
+                                                "application/json": {
+                                                    "schema": PetSchema
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+        )
+        p = get_paths(spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        get = c["get"]
+        assert get["responses"]["200"]["content"]["application/json"][
+            "schema"
+        ] == build_ref(spec, "schema", "Pet")
+
     @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True)
     def test_schema_uses_ref_in_parameters_and_request_body_if_available_v2(
         self, spec_fixture
@@ -648,6 +806,27 @@ class TestOperationHelper:
         schema_ref = post["requestBody"]["content"]["application/json"]["schema"]
         assert schema_ref == build_ref(spec_fixture.spec, "schema", "Pet")
 
+    @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True)
+    def test_callback_schema_uses_ref_in_parameters_and_request_body_if_available_v3(
+        self, make_pet_callback_spec
+    ):
+        spec_fixture = make_pet_callback_spec(
+            {
+                "get": {"parameters": [{"in": "query", "schema": PetSchema}]},
+                "post": {
+                    "requestBody": {
+                        "content": {"application/json": {"schema": PetSchema}}
+                    }
+                },
+            }
+        )
+        p = get_paths(spec_fixture.spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        assert "schema" in c["get"]["parameters"][0]
+        post = c["post"]
+        schema_ref = post["requestBody"]["content"]["application/json"]["schema"]
+        assert schema_ref == build_ref(spec_fixture.spec, "schema", "Pet")
+
     @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True)
     def test_schema_array_uses_ref_if_available_v2(self, spec_fixture):
         spec_fixture.spec.components.schema("Pet", schema=PetSchema)
@@ -720,6 +899,51 @@ class TestOperationHelper:
         ]
         assert response_schema == resolved_schema
 
+    @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True)
+    def test_callback_schema_array_uses_ref_if_available_v3(
+        self, make_pet_callback_spec
+    ):
+        spec_fixture = make_pet_callback_spec(
+            {
+                "get": {
+                    "parameters": [
+                        {
+                            "name": "Pet",
+                            "in": "query",
+                            "content": {
+                                "application/json": {
+                                    "schema": {"type": "array", "items": PetSchema}
+                                }
+                            },
+                        }
+                    ],
+                    "responses": {
+                        "200": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {"type": "array", "items": PetSchema}
+                                }
+                            }
+                        }
+                    },
+                }
+            }
+        )
+        p = get_paths(spec_fixture.spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        get = c["get"]
+        assert len(get["parameters"]) == 1
+        resolved_schema = {
+            "type": "array",
+            "items": build_ref(spec_fixture.spec, "schema", "Pet"),
+        }
+        request_schema = get["parameters"][0]["content"]["application/json"]["schema"]
+        assert request_schema == resolved_schema
+        response_schema = get["responses"]["200"]["content"]["application/json"][
+            "schema"
+        ]
+        assert response_schema == resolved_schema
+
     @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True)
     def test_schema_partially_v2(self, spec_fixture):
         spec_fixture.spec.components.schema("Pet", schema=PetSchema)
@@ -784,6 +1008,40 @@ class TestOperationHelper:
             },
         }
 
+    @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True)
+    def test_callback_schema_partially_v3(self, make_pet_callback_spec):
+        spec_fixture = make_pet_callback_spec(
+            {
+                "get": {
+                    "responses": {
+                        "200": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "type": "object",
+                                        "properties": {
+                                            "mother": PetSchema,
+                                            "father": PetSchema,
+                                        },
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        )
+        p = get_paths(spec_fixture.spec)["/pet"]
+        c = p["post"]["callbacks"]["petEvent"]["petCallbackUrl"]
+        get = c["get"]
+        assert get["responses"]["200"]["content"]["application/json"]["schema"] == {
+            "type": "object",
+            "properties": {
+                "mother": build_ref(spec_fixture.spec, "schema", "Pet"),
+                "father": build_ref(spec_fixture.spec, "schema", "Pet"),
+            },
+        }
+
     def test_parameter_reference(self, spec_fixture):
         if spec_fixture.spec.openapi_version.major < 3:
             param = {"schema": PetSchema}