Repository: marshmallow-code/apispec
Pull request #702 (2021-07-29 16:09:37)

Lazy component registration
Add lazy component registration feature.

Allows to register components only if they are actually referenced in the spec.

This can be useful for a framework that defines headers (pagination, ETag header) before knowing if the feature is actually used in the code. It avoids defining useless components. E.g. https://github.com/marshmallow-code/flask-smorest/issues/236.

Security schemes are not included in the feature because they are a bit more tricky and I though it was not critical. But I wouldn't object to including them if someone is willing to implement that.


Patch:
diff --git a/docs/special_topics.rst b/docs/special_topics.rst
index 43170a1..8c6f3de 100644
--- a/docs/special_topics.rst
+++ b/docs/special_topics.rst
@@ -76,6 +76,13 @@ The ``APISpec`` object contains helpers to add top-level components:
    * - Example
      - `spec.components.response <apispec.core.Components.example>`
      - 3
+   * - Security scheme
+     - `spec.components.response <apispec.core.Components.security_scheme>`
+     - 2, 3
+
+Most component registration methods provide a ``lazy`` keyword argument,
+allowing to define a component but only publish it in the generated
+documentation if it is actually referenced.
 
 To add other top-level objects, pass them to the ``APISpec`` as keyword arguments.
 
@@ -122,11 +129,6 @@ Here is an example that includes a `Server Object <https://github.com/OAI/OpenAP
     validate_spec(spec)
 
 
-When adding components, the main advantage of using dedicated methods over
-passing them as kwargs is the ability to use plugin helpers. For instance,
-`MarshmallowPlugin <apispec.ext.marshmallow.MarshmallowPlugin>` has helpers to
-resolve schemas in parameters and responses.
-
 Documenting Security Schemes
 ----------------------------
 
diff --git a/src/apispec/core.py b/src/apispec/core.py
index fcf1264..e7b0abd 100644
--- a/src/apispec/core.py
+++ b/src/apispec/core.py
@@ -35,9 +35,13 @@ class Components:
         self.headers = {}
         self.examples = {}
         self.security_schemes = {}
+        self.schemas_lazy = {}
+        self.responses_lazy = {}
+        self.parameters_lazy = {}
+        self.headers_lazy = {}
+        self.examples_lazy = {}
 
-    def to_dict(self):
-        subsections = {
+        self._subsections = {
             "schema": self.schemas,
             "response": self.responses,
             "parameter": self.parameters,
@@ -45,18 +49,60 @@ class Components:
             "example": self.examples,
             "security_scheme": self.security_schemes,
         }
+        self._subsections_lazy = {
+            "schema": self.schemas_lazy,
+            "response": self.responses_lazy,
+            "parameter": self.parameters_lazy,
+            "header": self.headers_lazy,
+            "example": self.examples_lazy,
+        }
+
+    def to_dict(self):
         return {
             COMPONENT_SUBSECTIONS[self.openapi_version.major][k]: v
-            for k, v in subsections.items()
+            for k, v in self._subsections.items()
             if v != {}
         }
 
-    def schema(self, component_id, component=None, **kwargs):
+    def _register_component(self, obj_type, component_id, component, *, lazy=False):
+        subsection = (self._subsections if lazy is False else self._subsections_lazy)[
+            obj_type
+        ]
+        subsection[component_id] = component
+
+    def _do_register_lazy_component(self, obj_type, component_id):
+        component_buffer = self._subsections_lazy[obj_type]
+        # If component was lazy registered, register it for real
+        if component_id in component_buffer:
+            self._subsections[obj_type][component_id] = component_buffer.pop(
+                component_id
+            )
+
+    def get_ref(self, obj_type, obj_or_component_id):
+        """Return object or reference
+
+        If obj is a dict, it is assumed to be a complete description and it is returned as is.
+        Otherwise, it is assumed to be a reference name as string and the corresponding $ref
+        string is returned.
+
+        :param str subsection: "schema", "parameter", "response" or "security_scheme"
+        :param dict|str obj: object in dict form or as ref_id string
+        """
+        if isinstance(obj_or_component_id, dict):
+            return obj_or_component_id
+        # Register the component if it was lazy registered
+        self._do_register_lazy_component(obj_type, obj_or_component_id)
+        return build_reference(
+            obj_type, self.openapi_version.major, obj_or_component_id
+        )
+
+    def schema(self, component_id, component=None, *, lazy=False, **kwargs):
         """Add a new schema to the spec.
 
-        :param str component_id: identifier by which schema may be referenced.
-        :param dict component: schema definition.
+        :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
 
         .. note::
 
@@ -90,15 +136,16 @@ class Components:
             except PluginMethodNotImplementedError:
                 continue
         self._resolve_refs_in_schema(ret)
-        self.schemas[component_id] = ret
+        self._register_component("schema", component_id, ret, lazy=lazy)
         return self
 
-    def response(self, component_id, component=None, **kwargs):
+    def response(self, component_id, component=None, *, lazy=False, **kwargs):
         """Add a response which can be referenced.
 
         :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
         """
         if component_id in self.responses:
             raise DuplicateComponentNameError(
@@ -113,16 +160,19 @@ class Components:
             except PluginMethodNotImplementedError:
                 continue
         self._resolve_refs_in_response(ret)
-        self.responses[component_id] = ret
+        self._register_component("response", component_id, ret, lazy=lazy)
         return self
 
-    def parameter(self, component_id, location, component=None, **kwargs):
+    def parameter(
+        self, component_id, location, component=None, *, lazy=False, **kwargs
+    ):
         """Add a parameter which can be referenced.
 
-        :param str component_id: identifier by which parameter may be referenced.
-        :param str location: location of the parameter.
-        :param dict component: parameter fields.
+        :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
         """
         if component_id in self.parameters:
             raise DuplicateComponentNameError(
@@ -144,14 +194,15 @@ class Components:
             except PluginMethodNotImplementedError:
                 continue
         self._resolve_refs_in_parameter_or_header(ret)
-        self.parameters[component_id] = ret
+        self._register_component("parameter", component_id, ret, lazy=lazy)
         return self
 
-    def header(self, component_id, component):
+    def header(self, component_id, component, *, lazy=False):
         """Add a header which can be referenced.
 
-        :param str component_id: identifier by which header may be referenced.
-        :param dict component: header fields.
+        :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
 
         https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#headerObject
         """
@@ -160,14 +211,15 @@ class Components:
                 f'Another header with name "{component_id}" is already registered.'
             )
         self._resolve_refs_in_parameter_or_header(component)
-        self.headers[component_id] = component
+        self._register_component("header", component_id, component, lazy=lazy)
         return self
 
-    def example(self, component_id, component):
+    def example(self, component_id, component, *, lazy=False):
         """Add an example which can be referenced
 
-        :param str component_id: identifier by which example may be referenced.
-        :param dict component: example fields.
+        :param str component_id: identifier by which example may be referenced
+        :param dict component: example fields
+        :param bool lazy: register component only when referenced in the spec
 
         https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject
         """
@@ -175,7 +227,7 @@ class Components:
             raise DuplicateComponentNameError(
                 f'Another example with name "{component_id}" is already registered.'
             )
-        self.examples[component_id] = component
+        self._register_component("example", component_id, component, lazy=lazy)
         return self
 
     def security_scheme(self, component_id, component):
@@ -188,23 +240,9 @@ class Components:
             raise DuplicateComponentNameError(
                 f'Another security scheme with name "{component_id}" is already registered.'
             )
-        self.security_schemes[component_id] = component
+        self._register_component("security_scheme", component_id, component)
         return self
 
-    def get_ref(self, obj_type, obj):
-        """Return object or reference
-
-        If obj is a dict, it is assumed to be a complete description and it is returned as is.
-        Otherwise, it is assumed to be a reference name as string and the corresponding $ref
-        string is returned.
-
-        :param str obj_type: "schema", "parameter", "response" or "security_scheme"
-        :param dict|str obj: object in dict form or as ref_id string
-        """
-        if isinstance(obj, dict):
-            return obj
-        return build_reference(obj_type, self.openapi_version.major, obj)
-
     def _resolve_schema(self, obj):
         """Replace schema reference as string with a $ref if needed
 


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 66e2ff8..5eace70 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -515,6 +515,82 @@ class TestComponents(RefsSchemaTestMixin):
         spec.components.header("header", header)
         self.assert_schema_refs(spec, get_headers(spec)["header"]["schema"])
 
+    def test_schema_lazy(self, spec):
+        spec.components.schema("Pet_1", {"properties": self.properties}, lazy=False)
+        spec.components.schema("Pet_2", {"properties": self.properties}, lazy=True)
+        schemas = get_schemas(spec)
+        assert "Pet_1" in schemas
+        assert "Pet_2" not in schemas
+        spec.components.schema("PetFriend", {"oneOf": ["Pet_1", "Pet_2"]})
+        schemas = get_schemas(spec)
+        assert "Pet_2" in schemas
+        assert schemas["Pet_2"]["properties"] == self.properties
+
+    def test_response_lazy(self, spec):
+        response_1 = {"description": "Reponse 1"}
+        response_2 = {"description": "Reponse 2"}
+        spec.components.response("Response_1", response_1, lazy=False)
+        spec.components.response("Response_2", response_2, lazy=True)
+        responses = get_responses(spec)
+        assert "Response_1" in responses
+        assert "Response_2" not in responses
+        spec.path("/path", operations={"get": {"responses": {"200": "Response_2"}}})
+        responses = get_responses(spec)
+        assert "Response_2" in responses
+
+    def test_parameter_lazy(self, spec):
+        parameter = {"format": "int64", "type": "integer"}
+        spec.components.parameter("Param_1", "path", parameter, lazy=False)
+        spec.components.parameter("Param_2", "path", parameter, lazy=True)
+        params = get_parameters(spec)
+        assert "Param_1" in params
+        assert "Param_2" not in params
+        spec.path("/path", operations={"get": {"parameters": ["Param_1", "Param_2"]}})
+        assert "Param_2" in params
+
+    # Referenced headers are only supported in OAS 3.x
+    @pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
+    def test_header_lazy(self, spec):
+        header = {"schema": {"type": "string"}}
+        spec.components.header("Header_1", header, lazy=False)
+        spec.components.header("Header_2", header, lazy=True)
+        headers = get_headers(spec)
+        assert "Header_1" in headers
+        assert "Header_2" not in headers
+        spec.path(
+            "/path",
+            operations={
+                "get": {"responses": {"200": {"headers": {"header_2": "Header_2"}}}}
+            },
+        )
+        assert "Header_2" in headers
+
+    # Referenced examples are only supported in OAS 3.x
+    @pytest.mark.parametrize("spec", ("3.0.0",), indirect=True)
+    def test_example_lazy(self, spec):
+        spec.components.example("Example_1", {"value": {"a": "b"}}, lazy=False)
+        spec.components.example("Example_2", {"value": {"a": "b"}}, lazy=True)
+        examples = get_examples(spec)
+        assert "Example_1" in examples
+        assert "Example_2" not in examples
+        spec.path(
+            "/path",
+            operations={
+                "get": {
+                    "responses": {
+                        "200": {
+                            "content": {
+                                "application/json": {
+                                    "examples": {"example_2": "Example_2"}
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+        )
+        assert "Example_2" in examples
+
 
 class TestPath(RefsSchemaTestMixin):
     paths = {