Repository: apiflask/apiflask (python)

Issue:
Support to add schemas to the OpenAPI spec explicitly
Sorry, the issue was submitted late

The specific problems are as follows

Like this doc, both RequestDTO and ResponseDTO will be displayed on the schema component of OpenAPI
```python
class A(MethodView):
    @input(RequestDTO, location="json")
    @output(ResponseDTO)
    def post(self, data):
        return {}
```
But not every schema is nested or explicitly used, such as checking data with marshmallow or dynamically specifying which schema to use, which cannot be displayed on the openapi.

Like this schema, it is dynamically loaded in the web app, but it wants to be displayed in the schema
```python
class S(Schema):
    f1 = fields.String()
    f2 = fields.String()
```

I hope there is a place to manually configure schema components in apiflask,TKS
 


Pull request description:
Support passing APISpec object to spec processor
Add a `SPEC_PROCESSOR_PASS_OBJECT` to control the argument type of the spec processor. Example usage:

```python
from apiflask import APIFlask

app = APIFlask(__name__)
app.config['SPEC_PROCESSOR_PASS_OBJECT'] = True

class FooSchema(Schema):
    id = Integer()

@app.spec_processor
def update_spec(spec):
    spec.title = 'Updated Title'
    spec.components.schema('Foo', schema=FooSchema)  # add a schema manually
    return spec
```

