Repository: apiflask/apiflask (python)

Issue:
Specifying `validation=False` on `@input` decorator yields unexpected input when endpoint accepts a list
For endpoints that accept in a list of values in their input, specifying `validation=False` on the `@input` decorator yields an empty dictionary when an empty list is passed in to the endpoint. This is a change in behaviour compared to when the `validation` argument is omitted, in which case an empty list is passed along to the application code.

```python
from marshmallow import fields

from apiflask import APIFlask, Schema

app = APIFlask(__name__)


class TestSchema(Schema):
    field = fields.Str(required=True)


@app.post("/test_with_validation")
@app.input(TestSchema(many=True), validation=True)
def test_with_validation(json_data):
    return json_data


@app.post("/test_without_validation")
@app.input(TestSchema(many=True), validation=False)
def test_without_validation(json_data):
    return json_data


client = app.test_client()
payload = [{'data': 'value'}]
result = client.post("/test_with_validation", json=payload)
assert result.status_code == 422  # Input validation fails
result = client.post("/test_without_validation", json=payload)
assert result.status_code == 200
assert result.json == payload

payload = []
result = client.post("/test_with_validation", json=payload)
assert result.status_code == 200
assert result.json == payload
result = client.post("/test_without_validation", json=payload)
assert result.status_code == 200
assert result.json == payload  # Fails; result is {} instead of []
```

The output from the `@input` decorator shouldn't be impacted by skipping validation (i.e., the raw input should be passed along, and not interfered with).

Environment:

- Python version: 3.11
- Flask version: 3.1.0
- APIFlask version: 2.3.1


Pull request description:
Modify input validation skip to use webargs implementation
Modified the `@input` data loading logic for the `validation=False` case by replacing the hand-rolled data loading implementation with an implementation from elements within the `webargs` library. Also fixed an undocumented issue where the `arg_name` parameter was not properly handled when input validation is skipped.

