diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index d5eafd7..a9971d7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -3,6 +3,23 @@ Changelog All notable changes to this project will be documented in this file. +[0.3.0] - 2025-04-10 +-------------------- + +Added +^^^^^ + + +Changed +^^^^^^^ +- Updated ``IsTypedDictValidator` and ``IsDataclassValidator`` to require a specific model and + checks if the input json is in the defined format. + +Removed +^^^^^^^ +- ``RemoveEmojisFilter`` +- ``ToPascaleCaseFilter`` + [0.2.0] - 2025-04-07 -------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index bf1663c..bd4c687 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,7 @@ project = "flask-inputfilter" copyright = "2025, Leander Cain Slotosch" author = "Leander Cain Slotosch" -release = "0.2.0" +release = "0.3.0" extensions = ["sphinx_rtd_theme"] diff --git a/docs/source/options/deserialization.rst b/docs/source/options/deserialization.rst new file mode 100644 index 0000000..4f749f1 --- /dev/null +++ b/docs/source/options/deserialization.rst @@ -0,0 +1,109 @@ +Deserialization +=============== + +Deserialization in Flask-InputFilter allows you to convert validated data +into custom model objects or maintain it as a dictionary. This feature is +particularly useful when you want to work with strongly-typed objects in +your application. + +Overview +-------- + +The deserialization process is handled through two main methods: + +- ``setModel()``: Sets the model class to be used for deserialization +- ``serialize()``: Converts the validated data into an instance of the + specified model class or returns the raw data as a dictionary + +Configuration +------------- + +The ``validate()`` method will automatically deserialize the validated data +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 + + + @dataclass + class User: + username: str + email: str + + + class UserInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.setModel(User) + +Examples +-------- + +Usage with Flask Routes +^^^^^^^^^^^^^^^^^^^^^^^ + +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: + def __init__(self, username: str): + self.username = username + + + class MyInputFilter(InputFilter): + def __init__(self): + super().__init__(methods=["GET"]) + self.add("username") + self.setModel(User) + + + app = Flask(__name__) + + @app.route("/test", methods=["GET"]) + @MyInputFilter.validate() + def test_route(): + # g.validated_data will contain the deserialized User instance + + validated_data: User = g.validated_data + +Usage outside of Flask Routes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +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: + def __init__(self, username: str): + self.username = username + + + class MyInputFilter(InputFilter): + def __init__(self): + super().__init__(methods=["GET"]) + self.add("username") + self.setModel(User) + + app = Flask(__name__) + + @app.route("/test", methods=["GET"]) + def test_route(): + input_filter = MyInputFilter() + input_filter.setData({"username": "test user"}) + + if not input_filter.isValid(): + return jsonify({"error": "Invalid data"}), 400 + + validated_data: User = input_filter.serialize() diff --git a/docs/source/options/index.rst b/docs/source/options/index.rst index eaaaae1..00b799c 100644 --- a/docs/source/options/index.rst +++ b/docs/source/options/index.rst @@ -11,3 +11,4 @@ Options condition copy external_api + deserialization diff --git a/docs/source/options/validator.rst b/docs/source/options/validator.rst index 746a831..ca1872b 100644 --- a/docs/source/options/validator.rst +++ b/docs/source/options/validator.rst @@ -504,16 +504,16 @@ IsDataclassValidator ~~~~~~~~~~~~~~~~~~~~ **Description:** -Validates that the provided value is an instance of a dataclass. Optionally checks whether it matches a specific dataclass type. +Validates that the provided value conforms to a specific dataclass type. **Parameters:** -- **dataclass_type** (*Optional[Type]*): The expected dataclass type. +- **dataclass_type** (*Type[dict]*): The expected dataclass type. - **error_message** (*Optional[str]*): Custom error message if validation fails. **Expected Behavior:** -Ensures the input is a dataclass (using Python’s dataclass mechanism) and, if specified, that it is an instance of the provided type. Raises a ``ValidationError`` otherwise. +Ensures the input is a dictionary and, that all expected keys are present. Raises a ``ValidationError`` if the structure does not match. **Example Usage:** @@ -974,16 +974,16 @@ IsTypedDictValidator ~~~~~~~~~~~~~~~~~~~~ **Description:** -Validates that the provided value is a TypedDict instance. Optionally, it checks whether the dictionary conforms to a specified TypedDict structure. +Validates that the provided value conforms to a specified TypedDict structure. **Parameters:** -- **typed_dict_type** (*Optional[Type[TypedDict]]*): The TypedDict class that defines the expected structure. +- **typed_dict_type** (*Type[TypedDict]*): The TypedDict class that defines the expected structure. - **error_message** (*Optional[str]*): Custom error message if the validation fails. **Expected Behavior:** -Ensures the input is a dictionary and, if a specific TypedDict type is provided, 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. **Example Usage:** diff --git a/flask_inputfilter/InputFilter.py b/flask_inputfilter/InputFilter.py index b114600..5510f09 100644 --- a/flask_inputfilter/InputFilter.py +++ b/flask_inputfilter/InputFilter.py @@ -1,6 +1,16 @@ import json import re -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Type, + TypeVar, + Union, +) from flask import Response, g, request from typing_extensions import final @@ -13,6 +23,8 @@ API_PLACEHOLDER_PATTERN = re.compile(r"{{(.*?)}}") +T = TypeVar("T") + class InputFilter: """ @@ -28,6 +40,7 @@ class InputFilter: "__data", "__validated_data", "__errors", + "__model_class", ) def __init__(self, methods: Optional[List[str]] = None) -> None: @@ -39,6 +52,7 @@ def __init__(self, methods: Optional[List[str]] = None) -> None: self.__data: Dict[str, Any] = {} self.__validated_data: Dict[str, Any] = {} self.__errors: Dict[str, str] = {} + self.__model_class: Optional[Type[T]] = None @final def add( @@ -333,7 +347,7 @@ def getValues(self) -> Dict[str, Any]: Retrieves a dictionary of key-value pairs from the current object. This method provides access to the internal state or configuration of the object in a dictionary format, where keys are strings and values - can be of various types depending on the object’s design. + can be of various types depending on the object's design. Returns: Dict[str, Any]: A dictionary containing string keys and their @@ -518,6 +532,31 @@ def merge(self, other: "InputFilter") -> None: else: self.__global_validators.append(validator) + @final + def setModel(self, model_class: Type[T]) -> None: + """ + Set the model class for serialization. + + Args: + model_class: The class to use for serialization. + """ + self.__model_class = model_class + + @final + def serialize(self) -> Union[Dict[str, Any], T]: + """ + Serialize the validated data. If a model class is set, + returns an instance of that class, otherwise returns the + raw validated data. + + Returns: + Union[Dict[str, Any], T]: The serialized data. + """ + if self.__model_class is None: + return self.__validated_data + + return self.__model_class(**self.__validated_data) + @final def isValid(self) -> bool: """ @@ -649,7 +688,12 @@ def wrapper( input_filter.__data = {**data, **kwargs} - g.validated_data = input_filter.validateData() + validated_data = input_filter.validateData() + + if input_filter.__model_class is not None: + validated_data = input_filter.serialize() + + g.validated_data = validated_data except ValidationError as e: return Response( diff --git a/flask_inputfilter/Validator/IsDataclassValidator.py b/flask_inputfilter/Validator/IsDataclassValidator.py index a586488..b8c06ab 100644 --- a/flask_inputfilter/Validator/IsDataclassValidator.py +++ b/flask_inputfilter/Validator/IsDataclassValidator.py @@ -1,9 +1,10 @@ -from dataclasses import is_dataclass -from typing import Any, Optional, Type +from typing import Any, Optional, Type, TypeVar from flask_inputfilter.Exception import ValidationError from flask_inputfilter.Validator import BaseValidator +T = TypeVar("T") + class IsDataclassValidator(BaseValidator): """ @@ -14,20 +15,21 @@ class IsDataclassValidator(BaseValidator): def __init__( self, - dataclass_type: Optional[Type[dict]] = None, + dataclass_type: Type[T], error_message: Optional[str] = None, ) -> None: self.dataclass_type = dataclass_type self.error_message = error_message def validate(self, value: Any) -> None: - if not is_dataclass(value): + if not isinstance(value, dict): raise ValidationError( self.error_message - or "The provided value is not a dataclass instance." + or "The provided value is not a dict instance." ) - if self.dataclass_type and not isinstance(value, self.dataclass_type): + expected_keys = self.dataclass_type.__annotations__.keys() + if any(key not in value for key in expected_keys): raise ValidationError( self.error_message or f"'{value}' is not an instance " diff --git a/flask_inputfilter/Validator/IsTypedDictValidator.py b/flask_inputfilter/Validator/IsTypedDictValidator.py index 5e1f344..82a2af6 100644 --- a/flask_inputfilter/Validator/IsTypedDictValidator.py +++ b/flask_inputfilter/Validator/IsTypedDictValidator.py @@ -15,7 +15,7 @@ class IsTypedDictValidator(BaseValidator): def __init__( self, - typed_dict_type: Optional[Type[TypedDict]] = None, + typed_dict_type: Type[TypedDict], error_message: Optional[str] = None, ) -> None: self.typed_dict_type = typed_dict_type @@ -25,14 +25,13 @@ def validate(self, value: Any) -> None: if not isinstance(value, dict): raise ValidationError( self.error_message - or "The provided value is not a TypedDict instance." + or "The provided value is not a dict instance." ) - if self.typed_dict_type: - expected_keys = self.typed_dict_type.__annotations__.keys() - if not all(key in value for key in expected_keys): - raise ValidationError( - self.error_message - or f"'{value}' does not match " - f"{self.typed_dict_type.__name__} structure." - ) + expected_keys = self.typed_dict_type.__annotations__.keys() + if any(key not in value for key in expected_keys): + raise ValidationError( + self.error_message + or f"'{value}' does not match " + f"{self.typed_dict_type.__name__} structure." + ) diff --git a/setup.py b/setup.py index f18a09e..223f3ca 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="flask_inputfilter", - version="0.2.0", + version="0.3.0", license="MIT", author="Leander Cain Slotosch", author_email="slotosch.leander@outlook.de", diff --git a/test/test_input_filter.py b/test/test_input_filter.py index 0d4aeb8..25492e1 100644 --- a/test/test_input_filter.py +++ b/test/test_input_filter.py @@ -1,4 +1,5 @@ import unittest +from dataclasses import dataclass from unittest.mock import Mock, patch from flask import Flask, g, jsonify @@ -351,6 +352,13 @@ def test_get_raw_values(self) -> None: {"field1": "raw1", "field2": "raw2"}, ) + self.inputFilter.clear() + + self.assertEqual( + self.inputFilter.getRawValues(), + {}, + ) + def test_get_unfiltered_data(self) -> None: self.inputFilter.add("field", filters=[ToIntegerFilter()]) self.inputFilter.setData({"field": "raw", "unknown_field": "raw2"}) @@ -440,6 +448,9 @@ def test_merge(self) -> None: self.inputFilter.getValues(), {"field1": "value1", "field2": None} ) + with self.assertRaises(TypeError): + self.inputFilter.merge("no input filter") + def test_merge_overrides_field(self) -> None: self.inputFilter.add("field1") @@ -948,6 +959,78 @@ def test_copy(self) -> None: ) self.assertEqual(validated_data["escapedUsername"], "test-user") + def test_serialize_and_set_model(self) -> None: + """ + Test that InputFilter.serialize() serializes the validated data. + """ + + class User: + def __init__(self, username: str): + self.username = username + + @dataclass + class User2: + username: str + + self.inputFilter.add("username") + self.inputFilter.setData({"username": "test user"}) + + self.inputFilter.isValid() + + self.inputFilter.setModel(User) + self.assertEqual(self.inputFilter.serialize().username, "test user") + + self.inputFilter.setModel(None) + self.assertEqual( + self.inputFilter.serialize(), {"username": "test user"} + ) + + self.inputFilter.setModel(User2) + self.assertEqual(self.inputFilter.serialize().username, "test user") + + def test_model_class_serialisation(self) -> None: + """ + Test that the model class is serialized correctly. + """ + + class User: + def __init__(self, username: str): + self.username = username + + class MyInputFilter(InputFilter): + def __init__(self): + super().__init__(methods=["GET"]) + + self.add("username") + self.setModel(User) + + app = Flask(__name__) + + @app.route("/test-custom", methods=["GET"]) + @MyInputFilter.validate() + def test_custom_route(): + validated_data = g.validated_data + + return jsonify(validated_data.username) + + with app.test_client() as client: + response = client.get( + "/test-custom", query_string={"username": "test user"} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, "test user") + + response = client.get( + "/test-custom", + query_string={"username": "test user2", "age": 20}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, "test user2") + + response = client.get("/test-custom", query_string={"age": 20}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, None) + def test_final_methods(self) -> None: def test_final_methods(self) -> None: final_methods = [ @@ -976,6 +1059,8 @@ def test_final_methods(self) -> None: "setData", "setUnfilteredData", "validateData", + "setModel", + "serialize", ] for method in final_methods: diff --git a/test/test_validator.py b/test/test_validator.py index 5777905..a7dce47 100644 --- a/test/test_validator.py +++ b/test/test_validator.py @@ -530,6 +530,9 @@ def test_float_precision_validator(self) -> None: with self.assertRaises(ValidationError): self.inputFilter.validateData({"price": "not a float"}) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"price": float("inf")}) + self.inputFilter.add( "custom_message2", validators=[ @@ -650,6 +653,9 @@ def test_is_base64_image_correct_size_validator(self) -> None: {"image": "iVBORw0KGgoAAAANSUhEUgAAAAU"} ) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"image": "iVBORw"}) + self.inputFilter.add( "image2", validators=[ @@ -712,17 +718,16 @@ def test_is_dataclass_validator(self) -> None: class User: id: int - @dataclass - class User2: - name: str + self.inputFilter.add("data", validators=[IsDataclassValidator(User)]) - self.inputFilter.add("data", validators=[IsDataclassValidator()]) - - self.inputFilter.validateData({"data": User(123)}) + self.inputFilter.validateData({"data": {"id": 123}}) with self.assertRaises(ValidationError): self.inputFilter.validateData({"data": "not a dataclass"}) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"data": {"user": {"id": 123}}}) + self.inputFilter.add( "data2", validators=[ @@ -732,10 +737,8 @@ class User2: ], ) - self.inputFilter.validateData({"data2": User(123)}) - with self.assertRaises(ValidationError): - self.inputFilter.validateData({"data2": User2}) + self.inputFilter.validateData({"data": "not a dict"}) def test_is_float_validator(self) -> None: """ @@ -878,6 +881,9 @@ def test_is_html_validator(self) -> None: with self.assertRaises(ValidationError): self.inputFilter.validateData({"html": "not an HTML content"}) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"html": 100}) + self.inputFilter.add( "html2", validators=[IsHtmlValidator(error_message="Custom error message")], @@ -1103,15 +1109,15 @@ def test_is_typed_dict_validator(self) -> None: class User(TypedDict): id: int - class User2(TypedDict): - name: str + self.inputFilter.add("data", validators=[IsTypedDictValidator(User)]) - self.inputFilter.add("data", validators=[IsTypedDictValidator()]) + self.inputFilter.validateData({"data": {"id": 123}}) - self.inputFilter.validateData({"data": User(id=123)}) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"data": "not a dict"}) with self.assertRaises(ValidationError): - self.inputFilter.validateData({"data": "not a TypedDict"}) + self.inputFilter.validateData({"data": {"user": {"id": 123}}}) self.inputFilter.add( "data2", @@ -1122,10 +1128,8 @@ class User2(TypedDict): ], ) - self.inputFilter.validateData({"data2": User(id=123)}) - with self.assertRaises(ValidationError): - self.inputFilter.validateData({"data2": User2}) + self.inputFilter.validateData({"data": "not a dict"}) def test_is_uppercase_validator(self) -> None: """ @@ -1139,6 +1143,9 @@ def test_is_uppercase_validator(self) -> None: with self.assertRaises(ValidationError): self.inputFilter.validateData({"name": "NotUppercase"}) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"name": 100}) + self.inputFilter.add( "name", validators=[ @@ -1161,6 +1168,9 @@ def test_is_url_validator(self) -> None: with self.assertRaises(ValidationError): self.inputFilter.validateData({"url": "not_a_url"}) + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"url": 100}) + self.inputFilter.add( "url2", validators=[IsUrlValidator(error_message="Custom error message")],