Repository: marshmallow-code/apispec
Pull request #703 (2021-08-10 19:06:22)

Add header helper
- Add BasePlugin.header_helper
- Add MarshmallowPlugin.header_helper


Patch:
diff --git a/src/apispec/core.py b/src/apispec/core.py
index e7b0abd..21e9bb0 100644
--- a/src/apispec/core.py
+++ b/src/apispec/core.py
@@ -101,8 +101,8 @@ class Components:
 
         :param str component_id: identifier by which schema may be referenced
         :param dict component: schema definition
-        :param kwargs: plugin-specific arguments
         :param bool lazy: register component only when referenced in the spec
+        :param kwargs: plugin-specific arguments
 
         .. note::
 
@@ -144,8 +144,8 @@ class Components:
 
         :param str component_id: ref_id to use as reference
         :param dict component: response fields
-        :param kwargs: plugin-specific arguments
         :param bool lazy: register component only when referenced in the spec
+        :param kwargs: plugin-specific arguments
         """
         if component_id in self.responses:
             raise DuplicateComponentNameError(
@@ -171,8 +171,8 @@ class Components:
         :param str component_id: identifier by which parameter may be referenced
         :param str location: location of the parameter
         :param dict component: parameter fields
-        :param kwargs: plugin-specific arguments
         :param bool lazy: register component only when referenced in the spec
+        :param kwargs: plugin-specific arguments
         """
         if component_id in self.parameters:
             raise DuplicateComponentNameError(
@@ -197,21 +197,30 @@ class Components:
         self._register_component("parameter", component_id, ret, lazy=lazy)
         return self
 
-    def header(self, component_id, component, *, lazy=False):
+    def header(self, component_id, component, *, lazy=False, **kwargs):
         """Add a header which can be referenced.
 
         :param str component_id: identifier by which header may be referenced
         :param dict component: header fields
         :param bool lazy: register component only when referenced in the spec
+        :param kwargs: plugin-specific arguments
 
         https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#headerObject
         """
+        component = component or {}
+        ret = component.copy()
         if component_id in self.headers:
             raise DuplicateComponentNameError(
                 f'Another header with name "{component_id}" is already registered.'
             )
-        self._resolve_refs_in_parameter_or_header(component)
-        self._register_component("header", component_id, component, lazy=lazy)
+        # Execute all helpers from plugins
+        for plugin in self._plugins:
+            try:
+                ret.update(plugin.header_helper(component, **kwargs) or {})
+            except PluginMethodNotImplementedError:
+                continue
+        self._resolve_refs_in_parameter_or_header(ret)
+        self._register_component("header", component_id, ret, lazy=lazy)
         return self
 
     def example(self, component_id, component, *, lazy=False):
diff --git a/src/apispec/ext/marshmallow/__init__.py b/src/apispec/ext/marshmallow/__init__.py
index 01f0b4b..3289dc3 100644
--- a/src/apispec/ext/marshmallow/__init__.py
+++ b/src/apispec/ext/marshmallow/__init__.py
@@ -174,7 +174,6 @@ class MarshmallowPlugin(BasePlugin):
         :param dict parameter: parameter fields. May contain a marshmallow
             Schema class or instance.
         """
-        # In OpenAPIv3, this only works when using the complex form using "content"
         self.resolver.resolve_schema(parameter)
         return parameter
 
@@ -188,6 +187,16 @@ class MarshmallowPlugin(BasePlugin):
         self.resolver.resolve_response(response)
         return response
 
+    def header_helper(self, header, **kwargs):
+        """Header component helper that allows using a marshmallow
+        :class:`Schema <marshmallow.Schema>` in header definition.
+
+        :param dict header: header fields. May contain a marshmallow
+            Schema class or instance.
+        """
+        self.resolver.resolve_schema(header)
+        return header
+
     def operation_helper(self, operations, **kwargs):
         self.resolver.resolve_operations(operations)
 
diff --git a/src/apispec/ext/marshmallow/schema_resolver.py b/src/apispec/ext/marshmallow/schema_resolver.py
index 5c6afb7..f6f2675 100644
--- a/src/apispec/ext/marshmallow/schema_resolver.py
+++ b/src/apispec/ext/marshmallow/schema_resolver.py
@@ -219,7 +219,7 @@ class SchemaResolver:
         if not isinstance(data, dict):
             return
 
-        # OAS 2 component or OAS 3 header
+        # OAS 2 component or OAS 3 parameter or header
         if "schema" in data:
             data["schema"] = self.resolve_schema_dict(data["schema"])
         # OAS 3 component except header
diff --git a/src/apispec/plugin.py b/src/apispec/plugin.py
index b0384dc..8ae9e7e 100644
--- a/src/apispec/plugin.py
+++ b/src/apispec/plugin.py
@@ -38,6 +38,14 @@ class BasePlugin:
         """
         raise PluginMethodNotImplementedError
 
+    def header_helper(self, header, **kwargs):
+        """May return header component description as a dict.
+
+        :param dict header: Header fields
+        :param kwargs: All additional keywords arguments sent to `APISpec.header()`
+        """
+        raise PluginMethodNotImplementedError
+
     def path_helper(self, path=None, operations=None, parameters=None, **kwargs):
         """May return a path as string and mutate operations dict and parameters list.
 


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 5eace70..763029f 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1040,6 +1040,10 @@ class TestPlugins:
                 if not return_none:
                     return {"description": "42"}
 
+            def header_helper(self, header, **kwargs):
+                if not return_none:
+                    return {"description": "some header"}
+
             def path_helper(self, path, operations, parameters, **kwargs):
                 if not return_none:
                     if path == "/path_1":
@@ -1105,6 +1109,24 @@ class TestPlugins:
         else:
             assert responses["Pet"] == {"description": "42"}
 
+    @pytest.mark.parametrize("openapi_version", ("3.0.0",))
+    @pytest.mark.parametrize("return_none", (True, False))
+    def test_plugin_header_helper_is_used(self, openapi_version, return_none):
+        spec = APISpec(
+            title="Swagger Petstore",
+            version="1.0.0",
+            openapi_version=openapi_version,
+            plugins=(self.test_plugin_factory(return_none),),
+        )
+        spec.components.header("Pet", {})
+        headers = get_headers(spec)
+        if return_none:
+            assert headers["Pet"] == {}
+        else:
+            assert headers["Pet"] == {
+                "description": "some header",
+            }
+
     @pytest.mark.parametrize("openapi_version", ("2.0", "3.0.0"))
     @pytest.mark.parametrize("return_none", (True, False))
     def test_plugin_path_helper_is_used(self, openapi_version, return_none):
diff --git a/tests/test_ext_marshmallow.py b/tests/test_ext_marshmallow.py
index 8477ba6..97369fc 100644
--- a/tests/test_ext_marshmallow.py
+++ b/tests/test_ext_marshmallow.py
@@ -21,7 +21,14 @@ from .schemas import (
     AnalysisWithListSchema,
 )
 
-from .utils import get_schemas, get_parameters, get_responses, get_paths, build_ref
+from .utils import (
+    get_schemas,
+    get_parameters,
+    get_responses,
+    get_headers,
+    get_paths,
+    build_ref,
+)
 
 
 class TestDefinitionHelper:
@@ -220,17 +227,26 @@ class TestDefinitionHelper:
 class TestComponentParameterHelper:
     @pytest.mark.parametrize("schema", [PetSchema, PetSchema()])
     def test_can_use_schema_in_parameter(self, spec, schema):
-        if spec.openapi_version.major < 3:
-            param = {"schema": schema}
-        else:
-            param = {"content": {"application/json": {"schema": schema}}}
+        param = {"schema": schema}
         spec.components.parameter("Pet", "body", param)
         parameter = get_parameters(spec)["Pet"]
         assert parameter["in"] == "body"
-        if spec.openapi_version.major < 3:
-            reference = parameter["schema"]
-        else:
-            reference = parameter["content"]["application/json"]["schema"]
+        reference = parameter["schema"]
+        assert reference == build_ref(spec, "schema", "Pet")
+
+        resolved_schema = spec.components.schemas["Pet"]
+        assert resolved_schema["properties"]["name"]["type"] == "string"
+        assert resolved_schema["properties"]["password"]["type"] == "string"
+        assert resolved_schema["properties"]["id"]["type"] == "integer"
+
+    @pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
+    @pytest.mark.parametrize("schema", [PetSchema, PetSchema()])
+    def test_can_use_schema_in_parameter_with_content(self, spec, schema):
+        param = {"content": {"application/json": {"schema": schema}}}
+        spec.components.parameter("Pet", "body", param)
+        parameter = get_parameters(spec)["Pet"]
+        assert parameter["in"] == "body"
+        reference = parameter["content"]["application/json"]["schema"]
         assert reference == build_ref(spec, "schema", "Pet")
 
         resolved_schema = spec.components.schemas["Pet"]
@@ -280,6 +296,22 @@ class TestComponentResponseHelper:
         assert response == resp
 
 
+class TestComponentHeaderHelper:
+    @pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
+    @pytest.mark.parametrize("schema", [PetSchema, PetSchema()])
+    def test_can_use_schema_in_header(self, spec, schema):
+        param = {"schema": schema}
+        spec.components.header("Pet", param)
+        header = get_headers(spec)["Pet"]
+        reference = header["schema"]
+        assert reference == build_ref(spec, "schema", "Pet")
+
+        resolved_schema = spec.components.schemas["Pet"]
+        assert resolved_schema["properties"]["name"]["type"] == "string"
+        assert resolved_schema["properties"]["password"]["type"] == "string"
+        assert resolved_schema["properties"]["id"]["type"] == "integer"
+
+
 class TestCustomField:
     def test_can_use_custom_field_decorator(self, spec_fixture):
         @spec_fixture.marshmallow_plugin.map_to_openapi_type(DateTime)