diff --git a/.github/workflows/test-lib-building.yaml b/.github/workflows/test-lib-building.yaml index cda4017..acb5ee9 100644 --- a/.github/workflows/test-lib-building.yaml +++ b/.github/workflows/test-lib-building.yaml @@ -25,7 +25,7 @@ jobs: id: build - name: Install built library - run: pip install dist/*.whl + run: pip install "$(ls dist/*.whl | head -n 1)[optional]" - name: Verify library usage - Part I run: | diff --git a/docs/changelog.rst b/docs/changelog.rst index e4b3360..78c88fb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,20 @@ Changelog All notable changes to this project will be documented in this file. +[0.2.0] - 2025-04-07 +-------------------- + +Added +^^^^^ +- getErrorMessages + +Changed +^^^^^^^ +- Updated error handling: The first error for each field is now returned in a combined format, + enabling more detailed and flexible error handling on the frontend. :doc:`Check it out ` +- Errors received through external_api request get logged. + + [0.1.2] - 2025-03-29 -------------------- diff --git a/docs/conf.py b/docs/conf.py index 4c8852b..0d8c346 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ project = "flask-inputfilter" copyright = "2025, Leander Cain Slotosch" author = "Leander Cain Slotosch" -release = "0.1.2" +release = "0.2.0" extensions = ["sphinx_rtd_theme"] diff --git a/docs/guides/frontend_validation.rst b/docs/guides/frontend_validation.rst new file mode 100644 index 0000000..6f77d9d --- /dev/null +++ b/docs/guides/frontend_validation.rst @@ -0,0 +1,66 @@ +Frontend Validation +=================== + +Keeping frontend and backend validations synchronized can take a lot of time and +may lead to unexpected behavior if not maintained properly. + +With ``flask_inputfilter`` you can easily implement an extra route to keep up with the validation and +use the same ``InputFilter`` definition both for the frontend and backend validation. + + +Example implementation +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from flask import Response, Flask + from flask_inputfilter import InputFilter + from flask_inputfilter.Condition import ExactlyOneOfCondition + from flask_inputfilter.Enum import RegexEnum + from flask_inputfilter.Filter import StringTrimFilter, ToIntegerFilter, ToNullFilter + from flask_inputfilter.Validator import IsIntegerValidator, IsStringValidator, RegexValidator + + app = Flask(__name__) + + class UpdateZipcodeInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add( + 'id', + required=True, + filters=[ToIntegerFilter(), ToNullFilter()], + validators=[ + IsIntegerValidator() + ] + ) + + self.add( + 'zipcode', + filters=[StringTrimFilter()], + validators=[ + RegexValidator( + pattern=RegexEnum.POSTAL_CODE.value, + error_message='The zipcode is not in the correct format.' + ) + ] + ) + + @app.route('/form-update-zipcode', methods=['POST']) + @UpdateZipcodeInputFilter.validate() + def updateZipcode(): + return Response(status=200) + +This basic implementation allows you to validate the form in the frontend through the route ``/form-update-zipcode``. +If the validation is successful, it returns an empty response with the status code 200. +If it fails, it returns an response with the status code 400 and the corresponding errors in json format. + +If the validation for the zipcode fails, the response would be: + +.. code-block:: python + + { + "zipcode": "The zipcode is not in the correct format." + } + +Validation errors of conditions can be found in the ``_condition`` field. diff --git a/docs/guides/index.rst b/docs/guides/index.rst index b4d3647..4ed48d8 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -6,3 +6,4 @@ Guides :glob: create_own_components + frontend_validation diff --git a/flask_inputfilter/InputFilter.py b/flask_inputfilter/InputFilter.py index 409d6d4..b114600 100644 --- a/flask_inputfilter/InputFilter.py +++ b/flask_inputfilter/InputFilter.py @@ -1,3 +1,4 @@ +import json import re from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -26,7 +27,7 @@ class InputFilter: "__global_validators", "__data", "__validated_data", - "__error_message", + "__errors", ) def __init__(self, methods: Optional[List[str]] = None) -> None: @@ -37,7 +38,7 @@ def __init__(self, methods: Optional[List[str]] = None) -> None: self.__global_validators: List[BaseValidator] = [] self.__data: Dict[str, Any] = {} self.__validated_data: Dict[str, Any] = {} - self.__error_message: str = "" + self.__errors: Dict[str, str] = {} @final def add( @@ -269,10 +270,10 @@ def clear(self) -> None: self.__global_validators.clear() self.__data.clear() self.__validated_data.clear() - self.__error_message = "" + self.__errors.clear() @final - def getErrorMessage(self) -> str: + def getErrorMessage(self, field_name: str) -> str: """ Retrieves and returns a predefined error message. @@ -286,7 +287,23 @@ def getErrorMessage(self) -> str: Returns: str: A string representing the predefined error message. """ - return self.__error_message + return self.__errors.get(field_name) + + @final + def getErrorMessages(self) -> Dict[str, str]: + """ + Retrieves all error messages associated with the fields in the + input filter. + + This method aggregates and returns a dictionary of error messages + where the keys represent field names, and the values are their + respective error messages. + + Returns: + Dict[str, str]: A dictionary containing field names as keys and + their corresponding error messages as values. + """ + return self.__errors @final def getValue(self, name: str) -> Any: @@ -516,8 +533,8 @@ def isValid(self) -> bool: try: self.validateData(self.__data) - except (ValidationError, Exception) as e: - self.__error_message = str(e) + except ValidationError as e: + self.__errors = e.args[0] return False return True @@ -549,6 +566,7 @@ def validateData( """ validated_data = self.__validated_data data = data or self.__data + errors = {} for field_name, field_info in self.__fields.items(): value = data.get(field_name) @@ -562,30 +580,38 @@ def validateData( external_api = field_info.external_api copy = field_info.copy - if copy: - value = validated_data.get(copy) - - if external_api: - value = self.__callExternalApi( - external_api, fallback, validated_data - ) + try: + if copy: + value = validated_data.get(copy) - value = self.__applyFilters(filters, value) + if external_api: + value = self.__callExternalApi( + external_api, fallback, validated_data + ) - value = self.__validateField(validators, fallback, value) or value + value = self.__applyFilters(filters, value) + value = ( + self.__validateField(validators, fallback, value) or value + ) + value = self.__applySteps(steps, fallback, value) or value + value = self.__checkForRequired( + field_name, required, default, fallback, value + ) - value = self.__applySteps(steps, fallback, value) or value + validated_data[field_name] = value - value = self.__checkForRequired( - field_name, required, default, fallback, value - ) + except ValidationError as e: + errors[field_name] = str(e) - validated_data[field_name] = value + try: + self.__checkConditions(validated_data) + except ValidationError as e: + errors["_condition"] = str(e) - self.__checkConditions(validated_data) + if errors: + raise ValidationError(errors) self.__validated_data = validated_data - return validated_data @classmethod @@ -626,7 +652,11 @@ def wrapper( g.validated_data = input_filter.validateData() except ValidationError as e: - return Response(status=400, response=str(e)) + return Response( + status=400, + response=json.dumps(e.args[0]), + mimetype="application/json", + ) return f(*args, **kwargs) @@ -724,8 +754,14 @@ def __callExternalApi( Raised if the external API call does not succeed and no fallback value is provided. """ + import logging + import requests + logger = logging.getLogger(__name__) + + data_key = config.data_key + requestData = { "headers": {}, "params": {}, @@ -753,25 +789,22 @@ def __callExternalApi( response = requests.request(**requestData) if response.status_code != 200: - raise ValidationError( - f"External API call failed with " - f"status code {response.status_code}" + logger.error( + f"External_api request inside of InputFilter " + f"failed: {response.text}" ) + raise result = response.json() - data_key = config.data_key if data_key: return result.get(data_key) return result - except Exception as e: + except Exception: if fallback is None: - self.__error_message = str(e) - raise ValidationError( - f"External API call failed for field " - f"'{config.data_key}'." + f"External API call failed for field " f"'{data_key}'." ) return fallback @@ -829,7 +862,9 @@ def __checkForRequired( raise ValidationError(f"Field '{field_name}' is required.") - def __checkConditions(self, validated_data: dict) -> None: + def __checkConditions(self, validated_data: Dict[str, Any]) -> None: for condition in self.__conditions: if not condition.check(validated_data): - raise ValidationError(f"Condition '{condition}' not met.") + raise ValidationError( + f"Condition '{condition.__class__.__name__}' not met." + ) diff --git a/setup.py b/setup.py index 7780fe9..f18a09e 100644 --- a/setup.py +++ b/setup.py @@ -2,11 +2,11 @@ setup( name="flask_inputfilter", - version="0.1.2", + version="0.2.0", license="MIT", author="Leander Cain Slotosch", author_email="slotosch.leander@outlook.de", - description="A library to filter and validate input data in " + description="A library to easily filter and validate input data in " "Flask applications", long_description=open("README.rst").read(), long_description_content_type="text/x-rst", @@ -16,10 +16,14 @@ ), install_requires=[ "flask>=2.1", - "pillow>=8.0.0", - "requests>=2.22.0", "typing_extensions>=3.6.2", ], + extras_require={ + "optional": [ + "pillow>=8.0.0", + "requests>=2.22.0", + ], + }, classifiers=[ "Operating System :: OS Independent", "Programming Language :: Python :: 3.14", diff --git a/test/test_input_filter.py b/test/test_input_filter.py index 7991b29..0d4aeb8 100644 --- a/test/test_input_filter.py +++ b/test/test_input_filter.py @@ -159,7 +159,7 @@ def test_route(): response = client.get("/test", query_string={"age": "not_an_int"}) self.assertEqual(response.status_code, 400) - self.assertEqual(response.data.decode(), "Invalid data") + self.assertEqual(response.json.get("age"), "Invalid data") def test_optional(self) -> None: """ @@ -250,7 +250,63 @@ def test_get_error_message(self) -> None: self.inputFilter.isValid() self.assertEqual( - "Field 'field' is required.", self.inputFilter.getErrorMessage() + self.inputFilter.getErrorMessage("field"), + "Field 'field' is required.", + ) + + def test_get_error_messages(self) -> None: + self.inputFilter.add( + "field", required=True, validators=[IsIntegerValidator()] + ) + self.inputFilter.add( + "field2", required=True, validators=[IsIntegerValidator()] + ) + self.inputFilter.setData({"field2": "value2"}) + self.inputFilter.isValid() + + self.assertEqual( + self.inputFilter.getErrorMessages().get("field"), + "Field 'field' is required.", + ) + self.assertEqual( + self.inputFilter.getErrorMessages().get("field2"), + "Value 'value2' is not an integer.", + ) + + def test_global_error_messages(self) -> None: + self.inputFilter.add("field", required=True) + self.inputFilter.add("field2", required=True) + self.inputFilter.setData({"field2": "value2"}) + self.inputFilter.addGlobalValidator(IsIntegerValidator()) + + self.inputFilter.isValid() + + self.assertEqual( + self.inputFilter.getErrorMessages().get("field"), + "Field 'field' is required.", + ) + self.assertEqual( + self.inputFilter.getErrorMessages().get("field2"), + "Value 'value2' is not an integer.", + ) + + def test_condition_error_messages(self) -> None: + self.inputFilter.add("field") + self.inputFilter.add("field2") + self.inputFilter.addCondition( + ExactlyOneOfCondition(["field", "field2"]) + ) + self.inputFilter.setData({"field2": "value2"}) + self.inputFilter.isValid() + + self.assertEqual(self.inputFilter.getErrorMessages(), {}) + + self.inputFilter.setData({"field": "value", "field2": "value2"}) + self.inputFilter.isValid() + + self.assertEqual( + self.inputFilter.getErrorMessages().get("_condition"), + "Condition 'ExactlyOneOfCondition' not met.", ) def test_get_input(self) -> None: diff --git a/test/test_validator.py b/test/test_validator.py index 08eeda7..5777905 100644 --- a/test/test_validator.py +++ b/test/test_validator.py @@ -327,7 +327,10 @@ def test_date_after_validator(self) -> None: self.inputFilter.validateData( {"custom_error": "2020-12-31T23:59:59"} ) - self.assertEqual(str(context.exception), "Custom error message") + self.assertEqual( + context.exception.args[0].get("custom_error"), + "Custom error message", + ) with self.assertRaises(ValidationError): self.inputFilter.validateData({"custom_error": "unparseable date"})