fixes #213

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.
- [x] Add an entry in `CHANGES.md` summarizing the change and linking to the issue.
- [x] 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 f52736c..83ef929 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,13 @@
+
+## Version 1.3.0
+
+- Add config `SPEC_PROCESSOR_PASS_OBJECT` to control the argument type of
+  spec processor. The `spec` argument will be an `apispec.APISpec` object
+  when this config is `True` ([issue #213][issue_213]).
+
+[issue_213]: https://github.com/apiflask/apiflask/issues/213
+
+
 ## Version 1.2.4
 
 Released: -
diff --git a/docs/configuration.md b/docs/configuration.md
index 3b98f69..2361c8e 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -516,6 +516,24 @@ app.config['YAML_SPEC_MIMETYPE'] = 'text/x-yaml'
     This configuration variable was added in the [version 0.4.0](/changelog/#version-040).
 
 
+### SPEC_PROCESSOR_PASS_OBJECT
+
+If `True`, the `spec` argument passed to the spec processor will be an
+[`apispec.APISpec`](https://apispec.readthedocs.io/en/latest/api_core.html#apispec.APISpec) object.
+
+- Type: `bool`
+- Default value: `False`
+- Examples:
+
+```python
+app.config['SPEC_PROCESSOR_PASS_OBJECT'] = True
+```
+
+!!! warning "Version >= 1.3.0"
+
+    This configuration variable was added in the [version 1.3.0](/changelog/#version-130).
+
+
 ## Automation behavior control
 
 The following configuration variables are used to control the automation behavior
diff --git a/docs/openapi.md b/docs/openapi.md
index 13b1dc1..6a0d78f 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -920,11 +920,25 @@ def update_spec(spec):
     return spec
 ```
 
-Notice the format of the spec depends on the value of the configuration
-variable `SPEC_FORMAT` (defaults to `'json'`):
+By default, the `spec` argument is a dict. When the `SPEC_PROCESSOR_PASS_OBJECT` config is
+`True`, the `spec` argument will be an
+[`apispec.APISpec`](https://apispec.readthedocs.io/en/latest/api_core.html#apispec.APISpec) object.
 
-- `'json'` -> dict
-- `'yaml'` -> string
+```python
+from apiflask import APIFlask
+
+app = APIFlask(__name__)
+app.config['SPEC_PROCESSOR_PASS_OBJECT'] = True
+
+class FooSchema(Schema):
+    id = Integer()
+
+@app.spec_processor
+def update_spec(spec):
+    spec.title = 'Updated Title'
+    spec.components.schema('Foo', schema=FooSchema)  # add a schema manually
+    return spec
+```
 
 Check out [the example application](https://github.com/apiflask/apiflask/tree/main/examples/openapi/app.py)
 for OpenAPI support, see [the examples page](/examples) for running the example application.
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index b0d481c..c237129 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -13,6 +13,7 @@ if sys.platform == 'win32' and (3, 8, 0) <= sys.version_info < (3, 9, 0):  # pra
 from apispec import APISpec
 from apispec import BasePlugin
 from apispec.ext.marshmallow import MarshmallowPlugin
+from apispec.yaml_utils import dict_to_yaml
 from flask import Blueprint
 from flask import Flask
 from flask import has_request_context
@@ -680,16 +681,29 @@ class APIFlask(APIScaffold, Flask):
 
         - Rename the method name to `_get_spec`.
         - Add the `force_update` parameter.
+
+        *Version changed: 1.3.0*
+
+        - Add the `SPEC_PROCESSOR_PASS_OBJECT` config to control the argument type
+          when calling the spec processor.
         """
         if spec_format is None:
             spec_format = self.config['SPEC_FORMAT']
         if self._spec is None or force_update:
-            if spec_format == 'json':
-                self._spec = self._generate_spec().to_dict()
-            else:
-                self._spec = self._generate_spec().to_yaml()
+            spec_object: APISpec = self._generate_spec()
             if self.spec_callback:
-                self._spec = self.spec_callback(self._spec)  # type: ignore
+                if self.config['SPEC_PROCESSOR_PASS_OBJECT']:
+                    self._spec = self.spec_callback(
+                        spec_object  # type: ignore
+                    ).to_dict()
+                else:
+                    self._spec = self.spec_callback(
+                        spec_object.to_dict()
+                    )
+            else:
+                self._spec = spec_object.to_dict()
+            if spec_format in ['yml', 'yaml']:
+                self._spec = dict_to_yaml(self._spec)  # type: ignore
         # sync local spec
         if self.config['SYNC_LOCAL_SPEC']:
             spec_path = self.config['LOCAL_SPEC_PATH']
diff --git a/src/apiflask/settings.py b/src/apiflask/settings.py
index 50a18f5..7951a3f 100644
--- a/src/apiflask/settings.py
+++ b/src/apiflask/settings.py
@@ -24,6 +24,7 @@ JSON_SPEC_MIMETYPE: str = 'application/json'
 LOCAL_SPEC_PATH: t.Optional[str] = None
 LOCAL_SPEC_JSON_INDENT: int = 2
 SYNC_LOCAL_SPEC: t.Optional[bool] = None
+SPEC_PROCESSOR_PASS_OBJECT: bool = False
 # Automation behavior control
 AUTO_TAGS: bool = True
 AUTO_SERVERS: bool = True
@@ -71,3 +72,6 @@ RAPIPDF_CONFIG: t.Optional[dict] = None
 
 # Version changed: 1.2.0
 # Change VALIDATION_ERROR_STATUS_CODE from 400 to 422.
+
+# Version added: 1.3.0
+# SPEC_PROCESSOR_PASS_OBJECT


Test patch:
diff --git a/tests/test_openapi_basic.py b/tests/test_openapi_basic.py
index 6dbcd46..4a5f287 100644
--- a/tests/test_openapi_basic.py
+++ b/tests/test_openapi_basic.py
@@ -8,7 +8,7 @@ from .schemas import Bar
 from .schemas import Baz
 from .schemas import Foo
 from apiflask import APIBlueprint
-from apiflask import Schema as BaseSchema
+from apiflask import Schema
 from apiflask.commands import spec_command
 from apiflask.fields import Integer
 
@@ -35,6 +35,26 @@ def test_spec_processor(app, client):
     assert rv.json['info']['title'] == 'Foo'
 
 
+def test_spec_processor_pass_object(app, client):
+    app.config['SPEC_PROCESSOR_PASS_OBJECT'] = True
+
+    class NotUsedSchema(Schema):
+        id = Integer()
+
+    @app.spec_processor
+    def process_spec(spec):
+        spec.title = 'Foo'
+        spec.components.schema('NotUsed', schema=NotUsedSchema)
+        return spec
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert rv.json['info']['title'] == 'Foo'
+    assert 'NotUsed' in rv.json['components']['schemas']
+    assert 'id' in rv.json['components']['schemas']['NotUsed']['properties']
+
+
 @pytest.mark.parametrize('spec_format', ['json', 'yaml', 'yml'])
 def test_get_spec(app, spec_format):
     spec = app._get_spec(spec_format)
@@ -114,7 +134,7 @@ def test_spec_schemas(app):
     def baz():
         pass
 
-    class Spam(BaseSchema):
+    class Spam(Schema):
         id = Integer()
 
     @app.route('/spam')
@@ -122,12 +142,12 @@ def test_spec_schemas(app):
     def spam():
         pass
 
-    class Schema(BaseSchema):
+    class Ham(Schema):
         id = Integer()
 
-    @app.route('/schema')
-    @app.output(Schema)
-    def schema():
+    @app.route('/ham')
+    @app.output(Ham)
+    def ham():
         pass
 
     spec = app.spec
@@ -136,7 +156,7 @@ def test_spec_schemas(app):
     assert 'Bar' in spec['components']['schemas']
     assert 'Baz' in spec['components']['schemas']
     assert 'Spam' in spec['components']['schemas']
-    assert 'Schema' in spec['components']['schemas']
+    assert 'Ham' in spec['components']['schemas']
 
 
 def test_servers_and_externaldocs(app):