Repository: apiflask/apiflask (python)

Issue:
Cannot return non-dictlike object from handler if BASE_RESPONSE_SCHEMA is set
If BASE_RESPONSE_SCHEMA is set and a non-dict like object is returned from a route, an exception `TypeError: argument of type '<type of object>' is not iterable` is thrown from [scaffold.py](https://github.com/apiflask/apiflask/blob/71c821ae59494b455d95f6df2e0f85c086652c23/src/apiflask/scaffold.py#L469) when checking `if data_key not in obj` (the `in` keyword doesn't work on all objects).

Environment:

- Python version:
- Flask version:
- APIFlask version:


Pull request description:
Fix the not-iterable exception occurred when directly returning a custom class
Fix the not-iterable exception occurred when a `@app.output()` decorated view function directly return a custom class.

<!--
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 #384

<!--
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.
- [ ] 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.
- [ ] 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 8367971..03df64b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -4,9 +4,12 @@ Released: -
 
 - Remove the validation of input locations ([issue #259][issue_259]).
 - Support passing `Function` type config to `SWAGGER_UI_CONFIG` ([issue #381][issue_381]).
+- Fix the base response support so that a custom class can be returned from
+  the view function ([issue #384][issue_384]).
 
 [issue_259]: https://github.com/apiflask/apiflask/issues/259
 [issue_381]: https://github.com/apiflask/apiflask/issues/381
+[issue_384]: https://github.com/apiflask/apiflask/issues/384
 
 
 ## Version 1.2.1
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index f7b4b34..847d8ef 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -457,11 +457,24 @@ class APIScaffold:
                 base_schema: OpenAPISchemaType = current_app.config['BASE_RESPONSE_SCHEMA']
                 if base_schema is not None and status_code != 204:
                     data_key: str = current_app.config['BASE_RESPONSE_DATA_KEY']
-                    if data_key not in obj:
-                        raise RuntimeError(
-                            f'The data key {data_key!r} is not found in the returned dict.'
+
+                    if isinstance(obj, dict):
+                        if data_key not in obj:
+                            raise RuntimeError(
+                                f'The data key {data_key!r} is not found in the returned dict.'
+                            )
+                        obj[data_key] = schema.dump(obj[data_key], many=many)  # type: ignore
+                    else:
+                        if not hasattr(obj, data_key):
+                            raise RuntimeError(
+                                f'The data key {data_key!r} is not found in the returned object.'
+                            )
+                        setattr(
+                            obj,
+                            data_key,
+                            schema.dump(getattr(obj, data_key), many=many)  # type: ignore
                         )
-                    obj[data_key] = schema.dump(obj[data_key], many=many)  # type: ignore
+
                     data = base_schema().dump(obj)  # type: ignore
                 else:
                     data = schema.dump(obj, many=many)  # type: ignore


Test patch:
diff --git a/tests/test_decorator_output.py b/tests/test_decorator_output.py
index d45ea66..6debf1a 100644
--- a/tests/test_decorator_output.py
+++ b/tests/test_decorator_output.py
@@ -1,10 +1,14 @@
+from dataclasses import dataclass
+
 from flask import make_response
 from openapi_spec_validator import validate_spec
 
 from .schemas import Foo
 from .schemas import Query
-from apiflask.fields import String
+from apiflask.fields import Field
 from apiflask.views import MethodView
+from apiflask import Schema
+from apiflask.fields import String
 
 
 def test_output(app, client):
@@ -170,6 +174,35 @@ def test_output_with_dict_schema(app, client):
         'content']['application/json']['schema']['$ref'] == '#/components/schemas/Generated1'
 
 
+def test_output_with_object_schema(app, client):
+    class BaseResponse(Schema):
+        data = Field()
+        message = String(dump_default='Success')
+
+    app.config['BASE_RESPONSE_SCHEMA'] = BaseResponse
+
+    class PetOut(Schema):
+        name = String()
+
+    @dataclass
+    class Pet:
+        name: str
+
+    @dataclass
+    class Response:
+        data: Pet
+
+    @app.get('/foo')
+    @app.output(PetOut)
+    def foo():
+        pet = Pet('foo')
+        return Response(data=pet)
+
+    rv = client.get('/foo')
+    assert rv.status_code == 200
+    assert rv.json['data'] == {'name': 'foo'}
+
+
 def test_output_body_example(app, client):
     example = {'name': 'foo', 'id': 2}
     examples = {