Repository: apiflask/apiflask (python)

Issue:
Add flag to allow disabling of validation on input
## Suggestion
There has been use cases where validation is not always wanted for input but having the input schema in the docs is. It would be nice if there was a way to optionally disable validation for a given input schema and just have APIFlask pass the unaltered JSON to the endpoint while still keeping the input documented in the schema. 

Something like:
```python
@some_blueprint.post("/foos/bulk")
@some_blueprint.input(SomeSchema, validate=False)
def create_foos(json_data: dict):
   ...
```
or
```python
@some_blueprint.post("/foos/bulk")
@some_blueprint.doc(input=SomeSchema)
def create_foos(json_data: dict):
   ...
```

This solves the problem as a lot of the times, bulk endpoints for things like bulk create, bulk edit can do a "best effort" processing. ie. Create/edit all valid entries and ignore invalid entries. Endpoints like `POST /foos/bulk` or `PUT /foos/bulk`.

I can't seem to find a way of doing it with subclassing or some other method of overriding.

I'm more than happy to help and contribute with an MR if this feature is deemed useful.


Pull request description:
Add flag to allow disabling of validation on input(issue 573)
<!--
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 #573 

<!--
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.
- [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.



set `validation=False` in `@app.input(someSchema)` to skip validatation and get raw data.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 577af54..f0ec363 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,11 @@
+## Version 2.3.0
+
+Released: -
+
+- Support skipping the validation for the request body with `@input(validation=False)` ([issue #573][issue_573]).
+
+[issue_573]: https://github.com/apiflask/apiflask/issues/573
+
 
 ## Version 2.2.1
 
diff --git a/docs/usage.md b/docs/usage.md
index 2c57a30..20118b6 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -530,6 +530,21 @@ def update_pet(pet_id, json_data):
     return pet
 ```
 
+
+If you want to disable validation on input, you can set `validation=False`. Now the
+input data will be passed to the view function without validation. If no input data,
+the view function will receive an empty dict:
+
+```python
+@app.patch('/pets_without_validation/<int:pet_id>')
+@app.input(PetIn, location='json', validation=False)
+def pets_without_validation(pet_id, json_data):
+    return {'pet_id': pet_id, 'json_data': json_data}
+```
+
+`headers` and `cookies` are special locations, they contain all the headers and cookies data, so
+you can't skip the validation for them.
+
 !!! warning
 
     Be sure to put the `@app.input` decorator under the routes decorators
diff --git a/mkdocs.yml b/mkdocs.yml
index 7f9210a..3dbef4b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -12,6 +12,8 @@ theme:
   icon:
     repo: fontawesome/brands/github
   language: en
+  features:
+    - content.code.copy
 repo_url: https://github.com/apiflask/apiflask
 repo_name: apiflask/apiflask
 extra:
diff --git a/src/apiflask/scaffold.py b/src/apiflask/scaffold.py
index 688e6f8..ef1fbd3 100644
--- a/src/apiflask/scaffold.py
+++ b/src/apiflask/scaffold.py
@@ -6,6 +6,7 @@ from functools import wraps
 
 from flask import current_app
 from flask import jsonify
+from flask import request
 from flask import Response
 from marshmallow import ValidationError as MarshmallowValidationError
 from webargs.flaskparser import FlaskParser as BaseFlaskParser
@@ -100,6 +101,46 @@ 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].
@@ -209,6 +250,7 @@ class APIScaffold:
         schema_name: str | None = None,
         example: t.Any | None = None,
         examples: dict[str, t.Any] | None = None,
+        validation: bool = True,
         **kwargs: t.Any,
     ) -> t.Callable[[DecoratedType], DecoratedType]:
         """Add input settings for view functions.
@@ -262,6 +304,11 @@ class APIScaffold:
                     },
                 }
                 ```
+            validation: Flag to allow disabling of validation on input. Default to `True`.
+
+        *Version changed: 2.2.2
+
+        - Add parameter `validation` to allow disabling of validation on input.
 
         *Version changed: 2.0.0*
 
@@ -290,6 +337,17 @@ class APIScaffold:
 
         def decorator(f):
             f = _ensure_sync(f)
+
+            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
+                    return f(*args, **kwargs)
+
+                return wrapper
+
             is_body_location = location in BODY_LOCATIONS
             if is_body_location and hasattr(f, '_spec') and 'body' in f._spec:
                 raise RuntimeError(


Test patch:
diff --git a/tests/test_decorator_input.py b/tests/test_decorator_input.py
index c473e8b..7453592 100644
--- a/tests/test_decorator_input.py
+++ b/tests/test_decorator_input.py
@@ -11,7 +11,10 @@ from .schemas import Files
 from .schemas import Foo
 from .schemas import Form
 from .schemas import FormAndFiles
+from apiflask import Schema
 from apiflask.fields import String
+from apiflask.validators import Length
+from apiflask.validators import OneOf
 
 
 def test_input(app, client):
@@ -413,3 +416,136 @@ def test_input_body_example(app, client):
         rv.json['paths']['/baz']['post']['requestBody']['content']['application/json']['examples']
         == examples
     )
+
+
+def test_skip_validation(app, client):
+    incorrect_json = {'name': 'Kitty', 'category': 'unknown'}
+
+    class PetIn(Schema):
+        name = String(required=True, validate=Length(0, 10))
+        category = String(required=True, validate=OneOf(['dog', 'cat']))
+
+    @app.patch('/pets_without_validation/<int:pet_id>')
+    @app.input(PetIn, validation=False)
+    def pets_without_validation(pet_id, json_data):
+        return {'pet_id': pet_id, 'json_data': json_data}
+
+    no_validated_rv = client.patch('/pets_without_validation/1', json=incorrect_json)
+    assert no_validated_rv.status_code == 200
+    assert no_validated_rv.json['json_data']['name'] == 'Kitty'
+    assert no_validated_rv.json['json_data']['category'] == 'unknown'
+
+
+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}