Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-lib-building.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
14 changes: 14 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <guides/frontend_validation>`
- Errors received through external_api request get logged.


[0.1.2] - 2025-03-29
--------------------

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down
66 changes: 66 additions & 0 deletions docs/guides/frontend_validation.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/guides/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ Guides
:glob:

create_own_components
frontend_validation
105 changes: 70 additions & 35 deletions flask_inputfilter/InputFilter.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

Expand Down Expand Up @@ -26,7 +27,7 @@ class InputFilter:
"__global_validators",
"__data",
"__validated_data",
"__error_message",
"__errors",
)

def __init__(self, methods: Optional[List[str]] = None) -> None:
Expand All @@ -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(
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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": {},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
)
12 changes: 8 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

setup(
name="flask_inputfilter",
version="0.1.2",
version="0.2.0",
license="MIT",
author="Leander Cain Slotosch",
author_email="[email protected]",
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",
Expand All @@ -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",
Expand Down
Loading