Fixes #629.

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 ba870a7..b45d0d1 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,12 @@
+## Version 2.3.2
+
+Released: -
+
+- Fix input data loading implementation when input validation is skipped ([issue #629][issue_629]).
+
+[issue_629]: https://github.com/apiflask/apiflask/issues/629
+
+
 ## Version 2.3.1
 
 Released: 2024/12/7
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index 767d2b1..9b643ac 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -6,7 +6,7 @@ from functools import wraps
 
 from flask import current_app
 from flask import jsonify
-from flask import request
+from flask import request as flask_request
 from flask import Response
 from marshmallow import ValidationError as MarshmallowValidationError
 from webargs.flaskparser import FlaskParser as BaseFlaskParser
@@ -55,9 +55,14 @@ class FlaskParser(BaseFlaskParser):
             error_headers,
         )
 
+    def load_location_data(self, schema: Schema, location: str) -> t.Any:
+        """
+        Expose the internal `_load_location_data` method to support loading data without validation
+        """
+        return self._load_location_data(schema=schema, req=flask_request, location=location)
+
 
 parser: FlaskParser = FlaskParser()
-use_args: t.Callable = parser.use_args
 
 
 def _get_files_and_form(request, schema):
@@ -101,46 +106,6 @@ def _generate_schema_from_mapping(schema: DictSchemaType, schema_name: str | Non
     return Schema.from_dict(schema, name=schema_name)()  # type: ignore
 
 
-def _load_raw_data(location: str = 'json') -> dict[t.Any, t.Any]:
-    """
-    Load raw data from the specified location in the request.
-
-    Arguments:
-        location (str): Specifies the source of the data to load, which can be one of the following:
-            - `json`: Load data from the request's JSON body.
-            - `form`: Load data from the request's form.
-            - `files` or `form_and_files`: Load data from both the request's form and files.
-            - `json_or_form`: Preferably load data from the request's JSON body or request's form.
-            - `query`: Load data from the request's query parameters.
-            - `path`: Load data from the request's view arguments.
-
-            `headers` and `cookies` are not supported by this function.
-
-    Returns:
-        dict: A dictionary containing the data loaded from the specified location.
-
-    Raises:
-        ValueError: If the provided location argument is not one of the expected values.
-    """
-    if location == 'json':
-        if request.is_json:
-            return request.get_json() or {}
-        return {}
-    if location == 'form':
-        return request.form.to_dict()
-    if location in ['files', 'form_and_files']:
-        return {**request.files.to_dict(), **request.form.to_dict()}
-    if location == 'json_or_form':
-        if request.is_json:
-            return request.get_json() or {}
-        return request.form.to_dict()
-    if location in ['query', 'querystring']:
-        return request.args.to_dict()
-    if location == 'path':
-        return request.view_args or {}
-    raise ValueError(f'Invalid location argument: {location}')
-
-
 class APIScaffold:
     """A base class for [`APIFlask`][apiflask.app.APIFlask] and
     [`APIBlueprint`][apiflask.blueprint.APIBlueprint].
@@ -385,19 +350,19 @@ class APIScaffold:
                 # TODO: Support set example for request parameters
                 f._spec['args'].append((schema, location))
 
+            arg_name_val = arg_name or f'{location}_data'
+
             if not validation:
 
                 @wraps(f)
                 def wrapper(*args: t.Any, **kwargs: t.Any):
-                    raw_data = _load_raw_data(location)
-                    kwargs[f'{location}_data'] = raw_data
+                    location_data = parser.load_location_data(schema=schema, location=location)
+                    kwargs[arg_name_val] = location_data
                     return f(*args, **kwargs)
 
                 return wrapper
 
-            return use_args(
-                schema, location=location, arg_name=arg_name or f'{location}_data', **kwargs
-            )(f)
+            return parser.use_args(schema, location=location, arg_name=arg_name_val, **kwargs)(f)
 
         return decorator
 


Test patch:
diff --git a/tests/test_decorator_input.py b/tests/test_decorator_input.py
index ab5b382..c21f336 100644
--- a/tests/test_decorator_input.py
+++ b/tests/test_decorator_input.py
@@ -446,116 +446,39 @@ def test_skip_validation(app, client):
     assert 'PetIn' in rv.json['components']['schemas']
 
 
-def test_location_raw_data(app):
-    from apiflask.scaffold import _load_raw_data
-
-    # will raises ValueError
-    location = 'unknown'
-    with pytest.raises(ValueError, match=f'Invalid location argument: {location}'):
-        _load_raw_data('unknown')
-
-    # `json` location
-    with app.test_request_context('/load_raw_data', method='POST', json={'name': 'Kitty'}):
-        json_data = _load_raw_data()
-        assert json_data['name'] == 'Kitty'
-
-    with app.test_request_context('/load_raw_data', method='POST', data={'name': 'Kitty'}):
-        json_data = _load_raw_data()
-        assert json_data == {}
-
-    # `form` location
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-        data={'username': 'Kitty', 'password': '<PASSWORD>'},
-        content_type='application/x-www-form-urlencoded',
-    ):
-        form_data = _load_raw_data('form')
-        assert form_data['username'] == 'Kitty'
-        assert form_data['password'] == '<PASSWORD>'
-
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-        json={'username': 'Kitty', 'password': '<PASSWORD>'},
-    ):
-        form_data = _load_raw_data('form')
-        assert form_data == {}
-
-    # `files` or `form_and_files` location
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-        data={'file': (io.BytesIO(b'Hello World!'), 'demo.txt')},
-        content_type='multipart/form-data',
-    ):
-        files_data = _load_raw_data('files')
-        assert files_data['file'].filename == 'demo.txt'
-        assert files_data['file'].stream.read() == b'Hello World!'
-
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-        data={'name': 'Kitty', 'file': (io.BytesIO(b'Hello World2!'), 'demo2.txt')},
-        content_type='multipart/form-data',
-    ):
-        files_and_form_rv = _load_raw_data('form_and_files')
-        assert files_and_form_rv['file'].filename == 'demo2.txt'
-        assert files_and_form_rv['file'].stream.read() == b'Hello World2!'
-        assert files_and_form_rv['name'] == 'Kitty'
-
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-        json={'username': 'Kitty', 'password': '<PASSWORD>'},
-    ):
-        form_and_files_data = _load_raw_data('form_and_files')
-        assert form_and_files_data == {}
-
-    # `query` location
-    with app.test_request_context(
-        '/load_raw_data?name=Kitty',
-        method='GET',
-    ):
-        query_data = _load_raw_data('query')
-        assert query_data == {'name': 'Kitty'}
-
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-    ):
-        query_data = _load_raw_data('query')
-        assert query_data == {}
-
-    # `json_or_form`
-    with app.test_request_context(
-        '/load_raw_data',
-        data={'username': 'Kitty', 'password': '<PASSWORD>'},
-        content_type='application/x-www-form-urlencoded',
-    ):
-        json_or_form_rv = _load_raw_data('json_or_form')
-        assert json_or_form_rv['username'] == 'Kitty'
-        assert json_or_form_rv['password'] == '<PASSWORD>'
-
-    with app.test_request_context(
-        '/load_raw_data',
-        json={'currentId': '<current_id>'},
-    ):
-        json_or_form_data = _load_raw_data('json_or_form')
-        json_or_form_data['currentId'] = '<current_id>'
-
-    with app.test_request_context(
-        '/load_raw_data',
-        method='POST',
-    ):
-        json_or_form_data = _load_raw_data('json_or_form')
-        assert json_or_form_data == {}
-
-    # `path` location
-    @app.get('/load_raw_data/<int:pet_id>')
-    def load_raw_data(pet_id):
-        return _load_raw_data('path')
-
-    with app.test_request_context('/load_raw_data/1', method='GET'):
-        path_data = app.dispatch_request()
-        assert path_data == {'pet_id': 1}
+@pytest.mark.parametrize('validation', [True, False])
+@pytest.mark.parametrize('payload', [[], [{'bar': 'baz'}], [{'qux': 'baz'}]])
+def test_skip_validation_list_input(app, client, validation, payload):
+    class FooIn(Schema):
+        bar = String(required=True)
+
+    @app.put('/foo/bulk')
+    @app.input(FooIn(many=True), validation=validation)
+    def bulk_put_foo(json_data):
+        return json_data
+
+    rv = client.put('/foo/bulk', json=payload)
+    if validation and payload and 'bar' not in payload[0]:
+        assert rv.status_code == 422
+    else:
+        assert rv.status_code == 200
+        assert rv.json == payload
+
+
+@pytest.mark.parametrize('validation', [True, False])
+@pytest.mark.parametrize('payload', [{}, {'bar': 'qux'}])
+def test_skip_validation_arg_name(app, client, validation, payload):
+    class FooIn(Schema):
+        bar = String(required=True)
+
+    @app.post('/foo')
+    @app.input(FooIn, arg_name='baz', validation=validation)
+    def post_foo(baz):
+        return baz
+
+    rv = client.post('/foo', json=payload)
+    if validation and not payload:
+        assert rv.status_code == 422
+    else:
+        assert rv.status_code == 200
+        assert rv.json == payload