diff --git a/CHAGELOG.md b/CHAGELOG.md index c1414a9..b63110a 100644 --- a/CHAGELOG.md +++ b/CHAGELOG.md @@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file. - New functionality to define steps for a field to have more control over the order of the validation and filtering process. +### Filter + +- New [`Base64ImageDownscaleFilter`](flask_inputfilter/Filter/Base64ImageDownscaleFilter.py) to reduce the size of an image. +- New [`Base64ImageResizeFilter`](flask_inputfilter/Filter/Base64ImageResizeFilter.py) to reduce the file size of an image. + +### Validator + +- New [`IsHorizontalImageValidator`](flask_inputfilter/Validator/IsHorizontalImageValidator.py) to check if an image is horizontical. +- New [`IsVerticalImageValidator`](flask_inputfilter/Validator/IsVerticalImageValidator.py) to check if an image is vertical. + +## Changed + +- Added UnicodeFormEnum to show possible config values for ToNormalizedUnicodeFilter. + Old config is still supportet, but will be removed at a later version. + # [0.0.7.1] - 2025-01-16 diff --git a/README.rst b/README.rst index 5234e91..90cabd6 100644 --- a/README.rst +++ b/README.rst @@ -40,7 +40,7 @@ Quickstart To use the `InputFilter` class, create a new class that inherits from it and define the fields you want to validate and filter. -There are numerous filters and validators available, but you can also create your `own `. +There are numerous filters and validators available, but you can also create your `own `_. Definition ---------- @@ -93,7 +93,7 @@ Usage ----- To use the `InputFilter` class, call the `validate` method on the class instance. -After calling `validate`, the validated data will be available in `g.validatedData`. +After calling `validate`, the validated data will be available in `g.validated_data`. If the data is invalid, a 400 response with an error message will be returned. .. code-block:: python @@ -106,7 +106,7 @@ If the data is invalid, a 400 response with an error message will be returned. @app.route('/update-zipcode', methods=['POST']) @UpdateZipcodeInputFilter.validate() def updateZipcode(): - data = g.validatedData + data = g.validated_data # Do something with validated data id = data.get('id') diff --git a/flask_inputfilter/Enum/ImageFormatEnum.py b/flask_inputfilter/Enum/ImageFormatEnum.py new file mode 100644 index 0000000..f248ebf --- /dev/null +++ b/flask_inputfilter/Enum/ImageFormatEnum.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class ImageFormatEnum(Enum): + JPEG = "JPEG" + PNG = "PNG" + GIF = "GIF" + BMP = "BMP" + TIFF = "TIFF" + WEBP = "WEBP" + ICO = "ICO" + PDF = "PDF" + EPS = "EPS" + SVG = "SVG" + PSD = "PSD" + XCF = "XCF" + HEIF = "HEIF" + AVIF = "AVIF" diff --git a/flask_inputfilter/Enum/UnicodeFormEnum.py b/flask_inputfilter/Enum/UnicodeFormEnum.py new file mode 100644 index 0000000..d8c9fbd --- /dev/null +++ b/flask_inputfilter/Enum/UnicodeFormEnum.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class UnicodeFormEnum(Enum): + NFC = "NFC" + NFD = "NFD" + NFKC = "NFKC" + NFKD = "NFKD" diff --git a/flask_inputfilter/Enum/__init__.py b/flask_inputfilter/Enum/__init__.py index ab16559..4af0393 100644 --- a/flask_inputfilter/Enum/__init__.py +++ b/flask_inputfilter/Enum/__init__.py @@ -1 +1,3 @@ +from .ImageFormatEnum import ImageFormatEnum from .RegexEnum import RegexEnum +from .UnicodeFormEnum import UnicodeFormEnum diff --git a/flask_inputfilter/Filter/Base64ImageDownscaleFilter.py b/flask_inputfilter/Filter/Base64ImageDownscaleFilter.py new file mode 100644 index 0000000..08e56e9 --- /dev/null +++ b/flask_inputfilter/Filter/Base64ImageDownscaleFilter.py @@ -0,0 +1,83 @@ +import base64 +import io +from typing import Any, Optional + +from PIL import Image + +from .BaseFilter import BaseFilter + + +class Base64ImageDownscaleFilter(BaseFilter): + """ + Filter that downscales a base64 image to a given size + """ + + def __init__( + self, + size: int = 1024 * 1024, + width: Optional[int] = None, + height: Optional[int] = None, + proportionally: bool = True, + ) -> None: + self.width = int(width or size**0.5) + self.height = int(height or size**0.5) + self.proportionally = proportionally + + def apply(self, value: Any) -> Any: + if not isinstance(value, (str, Image.Image)): + return value + + try: + if isinstance(value, Image.Image): + return self.resize_picture(value) + + image = Image.open(io.BytesIO(base64.b64decode(value))) + return self.resize_picture(image) + + except Exception: + return value + + def resize_picture(self, image: Image) -> str: + """ + Resizes the image if it exceeds the specified width/height + and returns the base64 representation. + """ + is_animated = getattr(image, "is_animated", False) + + if not is_animated and image.mode in ("RGBA", "P"): + image = image.convert("RGB") + + if ( + image.size[0] * image.size[1] < self.width * self.height + or is_animated + ): + return self.image_to_base64(image) + + if self.proportionally: + image = self.scale_image(image) + else: + image = image.resize((self.width, self.height), Image.LANCZOS) + + return self.image_to_base64(image) + + def scale_image(self, image: Image) -> Image: + """ + Scale the image proportionally to fit within the target width/height. + """ + original_width, original_height = image.size + aspect_ratio = original_width / original_height + + if original_width > original_height: + new_width = self.width + new_height = int(new_width / aspect_ratio) + else: + new_height = self.height + new_width = int(new_height * aspect_ratio) + + return image.resize((new_width, new_height), Image.LANCZOS) + + @staticmethod + def image_to_base64(image: Image) -> str: + buffered = io.BytesIO() + image.save(buffered, format="PNG") + return base64.b64encode(buffered.getvalue()).decode("ascii") diff --git a/flask_inputfilter/Filter/Base64ImageResizeFilter.py b/flask_inputfilter/Filter/Base64ImageResizeFilter.py new file mode 100644 index 0000000..c9efb2e --- /dev/null +++ b/flask_inputfilter/Filter/Base64ImageResizeFilter.py @@ -0,0 +1,94 @@ +import base64 +import io +from typing import Any + +from PIL import Image + +from ..Enum import ImageFormatEnum +from .BaseFilter import BaseFilter + + +class Base64ImageResizeFilter(BaseFilter): + """ + A filter to reduce the file size of a base64-encoded + image by resizing and compressing it. + """ + + def __init__( + self, + max_size: int = 4 * 1024 * 1024, + format: ImageFormatEnum = ImageFormatEnum.JPEG, + preserve_icc_profile: bool = False, + preserve_metadata: bool = False, + ) -> None: + self.max_size = max_size + self.format = format + self.preserve_metadata = preserve_metadata + self.preserve_icc_profile = preserve_icc_profile + + def apply(self, value: Any) -> Any: + if not isinstance(value, (str, Image.Image)): + return value + + try: + if isinstance(value, Image.Image): + return self.reduce_image(value) + + value = Image.open(io.BytesIO(base64.b64decode(value))) + return self.reduce_image(value) + except Exception: + return value + + def reduce_image(self, image: Image) -> Image: + """Reduce the size of an image by resizing and compressing it.""" + is_animated = getattr(image, "is_animated", False) + + if not is_animated and image.mode in ("RGBA", "P"): + image = image.convert("RGB") + + buffer = self.save_image_to_buffer(image, quality=80) + if buffer.getbuffer().nbytes <= self.max_size: + return self.image_to_base64(image) + + new_width, new_height = image.size + + while ( + buffer.getbuffer().nbytes > self.max_size + and new_width > 10 + and new_height > 10 + ): + new_width = int(new_width * 0.9) + new_height = int(new_height * 0.9) + image = image.resize((new_width, new_height), Image.LANCZOS) + + buffer = self.save_image_to_buffer(image, quality=80) + + quality = 80 + while buffer.getbuffer().nbytes > self.max_size and quality > 0: + buffer = self.save_image_to_buffer(image, quality) + quality -= 5 + + return self.image_to_base64(image) + + def save_image_to_buffer( + self, image: Image.Image, quality: int + ) -> io.BytesIO: + """Save the image to an in-memory buffer with the specified quality.""" + buffer = io.BytesIO() + image.save(buffer, format=self.format.value, quality=quality) + buffer.seek(0) + return buffer + + def image_to_base64(self, image: Image) -> str: + """Convert an image to a base64-encoded string.""" + buffered = io.BytesIO() + options = { + "format": self.format.value, + "optimize": True, + } + if self.preserve_icc_profile: + options["icc_profile"] = image.info.get("icc_profile", None) + if self.preserve_metadata: + options["exif"] = image.info.get("exif", None) + image.save(buffered, **options) + return base64.b64encode(buffered.getvalue()).decode("ascii") diff --git a/flask_inputfilter/Filter/README.md b/flask_inputfilter/Filter/README.md index 5cd0862..df02528 100644 --- a/flask_inputfilter/Filter/README.md +++ b/flask_inputfilter/Filter/README.md @@ -7,6 +7,8 @@ The `Filter` module contains the filters that can be used to filter the input da The following filters are available in the `Filter` module: 1. [`ArrayExplodeFilter`](ArrayExplodeFilter.py) - Explodes the input string into an array. +2. [`Base64ImageDownscaleFilter`](Base64ImageDownscaleFilter.py) - Downscale the base64 image. +3. [`Base64ImageResizeFilter`](Base64ImageResizeFilter.py) - Resize the base64 image. 2. [`BlacklistFilter`](BlacklistFilter.py) - Filters the string based on the blacklist. 3. [`RemoveEmojisFilter`](RemoveEmojisFilter.py) - Removes the emojis from the string. 4. [`SlugifyFilter`](SlugifyFilter.py) - Converts the string to a slug. diff --git a/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py b/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py index 80b6e88..e817c13 100644 --- a/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py +++ b/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py @@ -3,6 +3,7 @@ from typing_extensions import Literal +from ..Enum import UnicodeFormEnum from .BaseFilter import BaseFilter @@ -12,20 +13,26 @@ class ToNormalizedUnicodeFilter(BaseFilter): """ def __init__( - self, form: Literal["NFC", "NFD", "NFKC", "NFKD"] = "NFC" + self, + form: Union[ + UnicodeFormEnum, Literal["NFC", "NFD", "NFKC", "NFKD"] + ] = UnicodeFormEnum.NFC, ) -> None: + if not isinstance(form, UnicodeFormEnum): + form = UnicodeFormEnum(form) + self.form = form def apply(self, value: Any) -> Union[str, Any]: if not isinstance(value, str): return value - value = unicodedata.normalize(self.form, value) + value = unicodedata.normalize(self.form.value, value) value_without_accents = "".join( char - for char in unicodedata.normalize("NFD", value) + for char in unicodedata.normalize(UnicodeFormEnum.NFD.value, value) if unicodedata.category(char) != "Mn" ) - return unicodedata.normalize(self.form, value_without_accents) + return unicodedata.normalize(self.form.value, value_without_accents) diff --git a/flask_inputfilter/Filter/__init__.py b/flask_inputfilter/Filter/__init__.py index 7223626..d933235 100644 --- a/flask_inputfilter/Filter/__init__.py +++ b/flask_inputfilter/Filter/__init__.py @@ -1,4 +1,6 @@ from .ArrayExplodeFilter import ArrayExplodeFilter +from .Base64ImageDownscaleFilter import Base64ImageDownscaleFilter +from .Base64ImageResizeFilter import Base64ImageResizeFilter from .BaseFilter import BaseFilter from .BlacklistFilter import BlacklistFilter from .RemoveEmojisFilter import RemoveEmojisFilter diff --git a/flask_inputfilter/InputFilter.py b/flask_inputfilter/InputFilter.py index d1fd1cb..11fb2da 100644 --- a/flask_inputfilter/InputFilter.py +++ b/flask_inputfilter/InputFilter.py @@ -126,6 +126,9 @@ def __applySteps( Apply multiple filters and validators in a specific order. """ + if value is None: + return + field = self.fields.get(field_name) try: @@ -280,7 +283,7 @@ def validateData( self.__validateField(field_name, field_info, value) or value ) - value = self.__applySteps(field_name, field_info, value) + value = self.__applySteps(field_name, field_info, value) or value if field_info.get("external_api"): value = self.__callExternalApi(field_info, validated_data) diff --git a/flask_inputfilter/Validator/IsHorizontalImageValidator.py b/flask_inputfilter/Validator/IsHorizontalImageValidator.py new file mode 100644 index 0000000..022a288 --- /dev/null +++ b/flask_inputfilter/Validator/IsHorizontalImageValidator.py @@ -0,0 +1,31 @@ +import base64 +import io + +from PIL import Image +from PIL.Image import Image as ImageType + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Validator import BaseValidator + + +class IsHorizontalImageValidator(BaseValidator): + def __init__(self, error_message=None): + self.error_message = ( + error_message or "The image is not horizontically oriented." + ) + + def validate(self, value): + if not isinstance(value, (str, ImageType)): + raise ValidationError( + "The value is not an image or its base 64 representation." + ) + + try: + if isinstance(value, str): + value = Image.open(io.BytesIO(base64.b64decode(value))) + + if value.width < value.height: + raise + + except Exception: + raise ValidationError(self.error_message) diff --git a/flask_inputfilter/Validator/IsVerticalImageValidator.py b/flask_inputfilter/Validator/IsVerticalImageValidator.py new file mode 100644 index 0000000..963b766 --- /dev/null +++ b/flask_inputfilter/Validator/IsVerticalImageValidator.py @@ -0,0 +1,32 @@ +import base64 +import io +from typing import Any + +from PIL import Image +from PIL.Image import Image as ImageType + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Validator import BaseValidator + + +class IsVerticalImageValidator(BaseValidator): + def __init__(self, error_message=None): + self.error_message = ( + error_message or "The image is not vertically oriented." + ) + + def validate(self, value: Any) -> None: + if not isinstance(value, (str, ImageType)): + raise ValidationError( + "The value is not an image or its base 64 representation." + ) + + try: + if isinstance(value, str): + value = Image.open(io.BytesIO(base64.b64decode(value))) + + if value.width > value.height: + raise + + except Exception: + raise ValidationError(self.error_message) diff --git a/flask_inputfilter/Validator/README.md b/flask_inputfilter/Validator/README.md index 74f5f48..a157455 100644 --- a/flask_inputfilter/Validator/README.md +++ b/flask_inputfilter/Validator/README.md @@ -21,16 +21,18 @@ The following validators are available in the `Validator` module: 13. [`IsFloatValidator`](IsFloatValidator.py) - Validates that the value is a float. 14. [`IsFutureDateValidator`](IsFutureDateValidator.py) - Validates that the value is a future date. 15. [`IsHexadecimalValidator`](IsHexadecimalValidator.py) - Validates that the value is a hexadecimal string. -16. [`IsInstanceValidator`](IsInstanceValidator.py) - Validates that the value is an instance of a class. -17. [`IsIntegerValidator`](IsIntegerValidator.py) - Validates that the value is an integer. -18. [`IsJsonValidator`](IsJsonValidator.py) - Validates that the value is a json string. -19. [`IsPastDateValidator`](IsPastDateValidator.py) - Validates that the value is a past date. -20. [`IsStringValidator`](IsStringValidator.py) - Validates that the value is a string. -21. [`IsUUIDValidator`](IsUUIDValidator.py) - Validates that the value is a UUID. -22. [`IsWeekdayValidator`](IsWeekdayValidator.py) - Validates that the value is a weekday. -23. [`IsWeekendValidator`](IsWeekendValidator.py) - Validates that the value is a weekend. -24. [`LengthValidator`](LengthValidator.py) - Validates the length of the value. -25. [`NotInArrayValidator`](NotInArrayValidator.py) - Validates that the value is not in the given array. -26. [`NotValidator`](NotValidator.py) - Validates that inverts the result of another validator. -26. [`RangeValidator`](RangeValidator.py) - Validates that the value is within a specified range. -27. [`RegexValidator`](RegexValidator.py) - Validates that the value matches a regex pattern. +16. [`IsHorizontalImageValidator`](IsHorizontalImageValidator.py) - Validates that the value is a horizontally flipped image. +17. [`IsInstanceValidator`](IsInstanceValidator.py) - Validates that the value is an instance of a class. +18. [`IsIntegerValidator`](IsIntegerValidator.py) - Validates that the value is an integer. +19. [`IsJsonValidator`](IsJsonValidator.py) - Validates that the value is a json string. +20. [`IsPastDateValidator`](IsPastDateValidator.py) - Validates that the value is a past date. +21. [`IsStringValidator`](IsStringValidator.py) - Validates that the value is a string. +22. [`IsUUIDValidator`](IsUUIDValidator.py) - Validates that the value is a UUID. +23. [`IsVerticalImageValidator`](IsVerticalImageValidator.py) - Validates that the value is a vertically flipped image. +24. [`IsWeekdayValidator`](IsWeekdayValidator.py) - Validates that the value is a weekday. +25. [`IsWeekendValidator`](IsWeekendValidator.py) - Validates that the value is a weekend. +26. [`LengthValidator`](LengthValidator.py) - Validates the length of the value. +27. [`NotInArrayValidator`](NotInArrayValidator.py) - Validates that the value is not in the given array. +28. [`NotValidator`](NotValidator.py) - Validates that inverts the result of another validator. +29. [`RangeValidator`](RangeValidator.py) - Validates that the value is within a specified range. +30. [`RegexValidator`](RegexValidator.py) - Validates that the value matches a regex pattern. diff --git a/flask_inputfilter/Validator/__init__.py b/flask_inputfilter/Validator/__init__.py index 436f222..da9bfa2 100644 --- a/flask_inputfilter/Validator/__init__.py +++ b/flask_inputfilter/Validator/__init__.py @@ -16,12 +16,14 @@ from .IsFloatValidator import IsFloatValidator from .IsFutureDateValidator import IsFutureDateValidator from .IsHexadecimalValidator import IsHexadecimalValidator +from .IsHorizontalImageValidator import IsHorizontalImageValidator from .IsInstanceValidator import IsInstanceValidator from .IsIntegerValidator import IsIntegerValidator from .IsJsonValidator import IsJsonValidator from .IsPastDateValidator import IsPastDateValidator from .IsStringValidator import IsStringValidator from .IsUUIDValidator import IsUUIDValidator +from .IsVerticalImageValidator import IsVerticalImageValidator from .IsWeekdayValidator import IsWeekdayValidator from .IsWeekendValidator import IsWeekendValidator from .LengthValidator import LengthValidator diff --git a/test/test_filter.py b/test/test_filter.py index d4c304f..16fb57b 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,10 +1,16 @@ +import base64 +import io import unittest from datetime import date, datetime from enum import Enum +from PIL import Image + from flask_inputfilter import InputFilter from flask_inputfilter.Filter import ( ArrayExplodeFilter, + Base64ImageDownscaleFilter, + Base64ImageResizeFilter, BaseFilter, BlacklistFilter, RemoveEmojisFilter, @@ -37,14 +43,12 @@ def setUp(self) -> None: """ Set up a InputFilter instance for testing. """ - self.inputFilter = InputFilter() def test_array_explode_filter(self) -> None: """ Test that ArrayExplodeFilter explodes a string to a list. """ - self.inputFilter.add( "tags", required=False, @@ -65,12 +69,81 @@ def test_array_explode_filter(self) -> None: ) self.assertEqual(validated_data["items"], ["item1", "item2", "item3"]) + def test_base64_image_downscale_filter(self) -> None: + """ + Test Base64ImageDownscaleFilter. + """ + self.inputFilter.add( + "image", + filters=[Base64ImageDownscaleFilter(size=144)], + ) + + with open("test/data/base64_image.txt", "r") as file: + validated_data = self.inputFilter.validateData( + {"image": file.read()} + ) + size = Image.open( + io.BytesIO(base64.b64decode(validated_data["image"])) + ).size + self.assertEqual(size, (12, 12)) + + with open("test/data/base64_image.txt", "r") as file: + validated_data = self.inputFilter.validateData( + { + "image": Image.open( + io.BytesIO(base64.b64decode(file.read())) + ) + } + ) + size = Image.open( + io.BytesIO(base64.b64decode(validated_data["image"])) + ).size + self.assertEqual(size, (12, 12)) + + def test_base64_image_size_reduce_filter(self) -> None: + """ + Test Base64ImageResizeFilter. + """ + self.inputFilter.add( + "image", + filters=[Base64ImageResizeFilter(max_size=1024)], + ) + + with open("test/data/base64_image.txt", "r") as file: + validated_data = self.inputFilter.validateData( + {"image": file.read()} + ) + image = Image.open( + io.BytesIO(base64.b64decode(validated_data["image"])) + ) + + buffer = io.BytesIO() + image.save(buffer, format="JPEG") + size = buffer.tell() + self.assertLessEqual(size, 1024) + + with open("test/data/base64_image.txt", "r") as file: + validated_data = self.inputFilter.validateData( + { + "image": Image.open( + io.BytesIO(base64.b64decode(file.read())) + ) + } + ) + image = Image.open( + io.BytesIO(base64.b64decode(validated_data["image"])) + ) + + buffer = io.BytesIO() + image.save(buffer, format="JPEG") + size = buffer.tell() + self.assertLessEqual(size, 1024) + def test_base_filter(self) -> None: """ Test that BaseFilter raises NotImplementedError when apply method is called. """ - with self.assertRaises(NotImplementedError): BaseFilter().apply("test") @@ -78,7 +151,6 @@ def test_blacklist_filter(self) -> None: """ Test that BlacklistFilter filters out values that are in the blacklist. """ - self.inputFilter.add( "blacklisted_field", required=False, @@ -111,7 +183,6 @@ def test_remove_emojis_filter(self) -> None: """ Test that RemoveEmojisFilter removes emojis from a string. """ - self.inputFilter.add( "text", required=False, @@ -130,7 +201,6 @@ def test_slugify_filter(self) -> None: """ Test that SlugifyFilter slugifies a string. """ - self.inputFilter.add( "slug", required=False, @@ -149,7 +219,6 @@ def test_string_trim_filter(self) -> None: """ Test that StringTrimFilter trims whitespace. """ - self.inputFilter.add( "trimmed_field", required=False, filters=[StringTrimFilter()] ) @@ -163,7 +232,6 @@ def test_to_alphanumeric_filter(self) -> None: """ Test that ToAlphaNumericFilter removes non-alphanumeric characters. """ - self.inputFilter.add( "alphanumeric_field", required=False, @@ -184,7 +252,6 @@ def test_to_boolean_filter(self) -> None: """ Test that ToBooleanFilter converts string to boolean. """ - self.inputFilter.add( "is_active", required=True, filters=[ToBooleanFilter()] ) @@ -196,7 +263,6 @@ def test_to_camel_case_filter(self) -> None: """ Test that CamelCaseFilter converts string to camel case. """ - self.inputFilter.add( "username", required=True, filters=[ToCamelCaseFilter()] ) @@ -213,7 +279,6 @@ def test_to_date_filter(self) -> None: """ Test that ToDateFilter converts string to date. """ - self.inputFilter.add("dob", required=True, filters=[ToDateFilter()]) validated_data = self.inputFilter.validateData({"dob": "1996-12-01"}) @@ -239,7 +304,6 @@ def test_to_datetime_filter(self) -> None: """ Test that ToDateTimeFilter converts string to datetime. """ - self.inputFilter.add( "created_at", required=True, filters=[ToDateTimeFilter()] ) @@ -310,7 +374,6 @@ def test_to_float_filter(self) -> None: """ Test that ToFloatFilter converts string to float. """ - self.inputFilter.add("price", required=True, filters=[ToFloatFilter()]) validated_data = self.inputFilter.validateData({"price": "19.99"}) @@ -326,7 +389,6 @@ def test_to_integer_filter(self) -> None: """ Test that ToIntegerFilter converts string to integer. """ - self.inputFilter.add("age", required=True, filters=[ToIntegerFilter()]) validated_data = self.inputFilter.validateData({"age": "25"}) @@ -346,7 +408,6 @@ def test_to_iso_filter(self) -> None: Test that ToIsoFilter converts date or datetime to ISO 8601 formatted string. """ - self.inputFilter.add("date", filters=[ToIsoFilter()]) validated_data = self.inputFilter.validateData( @@ -365,7 +426,6 @@ def test_to_lower_filter(self) -> None: """ Test that ToLowerFilter converts string to lowercase. """ - self.inputFilter.add( "username", required=True, filters=[ToLowerFilter()] ) @@ -382,7 +442,6 @@ def test_to_normalized_unicode_filter(self) -> None: """ Test that NormalizeUnicodeFilter normalizes Unicode characters. """ - self.inputFilter.add( "unicode_field", required=False, @@ -401,7 +460,6 @@ def test_to_null_filter(self) -> None: """ Test that ToNullFilter transforms empty string to None. """ - self.inputFilter.add( "optional_field", required=False, filters=[ToNullFilter()] ) @@ -413,7 +471,6 @@ def test_to_pascal_case_filter(self) -> None: """ Test that PascalCaseFilter converts string to pascal case. """ - self.inputFilter.add( "username", required=True, filters=[ToPascaleCaseFilter()] ) @@ -430,7 +487,6 @@ def test_snake_case_filter(self) -> None: """ Test that SnakeCaseFilter converts string to snake case. """ - self.inputFilter.add( "username", required=True, filters=[ToSnakeCaseFilter()] ) @@ -447,7 +503,6 @@ def test_to_string_filter(self) -> None: """ Test that ToStringFilter converts any type to string. """ - self.inputFilter.add("age", required=True, filters=[ToStringFilter()]) validated_data = self.inputFilter.validateData({"age": 25}) @@ -457,7 +512,6 @@ def test_to_upper_filter(self) -> None: """ Test that ToUpperFilter converts string to uppercase. """ - self.inputFilter.add( "username", required=True, filters=[ToUpperFilter()] ) @@ -474,7 +528,6 @@ def test_truncate_filter(self) -> None: """ Test that TruncateFilter truncates a string. """ - self.inputFilter.add( "truncated_field", required=False, filters=[TruncateFilter(5)] ) @@ -494,7 +547,6 @@ def test_whitelist_filter(self) -> None: Test that WhitelistFilter filters out values that are not in the whitelist. """ - self.inputFilter.add( "whitelisted_field", required=False, @@ -525,7 +577,6 @@ def test_whitespace_collapse_filter(self) -> None: """ Test that WhitespaceCollapseFilter collapses whitespace. """ - self.inputFilter.add( "collapsed_field", required=False, diff --git a/test/test_input_filter.py b/test/test_input_filter.py index 4fc78d1..031ee82 100644 --- a/test/test_input_filter.py +++ b/test/test_input_filter.py @@ -163,7 +163,6 @@ def test_optional(self) -> None: """ Test that optional field validation works. """ - self.inputFilter.add("name", required=True) self.inputFilter.validateData({"name": "Alice"}) @@ -175,24 +174,18 @@ def test_default(self) -> None: """ Test that default field works. """ - self.inputFilter.add("available", default=True) - # Default case triggert validated_data = self.inputFilter.validateData({}) - self.assertEqual(validated_data["available"], True) - # Override default case validated_data = self.inputFilter.validateData({"available": False}) - self.assertEqual(validated_data["available"], False) def test_fallback(self) -> None: """ Test that fallback field works. """ - self.inputFilter.add("available", required=True, fallback=True) self.inputFilter.add( "color", @@ -246,7 +239,6 @@ def test_steps(self) -> None: """ Test that custom steps works. """ - self.inputFilter.add( "name_upper", steps=[ @@ -267,12 +259,71 @@ def test_steps(self) -> None: ) self.assertEqual(validated_data["name_upper"], "ALICE") + self.inputFilter.add( + "fallback", + fallback="fallback", + steps=[ + ToUpperFilter(), + InArrayValidator(["FALLBACK"]), + ToLowerFilter(), + ], + ) + + validated_data = self.inputFilter.validateData( + {"fallback": "fallback"} + ) + self.assertEqual(validated_data["fallback"], "fallback") + + self.inputFilter.add( + "default", + default="default", + steps=[ + ToUpperFilter(), + InArrayValidator(["DEFAULT"]), + ToLowerFilter(), + ], + ) + + validated_data = self.inputFilter.validateData({}) + self.assertEqual(validated_data["default"], "default") + + self.inputFilter.add( + "fallback_with_default", + default="default", + fallback="fallback", + steps=[ + ToUpperFilter(), + InArrayValidator(["DEFAULT"]), + ToLowerFilter(), + ], + ) + + validated_data = self.inputFilter.validateData({}) + self.assertEqual(validated_data["fallback_with_default"], "default") + + validated_data = self.inputFilter.validateData( + {"fallback_with_default": "fallback"} + ) + self.assertEqual(validated_data["fallback_with_default"], "fallback") + + self.inputFilter.add( + "required_without_fallback", + required=True, + steps=[ + ToUpperFilter(), + InArrayValidator(["REQUIRED"]), + ToLowerFilter(), + ], + ) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({}) + @patch("requests.request") def test_external_api(self, mock_request: Mock) -> None: """ Test that external API calls work. """ - mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"is_valid": True} diff --git a/test/test_validator.py b/test/test_validator.py index 41c116f..8f54ef7 100644 --- a/test/test_validator.py +++ b/test/test_validator.py @@ -5,6 +5,7 @@ from flask_inputfilter import InputFilter from flask_inputfilter.Enum import RegexEnum from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Filter import Base64ImageDownscaleFilter from flask_inputfilter.Validator import ( ArrayElementValidator, ArrayLengthValidator, @@ -22,12 +23,14 @@ IsFloatValidator, IsFutureDateValidator, IsHexadecimalValidator, + IsHorizontalImageValidator, IsInstanceValidator, IsIntegerValidator, IsJsonValidator, IsPastDateValidator, IsStringValidator, IsUUIDValidator, + IsVerticalImageValidator, IsWeekdayValidator, IsWeekendValidator, LengthValidator, @@ -660,6 +663,50 @@ def test_is_hexadecimal_validator(self) -> None: with self.assertRaises(ValidationError): self.inputFilter.validateData({"hex2": 123}) + def test_is_horizontally_image_validator(self) -> None: + """ + Test IsHorizontallyImageValidator. + """ + + self.inputFilter.add( + "image", validators=[IsHorizontalImageValidator()] + ) + + with open("test/data/base64_image.txt", "r") as file: + self.inputFilter.validateData({"image": file.read()}) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"image": "not_a_base64_image"}) + + self.inputFilter.add( + "horizontally_image", + filters=[ + Base64ImageDownscaleFilter( + width=200, height=100, proportionally=False + ) + ], + validators=[IsHorizontalImageValidator()], + ) + + with open("test/data/base64_image.txt", "r") as file: + self.inputFilter.validateData({"horizontally_image": file.read()}) + + self.inputFilter.add( + "vertically_image", + filters=[ + Base64ImageDownscaleFilter( + width=100, height=200, proportionally=False + ) + ], + validators=[IsHorizontalImageValidator()], + ) + + with open("test/data/base64_image.txt", "r") as file: + with self.assertRaises(ValidationError): + self.inputFilter.validateData( + {"vertically_image": file.read()} + ) + def test_is_instance_validator(self) -> None: """ Test IsInstanceValidator. @@ -804,6 +851,48 @@ def test_is_uuid_validator(self) -> None: with self.assertRaises(ValidationError): self.inputFilter.validateData({"uuid": 123}) + def test_is_vertically_image_validator(self) -> None: + """ + Test IsVerticalImageValidator. + """ + + self.inputFilter.add("image", validators=[IsVerticalImageValidator()]) + + with open("test/data/base64_image.txt", "r") as file: + self.inputFilter.validateData({"image": file.read()}) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"image": "not_a_base64_image"}) + + self.inputFilter.add( + "horizontally_image", + filters=[ + Base64ImageDownscaleFilter( + width=200, height=100, proportionally=False + ) + ], + validators=[IsVerticalImageValidator()], + ) + + with open("test/data/base64_image.txt", "r") as file: + with self.assertRaises(ValidationError): + self.inputFilter.validateData( + {"horizontally_image": file.read()} + ) + + self.inputFilter.add( + "vertically_image", + filters=[ + Base64ImageDownscaleFilter( + width=100, height=200, proportionally=False + ) + ], + validators=[IsVerticalImageValidator()], + ) + + with open("test/data/base64_image.txt", "r") as file: + self.inputFilter.validateData({"vertically_image": file.read()}) + def test_is_weekday_validator(self) -> None: """ Test IsWeekdayValidator.