Repository: marshmallow-code/apispec
Pull request #455 (2019-06-12 12:48:20)

Add support for path level parameters
For #453 

* Create `clean_parameters` function to avoid duplicate code
* Extracted `get_ref` from `clean_operations` to reuse it in
  `clean_parameters`
* Added `parameters` argument to `APISpec.path()`
* Sending `parameters` argument to `plugin.path_helper` as a kwargs,
  allowing plugins to update it
* Added tests for global parameters feature
* Added tests for duplicate parameters in operations
* Added name to existing parameters in tests as required by OpenAPI
  specification
* Now raising an `APISpecError` in case of duplicate parameters in
  path / operations


Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 485b4c5..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-open_collective: "marshmallow"
-tidelift: "pypi/apispec"
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 2c62f64..6422733 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -57,3 +57,4 @@ Contributors (chronological)
 - Szabolcs Blága `@blagasz <https://github.com/blagasz>`_
 - Andrew Johnson `@andrjohn <https://github.com/andrjohn>`_
 - Dave `@zedrdave <https://github.com/zedrdave>`_
+- Emmanuel Valette `@karec <https://github.com/karec/>`_
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 4bc1381..a7a1bc4 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,4 +1,4 @@
-marshmallow==2.19.4
+marshmallow==2.19.2
 pyyaml==5.1.1
 sphinx==2.1.1
 sphinx-issues==1.2.0
diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst
index 121349d..e9a2cb2 100644
--- a/docs/writing_plugins.rst
+++ b/docs/writing_plugins.rst
@@ -37,6 +37,54 @@ A plugin with a path helper function may look something like this:
             return Path(path=path, operations=operations)
 
 
+All plugin helpers must accept extra `**kwargs`, allowing custom plugins to define new arguments if required.
+
+A plugin with an operation helper that adds `deprecated` flag may look like this
+
+.. code-block:: python
+
+   # deprecated_plugin.py
+
+   from apispec import BasePlugin
+   from apispec.compat import iteritems
+   from apispec.yaml_utils import load_operations_from_docstring
+
+
+   class DeprecatedPlugin(BasePlugin):
+       def operation_helper(self, path, operations, **kwargs):
+           """Operation helper that add `deprecated` flag if in `kwargs`
+           """
+           if kwargs.pop("deprecated", False) is True:
+               for key, value in iteritems(operations):
+                   value["deprecated"] = True
+
+
+Using this plugin
+
+.. code-block:: python
+
+   import json
+   from apispec import APISpec
+   from deprecated_plugin import DeprecatedPlugin
+
+   spec = APISpec(
+       title="Gisty",
+       version="1.0.0",
+       openapi_version="3.0.2",
+       plugins=[DeprecatedPlugin()],
+   )
+
+   # path will call operation_helper on his operations
+   spec.path(
+       path="/gists/{gist_id}",
+       operations={"get": {"responses": {"200": {"description": "standard response"}}}},
+       deprecated=True,
+   )
+   print(json.dumps(spec.to_dict()["paths"]))
+   # {"/gists/{gist_id}": {"get": {"responses": {"200": {"description": "standard response"}}, "deprecated": true}}}
+
+
+
 The ``init_spec`` Method
 ------------------------
 
@@ -111,5 +159,5 @@ Next Steps
 To learn more about how to write plugins:
 
 * Consult the :doc:`Core API docs <api_core>` for `BasePlugin <apispec.BasePlugin>`
-* View the source for an existing apispec plugin, e.g. `FlaskPlugin <https://github.com/marshmallow-code/apispec-webframeworks/blob/master/apispec_webframeworks/flask.py>`_.
+* View the source for an existing apispec plugin, e.g. `FlaskPlugin <https://github.com/marshmallow-code/apispec-webframeworks/blob/master/src/apispec_webframeworks/flask.py>`_.
 * Check out some projects using apispec: https://github.com/marshmallow-code/apispec/wiki/Ecosystem
diff --git a/src/apispec/core.py b/src/apispec/core.py
index b26699a..88f8fa1 100644
--- a/src/apispec/core.py
+++ b/src/apispec/core.py
@@ -8,6 +8,8 @@ from .exceptions import (
     APISpecError,
     PluginMethodNotImplementedError,
     DuplicateComponentNameError,
+    DuplicateParameterError,
+    InvalidParameterError,
 )
 from .utils import OpenAPIVersion, deepupdate, COMPONENT_SUBSECTIONS, build_reference
 
@@ -18,6 +20,60 @@ VALID_METHODS_OPENAPI_V3 = VALID_METHODS_OPENAPI_V2 + ["trace"]
 VALID_METHODS = {2: VALID_METHODS_OPENAPI_V2, 3: VALID_METHODS_OPENAPI_V3}
 
 
