Repository: apiflask/apiflask (python)

Issue:
Allow custom APISpec plugins
APIFlask currently doesn't allow including a custom APISpec plugin.

The suggestion is to add such feature to APIFlask. It would look like this (param name is negotiable):

```py
app = APIFlask(
    ...,
    apispec_plugins=[SomeCustomPlugin()],
)
```

And the code change according to proposed feature would look like this:

```py
class APIFlask(APIScaffold, Flask):
    ...
    # pass plugins list all the way through APIFlask __init__()
    # or instead it could be assigned as instance attribute on __init__() and used as self.plugins then
    def _generate_spec(self, plugins: list[apispec.BasePlugin]) -> APISpec:
        ...
        spec: APISpec = APISpec(
            ...,
            plugins=[ma_plugin, *plugins],
            ...,
        )
        ...
    ...
```

Pull request description:
Support to use custom apispec plugins with `spec_plugins` param
<!--
For features and bug fixes, before opening a PR, please open an issue describing
the bug or feature the PR will address. You can skip this step if it's a typo fix.

Replace this comment with a description of the change. Describe how it
addresses the linked issue.
-->

<!--
Link to relevant issues or previous PRs, one per line. Use "fixes" to
automatically close an issue.
-->

- fixes #349 

<!--
If needed, ensure each step in the checklist below is complete. If only docs were changed, these aren't relevant and can be removed.
-->

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the `docs` folder and in code docstring.
- [ ] Add an entry in `CHANGES.md` summarizing the change and linking to the issue and your username.
- [ ] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 49d11cc..79cff87 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,8 +7,10 @@ Released: -
 
 - Add `Enum` field from marshmallow 3.18.
 - Fix OpenAPI spec generating for path parameters when path schema is provided ([issue #350][issue_350]).
+- Add `spec_plugins` param to `APIFlask` class to support using custom apispec plugins ([issue #349][issue_349]).
 
 [issue_350]: https://github.com/apiflask/apiflask/issues/350
+[issue_349]: https://github.com/apiflask/apiflask/issues/349
 
 
 ## Version 1.1.3
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 6a6458a..4c33d2d 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -10,6 +10,7 @@ if sys.platform == 'win32' and (3, 8, 0) <= sys.version_info < (3, 9, 0):  # pra
     asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())  # pragma: no cover
 
 from apispec import APISpec
+from apispec import BasePlugin
 from apispec.ext.marshmallow import MarshmallowPlugin
 from flask import Blueprint
 from flask import Flask
@@ -277,6 +278,7 @@ class APIFlask(APIScaffold, Flask):
         openapi_blueprint_url_prefix: t.Optional[str] = None,
         json_errors: bool = True,
         enable_openapi: bool = True,
+        spec_plugins: t.Optional[t.List[BasePlugin]] = None,
         static_url_path: t.Optional[str] = None,
         static_folder: str = 'static',
         static_host: t.Optional[str] = None,
@@ -310,9 +312,16 @@ class APIFlask(APIScaffold, Flask):
                 `docs_path`, etc.), defaults to `None`.
             json_errors: If `True`, APIFlask will return a JSON response for HTTP errors.
             enable_openapi: If `False`, will disable OpenAPI spec and API docs views.
+            spec_plugins: List of apispec-compatible plugins (subclasses of `apispec.BasePlugin`),
+                defaults to `None`. The `MarshmallowPlugin` for apispec is already included
+                by default, so it doesn't need to be provided here.
 
         Other keyword arguments are directly passed to `flask.Flask`.
 
+        *Version changed: 1.2.0*
+
+        - Add `spec_plugins` parameter.
+
         *Version changed: 1.1.0*
 
         - Add `docs_ui` parameter.
@@ -352,6 +361,7 @@ class APIFlask(APIScaffold, Flask):
         self.error_callback: ErrorCallbackType = self._error_handler
         self.schema_name_resolver = self._schema_name_resolver
 
+        self.spec_plugins: t.List[BasePlugin] = spec_plugins or []
         self._spec: t.Optional[t.Union[dict, str]] = None
         self._auth_blueprints: t.Dict[str, t.Dict[str, t.Any]] = {}
 
@@ -853,11 +863,14 @@ class APIFlask(APIScaffold, Flask):
         ma_plugin: MarshmallowPlugin = MarshmallowPlugin(
             schema_name_resolver=self.schema_name_resolver
         )
+
+        spec_plugins: t.List[BasePlugin] = [ma_plugin, *self.spec_plugins]
+
         spec: APISpec = APISpec(
             title=self.title,
             version=self.version,
             openapi_version=self.config['OPENAPI_VERSION'],
-            plugins=[ma_plugin],
+            plugins=spec_plugins,
             info=self._make_info(),
             tags=self._make_tags(),
             **kwargs


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index afe4f15..aa84af7 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,4 +1,5 @@
 import pytest
+from apispec import BasePlugin
 from flask import Blueprint
 from flask.views import MethodView
 from openapi_spec_validator import validate_spec
@@ -296,3 +297,19 @@ def test_return_list_as_json(app, client):
     assert rv.status_code == 201
     assert rv.headers['Content-Type'] == 'application/json'
     assert rv.json == test_list
+
+
+def test_apispec_plugins(app):
+    class TestPlugin(BasePlugin):
+        def operation_helper(self, path=None, operations=None, **kwargs) -> None:
+            operations.update({'post': 'some_injected_test_data'})
+
+    app.spec_plugins = [TestPlugin()]
+
+    @app.get('/plugin_test')
+    def single_value():
+        return 'plugin_test'
+
+    spec = app._get_spec('json')
+
+    assert spec['paths']['/plugin_test'].get('post') == 'some_injected_test_data'