diff --git a/.github/workflows/publish-to-pypi.yaml b/.github/workflows/publish-to-pypi.yaml index 1fcfc7a..c9e4f4f 100644 --- a/.github/workflows/publish-to-pypi.yaml +++ b/.github/workflows/publish-to-pypi.yaml @@ -137,7 +137,5 @@ jobs: path: dist merge-multiple: true - - run: ls -la dist - - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 590acbc..39f8ec3 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -3,6 +3,19 @@ Changelog All notable changes to this project will be documented in this file. +[0.5.4] - 2025-05-24 +-------------------- + +Added +^^^^^ +- Added ``ArrayElementFilter`` to filter elements in an array against specific filter. + +Changed +^^^^^^^ +- Updated ``ArrayElementValidator`` to support validators directly. +- Updated ``IsDataclassValidator`` to also check against their types, including nested dataclasses, lists, and dictionaries. + + [0.5.3] - 2025-04-28 -------------------- diff --git a/docs/source/guides/frontend_validation.rst b/docs/source/guides/frontend_validation.rst index 2cc3d86..efccb82 100644 --- a/docs/source/guides/frontend_validation.rst +++ b/docs/source/guides/frontend_validation.rst @@ -14,11 +14,6 @@ Example implementation .. code-block:: python from flask import Response, Flask - from flask_inputfilter import InputFilter - from flask_inputfilter.conditions import ExactlyOneOfCondition - from flask_inputfilter.enums import RegexEnum - from flask_inputfilter.filters import StringTrimFilter, ToIntegerFilter, ToNullFilter - from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator, RegexValidator app = Flask(__name__) diff --git a/docs/source/index.rst b/docs/source/index.rst index 6034d2d..e9e92c1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -55,12 +55,6 @@ Definition .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.conditions import ExactlyOneOfCondition - from flask_inputfilter.enums import RegexEnum - from flask_inputfilter.filters import StringTrimFilter, ToIntegerFilter, ToNullFilter - from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator, RegexValidator - class UpdateZipcodeInputFilter(InputFilter): def __init__(self): super().__init__() diff --git a/docs/source/options/condition.rst b/docs/source/options/condition.rst index f636dfd..a726bff 100644 --- a/docs/source/options/condition.rst +++ b/docs/source/options/condition.rst @@ -13,11 +13,6 @@ Example .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.conditions import OneOfCondition - from flask_inputfilter.filters import StringTrimFilter - from flask_inputfilter.validators import IsStringValidator - class TestInputFilter(InputFilter): def __init__(self): super().__init__() diff --git a/docs/source/options/copy.rst b/docs/source/options/copy.rst index 0c40c62..479a210 100644 --- a/docs/source/options/copy.rst +++ b/docs/source/options/copy.rst @@ -19,15 +19,15 @@ Basic Copy Integration .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.filters import StringSlugifyFilter - class MyInputFilter(InputFilter): def __init__(self): super().__init__() self.add( - "username" + "username", + validator=[ + IsStringValidator() + ] ) self.add( @@ -52,9 +52,6 @@ The coping can also be used as a chain. .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.filters import StringSlugifyFilter, ToUpperFilter, ToLowerFilter - class MyInputFilter(InputFilter): def __init__(self): super().__init__() @@ -80,3 +77,15 @@ The coping can also be used as a chain. copy="escapedUsername" filters=[ToLowerFilter()] ) + + # Example usage + # Body: {"username": "Very Important User"} + + @app.route("/test", methods=["GET"]) + @MyInputFilter.validate() + def test_route(): + validated_data = g.validated_data + + # Contains the same value as username but escaped eg. "very-important-user" + # and in upper-case eg. "VERY-IMPORTANT-USER" + print(validated_data["upperEscapedUsername"]) diff --git a/docs/source/options/deserialization.rst b/docs/source/options/deserialization.rst index a37d354..2305600 100644 --- a/docs/source/options/deserialization.rst +++ b/docs/source/options/deserialization.rst @@ -23,7 +23,6 @@ into an instance of the model class, if there is a model class set. .. code-block:: python - from flask_inputfilter import InputFilter from dataclasses import dataclass @@ -50,7 +49,6 @@ You can also use deserialization in your Flask routes: .. code-block:: python from flask import Flask, jsonify, g - from flask_inputfilter import InputFilter class User: @@ -82,7 +80,6 @@ You can also use deserialization outside of Flask routes: .. code-block:: python from flask import Flask, jsonify, g - from flask_inputfilter import InputFilter class User: diff --git a/docs/source/options/external_api.rst b/docs/source/options/external_api.rst index 4db3f8b..576bdac 100644 --- a/docs/source/options/external_api.rst +++ b/docs/source/options/external_api.rst @@ -53,8 +53,6 @@ Basic External API Integration .. code-block:: python - from flask_inputfilter import InputFilter - class MyInputFilter(InputFilter): def __init__(self): super().__init__() diff --git a/docs/source/options/filter.rst b/docs/source/options/filter.rst index 6cf0315..5b33d6a 100644 --- a/docs/source/options/filter.rst +++ b/docs/source/options/filter.rst @@ -15,9 +15,6 @@ Example .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.filters import StringTrimFilter, ToLowerFilter - class TestInputFilter(InputFilter): def __init__(self): super().__init__() @@ -39,6 +36,7 @@ Example Available Filters ----------------- +- `ArrayElementFilter <#flask_inputfilter.filters.ArrayElementFilter>`_ - `ArrayExplodeFilter <#flask_inputfilter.filters.ArrayExplodeFilter>`_ - `Base64ImageDownscaleFilter <#flask_inputfilter.filters.Base64ImageDownscaleFilter>`_ - `Base64ImageResizeFilter <#flask_inputfilter.filters.Base64ImageResizeFilter>`_ diff --git a/docs/source/options/special_validator.rst b/docs/source/options/special_validator.rst index 481a58d..06b6af4 100644 --- a/docs/source/options/special_validator.rst +++ b/docs/source/options/special_validator.rst @@ -12,9 +12,6 @@ Example .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.validators import NotValidator, IsIntegerValidator - class NotIntegerInputFilter(InputFilter): def __init__(self): super().__init__() diff --git a/docs/source/options/validator.rst b/docs/source/options/validator.rst index 57e3035..b3d6cd3 100644 --- a/docs/source/options/validator.rst +++ b/docs/source/options/validator.rst @@ -16,9 +16,6 @@ Example .. code-block:: python - from flask_inputfilter import InputFilter - from flask_inputfilter.validators import IsIntegerValidator, RangeValidator - class UpdatePointsInputFilter(InputFilter): def __init__(self): super().__init__() diff --git a/examples/example 1/README.md b/examples/example 1/README.md new file mode 100644 index 0000000..916715d --- /dev/null +++ b/examples/example 1/README.md @@ -0,0 +1,149 @@ +# Flask-InputFilter Example Application + +This example demonstrates various use cases of the Flask-InputFilter package, showing how to implement input validation and filtering in a Flask application. + +## Project Structure + +``` +example 1/ +├── app.py # Main Flask application +├── filters/ # InputFilter classes +│ ├── __init__.py # Package exports +│ ├── product_inputfilter.py # Product input validation +│ ├── profile_inputfilter.py # Profile input validation +│ └── user_inputfilter.py # User input validation +├── test.http # HTTP test requests +└── README.md # This file +``` + +## Features Demonstrated + +1. Basic filtering with required fields +2. Nested filtering with complex objects +3. List filtering with type validation +4. Using the `@validate()` decorator for automatic validation +5. Modular filter organization + +## Setup + +1. Make sure you have Flask and Flask-InputFilter installed: +```bash + pip install flask flask-inputfilter +``` + +2. Run the example application: +```bash + python app.py +``` + +The server will start on `http://localhost:5000`. + +## Testing the Endpoints + +You can use the provided `test.http` file to test the endpoints. This file contains example requests for both successful and error cases. + +### Available Endpoints + +1. `POST /api/user` + - Creates a new user + - Required fields: name, age, email + - Uses StringTrimFilter and ToIntegerFilter for data cleaning + +2. `POST /api/profile` + - Creates a new profile with nested user and address information + - Demonstrates nested filtering with multiple InputFilter classes + - Optional phone number field + +3. `POST /api/product` + - Creates a new product + - Demonstrates list validation for tags + - Required fields: name, price + - Optional tags field as a list of strings + +## Example Requests + +### Successful User Creation +```json +{ + "name": "John Doe", + "age": 30, + "email": "john.doe@example.com" +} +``` + +### Successful Profile Creation +```json +{ + "user": { + "name": "John Doe", + "age": 30, + "email": "john.doe@example.com" + }, + "address": { + "street": "123 Main St", + "city": "New York", + "zip_code": 10001 + }, + "phone": "+1234567890" +} +``` + +### Successful Product Creation +```json +{ + "name": "Laptop", + "price": 999, + "tags": [ + "electronics", + "sports" + ] +} +``` + +## Key Features + +1. **Modular Organization** + - Each InputFilter class in its own file + - Easy to maintain and reuse + - Clear separation of concerns + +2. **Decorator-based Validation** + - Use `@InputFilter.validate()` to automatically validate request data + - Access validated data through Flask's `g.validated_data` + +3. **Field Configuration** + - Add fields in `__init__` using `self.add()` + - Configure required fields and filters + - Chain multiple filters for complex transformations + +4. **Built-in Filters** + - StringTrimFilter: Removes leading/trailing whitespace + - ToIntegerFilter: Converts to integer + - ToFloatFilter: Converts to float + - ToStringFilter: Converts to string + +5. **Error Handling** + - Automatic 400 responses for validation errors + - Detailed error messages in JSON format + +## Best Practices + +1. Organize InputFilter classes in separate files +2. Use `__init__.py` to expose filter classes +3. Always call `super().__init__()` in your InputFilter classes +4. Use appropriate filters for data type conversion +5. Chain filters when multiple transformations are needed +6. Use the `@validate()` decorator for automatic validation +7. Access validated data through `g.validated_data` + +## Error Handling + +The application demonstrates various validation errors: +- Missing required fields +- Invalid email format +- Invalid age format (must be integer) +- Invalid price format +- Invalid tags format (must be a list of strings) +- Invalid nested data structures + +Each error will return a 400 status code with a descriptive error message. \ No newline at end of file diff --git a/examples/example 1/__init__.py b/examples/example 1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/example 1/app.py b/examples/example 1/app.py new file mode 100644 index 0000000..da9c195 --- /dev/null +++ b/examples/example 1/app.py @@ -0,0 +1,42 @@ +from flask import Flask, Response, g + +from .filters import ProductInputFilter, ProfileInputFilter, UserInputFilter + +app = Flask(__name__) + + +@app.route("/api/user", methods=["POST"], endpoint="create-user") +@UserInputFilter.validate() +def create_user(): + return Response( + {"message": "User created successfully", "data": g.validated_data}, + 201, + ) + + +@app.route("/api/profile", methods=["POST"], endpoint="create-profile") +@ProfileInputFilter.validate() +def create_profile(): + return Response( + { + "message": "Profile created successfully", + "data": g.validated_data, + }, + 201, + ) + + +@app.route("/api/product", methods=["POST"], endpoint="create-product") +@ProductInputFilter.validate() +def create_products(): + return Response( + { + "message": "Products created successfully", + "data": g.validated_data, + }, + 201, + ) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/examples/example 1/filters/__init__.py b/examples/example 1/filters/__init__.py new file mode 100644 index 0000000..ad3dc9e --- /dev/null +++ b/examples/example 1/filters/__init__.py @@ -0,0 +1,3 @@ +from .product_inputfilter import ProductInputFilter +from .profile_inputfilter import ProfileInputFilter +from .user_inputfilter import UserInputFilter diff --git a/examples/example 1/filters/product_inputfilter.py b/examples/example 1/filters/product_inputfilter.py new file mode 100644 index 0000000..fef4670 --- /dev/null +++ b/examples/example 1/filters/product_inputfilter.py @@ -0,0 +1,51 @@ +from enum import Enum + +from flask_inputfilter import InputFilter +from flask_inputfilter.filters import ToFloatFilter +from flask_inputfilter.validators import ( + ArrayElementValidator, + InEnumValidator, + IsArrayValidator, + IsFloatValidator, + IsStringValidator, +) + + +class Tags(Enum): + ELECTRONICS = "electronics" + FASHION = "fashion" + HOME = "home" + BEAUTY = "beauty" + SPORTS = "sports" + TOYS = "toys" + + +class ProductInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add( + "name", + required=True, + validators=[ + IsStringValidator(), + ], + ) + + self.add( + "price", + required=True, + filters=[ToFloatFilter()], + validators=[ + IsFloatValidator(), + ], + ) + + self.add( + "tags", + required=False, + validators=[ + IsArrayValidator(), + ArrayElementValidator(InEnumValidator(Tags)), + ], + ) diff --git a/examples/example 1/filters/profile_inputfilter.py b/examples/example 1/filters/profile_inputfilter.py new file mode 100644 index 0000000..e1647fe --- /dev/null +++ b/examples/example 1/filters/profile_inputfilter.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +from flask_inputfilter import InputFilter +from flask_inputfilter.validators import ( + IsDataclassValidator, + IsStringValidator, +) + + +@dataclass +class User: + name: str + age: int + email: str + + +@dataclass +class Address: + street: str + city: str + zip_code: int + + +class ProfileInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add( + "user", + required=True, + validators=[IsDataclassValidator(dataclass_type=User)], + ) + + self.add( + "address", + required=True, + validators=[IsDataclassValidator(dataclass_type=Address)], + ) + + self.add("phone", required=False, validators=[IsStringValidator()]) diff --git a/examples/example 1/filters/user_inputfilter.py b/examples/example 1/filters/user_inputfilter.py new file mode 100644 index 0000000..67b8c8e --- /dev/null +++ b/examples/example 1/filters/user_inputfilter.py @@ -0,0 +1,13 @@ +from flask_inputfilter import InputFilter +from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator + + +class UserInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add("name", required=True, validators=[IsStringValidator()]) + + self.add("age", required=True, validators=[IsIntegerValidator()]) + + self.add("email", required=True, validators=[IsStringValidator()]) diff --git a/examples/example 1/test.http b/examples/example 1/test.http new file mode 100644 index 0000000..46d27e2 --- /dev/null +++ b/examples/example 1/test.http @@ -0,0 +1,101 @@ +### Create a new user (Success case) +POST http://localhost:5000/api/user +Content-Type: application/json + +{ + "name": "John Doe", + "age": 30, + "email": "john.doe@example.com" +} + +### Create a new user (Validation error - invalid age) +POST http://localhost:5000/api/user +Content-Type: application/json + +{ + "name": "John Doe", + "age": "invalid", + "email": "john.doe@example.com" +} + +### Create a new user (Validation error - missing required field) +POST http://localhost:5000/api/user +Content-Type: application/json + +{ + "name": "John Doe", + "email": "john.doe@example.com" +} + +### Create a profile (Success case) +POST http://localhost:5000/api/profile +Content-Type: application/json + +{ + "user": { + "name": "John Doe", + "age": 30, + "email": "john.doe@example.com" + }, + "address": { + "street": "123 Main St", + "city": "New York", + "zip_code": 10001 + }, + "phone": "+1234567890" +} + +### Create a profile (Validation error - invalid nested data) +POST http://localhost:5000/api/profile +Content-Type: application/json + +{ + "user": { + "name": "John Doe", + "age": "invalid", + "email": "john.doe@example.com" + }, + "address": { + "street": "123 Main St", + "city": "New York", + "zip_code": 10001 + } +} + +### Create products (Success case) +POST http://localhost:5000/api/product +Content-Type: application/json + +{ + "name": "Laptop", + "price": 999, + "tags": [ + "electronics", + "sports" + ] +} + +### Create products (Validation error - invalid price) +POST http://localhost:5000/api/product +Content-Type: application/json + +{ + "name": "Laptop", + "price": "invalid", + "tags": [ + "electronics" + ] +} + +### Create products (Validation error - invalid tags format) +POST http://localhost:5000/api/product +Content-Type: application/json + +{ + "name": "Laptop", + "price": 999.99, + "tags": [ + "home", + "children" + ] +} diff --git a/flask_inputfilter/filters/__init__.py b/flask_inputfilter/filters/__init__.py index a7b15d3..05e75cf 100644 --- a/flask_inputfilter/filters/__init__.py +++ b/flask_inputfilter/filters/__init__.py @@ -1,5 +1,6 @@ from flask_inputfilter.filters.base_filter import BaseFilter +from .array_element_filter import ArrayElementFilter from .array_explode_filter import ArrayExplodeFilter from .base_64_image_downscale_filter import Base64ImageDownscaleFilter from .base_64_image_resize_filter import Base64ImageResizeFilter @@ -31,6 +32,7 @@ from .whitespace_collapse_filter import WhitespaceCollapseFilter __all__ = [ + "ArrayElementFilter", "ArrayExplodeFilter", "Base64ImageDownscaleFilter", "Base64ImageResizeFilter", diff --git a/flask_inputfilter/filters/array_element_filter.py b/flask_inputfilter/filters/array_element_filter.py new file mode 100644 index 0000000..6bf18af --- /dev/null +++ b/flask_inputfilter/filters/array_element_filter.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, List, Optional, Union + +from flask_inputfilter.filters.base_filter import BaseFilter + + +class ArrayElementFilter(BaseFilter): + """ + Filters each element in an array by applying one or more `BaseFilter` + + **Parameters:** + + - **element_filter** (*BaseFilter* | *list[BaseFilter]*): A filter or a + list of filters to apply to each element in the array. + + **Expected Behavior:** + + Validates that the input is a list and applies the specified filter(s) to + each element. If any element does not conform to the expected structure, + a `ValueError` is raised. + + **Example Usage:** + + .. code-block:: python + + class TagInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add('tags', validators=[ + ArrayElementValidator(element_filter=IsStringValidator()) + ]) + """ + + __slots__ = ("element_filter",) + + def __init__( + self, + element_filter: Union[BaseFilter, List[BaseFilter]], + error_message: Optional[str] = None, + ) -> None: + self.element_filter = element_filter + self.error_message = error_message + + def apply(self, value: Any) -> List[Any]: + if not isinstance(value, list): + return value + + result = [] + for element in value: + if isinstance(self.element_filter, BaseFilter): + result.append(self.element_filter.apply(element)) + continue + + elif isinstance(self.element_filter, list) and all( + isinstance(v, BaseFilter) for v in self.element_filter + ): + for filter_instance in self.element_filter: + element = filter_instance.apply(element) + result.append(element) + continue + + result.append(element) + return result diff --git a/flask_inputfilter/validators/array_element_validator.py b/flask_inputfilter/validators/array_element_validator.py index 76699f1..350d944 100644 --- a/flask_inputfilter/validators/array_element_validator.py +++ b/flask_inputfilter/validators/array_element_validator.py @@ -1,7 +1,7 @@ from __future__ import annotations from copy import deepcopy -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from flask_inputfilter.exceptions import ValidationError from flask_inputfilter.validators import BaseValidator @@ -18,10 +18,10 @@ class ArrayElementValidator(BaseValidator): **Parameters:** - - **elementFilter** (*InputFilter*): An instance used to validate - each element. - - **error_message** (*Optional[str]*): Custom error message for - validation failure. + - **elementFilter** (*InputFilter* | *BaseValidator* | + *list[BaseValidator]*): An instance used to validate each element. + - **error_message** (*Optional[str]*): Custom error message for validation + failure. **Expected Behavior:** @@ -31,16 +31,31 @@ class ArrayElementValidator(BaseValidator): **Example Usage:** + This example demonstrates how to use the `ArrayElementValidator` with a + custom `InputFilter` for validating elements in an array. + .. code-block:: python - from my_filters import MyElementFilter + from my_filters import UserInputFilter + + class UsersInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add('users', validators=[ + ArrayElementValidator(element_filter=UserInputFilter()) + ]) + + Additionally, you can use a validator directly on your elements: + + .. code-block:: python class TagInputFilter(InputFilter): def __init__(self): super().__init__() self.add('tags', validators=[ - ArrayElementValidator(elementFilter=MyElementFilter()) + ArrayElementValidator(element_filter=IsStringValidator()) ]) """ @@ -48,10 +63,12 @@ def __init__(self): def __init__( self, - elementFilter: "InputFilter", + element_filter: Union[ + "InputFilter", BaseValidator, List[BaseValidator] + ], error_message: Optional[str] = None, ) -> None: - self.element_filter = elementFilter + self.element_filter = element_filter self.error_message = error_message def validate(self, value: Any) -> None: @@ -60,13 +77,25 @@ def validate(self, value: Any) -> None: for i, element in enumerate(value): try: + if isinstance(self.element_filter, BaseValidator): + self.element_filter.validate(element) + value[i] = element + continue + + elif isinstance(self.element_filter, list) and all( + isinstance(v, BaseValidator) for v in self.element_filter + ): + for validator in self.element_filter: + validator.validate(element) + value[i] = element + continue + if not isinstance(element, Dict): - raise ValidationError + raise ValidationError( + f"Element is not a dictionary: {element}" + ) - value[i] = deepcopy(self.element_filter.validateData(element)) + value[i] = deepcopy(self.element_filter.validate_data(element)) - except ValidationError: - raise ValidationError( - self.error_message - or f"Value '{element}' is not in '{self.element_filter}'" - ) + except ValidationError as e: + raise ValidationError(self.error_message or str(e)) diff --git a/flask_inputfilter/validators/is_dataclass_validator.py b/flask_inputfilter/validators/is_dataclass_validator.py index 6d5a415..6fa8450 100644 --- a/flask_inputfilter/validators/is_dataclass_validator.py +++ b/flask_inputfilter/validators/is_dataclass_validator.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Optional, Type, TypeVar +import dataclasses +from typing import Any, Optional, Type, TypeVar, Union, _GenericAlias from flask_inputfilter.exceptions import ValidationError from flask_inputfilter.validators import BaseValidator @@ -8,6 +9,30 @@ T = TypeVar("T") +# TODO: Replace with typing.get_origin when Python 3.7 support is dropped. +def get_origin(tp: Any) -> Optional[Type[Any]]: + """Get the unsubscripted version of a type. + + This supports typing types like List, Dict, etc. and their + typing_extensions equivalents. + """ + if isinstance(tp, _GenericAlias): + return tp.__origin__ + return None + + +# TODO: Replace with typing.get_args when Python 3.7 support is dropped. +def get_args(tp: Any) -> tuple[Any, ...]: + """Get type arguments with all substitutions performed. + + For unions, basic types, and special typing forms, returns + the type arguments. For example, for List[int] returns (int,). + """ + if isinstance(tp, _GenericAlias): + return tp.__args__ + return () + + class IsDataclassValidator(BaseValidator): """ Validates that the provided value conforms to a specific dataclass type. @@ -20,8 +45,10 @@ class IsDataclassValidator(BaseValidator): **Expected Behavior:** - Ensures the input is a dictionary and, that all expected keys are - present. Raises a ``ValidationError`` if the structure does not match. + Ensures the input is a dictionary and, that all expected keys are present. + Raises a ``ValidationError`` if the structure does not match. + All fields in the dataclass are validated against their types, including + nested dataclasses, lists, and dictionaries. **Example Usage:** @@ -60,10 +87,76 @@ def validate(self, value: Any) -> None: or "The provided value is not a dict instance." ) - expected_keys = self.dataclass_type.__annotations__.keys() - if any(key not in value for key in expected_keys): + if not dataclasses.is_dataclass(self.dataclass_type): raise ValidationError( self.error_message - or f"'{value}' is not an instance " - f"of '{self.dataclass_type}'." + or f"'{self.dataclass_type}' is not a valid dataclass." ) + + for field in dataclasses.fields(self.dataclass_type): + field_name = field.name + field_type = field.type + has_default = ( + field.default is not dataclasses.MISSING + or field.default_factory is not dataclasses.MISSING + ) + + if field_name not in value: + if not has_default: + raise ValidationError( + self.error_message + or f"Missing required field '{field_name}' in " + f"value '{value}'." + ) + continue + + field_value = value[field_name] + + origin = get_origin(field_type) + args = get_args(field_type) + + if origin is not None: + if origin is list: + if not isinstance(field_value, list) or not all( + isinstance(item, args[0]) for item in field_value + ): + raise ValidationError( + self.error_message + or f"Field '{field_name}' in value '{value}' is " + f"not a valid list of '{args[0]}'." + ) + elif origin is dict: + if not isinstance(field_value, dict) or not all( + isinstance(k, args[0]) and isinstance(v, args[1]) + for k, v in field_value.items() + ): + raise ValidationError( + self.error_message + or f"Field '{field_name}' in value '{value}' is " + f"not a valid dict with keys of type " + f"'{args[0]}' and values of type '{args[1]}'." + ) + elif origin is Union and type(None) in args: + if field_value is not None and not isinstance( + field_value, args[0] + ): + raise ValidationError( + self.error_message + or f"Field '{field_name}' in value '{value}' is " + f"not of type '{args[0]}'." + ) + else: + raise ValidationError( + self.error_message + or f"Unsupported type '{field_type}' for field " + f"'{field_name}'." + ) + elif dataclasses.is_dataclass(field_type): + IsDataclassValidator(field_type).validate(field_value) + else: + if not isinstance(field_value, field_type): + raise ValidationError( + self.error_message + or f"Field '{field_name}' in value '{value}' is not " + f"of type '{field_type}'." + ) diff --git a/pyproject.toml b/pyproject.toml index 855b1fc..e1e57b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "flask_inputfilter" -version = "0.5.3" +version = "0.5.4" description = "A library to easily filter and validate input data in Flask applications" readme = "README.md" keywords = [ diff --git a/scripts/lint b/scripts/lint index 4cd9cb0..83eecbf 100644 --- a/scripts/lint +++ b/scripts/lint @@ -7,12 +7,12 @@ find /app/flask_inputfilter -name "*.py" ! -name "__init__.py" | while read -r f fi done -echo "Running isort" -isort /app - echo "Running autoflake" autoflake --in-place --remove-all-unused-imports --ignore-init-module-imports --recursive /app +echo "Running isort" +isort /app + echo "Running black" black /app diff --git a/tests/filters/test_array_element_filter.py b/tests/filters/test_array_element_filter.py new file mode 100644 index 0000000..8d10931 --- /dev/null +++ b/tests/filters/test_array_element_filter.py @@ -0,0 +1,47 @@ +import unittest + +from flask_inputfilter import InputFilter +from flask_inputfilter.filters import ( + ArrayElementFilter, + StringSlugifyFilter, + ToIntegerFilter, + ToUpperFilter, +) + + +class TestArrayElementFilter(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.input_filter = InputFilter() + + def test_valid_array_elements(self) -> None: + self.input_filter.add( + "items", filters=[ArrayElementFilter(ToIntegerFilter())] + ) + validated_data = self.input_filter.validate_data( + {"items": ["1", "2", "3"]} + ) + self.assertEqual(validated_data["items"], [1, 2, 3]) + + def test_invalid_non_array_input(self) -> None: + self.input_filter.add( + "items", filters=[ArrayElementFilter(ToIntegerFilter())] + ) + validated_data = self.input_filter.validate_data( + {"items": "not an array"} + ) + self.assertEqual(validated_data["items"], "not an array") + + def test_filter_with_multiple_filters(self) -> None: + self.input_filter.add( + "items", + filters=[ + ArrayElementFilter([StringSlugifyFilter(), ToUpperFilter()]) + ], + ) + validated_data = self.input_filter.validate_data( + {"items": ["test test", "example example"]} + ) + self.assertEqual( + validated_data["items"], ["TEST-TEST", "EXAMPLE-EXAMPLE"] + ) diff --git a/tests/filters/test_array_explode_filter.py b/tests/filters/test_array_explode_filter.py index 13a3755..4a5380f 100644 --- a/tests/filters/test_array_explode_filter.py +++ b/tests/filters/test_array_explode_filter.py @@ -6,7 +6,6 @@ class TestArrayExplodeFilter(unittest.TestCase): def setUp(self) -> None: - """Set up a new InputFilter instance before each test.""" self.input_filter = InputFilter() def test_explode_comma_separated_string(self) -> None: diff --git a/tests/filters/test_base64_image_downscale_filter.py b/tests/filters/test_base64_image_downscale_filter.py index 1b12229..954ed54 100644 --- a/tests/filters/test_base64_image_downscale_filter.py +++ b/tests/filters/test_base64_image_downscale_filter.py @@ -10,7 +10,6 @@ class TestBase64ImageDownscaleFilter(unittest.TestCase): def setUp(self) -> None: - """Set up a new InputFilter instance before each test.""" self.input_filter = InputFilter() def test_downscale_base64_image_string(self) -> None: diff --git a/tests/validators/test_array_element_validator.py b/tests/validators/test_array_element_validator.py index b0ec442..4a600d8 100644 --- a/tests/validators/test_array_element_validator.py +++ b/tests/validators/test_array_element_validator.py @@ -4,6 +4,8 @@ from flask_inputfilter.validators import ( ArrayElementValidator, IsIntegerValidator, + IsStringValidator, + LengthValidator, ) from tests.validators import BaseValidatorTest @@ -44,6 +46,36 @@ def test_invalid_non_array_input(self) -> None: "items", "not an array", "Value 'not an array' is not an array" ) + def test_validator_with_direct_validator(self) -> None: + self.input_filter.add( + "items", + validators=[ArrayElementValidator(IsStringValidator())], + ) + with self.assertRaises(ValidationError): + self.input_filter.validate_data({"items": ["str1", 123]}) + + self.input_filter.validate_data({"items": ["str1", "str2"]}) + + def test_validator_with_direct_validators(self) -> None: + self.input_filter.add( + "items", + validators=[ + ArrayElementValidator( + [ + IsStringValidator(), + LengthValidator(min_length=3, max_length=5), + ] + ) + ], + ) + with self.assertRaises(ValidationError): + self.input_filter.validate_data({"items": ["str1", 123]}) + self.input_filter.validate_data( + {"items": ["str1", "stringtoolong"]} + ) + + self.input_filter.validate_data({"items": ["str1", "str2"]}) + def test_custom_error_message(self) -> None: self.input_filter.add( "items", diff --git a/tests/validators/test_is_dataclass_validator.py b/tests/validators/test_is_dataclass_validator.py index 0e8997c..56335dd 100644 --- a/tests/validators/test_is_dataclass_validator.py +++ b/tests/validators/test_is_dataclass_validator.py @@ -1,6 +1,6 @@ from dataclasses import dataclass +from typing import Optional -from flask_inputfilter.exceptions import ValidationError from flask_inputfilter.validators import IsDataclassValidator from tests.validators import BaseValidatorTest @@ -8,23 +8,69 @@ @dataclass class User: id: int + name: str = "Default Name" + age: Optional[int] = None + + +@dataclass +class Profile: + user: User + address: str class TestIsDataclassValidator(BaseValidatorTest): def test_valid_dataclass(self) -> None: + self.input_filter.add("data", validators=[IsDataclassValidator(User)]) + self.input_filter.validate_data( + {"data": {"id": 1, "name": "John", "age": 30}} + ) + + def test_missing_optional_field(self) -> None: + self.input_filter.add("data", validators=[IsDataclassValidator(User)]) + self.input_filter.validate_data({"data": {"id": 1, "name": "John"}}) + + def test_default_value(self) -> None: self.input_filter.add("data", validators=[IsDataclassValidator(User)]) self.input_filter.validate_data({"data": {"id": 1}}) def test_invalid_dataclass(self) -> None: self.input_filter.add("data", validators=[IsDataclassValidator(User)]) - with self.assertRaises(ValidationError): - self.input_filter.validate_data({"data": "not_dict"}) + self.assertValidationError("data", {"not_dict"}) + + def test_invalid_field_type(self) -> None: + self.input_filter.add("data", validators=[IsDataclassValidator(User)]) + self.assertValidationError("data", {"id": "invalid", "name": "John"}) + + def test_nested_dataclass(self) -> None: + self.input_filter.add( + "data", validators=[IsDataclassValidator(Profile)] + ) + self.input_filter.validate_data( + { + "data": { + "user": {"id": 1, "name": "John", "age": 30}, + "address": "123 Main St", + } + } + ) + + def test_invalid_nested_dataclass(self) -> None: + self.input_filter.add( + "data", validators=[IsDataclassValidator(Profile)] + ) + self.assertValidationError( + "data", + { + "user": {"id": 1, "name": "John", "age": 30}, + "address": 123, + }, + ) def test_custom_error_message(self) -> None: self.input_filter.add( - "data2", + "data", validators=[ IsDataclassValidator(User, error_message="Custom error") ], ) - self.assertValidationError("data2", "wrong", "Custom error") + self.assertValidationError("data", "wrong", "Custom error")