+def get_ref(obj_type, obj, openapi_major_version):
+    """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: "parameter" or "response"
+    :param dict|str obj: parameter or response in dict form or as ref_id string
+    :param int openapi_major_version: The major version of the OpenAPI standard
+    """
+    if isinstance(obj, dict):
+        return obj
+    return build_reference(obj_type, openapi_major_version, obj)
+
+
+def clean_parameters(parameters, openapi_major_version):
+    """Ensure that all parameters with "in" equal to "path" are also required
+    as required by the OpenAPI specification, as well as normalizing any
+    references to global parameters and checking for duplicates parameters
+
+    See https ://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject.
+
+    :param list parameters: List of parameters mapping
+    :param int openapi_major_version: The major version of the OpenAPI standard
+    """
+    seen = set()
+    for parameter in [p for p in parameters if isinstance(p, dict)]:
+
+        # check missing name / location
+        missing_attrs = [attr for attr in ("name", "in") if attr not in parameter]
+        if missing_attrs:
+            raise InvalidParameterError(
+                "Missing keys {} for parameter".format(missing_attrs)
+            )
+
+        # OpenAPI Spec 3 and 2 don't allow for duplicated parameters
+        # A unique parameter is defined by a combination of a name and location
+        unique_key = (parameter["name"], parameter["in"])
+        if unique_key in seen:
+            raise DuplicateParameterError(
+                "Duplicate parameter with name {} and location {}".format(
+                    parameter["name"], parameter["in"]
+                )
+            )
+        seen.add(unique_key)
+
+        # Add "required" attribute to path parameters
+        if parameter["in"] == "path":
+            parameter["required"] = True
+
+    return [get_ref("parameter", p, openapi_major_version) for p in parameters]
+
+
 def clean_operations(operations, openapi_major_version):
     """Ensure that all parameters with "in" equal to "path" are also required
     as required by the OpenAPI specification, as well as normalizing any
@@ -39,34 +95,11 @@ def clean_operations(operations, openapi_major_version):
             "One or more HTTP methods are invalid: {}".format(", ".join(invalid))
         )
 
-    def get_ref(obj_type, obj, openapi_major_version):
-        """Return object or rererence
-
-        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: "parameter" or "response"
-        :param dict|str obj: parameter or response in dict form or as ref_id string
-        :param int openapi_major_version: The major version of the OpenAPI standard
-        """
-        if isinstance(obj, dict):
-            return obj
-        return build_reference(obj_type, openapi_major_version, obj)
-
     for operation in (operations or {}).values():
         if "parameters" in operation:
-            parameters = operation["parameters"]
-            for parameter in parameters:
-                if (
-                    isinstance(parameter, dict)
-                    and "in" in parameter
-                    and parameter["in"] == "path"
-                ):
-                    parameter["required"] = True
-            operation["parameters"] = [
-                get_ref("parameter", p, openapi_major_version) for p in parameters
-            ]
+            operation["parameters"] = clean_parameters(
+                operation["parameters"], openapi_major_version
+            )
         if "responses" in operation:
             responses = OrderedDict()
             for code, response in iteritems(operation["responses"]):
@@ -164,6 +197,11 @@ class Components(object):
         ret = component.copy()
         ret.setdefault("name", component_id)
         ret["in"] = location
+
+        # if "in" is set to "path", enforce required flag to True
+        if location == "path":
+            ret["required"] = True
+
         # Execute all helpers from plugins
         for plugin in self._plugins:
             try:
@@ -277,7 +315,13 @@ class APISpec(object):
         return self
 
     def path(
-        self, path=None, operations=None, summary=None, description=None, **kwargs
+        self,
+        path=None,
+        operations=None,
+        summary=None,
+        description=None,
+        parameters=None,
+        **kwargs
     ):
         """Add a new path object to the spec.
 
@@ -287,14 +331,18 @@ class APISpec(object):
         :param dict|None operations: describes the http methods and options for `path`
         :param str summary: short summary relevant to all operations in this path
         :param str description: long description relevant to all operations in this path
+        :param list|None parameters: list of parameters relevant in this path
         :param dict kwargs: parameters used by any path helpers see :meth:`register_path_helper`
         """
         operations = operations or OrderedDict()
+        parameters = parameters or []
 
         # Execute path helpers
         for plugin in self.plugins:
             try:
-                ret = plugin.path_helper(path=path, operations=operations, **kwargs)
+                ret = plugin.path_helper(
+                    path=path, operations=operations, parameters=parameters, **kwargs
+                )
             except PluginMethodNotImplementedError:
                 continue
             if ret is not None:
@@ -316,4 +364,7 @@ class APISpec(object):
             self._paths[path]["summary"] = summary
         if description is not None:
             self._paths[path]["description"] = description
+        if parameters:
+            parameters = clean_parameters(parameters, self.openapi_version.major)
+            self._paths[path]["parameters"] = parameters
         return self
diff --git a/src/apispec/exceptions.py b/src/apispec/exceptions.py
index 7bcabe4..5a8bff1 100644
--- a/src/apispec/exceptions.py
+++ b/src/apispec/exceptions.py
@@ -14,5 +14,13 @@ class DuplicateComponentNameError(APISpecError):
     """Raised when registering two components with the same name"""
 
 
+class DuplicateParameterError(APISpecError):
+    """Raised when registering a parameter already existing in a given scope"""
+
+
+class InvalidParameterError(APISpecError):
+    """Raised when parameter doesn't contains required keys"""
+
+
 class OpenAPIError(APISpecError):
     """Raised when a OpenAPI spec validation fails."""
