Repository: apiflask/apiflask (python)

Issue:
Add file validators for File field
Add `FileType` and `FileSize` validators.

Pull request description:
Add file validators for File field
fixes #253 

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 b696457..ca6d663 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,14 +2,16 @@
 
 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 `FileType` and `FileSize` validators ([issue #253][issue_253]).
+- 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
+- Fix the base repsonse schema inconsistency issue.
 
 [issue_494]: https://github.com/apiflask/apiflask/issues/494
 [issue_507]: https://github.com/apiflask/apiflask/issues/507
 [issue_483]: https://github.com/apiflask/apiflask/issues/483
+[issue_253]: https://github.com/apiflask/apiflask/issues/253
 
 
 ## Version 2.0.2
diff --git a/NOTICE b/NOTICE
index c336807..49fee26 100644
--- a/NOTICE
+++ b/NOTICE
@@ -74,3 +74,27 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
+
+## Loguru
+
+MIT License
+
+Copyright (c) 2017
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/docs/request.md b/docs/request.md
index 6543e1b..5db9474 100644
--- a/docs/request.md
+++ b/docs/request.md
@@ -223,13 +223,14 @@ the form data (equals to `form_and_files`).
 
 !!! notes
 
-    Validators for the file field will be available in the version 1.1
-    ([#253](https://github.com/apiflask/apiflask/issues/253)). For now,
-    you can manually validate the file in the view function or the schema:
+    From APIFlask 2.1.0, you can use the `FileType` and `FileSize` validators
+    to validate file type and size:
 
     ```python
+    from apiflask.validators import FileType, FileSize
+
     class Image(Schema):
-        image = File(validate=lambda f: f.mimetype in ['image/jpeg', 'image/png'])
+        image = File(validate=[FileType(['.png', '.jpg', '.jpeg', '.gif']), FileSize(max='5 MB')])
     ```
 
 
diff --git a/docs/schema.md b/docs/schema.md
index 85702b5..61c1904 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -168,7 +168,8 @@ API documentation: <https://apiflask.com/api/fields>
 
 ## Data validators
 
-APIFlask's `aipflask.validators` contains all the validator class provided by marshmallow:
+APIFlask's `aipflask.validators` contains all the validator class provided by marshmallow
+and two extra validators `FileType` and `FileSize`:
 
 - `ContainsNoneOf`
 - `ContainsOnly`
@@ -182,8 +183,11 @@ APIFlask's `aipflask.validators` contains all the validator class provided by ma
 - `Regexp`
 - `URL`
 - `Validator`
+- `FileType`
+- `FileSize`
 
-See the [API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.validate.html)
+See the [marshmallow API documentation](https://marshmallow.readthedocs.io/en/stable/marshmallow.validate.html)
+and the [apiflask API documentation](https://apiflask.com/api/validators)
 for the detailed usage.
 
 When specifying validators for a field, you can pass a single validator to the `validate` parameter:
diff --git a/examples/file_upload/app.py b/examples/file_upload/app.py
index 6067f70..5d470be 100644
--- a/examples/file_upload/app.py
+++ b/examples/file_upload/app.py
@@ -3,6 +3,7 @@ import os
 from werkzeug.utils import secure_filename
 from apiflask import APIFlask, Schema
 from apiflask.fields import File, String
+from apiflask.validators import FileSize, FileType
 
 app = APIFlask(__name__)
 
@@ -10,12 +11,12 @@ upload_dir = './upload'
 
 
 class Image(Schema):
-    image = File()
+    image = File(validate=[FileType(['.png', '.jpg', '.jpeg', '.gif']), FileSize(max='5 MB')])
 
 
 class ProfileIn(Schema):
     name = String()
-    avatar = File()
+    avatar = File(validate=[FileType(['.png', '.jpg', '.jpeg']), FileSize(max='2 MB')])
 
 
 @app.post('/images')
diff --git a/src/apiflask/helpers.py b/src/apiflask/helpers.py
index 8e8491d..a7a838b 100644
--- a/src/apiflask/helpers.py
+++ b/src/apiflask/helpers.py
@@ -1,7 +1,9 @@
+import re
 import typing as t
 
 from flask import request
 from flask import url_for
+from werkzeug.datastructures import FileStorage
 from werkzeug.http import HTTP_STATUS_CODES
 
 from .types import PaginationType
@@ -107,3 +109,49 @@ def pagination_builder(pagination: PaginationType, **kwargs: t.Any) -> dict:
         'last': get_page_url(pagination.pages),
         'current': get_page_url(pagination.page),
     }
+
+
+def get_filestorage_size(file: FileStorage) -> int:
+    """A helper function to get the size of the FileStorage object in bytes.
+
+    Arguments:
+        file: A FileStorage object.
+
+    *Version added: 2.1.0*
+    """
+    size = len(file.read())
+    file.stream.seek(0)
+    return size
+
+
+# This helper function is copied from loguru with few modifications.
+# https://github.com/Delgan/loguru/blob/master/loguru/_string_parsers.py#L35
+def parse_size(size: str) -> float:  # noqa: E302
+    """A helper function to parse a str representing the size into a number in bytes.
+
+    Arguments:
+        size: A str representing the size.
+
+    *Version added: 2.1.0*
+    """
+    size = size.strip()
+    reg = re.compile(r'([e\+\-\.\d]+)\s*([kmgtpezy])?(i)?(b)', flags=re.I)
+
+    match = reg.fullmatch(size)
+
+    if not match:
+        raise ValueError(f"Invalid size value: '{size!r}'")
+
+    s, u, i, b = match.groups()
+
+    try:
+        s = float(s)
+    except ValueError as e:
+        raise ValueError(f"Invalid float value while parsing size: '{s!r}'") from e
+
+    u = 'kmgtpezy'.index(u.lower()) + 1 if u else 0
+    i = 1024 if i else 1000
+    b = {'b': 8, 'B': 1}[b] if b else 1
+    size: float = s * i**u / b
+
+    return size
diff --git a/src/apiflask/validators.py b/src/apiflask/validators.py
index a996979..8283446 100644
--- a/src/apiflask/validators.py
+++ b/src/apiflask/validators.py
@@ -1,3 +1,7 @@
+import os
+import typing as t
+
+from marshmallow.exceptions import ValidationError
 from marshmallow.validate import ContainsNoneOf as ContainsNoneOf
 from marshmallow.validate import ContainsOnly as ContainsOnly
 from marshmallow.validate import Email as Email
@@ -10,3 +14,158 @@ from marshmallow.validate import Range as Range
 from marshmallow.validate import Regexp as Regexp
 from marshmallow.validate import URL as URL
 from marshmallow.validate import Validator as Validator
+from werkzeug.datastructures import FileStorage
+
+from .helpers import get_filestorage_size
+from .helpers import parse_size
+
+
+class FileSize(Validator):
+    """Validator which succeeds if the file passed to it is within the specified
+    size range. If ``min`` is not specified, or is specified as `None`,
+    no lower bound exists. If ``max`` is not specified, or is specified as `None`,
+    no upper bound exists. The inclusivity of the bounds (if they exist) is configurable.
+    If ``min_inclusive`` is not specified, or is specified as `True`, then
+    the ``min`` bound is included in the range. If ``max_inclusive`` is not specified,
+    or is specified as `True`, then the ``max`` bound is included in the range.
+
+    Examples:
+    ```python
+    from apiflask import Schema
+    from apiflask.fields import File
+    from apiflask.validators import FileSize
+
+
+    class Image(Schema):
+        image = File(required=True, validate=FileSize(min='1 MiB', max='2 MiB'))
+    ```
+
+    Arguments:
+        min: The minimum size (lower bound). If not provided, minimum
+            size will not be checked.
+        max: The maximum size (upper bound). If not provided, maximum
+            size will not be checked.
+        min_inclusive: Whether the `min` bound is included in the range.
+        max_inclusive: Whether the `max` bound is included in the range.
+        error: Error message to raise in case of a validation error.
+            Can be interpolated with `{input}`, `{min}` and `{max}`.
+
+    *Version added: 2.1.0*
+    """
+
+    message_min = 'Must be {min_op} {{min}}.'
+    message_max = 'Must be {max_op} {{max}}.'
+    message_all = 'Must be {min_op} {{min}} and {max_op} {{max}}.'
+
+    message_gte = 'greater than or equal to'
+    message_gt = 'greater than'
+    message_lte = 'less than or equal to'
+    message_lt = 'less than'
+
+    def __init__(
+        self,
+        min: t.Optional[str] = None,
+        max: t.Optional[str] = None,
+        min_inclusive: bool = True,
+        max_inclusive: bool = True,
+        error: t.Optional[str] = None,
+    ) -> None:
+        self.min = min
+        self.max = max
+        self.min_size = parse_size(self.min) if self.min else None
+        self.max_size = parse_size(self.max) if self.max else None
+        self.min_inclusive = min_inclusive
+        self.max_inclusive = max_inclusive
+        self.error = error
+
+        self.message_min = self.message_min.format(
+            min_op=self.message_gte if self.min_inclusive else self.message_gt
+        )
+        self.message_max = self.message_max.format(
+            max_op=self.message_lte if self.max_inclusive else self.message_lt
+        )
+        self.message_all = self.message_all.format(
+            min_op=self.message_gte if self.min_inclusive else self.message_gt,
+            max_op=self.message_lte if self.max_inclusive else self.message_lt,
+        )
+
+    def _repr_args(self) -> str:
+        return 'min={!r}, max={!r}, min_inclusive={!r}, max_inclusive={!r}'.format(
+            self.min, self.max, self.min_inclusive, self.max_inclusive
+        )
+
+    def _format_error(self, value: FileStorage, message: str) -> str:
+        return (self.error or message).format(input=value, min=self.min, max=self.max)
+
+    def __call__(self, value: FileStorage) -> FileStorage:
+        if not isinstance(value, FileStorage):
+            raise TypeError(
+                f'A FileStorage object is required, not {type(value).__name__!r}'
+            )
+
+        file_size = get_filestorage_size(value)
+        if self.min_size is not None and (
+            file_size < self.min_size if self.min_inclusive else file_size <= self.min_size
+        ):
+            message = self.message_min if self.max is None else self.message_all
+            raise ValidationError(self._format_error(value, message))
+
+        if self.max_size is not None and (
+            file_size > self.max_size if self.max_inclusive else file_size >= self.max_size
+        ):
+            message = self.message_max if self.min is None else self.message_all
+            raise ValidationError(self._format_error(value, message))
+
+        return value
+
+
+class FileType(Validator):
+    """Validator which succeeds if the uploaded file is allowed by a given list
+        of extensions.
+
+    Examples:
+    ```python
+    from apiflask import Schema
+    from apiflask.fields import File
+    from apiflask.validators import FileType
+
+
+    class Image(Schema):
+        image = File(required=True, validate=FileType(['.png']))
+    ```
+
+    Arguments:
+        accept: A sequence of allowed extensions.
+        error: Error message to raise in case of a validation error.
+            Can be interpolated with `{input}` and `{extensions}`.
+
+    *Version added: 2.1.0*
+    """
+
+    default_message = 'Not an allowed file type. Allowed file types: [{extensions}]'
+
+    def __init__(
+        self,
+        accept: t.Iterable[str],
+        error: t.Optional[str] = None,
+    ) -> None:
+        self.allowed_types = {ext.lower() for ext in accept}
+        self.error = error or self.default_message
+
+    def _format_error(self, value: FileStorage) -> str:
+        return (self.error or self.default_message).format(
+            input=value,
+            extensions=''.join(self.allowed_types)
+        )
+
+    def __call__(self, value: FileStorage) -> FileStorage:
+        if not isinstance(value, FileStorage):
+            raise TypeError(
+                f'A FileStorage object is required, not {type(value).__name__!r}'
+            )
+
+        _, extension = os.path.splitext(value.filename) if value.filename else (None, None)
+        if extension is None or extension.lower() not in self.allowed_types:
+            raise ValidationError(self._format_error(value))
+
+        return value


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 2cb38e9..10c809e 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -1,8 +1,13 @@
+import io
+
 import pytest
+from werkzeug.datastructures import FileStorage
 
 from apiflask import get_reason_phrase
 from apiflask import pagination_builder
 from apiflask import PaginationSchema
+from apiflask.helpers import get_filestorage_size
+from apiflask.helpers import parse_size
 
 
 @pytest.mark.parametrize('code', [204, 400, 404, 456, 4123])
@@ -57,3 +62,36 @@ def test_pagination_builder(app, client):
     assert 'next_num' not in rv.json
     assert 'has_prev' not in rv.json
     assert 'prev_num' not in rv.json
+
+
+def test_get_filestorage_size():
+    rv = get_filestorage_size(FileStorage(io.BytesIO(b''.ljust(1024))))
+    assert rv == 1024
+
+
+@pytest.mark.parametrize(
+    'size',
+    ['1 KB', '1 KiB', '1 MB', '1 MiB', '1 GB', '1 GiB']
+)
+def test_parse_size(size):
+    rv = parse_size(size)
+    if size == '1 KB':
+        assert rv == 1000
+    elif size == '1 KiB':
+        assert rv == 1024
+    elif size == '1 MB':
+        assert rv == 1000000
+    elif size == '1 MiB':
+        assert rv == 1048576
+    elif size == '1 GB':
+        assert rv == 1000000000
+    elif size == '1 GiB':
+        assert rv == 1073741824
+
+
+def test_parse_size_wrong_value():
+    with pytest.raises(ValueError, match='Invalid size value: '):
+        parse_size('wrong_format')
+
+    with pytest.raises(ValueError, match='Invalid float value while parsing size: '):
+        parse_size('1.2.3 MiB')
diff --git a/tests/test_validators.py b/tests/test_validators.py
new file mode 100644
index 0000000..f5141f5
--- /dev/null
+++ b/tests/test_validators.py
@@ -0,0 +1,92 @@
+import io
+
+import pytest
+from marshmallow.exceptions import ValidationError
+from werkzeug.datastructures import FileStorage
+
+from apiflask import validators
+
+
+def test_filesize_min():
+    fs = FileStorage(io.BytesIO(b''.ljust(1024)))
+    assert validators.FileSize(min='1 KiB', max='2 KiB')(fs) is fs
+    assert validators.FileSize(min='0 KiB', max='1 KiB')(fs) is fs
+    assert validators.FileSize()(fs) is fs
+    assert validators.FileSize(min_inclusive=False, max_inclusive=False)(fs) is fs
+    assert validators.FileSize(min='1 KiB', max='1 KiB')(fs) is fs
+
+    with pytest.raises(ValidationError, match='Must be greater than or equal to 2 KiB'):
+        validators.FileSize(min='2 KiB', max='3 KiB')(fs)
+    with pytest.raises(ValidationError, match='Must be greater than or equal to 2 KiB'):
+        validators.FileSize(min='2 KiB')(fs)
+    with pytest.raises(ValidationError, match='Must be greater than 1 KiB'):
+        validators.FileSize(min='1 KiB', max='2 KiB', min_inclusive=False, max_inclusive=True)(fs)
+    with pytest.raises(ValidationError, match='less than 1 KiB'):
+        validators.FileSize(min='1 KiB', max='1 KiB', min_inclusive=True, max_inclusive=False)(fs)
+
+
+def test_filesize_max():
+    fs = FileStorage(io.BytesIO(b''.ljust(2048)))
+    assert validators.FileSize(min='1 KiB', max='2 KiB')(fs) is fs
+    assert validators.FileSize(max='2 KiB')(fs) is fs
+    assert validators.FileSize()(fs) is fs
+    assert validators.FileSize(min_inclusive=False, max_inclusive=False)(fs) is fs
+    assert validators.FileSize(min='2 KiB', max='2 KiB')(fs) is fs
+
+    with pytest.raises(ValidationError, match='less than or equal to 1 KiB'):
+        validators.FileSize(min='0 KiB', max='1 KiB')(fs)
+    with pytest.raises(ValidationError, match='less than or equal to 1 KiB'):
+        validators.FileSize(max='1 KiB')(fs)
+    with pytest.raises(ValidationError, match='less than 2 KiB'):
+        validators.FileSize(min='1 KiB', max='2 KiB', min_inclusive=True, max_inclusive=False)(fs)
+    with pytest.raises(ValidationError, match='greater than 2 KiB'):
+        validators.FileSize(min='2 KiB', max='2 KiB', min_inclusive=False, max_inclusive=True)(fs)
+
+
+def test_filesize_repr():
+    assert (
+        repr(
+            validators.FileSize(
+                min=None, max=None, error=None, min_inclusive=True, max_inclusive=True
+            )
+        )
+        == '<FileSize(min=None, max=None, min_inclusive=True, max_inclusive=True, error=None)>'
+    )
+
+    assert repr(
+        validators.FileSize(
+            min='1 KiB', max='3 KiB', error='foo', min_inclusive=False, max_inclusive=False
+        )
+    ) == "<FileSize(min='1 KiB', max='3 KiB', min_inclusive=False, max_inclusive=False, error='foo')>"  # noqa: E501
+
+
+def test_filesize_wrongtype():
+    with pytest.raises(TypeError, match='A FileStorage object is required, not '):
+        validators.FileSize()(1)
+
+
+def test_filetype():
+    png_fs = FileStorage(io.BytesIO(b''.ljust(1024)), 'test.png')
+    assert validators.FileType(['.png'])(png_fs) is png_fs
+    assert validators.FileType(['.PNG'])(png_fs) is png_fs
+
+    PNG_fs = FileStorage(io.BytesIO(b''.ljust(1024)), 'test.PNG')
+    assert validators.FileType(['.png'])(PNG_fs) is PNG_fs
+    assert validators.FileType(['.PNG'])(PNG_fs) is PNG_fs
+
+    with pytest.raises(TypeError, match='A FileStorage object is required, not '):
+        validators.FileType(['.png'])(1)
+
+    with pytest.raises(
+        ValidationError,
+        match=r'Not an allowed file type. Allowed file types: \[.*?\]'  # noqa: W605
+    ):
+        jpg_fs = FileStorage(io.BytesIO(b''.ljust(1024)), 'test.jpg')
+        validators.FileType(['.png'])(jpg_fs)
+
+    with pytest.raises(
+        ValidationError,
+        match=r'Not an allowed file type. Allowed file types: \[.*?\]'  # noqa: W605
+    ):
+        no_ext_fs = FileStorage(io.BytesIO(b''.ljust(1024)), 'test')
+        validators.FileType(['.png'])(no_ext_fs)