Repository: apiflask/apiflask (python)

Issue:
Allow returning list as JSON response
Like how dict return value was handled:

```py
@app.get('/')
def many():
    return ['foo', 'bar', 'baz']  # -> become JSON response
```


Pull request description:
Automatic serialize list return value to JSON
fixes #321

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 and your username.
- [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 b738d89..2e53271 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -5,6 +5,7 @@
 
 Released: -
 
+- Allow the view function to return a list as JSON response ([issue #321][issue_321]).
 - Add new docs UI support: RapiDoc, RapiPDF, and Elements ([pr #308][pr_308]).
 - Add a `docs_ui` parameter to APIFlask to set the API docs UI (can be
   `swagger-ui` (default), `redoc`, `rapidoc`, and `rapipdf`).
@@ -24,6 +25,7 @@ Released: -
 
 [pr_308]: https://github.com/apiflask/apiflask/pull/308
 [issue_312]: https://github.com/apiflask/apiflask/issues/312
+[issue_321]: https://github.com/apiflask/apiflask/issues/321
 
 
 ## Version 1.0.2
diff --git a/README.md b/README.md
index bb80748..e4b06af 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,7 @@ class PetOutSchema(Schema):
 
 @app.get('/')
 def say_hello():
-    # returning a dict equals to use jsonify()
+    # returning a dict or list equals to use jsonify()
     return {'message': 'Hello!'}
 
 
diff --git a/docs/api/fields.md b/docs/api/fields.md
index 3b325d9..d16c673 100644
--- a/docs/api/fields.md
+++ b/docs/api/fields.md
@@ -1,12 +1,3 @@
 # Fields
 
 ::: apiflask.fields
-
-
-## External documentation
-
-Check out the following docs for the fields provided by marshallow, Flask-Marshmallow, and wegbargs:
-
-- marshmallow: <https://marshmallow.readthedocs.io/en/stable/marshmallow.fields.html>
-- Flask-Marshmallow: <https://flask-marshmallow.readthedocs.io/en/latest/#flask-marshmallow-fields>
-- webargs: <https://webargs.readthedocs.io/en/latest/api.html#module-webargs.fields>
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index bfb4a48..01d7225 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -1,11 +1,3 @@
 # Schemas
 
 ::: apiflask.schemas
-
-
-## External documentation
-
-Check out the API docs for `Schema` class in the marshmallow docs:
-
-- Schema: <https://marshmallow.readthedocs.io/en/stable/marshmallow.schema.html>
-- Decorators: <https://marshmallow.readthedocs.io/marshmallow.decorators.html>
diff --git a/docs/api/security.md b/docs/api/security.md
index 506d460..9017a47 100644
--- a/docs/api/security.md
+++ b/docs/api/security.md
@@ -1,11 +1,3 @@
 # Security
 
 ::: apiflask.security
-
-
-## External documentation
-
-See Flask-HTTPAuth's API docs for the full usage of the following classes:
-
-- HTTPBasicAuth: <https://flask-httpauth.readthedocs.io/en/latest/#flask_httpauth.HTTPBasicAuth>
-- HTTPTokenAuth: <https://flask-httpauth.readthedocs.io/en/latest/#flask_httpauth.HTTPTokenAuth>
diff --git a/docs/api/validators.md b/docs/api/validators.md
index eff9ae2..0d36ff4 100644
--- a/docs/api/validators.md
+++ b/docs/api/validators.md
@@ -1,9 +1,3 @@
 # Validators
 
 ::: apiflask.validators
-
-
-## External documentation
-
-Check out the [Validator API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.validate.html)
-for the validators from marshmallow.
diff --git a/docs/comparison.md b/docs/comparison.md
index 1ad4c11..2b2d21c 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -53,6 +53,7 @@ extension is:
 - I have to rewrite the `Flask` class to ensure the natural order of the arguments
 injected into the view function.
 - I have to rewrite the `Flask` and the `Blueprint` class to add route shortcuts.
+- Possible to add more high-level features, such as support returning list as JSON response.
 
 See the following two sections for more details.
 
diff --git a/docs/migrating.md b/docs/migrating.md
index 2c0a7e1..9d0b16c 100644
--- a/docs/migrating.md
+++ b/docs/migrating.md
@@ -219,8 +219,32 @@ def bar():
 
 ### The return values of view function
 
+For a simple view function without `@app.output` decorator, you can return a dict or
+a list as JSON response. The returned dict and list will be converted to a JSON
+response automatically (by calling
+[`jsonify()`](https://flask.palletsprojects.com/api/#flask.json.jsonify) underly).
+
+!!! tip
+
+    Although list looks like tuple, only list return values will be serialized to JSON
+    response. Tuple has
+    [special meaning](https://flask.palletsprojects.com/quickstart/#about-responses).
+
+    Starting from Flask 2.2, the list return values are supported natively.
+
+```python
+@app.get('/foo')
+def foo():
+    return {'message': 'foo'}
+
+
+@app.get('/bar')
+def bar():
+    return ['foo', 'bar', 'baz']
+```
+
 When you added a `@app.output` decorator for your view function, APIFlask expects you to
-return an ORM/ODM model object or a dict that matches the schema you passed in the
+return an ORM/ODM model object or a dict/list that matches the schema you passed in the
 `@app.output` decorator. If you return a `Response` object, APIFlask will return it
 directly without any process.
 
diff --git a/docs/schema.md b/docs/schema.md
index 7bfd303..56ace69 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -288,7 +288,7 @@ responses to the following format:
         "category": "cat"
     },
     "message": "some message",
-    "code": "custom code"
+    "status_code": "custom code"
 }
 ```
 
@@ -302,9 +302,9 @@ from apiflask.fields import String, Integer, Field
 app = APIFlask(__name__)
 
 class BaseResponseSchema(Schema):
-    data = Field()  # the data key
     message = String()
-    code = Integer()
+    status_code = Integer()
+    data = Field()  # the data key
 
 app.config['BASE_RESPONSE_SCHEMA'] = BaseResponseSchema
 ```
@@ -322,11 +322,28 @@ Now you can return a dict matches the base response schema in your view function
 @app.get('/')
 def say_hello():
     data = {'name': 'Grey'}
-    return {
-        'data': data,
-        'message': 'Success!',
-        'code': 200
-    }
+    return {'message': 'Success!', 'status_code': 200, 'data': data}
+```
+
+To make it more elegant, you can create a function to make response dict:
+
+```python
+def make_resp(message, status_code, data):
+    return {'message': message, 'status_code': status_code, 'data': data}
+
+
+@app.get('/')
+def say_hello():
+    data = {'message': 'Hello!'}
+    return make_resp('Success!', 200, data)
+
+
+@app.get('/pets/<int:pet_id>')
+@app.output(PetOutSchema)
+def get_pet(pet_id):
+    if pet_id > len(pets) - 1 or pets[pet_id].get('deleted'):
+        abort(404)
+    return make_resp('Success!', 200, pets[pet_id])
 ```
 
 Check out [the complete example application](https://github.com/apiflask/apiflask/tree/main/examples/base_response/app.py)
diff --git a/examples/base_response/app.py b/examples/base_response/app.py
index 5345e74..24c531a 100644
--- a/examples/base_response/app.py
+++ b/examples/base_response/app.py
@@ -6,9 +6,9 @@ app = APIFlask(__name__)
 
 
 class BaseResponseSchema(Schema):
-    data = Field()  # the data key
     message = String()
-    code = Integer()
+    status_code = Integer()
+    data = Field()  # the data key
 
 
 app.config['BASE_RESPONSE_SCHEMA'] = BaseResponseSchema
@@ -34,14 +34,16 @@ class PetOutSchema(Schema):
     category = String()
 
 
+def make_resp(message, status_code, data):
+    # the return value should match the base response schema
+    # and the data key should match
+    return {'message': message, 'status_code': status_code, 'data': data}
+
+
 @app.get('/')
 def say_hello():
     data = {'message': 'Hello!'}
-    return {
-        'data': data,
-        'message': 'Success!',
-        'code': 200
-    }
+    return make_resp('Success!', 200, data)
 
 
 @app.get('/pets/<int:pet_id>')
@@ -49,21 +51,13 @@ def say_hello():
 def get_pet(pet_id):
     if pet_id > len(pets) - 1 or pets[pet_id].get('deleted'):
         abort(404)
-    return {
-        'data': pets[pet_id],
-        'message': 'Success!',
-        'code': 200,
-    }
+    return make_resp('Success!', 200, pets[pet_id])
 
 
 @app.get('/pets')
 @app.output(PetOutSchema(many=True))
 def get_pets():
-    return {
-        'data': pets,
-        'message': 'Success!',
-        'code': 200,
-    }
+    return make_resp('Success!', 200, pets)
 
 
 @app.post('/pets')
@@ -73,11 +67,7 @@ def create_pet(data):
     pet_id = len(pets)
     data['id'] = pet_id
     pets.append(data)
-    return {
-        'data': pets[pet_id],
-        'message': 'Pet created.',
-        'code': 201
-    }
+    return make_resp('Pet created.', 201, pets[pet_id])
 
 
 @app.patch('/pets/<int:pet_id>')
@@ -88,11 +78,7 @@ def update_pet(pet_id, data):
         abort(404)
     for attr, value in data.items():
         pets[pet_id][attr] = value
-    return {
-        'data': pets[pet_id],
-        'message': 'Pet updated.',
-        'code': 200
-    }
+    return make_resp('Pet updated.', 200, pets[pet_id])
 
 
 @app.delete('/pets/<int:pet_id>')
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 3164d1f..29a7818 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -17,6 +17,7 @@ from flask import jsonify
 from flask import render_template_string
 from flask.config import ConfigAttribute
 from flask.globals import _request_ctx_stack
+from flask.wrappers import Response
 
 with warnings.catch_warnings():
     warnings.simplefilter('ignore')
@@ -426,6 +427,19 @@ class APIFlask(APIScaffold, Flask):
         else:
             return view_function(*req.view_args.values())  # type: ignore
 
+    # TODO: remove this function when we dropped the Flask 1.x support
+    # the list return values are supported in Flask 2.2
+    def make_response(self, rv) -> Response:
+        """Patch the make_response form Flask to allow returning list as JSON.
+
+        *Version added: 1.1.0*
+        """
+        if isinstance(rv, list):
+            rv = jsonify(rv)
+        elif isinstance(rv, tuple) and isinstance(rv[0], list):
+            rv = (jsonify(rv[0]), *rv[1:])
+        return super().make_response(rv)
+
     @staticmethod
     def _error_handler(
         error: HTTPError
diff --git a/src/apiflask/types.py b/src/apiflask/types.py
index 575f53f..8c156a5 100644
--- a/src/apiflask/types.py
+++ b/src/apiflask/types.py
@@ -21,7 +21,9 @@ if t.TYPE_CHECKING:  # pragma: no cover
 DecoratedType = t.TypeVar('DecoratedType', bound=t.Callable[..., t.Any])
 RequestType = t.TypeVar('RequestType')
 
-ResponseBodyType = t.Union[str, bytes, t.Dict[str, t.Any], t.Generator[str, None, None], 'Response']
+ResponseBodyType = t.Union[
+    str, bytes, list, t.Dict[str, t.Any], t.Generator[str, None, None], 'Response'
+]
 ResponseStatusType = t.Union[str, int]
 _HeaderName = str
 _HeaderValue = t.Union[str, t.List[str], t.Tuple[str, ...]]


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index ca2d774..4b9742c 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -273,3 +273,25 @@ def test_docs_ui(ui_name):
 def test_bad_docs_ui():
     with pytest.raises(ValueError):
         APIFlask(__name__, docs_ui='bad')
+
+
+def test_return_list_as_json(app, client):
+    test_list = ['foo', 'bar', 'baz']
+
+    @app.get('/single')
+    def single_value():
+        return test_list
+
+    @app.get('/multi')
+    def multi_values():
+        return test_list, 201, {'X-Foo': 'bar'}
+
+    rv = client.get('/single')
+    assert rv.status_code == 200
+    assert rv.headers['Content-Type'] == 'application/json'
+    assert rv.json == test_list
+
+    rv = client.get('/multi')
+    assert rv.status_code == 201
+    assert rv.headers['Content-Type'] == 'application/json'
+    assert rv.json == test_list