diff --git a/src/apispec/ext/marshmallow/__init__.py b/src/apispec/ext/marshmallow/__init__.py
index 17845cd..230b674 100644
--- a/src/apispec/ext/marshmallow/__init__.py
+++ b/src/apispec/ext/marshmallow/__init__.py
@@ -84,18 +84,18 @@ class MarshmallowPlugin(BasePlugin):
     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
+            if isinstance(parameter, dict) and not isinstance(
+                parameter.get("schema", {}), dict
             ):
-                schema_instance = resolve_schema_instance(parameter.pop("schema"))
-                resolved += self.openapi.schema2parameters(
-                    schema_instance, default_in=parameter.pop("in"), **parameter
-                )
-            else:
-                self.resolve_schema(parameter)
-                resolved.append(parameter)
+                schema_instance = resolve_schema_instance(parameter["schema"])
+                if "in" in parameter:
+                    del parameter["schema"]
+                    resolved += self.openapi.schema2parameters(
+                        schema_instance, default_in=parameter.pop("in"), **parameter
+                    )
+                    continue
+            self.resolve_schema(parameter)
+            resolved.append(parameter)
         return resolved
 
     def resolve_schema_in_request_body(self, request_body):
diff --git a/src/apispec/plugin.py b/src/apispec/plugin.py
index fce1a71..f6ced1a 100644
--- a/src/apispec/plugin.py
+++ b/src/apispec/plugin.py
@@ -15,23 +15,40 @@ class BasePlugin(object):
         """
 
     def schema_helper(self, name, definition, **kwargs):
-        """May return definition as a dict."""
+        """May return definition as a dict.
+
+        :param str name: Identifier by which schema may be referenced
+        :param dict definition: Schema definition
+        :param dict kwargs: All additional keywords arguments sent to `APISpec.schema()`
+        """
         raise PluginMethodNotImplementedError
 
     def parameter_helper(self, parameter, **kwargs):
-        """May return parameter component description as a dict."""
+        """May return parameter component description as a dict.
+
+        :param dict parameter: Parameter fields
+        :param dict kwargs: All additional keywords arguments sent to `APISpec.parameter()`
+        """
         raise PluginMethodNotImplementedError
 
     def response_helper(self, response, **kwargs):
-        """May return response component description as a dict."""
+        """May return response component description as a dict.
+
+        :param dict response: Response fields
+        :param dict kwargs: All additional keywords arguments sent to `APISpec.response()`
+        """
         raise PluginMethodNotImplementedError
 
-    def path_helper(self, path=None, operations=None, **kwargs):
-        """May return a path as string and mutate operations dict.
+    def path_helper(self, path=None, operations=None, parameters=None, **kwargs):
+        """May return a path as string and mutate operations dict and parameters list.
 
         :param str path: Path to the resource
         :param dict operations: A `dict` mapping HTTP methods to operation object. See
             https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
+        :param list parameters: A `list` of parameters objects or references for the path. See
+            https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#parameterObject
+            and https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#referenceObject
+        :param dict kwargs: All additional keywords arguments sent to `APISpec.path()`
 
         Return value should be a string or None. If a string is returned, it
         is set as the path.
@@ -46,6 +63,8 @@ class BasePlugin(object):
         """May mutate operations.
 
         :param str path: Path to the resource
