Repository: apiflask/apiflask
Pull request #515 (2023-12-16 04:28:43)

Fix base response schema inconsistency issue
- Update changelog for 2.1.0
- Re-organize spec response-related methods

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 dd395d8..b696457 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,19 +1,17 @@
-## Version 2.0.3
+## Version 2.1.0
 
 Released: -
 
 - Allow adding multiple media types for a response ([issue #494][issue_494])
 - Support adding headers to the response schema ([issue #507][issue_507])
+- Add support for adding decorators to the OpenAPI spec and documentation UI endpoints ([issue #483][issue_483]).
+- Fix the base repsonse schema inconsistency
 
 [issue_494]: https://github.com/apiflask/apiflask/issues/494
 [issue_507]: https://github.com/apiflask/apiflask/issues/507
-
-## Version 2.0.3
-
-- Add support for adding decorators to the OpenAPI spec and documentation UI endpoints ([issue #483][issue_483]).
-
 [issue_483]: https://github.com/apiflask/apiflask/issues/483
 
+
 ## Version 2.0.2
 
 Released: 2023/9/24
diff --git a/docs/openapi.md b/docs/openapi.md
index 67aa6bc..52b5da7 100644
--- a/docs/openapi.md
+++ b/docs/openapi.md
@@ -717,9 +717,9 @@ def new_pet():
 
 ## Response `headers`
 
-!!! warning "Version >= 2.0.3"
+!!! warning "Version >= 2.1.0"
 
-    This feature was added in the [version 2.0.3](/changelog/#version-203).
+    This feature was added in the [version 2.1.0](/changelog/#version-210).
 
 You can pass a schema class to `headers` in the output decorator:
 ```python
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 038ad4f..e2c9afe 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -32,6 +32,8 @@ from .exceptions import _bad_schema_message
 from .helpers import get_reason_phrase
 from .route import route_patch
 from .schemas import Schema
+from .schemas import FileSchema
+from .schemas import EmptySchema
 from .types import ResponseReturnValueType, ResponsesType
 from .types import ViewFuncType
 from .types import ErrorCallbackType
@@ -40,8 +42,6 @@ from .types import SchemaType
 from .types import HTTPAuthType
 from .types import TagsType
 from .types import OpenAPISchemaType
-from .openapi import add_response
-from .openapi import add_response_with_schema
 from .openapi import default_bypassed_endpoints
 from .openapi import default_response
 from .openapi import get_tag
@@ -523,7 +523,7 @@ class APIFlask(APIScaffold, Flask):
         The name of the blueprint is "openapi". This blueprint will hold the view
         functions for spec file and API docs.
 
-        *Version changed: 2.0.3*
+        *Version changed: 2.1.0*
 
         - Inject the OpenAPI endpoints decorators.
 
@@ -827,11 +827,11 @@ class APIFlask(APIScaffold, Flask):
         if self.external_docs:
             kwargs['externalDocs'] = self.external_docs
 
-        ma_plugin: MarshmallowPlugin = MarshmallowPlugin(
+        self._ma_plugin: MarshmallowPlugin = MarshmallowPlugin(
             schema_name_resolver=self.schema_name_resolver  # type: ignore
         )
 
-        spec_plugins: t.List[BasePlugin] = [ma_plugin, *self.spec_plugins]
+        spec_plugins: t.List[BasePlugin] = [self._ma_plugin, *self.spec_plugins]
 
         spec: APISpec = APISpec(
             title=self.title,
@@ -844,12 +844,12 @@ class APIFlask(APIScaffold, Flask):
         )
 
         # configure flask-marshmallow URL types
-        ma_plugin.converter.field_mapping[fields.URLFor] = ('string', 'url')  # type: ignore
-        ma_plugin.converter.field_mapping[fields.AbsoluteURLFor] = (  # type: ignore
+        self._ma_plugin.converter.field_mapping[fields.URLFor] = ('string', 'url')  # type: ignore
+        self._ma_plugin.converter.field_mapping[fields.AbsoluteURLFor] = (  # type: ignore
             'string', 'url'
         )
         if sqla is not None:  # pragma: no cover
-            ma_plugin.converter.field_mapping[sqla.HyperlinkRelated] = (  # type: ignore
+            self._ma_plugin.converter.field_mapping[sqla.HyperlinkRelated] = (  # type: ignore
                 'string', 'url'
             )
 
@@ -997,27 +997,6 @@ class APIFlask(APIScaffold, Flask):
                 # responses
                 if view_func._spec.get('response'):
                     schema = view_func._spec.get('response')['schema']
-                    base_schema: OpenAPISchemaType = self.config['BASE_RESPONSE_SCHEMA']
-                    if base_schema is not None:
-                        base_schema_spec: dict
-                        if isinstance(base_schema, type):
-                            base_schema_spec = \
-                                ma_plugin.converter.schema2jsonschema(  # type: ignore
-                                    base_schema()
-                                )
-                        elif isinstance(base_schema, dict):
-                            base_schema_spec = base_schema
-                        else:
-                            raise TypeError(_bad_schema_message)
-                        data_key: str = self.config['BASE_RESPONSE_DATA_KEY']
-                        if data_key not in base_schema_spec['properties']:
-                            raise RuntimeError(
-                                f'The data key {data_key!r} is not found in'
-                                ' the base response schema spec.'
-                            )
-                        base_schema_spec['properties'][data_key] = schema
-                        schema = base_schema_spec
-
                     status_code: str = str(view_func._spec.get('response')['status_code'])
                     description: str = view_func._spec.get('response')['description'] or \
                         self.config['SUCCESS_DESCRIPTION']
@@ -1026,24 +1005,26 @@ class APIFlask(APIScaffold, Flask):
                     links = view_func._spec.get('response')['links']
                     content_type = view_func._spec.get('response')['content_type']
                     headers = view_func._spec.get('response')['headers']
-                    add_response(
+                    self._add_response(
                         operation,
                         status_code,
                         schema,
                         description,
-                        example,
-                        examples,
-                        links,
-                        content_type,
-                        headers,
-                        ma_plugin,
+                        example=example,
+                        examples=examples,
+                        links=links,
+                        content_type=content_type,
+                        headers_schema=headers,
                     )
                 else:
                     # add a default 200 response for views without using @app.output
                     # or @app.doc(responses={...})
                     if not view_func._spec.get('responses') and self.config['AUTO_200_RESPONSE']:
-                        add_response(
-                            operation, '200', {}, self.config['SUCCESS_DESCRIPTION']
+                        self._add_response(
+                            operation,
+                            '200',
+                            {},
+                            self.config['SUCCESS_DESCRIPTION'],
                         )
 
                 # add validation error response
@@ -1056,7 +1037,7 @@ class APIFlask(APIScaffold, Flask):
                         'VALIDATION_ERROR_DESCRIPTION'
                     ]
                     schema: SchemaType = self.config['VALIDATION_ERROR_SCHEMA']  # type: ignore
-                    add_response_with_schema(
+                    self._add_response_with_schema(
                         spec, operation, status_code, schema, 'ValidationError', description
                     )
 
@@ -1072,7 +1053,7 @@ class APIFlask(APIScaffold, Flask):
                     )
                     description: str = self.config['AUTH_ERROR_DESCRIPTION']  # type: ignore
                     schema: SchemaType = self.config['HTTP_ERROR_SCHEMA']  # type: ignore
-                    add_response_with_schema(
+                    self._add_response_with_schema(
                         spec, operation, status_code, schema, 'HTTPError', description
                     )
 
@@ -1080,7 +1061,7 @@ class APIFlask(APIScaffold, Flask):
                 if self.config['AUTO_404_RESPONSE'] and rule.arguments:
                     description: str = self.config['NOT_FOUND_DESCRIPTION']  # type: ignore
                     schema: SchemaType = self.config['HTTP_ERROR_SCHEMA']  # type: ignore
-                    add_response_with_schema(
+                    self._add_response_with_schema(
                         spec, operation, '404', schema, 'HTTPError', description
                     )
 
@@ -1115,11 +1096,11 @@ class APIFlask(APIScaffold, Flask):
                         # add error response schema for error responses
                         if status_code.startswith('4') or status_code.startswith('5'):
                             schema: SchemaType = self.config['HTTP_ERROR_SCHEMA']  # type: ignore
-                            add_response_with_schema(
+                            self._add_response_with_schema(
                                 spec, operation, status_code, schema, 'HTTPError', description
                             )
                         else:  # add default response for other responses
-                            add_response(operation, status_code, {}, description)
+                            self._add_response(operation, status_code, {}, description)
 
                 # requestBody
                 if view_func._spec.get('body'):
@@ -1222,3 +1203,100 @@ class APIFlask(APIScaffold, Flask):
                 return decorated_func(*args, **kwargs)
             return wrapper
         return decorator
+
+    def _add_response(
+        self,
+        operation: dict,
+        status_code: str,
+        schema: t.Union[SchemaType, dict],
+        description: str,
+        example: t.Optional[t.Any] = None,
+        examples: t.Optional[t.Dict[str, t.Any]] = None,
+        links: t.Optional[t.Dict[str, t.Any]] = None,
+        content_type: t.Optional[str] = 'application/json',
+        headers_schema: t.Optional[SchemaType] = None,
+    ) -> None:
+        """Add response to operation.
+
+        *Version changed: 2.1.0*
+
+        - Add parameter `headers_schema`.
+
+        *Version changed: 1.3.0*
+
+        - Add parameter `content_type`.
+
+        *Version changed: 0.10.0*
+
+        - Add `links` parameter.
+        """
+        base_schema: t.Optional[OpenAPISchemaType] = self.config['BASE_RESPONSE_SCHEMA']
+        data_key: str = self.config['BASE_RESPONSE_DATA_KEY']
+        if base_schema is not None:
+            base_schema_spec: t.Dict[str, t.Any]
+            if isinstance(base_schema, type):
+                base_schema_spec = \
+                    self._ma_plugin.converter.schema2jsonschema(  # type: ignore
+                        base_schema()
+                    )
+            elif isinstance(base_schema, dict):
+                base_schema_spec = base_schema
+            else:
+                raise TypeError(_bad_schema_message)
+            if data_key not in base_schema_spec['properties']:  # type: ignore
+                raise RuntimeError(
+                    f'The data key {data_key!r} is not found in'
+                    ' the base response schema spec.'
+                )
+            base_schema_spec['properties'][data_key] = schema  # type: ignore
+            schema = base_schema_spec
+
+        operation['responses'][status_code] = {}
+        if status_code != '204':
+            if isinstance(schema, FileSchema):
+                schema = {'type': schema.type, 'format': schema.format}
+            elif isinstance(schema, EmptySchema):
+                schema = {}
+            operation['responses'][status_code]['content'] = {
+                content_type: {
+                    'schema': schema
+                }
+            }
+        operation['responses'][status_code]['description'] = description
+        if example is not None:
+            operation['responses'][status_code]['content'][
+                content_type]['example'] = example
+        if examples is not None:
+            operation['responses'][status_code]['content'][
+                content_type]['examples'] = examples
+        if links is not None:
+            operation['responses'][status_code]['links'] = links
+        if headers_schema is not None:
+            headers = self._ma_plugin.converter.schema2parameters(  # type: ignore
+                headers_schema,
+                location='headers'
+            )
+            operation['responses'][status_code]['headers'] = {
+                header['name']: header for header in headers
+            }
+
+    def _add_response_with_schema(
+        self,
+        spec: APISpec,
+        operation: dict,
+        status_code: str,
+        schema: OpenAPISchemaType,
+        schema_name: str,
+        description: str
+    ) -> None:
+        """Add response with given schema to operation."""
+        if isinstance(schema, type):
+            schema = schema()
+            self._add_response(operation, status_code, schema, description)
+        elif isinstance(schema, dict):
+            if schema_name not in spec.components.schemas:
+                spec.components.schema(schema_name, schema)
+            schema_ref = {'$ref': f'#/components/schemas/{schema_name}'}
+            self._add_response(operation, status_code, schema_ref, description)
+        else:
+            raise TypeError(_bad_schema_message)
diff --git a/src/apiflask/openapi.py b/src/apiflask/openapi.py
index bdb0501..eb4df7b 100644
--- a/src/apiflask/openapi.py
+++ b/src/apiflask/openapi.py
@@ -2,17 +2,9 @@ from __future__ import annotations
 
 import typing as t
 
-from apispec import APISpec
-from apispec.ext.marshmallow import MarshmallowPlugin
-
-from .exceptions import _bad_schema_message
-from .schemas import EmptySchema
-from .schemas import FileSchema
 from .security import HTTPBasicAuth
 from .security import HTTPTokenAuth
 from .types import HTTPAuthType
-from .types import OpenAPISchemaType
-from .types import SchemaType
 
 if t.TYPE_CHECKING:  # pragma: no cover
     from .blueprint import APIBlueprint
@@ -160,84 +152,6 @@ def get_path_description(func: t.Callable) -> str:
     return ''
 
 
-def add_response(
-    operation: dict,
-    status_code: str,
-    schema: t.Union[SchemaType, dict],
-    description: str,
-    example: t.Optional[t.Any] = None,
-    examples: t.Optional[t.Dict[str, t.Any]] = None,
-    links: t.Optional[t.Dict[str, t.Any]] = None,
-    content_type: t.Optional[str] = 'application/json',
-    headers_schema: t.Optional[SchemaType] = None,
-    ma_plugin: t.Optional[MarshmallowPlugin] = None,
-) -> None:
-    """Add response to operation.
-
-    *Version changed: 2.0.3*
-
-    - Add parameter `headers_schema`.
-    - Add parameter `ma_plugin`.
-
-    *Version changed: 1.3.0*
-
-    - Add parameter `content_type`.
-
-    *Version changed: 0.10.0*
-
-    - Add `links` parameter.
-    """
-    operation['responses'][status_code] = {}
-    if status_code != '204':
-        if isinstance(schema, FileSchema):
-            schema = {'type': schema.type, 'format': schema.format}
-        elif isinstance(schema, EmptySchema):
-            schema = {}
-        operation['responses'][status_code]['content'] = {
-            content_type: {
-                'schema': schema
-            }
-        }
-    operation['responses'][status_code]['description'] = description
-    if example is not None:
-        operation['responses'][status_code]['content'][
-            content_type]['example'] = example
-    if examples is not None:
-        operation['responses'][status_code]['content'][
-            content_type]['examples'] = examples
-    if links is not None:
-        operation['responses'][status_code]['links'] = links
-    if headers_schema is not None:
-        headers = ma_plugin.converter.schema2parameters(  # type: ignore
-            headers_schema,
-            location='headers'
-        )
-        operation['responses'][status_code]['headers'] = {
-            header['name']: header for header in headers
-        }
-
-
-def add_response_with_schema(
-    spec: APISpec,
-    operation: dict,
-    status_code: str,
-    schema: OpenAPISchemaType,
-    schema_name: str,
-    description: str
-) -> None:
-    """Add response with given schema to operation."""
-    if isinstance(schema, type):
-        schema = schema()
-        add_response(operation, status_code, schema, description)
-    elif isinstance(schema, dict):
-        if schema_name not in spec.components.schemas:
-            spec.components.schema(schema_name, schema)
-        schema_ref = {'$ref': f'#/components/schemas/{schema_name}'}
-        add_response(operation, status_code, schema_ref, description)
-    else:
-        raise TypeError(_bad_schema_message)
-
-
 def get_argument(argument_type: str, argument_name: str) -> t.Dict[str, t.Any]:
     """Make argument from given type and name."""
     argument: t.Dict[str, t.Any] = {
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index d6ce574..2b6f7eb 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -409,7 +409,7 @@ class APIScaffold:
             content_type: The content/media type of the response. It defautls to `application/json`.
             headers: The schemas of the headers.
 
-        *Version changed: 2.0.3*
+        *Version changed: 2.1.0*
 
         - Add parameter `headers`.
 


Test patch:
diff --git a/tests/test_base_response.py b/tests/test_base_response.py
index 6a4e6a3..efe910b 100644
--- a/tests/test_base_response.py
+++ b/tests/test_base_response.py
@@ -161,3 +161,38 @@ def test_base_response_many(app, client):
     assert 'data' in rv.json
     assert not isinstance(rv.json, list)
     assert isinstance(rv.json['data'], list)
+
+
+def test_bare_view_base_response_spec(app, client):
+    app.config['BASE_RESPONSE_SCHEMA'] = BaseResponse
+
+    @app.get('/')
+    def foo():
+        data = {'id': '123', 'name': 'test'}
+        return {'message': 'Success.', 'status_code': '200', 'data': data}
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    schema = rv.json['paths']['/']['get']['responses']['200']['content'][
+        'application/json']['schema']
+    assert schema['properties']['status_code'] == {'type': 'integer'}
+    assert schema['properties']['message'] == {'type': 'string'}
+
+
+def test_input_with_base_response_spec(app, client):
+    app.config['BASE_RESPONSE_SCHEMA'] = BaseResponse
+
+    @app.get('/')
+    @app.input(Foo)
+    def foo():
+        data = {'id': '123', 'name': 'test'}
+        return {'message': 'Success.', 'status_code': '200', 'data': data}
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    schema = rv.json['paths']['/']['get']['responses']['200']['content'][
+        'application/json']['schema']
+    assert schema['properties']['status_code'] == {'type': 'integer'}
+    assert schema['properties']['message'] == {'type': 'string'}