-        :param dict operations: A `dict` mapping HTTP methods to operation object. See
+        :param dict operations: A `dict` mapping HTTP methods to operation object.
+            See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operationObject
+        :param dict kwargs: All additional keywords arguments sent to `APISpec.path()`
         """
         raise PluginMethodNotImplementedError


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 986012e..5ad5dbf 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -6,7 +6,12 @@ import sys
 import yaml
 
 from apispec import APISpec, BasePlugin
-from apispec.exceptions import APISpecError, DuplicateComponentNameError
+from apispec.exceptions import (
+    APISpecError,
+    DuplicateComponentNameError,
+    DuplicateParameterError,
+    InvalidParameterError,
+)
 
 from .utils import (
     get_schemas,
@@ -185,6 +190,7 @@ class TestComponents:
             "type": "integer",
             "in": "path",
             "name": "PetId",
+            "required": True,
         }
 
     def test_parameter_is_chainable(self, spec):
@@ -399,6 +405,85 @@ class TestPath:
                 == metadata["components"]["parameters"]["test_parameter"]
             )
 
+    @pytest.mark.parametrize(
+        "parameters",
+        ([{"name": "petId"}], [{"in": "path"}]),  # missing "in"  # missing "name"
+    )
+    def test_invalid_parameter(self, spec, parameters):
+        path = "/pet/{petId}"
+
+        with pytest.raises(InvalidParameterError):
+            spec.path(path=path, operations=dict(put={}, get={}), parameters=parameters)
+
+    def test_parameter_duplicate(self, spec):
+        spec.path(
+            path="/pet/{petId}",
+            operations={
+                "get": {
+                    "parameters": [
+                        {"name": "petId", "in": "path"},
+                        {"name": "petId", "in": "query"},
+                    ]
+                }
+            },
+        )
+
+        with pytest.raises(DuplicateParameterError):
+            spec.path(
+                path="/pet/{petId}",
+                operations={
+                    "get": {
+                        "parameters": [
+                            {"name": "petId", "in": "path"},
+                            {"name": "petId", "in": "path"},
+                        ]
+                    }
+                },
+            )
+
+    def test_global_parameters(self, spec):
+        path = "/pet/{petId}"
+        route_spec = self.paths["/pet/{petId}"]["get"]
+
+        spec.components.parameter("test_parameter", "path", route_spec["parameters"][0])
+        spec.path(
+            path=path,
+            operations=dict(put={}, get={}),
+            parameters=[{"name": "petId", "in": "path"}, "test_parameter"],
+        )
+
+        assert get_paths(spec)[path]["parameters"] == [
+            {"name": "petId", "in": "path", "required": True},
+            build_ref(spec, "parameter", "test_parameter"),
+        ]
+
+    def test_global_parameter_duplicate(self, spec):
+        path = "/pet/{petId}"
+        spec.path(
+            path=path,
+            operations=dict(put={}, get={}),
+            parameters=[
+                {"name": "petId", "in": "path"},
+                {"name": "petId", "in": "query"},
+            ],
+        )
+
+        assert get_paths(spec)[path]["parameters"] == [
+            {"name": "petId", "in": "path", "required": True},
+            {"name": "petId", "in": "query"},
+        ]
+
+        with pytest.raises(DuplicateParameterError):
+            spec.path(
+                path=path,
+                operations=dict(put={}, get={}),
+                parameters=[
+                    {"name": "petId", "in": "path"},
+                    {"name": "petId", "in": "path"},
+                    "test_parameter",
+                ],
+            )
+
     def test_response(self, spec):
         route_spec = self.paths["/pet/{petId}"]["get"]
 
@@ -475,10 +560,11 @@ class TestPlugins:
                 if not return_none:
                     return {"description": "42"}
 
-            def path_helper(self, path, operations, **kwargs):
+            def path_helper(self, path, operations, parameters, **kwargs):
                 if not return_none:
                     if path == "/path_1":
                         operations.update({"get": {"responses": {"200": {}}}})
+                        parameters.append({"name": "page", "in": "query"})
                         return "/path_1_modified"
 
             def operation_helper(self, path, operations, **kwargs):
@@ -554,7 +640,10 @@ class TestPlugins:
         if return_none:
             assert paths["/path_1"] == {}
         else:
-            assert paths["/path_1_modified"] == {"get": {"responses": {"200": {}}}}
+            assert paths["/path_1_modified"] == {
+                "get": {"responses": {"200": {}}},
+                "parameters": [{"in": "query", "name": "page"}],
+            }
 
     @pytest.mark.parametrize("openapi_version", ("2.0", "3.0.0"))
     def test_plugin_operation_helper_is_used(self, openapi_version):
diff --git a/tests/test_ext_marshmallow.py b/tests/test_ext_marshmallow.py
index bac008e..ee4ea0e 100644
--- a/tests/test_ext_marshmallow.py
+++ b/tests/test_ext_marshmallow.py
@@ -572,7 +572,11 @@ class TestOperationHelper:
             operations={
                 "get": {
                     "parameters": [
-                        {"in": "body", "schema": {"type": "array", "items": PetSchema}}
+                        {
+                            "name": "petSchema",
+                            "in": "body",
+                            "schema": {"type": "array", "items": PetSchema},
+                        }
                     ],
                     "responses": {
                         200: {"schema": {"type": "array", "items": PetSchema}}
@@ -598,7 +602,8 @@ class TestOperationHelper:
                 "get": {
                     "parameters": [
                         {
-                            "in": "body",
+                            "name": "Pet",
+                            "in": "query",
                             "content": {
                                 "application/json": {
                                     "schema": {"type": "array", "items": PetSchema}