Skip to content

Commit 3624c0f

Browse files
committed
Add first implementation
1 parent dbeabaa commit 3624c0f

File tree

15 files changed

+537
-4
lines changed

15 files changed

+537
-4
lines changed

docs/source/changelog.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ Changelog
44
All notable changes to this project will be documented in this file.
55

66

7+
[0.7.5] - 2025-10-17
8+
--------------------
9+
10+
Added
11+
^^^^^
12+
- **Nested InputFilter Support**: New feature to validate nested dictionary
13+
structures using InputFilters. Use the ``input_filter`` parameter in
14+
``field()`` to specify an InputFilter class for nested validation. This
15+
enables composition of complex validation structures with multiple levels
16+
of nesting. See :doc:`Field Decorator documentation <options/field_decorator>`
17+
for more details.
18+
19+
720
[0.7.4] - 2025-10-08
821
--------------------
922

docs/source/options/field_decorator.rst

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,106 @@ Computed fields are:
378378
computed=lambda data: data['subtotal'] + data.get('tax', 0)
379379
)
380380
381+
input_filter
382+
~~~~~~~~~~~~
383+
384+
**Type**: ``type``
385+
**Default**: ``None``
386+
387+
Specify an InputFilter class to use for nested validation. When this parameter is
388+
provided, the field value must be a dictionary and will be validated against the
389+
nested InputFilter's rules.
390+
391+
This allows you to compose complex validation structures by nesting InputFilters
392+
within each other, enabling validation of nested objects and hierarchical data
393+
structures.
394+
395+
**Key Features:**
396+
397+
- Validates nested dictionary structures
398+
- Applies all filters and validators from the nested InputFilter
399+
- Supports multiple levels of nesting
400+
- Provides clear error messages with field context
401+
402+
.. code-block:: python
403+
404+
from flask_inputfilter import InputFilter
405+
from flask_inputfilter.declarative import field
406+
from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator
407+
408+
class UserInputFilter(InputFilter):
409+
id: int = field(required=True, validators=[IsIntegerValidator()])
410+
name: str = field(required=True, validators=[IsStringValidator()])
411+
email: str = field(required=True, validators=[IsStringValidator()])
412+
413+
class OrderInputFilter(InputFilter):
414+
quantity: int = field(required=True, validators=[IsIntegerValidator()])
415+
user: dict = field(required=True, input_filter=UserInputFilter)
416+
417+
**Usage Example:**
418+
419+
.. code-block:: python
420+
421+
order_filter = OrderInputFilter()
422+
order_filter.data = {
423+
'quantity': 5,
424+
'user': {
425+
'id': 123,
426+
'name': 'John Doe',
427+
'email': '[email protected]'
428+
}
429+
}
430+
431+
validated_data = order_filter.validate_data()
432+
# validated_data['user'] is now a validated dict
433+
434+
**Multiple Levels of Nesting:**
435+
436+
.. code-block:: python
437+
438+
class AddressInputFilter(InputFilter):
439+
city: str = field(required=True, validators=[IsStringValidator()])
440+
zipcode: str = field(required=True, validators=[IsStringValidator()])
441+
442+
class UserInputFilter(InputFilter):
443+
id: int = field(required=True, validators=[IsIntegerValidator()])
444+
name: str = field(required=True, validators=[IsStringValidator()])
445+
address: dict = field(required=True, input_filter=AddressInputFilter)
446+
447+
class OrderInputFilter(InputFilter):
448+
quantity: int = field(required=True, validators=[IsIntegerValidator()])
449+
user: dict = field(required=True, input_filter=UserInputFilter)
450+
451+
# Now you can validate deeply nested structures
452+
order_filter = OrderInputFilter()
453+
order_filter.data = {
454+
'quantity': 10,
455+
'user': {
456+
'id': 456,
457+
'name': 'Jane Smith',
458+
'address': {
459+
'city': 'New York',
460+
'zipcode': '10001'
461+
}
462+
}
463+
}
464+
465+
**Error Handling:**
466+
467+
If nested validation fails, the error will include context about which field failed:
468+
469+
.. code-block:: python
470+
471+
# If user.name is missing:
472+
# ValidationError: {'user': "Nested validation failed for field 'user': {'name': \"Field 'name' is required.\"}"}
473+
474+
**Important Notes:**
475+
476+
- The field value must be a dictionary, otherwise a validation error is raised
477+
- If the field is optional (``required=False``) and the value is ``None``, nested validation is skipped
478+
- All filters and validators from the nested InputFilter are applied
479+
- The nested InputFilter can also have its own nested fields, allowing unlimited nesting depth
480+
381481
Advanced Field Patterns
382482
-----------------------
383483

flask_inputfilter/declarative/_field_descriptor.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ cdef class FieldDescriptor:
1313
ExternalApiConfig external_api
1414
str copy
1515
object computed
16+
object input_filter
1617
cdef public:
1718
str name
1819

flask_inputfilter/declarative/_field_descriptor.pyx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ cdef class FieldDescriptor:
2222
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of combined filters and validators.
2323
- **external_api** (*Optional[ExternalApiConfig]*): External API configuration.
2424
- **copy** (*Optional[str]*): Field to copy value from if this field is missing.
25+
- **input_filter** (*Optional[type]*): An InputFilter class for nested validation.
2526
2627
**Expected Behavior:**
2728
@@ -40,6 +41,7 @@ cdef class FieldDescriptor:
4041
object external_api = None,
4142
str copy = None,
4243
object computed = None,
44+
object input_filter = None,
4345
) -> None:
4446
"""
4547
Initialize a field descriptor.
@@ -62,6 +64,8 @@ cdef class FieldDescriptor:
6264
from.
6365
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
6466
that computes the field value from validated data.
67+
- **input_filter** (*Optional[type]*): An InputFilter class for nested
68+
validation.
6569
"""
6670
self.required = required
6771
self._default = default
@@ -72,6 +76,7 @@ cdef class FieldDescriptor:
7276
self.external_api = external_api
7377
self.copy = copy
7478
self.computed = computed
79+
self.input_filter = input_filter
7580
self.name = None
7681
7782
@property

flask_inputfilter/declarative/field.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def field(
2323
external_api: Optional[ExternalApiConfig] = None,
2424
copy: Optional[str] = None,
2525
computed: Optional[Any] = None,
26+
input_filter: Optional[type] = None,
2627
) -> FieldDescriptor:
2728
"""
2829
Create a field descriptor for declarative field definition.
@@ -51,6 +52,9 @@ def field(
5152
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
5253
that computes the field value from validated data.
5354
Default: None.
55+
- **input_filter** (*Optional[type]*): An InputFilter class to use for
56+
nested validation. When specified, the field value (must be a dict) will
57+
be validated against the nested InputFilter's rules. Default: None.
5458
5559
**Returns:**
5660
@@ -78,4 +82,5 @@ class UserInputFilter(InputFilter):
7882
external_api=external_api,
7983
copy=copy,
8084
computed=computed,
85+
input_filter=input_filter,
8186
)

flask_inputfilter/declarative/field_descriptor.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class FieldDescriptor:
3434
is missing.
3535
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
3636
that computes the field value from validated data.
37+
- **input_filter** (*Optional[type]*): An InputFilter class for nested
38+
validation.
3739
3840
**Expected Behavior:**
3941
@@ -53,6 +55,7 @@ def __init__(
5355
external_api: Optional[ExternalApiConfig] = None,
5456
copy: Optional[str] = None,
5557
computed: Optional[Any] = None,
58+
input_filter: Optional[type] = None,
5659
) -> None:
5760
"""
5861
Initialize a field descriptor.
@@ -77,6 +80,8 @@ def __init__(
7780
from.
7881
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A
7982
callable that computes the field value from validated data.
83+
- **input_filter** (*Optional[type]*): An InputFilter class for nested
84+
validation.
8085
"""
8186
self.required = required
8287
self.default = default
@@ -87,6 +92,7 @@ def __init__(
8792
self.external_api = external_api
8893
self.copy = copy
8994
self.computed = computed
95+
self.input_filter = input_filter
9096
self.name: Optional[str] = None
9197

9298
def __set_name__(self, owner: type, name: str) -> None:
@@ -146,5 +152,6 @@ def __repr__(self) -> str:
146152
f"external_api={self.external_api!r}, "
147153
f"copy={self.copy!r}, "
148154
f"computed={self.computed!r}, "
155+
f"input_filter={self.input_filter!r}, "
149156
f")"
150157
)

flask_inputfilter/input_filter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ def _register_decorator_components(self) -> None:
249249
attr_value.external_api,
250250
attr_value.copy,
251251
attr_value.computed,
252+
attr_value.input_filter,
252253
)
253254

254255
conditions = getattr(base_cls, "_conditions", None)

flask_inputfilter/mixins/validation_mixin/_validation_mixin.pxd

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,11 @@ cdef class ValidationMixin:
2929
)
3030

3131
@staticmethod
32-
cdef inline object get_field_value(str field_name, FieldModel field_info, dict[str, Any] data, dict[str, Any] validated_data)
32+
cdef inline object get_field_value(str field_name, FieldModel field_info, dict[str, Any] data, dict[str, Any] validated_data)
33+
34+
@staticmethod
35+
cdef dict apply_nested_input_filter(
36+
str field_name,
37+
object input_filter_class,
38+
object value
39+
) except *

flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,13 @@ cdef class ValidationMixin:
325325
value
326326
)
327327

328+
if field_info.input_filter is not None and value is not None:
329+
value = ValidationMixin.apply_nested_input_filter(
330+
field_name,
331+
field_info.input_filter,
332+
value
333+
)
334+
328335
validated_data[field_name] = value
329336
except ValidationError as e:
330337
errors[field_name] = str(e)
@@ -372,4 +379,51 @@ cdef class ValidationMixin:
372379
field_info.fallback,
373380
validated_data
374381
)
375-
return data.get(field_name)
382+
return data.get(field_name)
383+
384+
@staticmethod
385+
cdef dict apply_nested_input_filter(
386+
str field_name,
387+
object input_filter_class,
388+
object value
389+
):
390+
"""
391+
Apply nested InputFilter validation to a field value.
392+
393+
**Parameters:**
394+
395+
- **field_name** (*str*): The name of the field being validated.
396+
- **input_filter_class** (*type*): The InputFilter class to use for
397+
validation.
398+
- **value** (*Any*): The value to validate (should be a dict).
399+
400+
**Returns:**
401+
402+
- (*dict[str, Any]*): The validated nested data.
403+
404+
**Raises:**
405+
406+
- **ValidationError**: If the value is not a dict or if nested
407+
validation fails.
408+
"""
409+
if not isinstance(value, dict):
410+
raise ValidationError(
411+
f"Field '{field_name}' must be a dict for nested InputFilter "
412+
f"validation, got {type(value).__name__}."
413+
)
414+
415+
cdef object nested_filter = input_filter_class()
416+
nested_filter.data = value
417+
418+
cdef object validated_nested_data
419+
try:
420+
validated_nested_data = nested_filter.validate_data(value)
421+
if isinstance(validated_nested_data, dict):
422+
return validated_nested_data
423+
if hasattr(validated_nested_data, "__dict__"):
424+
return validated_nested_data.__dict__
425+
return validated_nested_data
426+
except ValidationError as e:
427+
raise ValidationError(
428+
f"Nested validation failed for field '{field_name}': {str(e)}"
429+
) from e

flask_inputfilter/mixins/validation_mixin/validation_mixin.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,64 @@ def validate_fields(
237237
field_name, field_info, value
238238
)
239239

240+
if field_info.input_filter is not None and value is not None:
241+
value = ValidationMixin.apply_nested_input_filter(
242+
field_name, field_info.input_filter, value
243+
)
244+
240245
validated_data[field_name] = value
241246
except ValidationError as e:
242247
errors[field_name] = str(e)
243248

244249
return validated_data, errors
245250

251+
@staticmethod
252+
def apply_nested_input_filter(
253+
field_name: str,
254+
input_filter_class: type,
255+
value: Any,
256+
) -> dict[str, Any]:
257+
"""
258+
Apply nested InputFilter validation to a field value.
259+
260+
**Parameters:**
261+
262+
- **field_name** (*str*): The name of the field being validated.
263+
- **input_filter_class** (*type*): The InputFilter class to use for
264+
validation.
265+
- **value** (*Any*): The value to validate (should be a dict).
266+
267+
**Returns:**
268+
269+
- (*dict[str, Any]*): The validated nested data.
270+
271+
**Raises:**
272+
273+
- **ValidationError**: If the value is not a dict or if nested
274+
validation fails.
275+
"""
276+
if not isinstance(value, dict):
277+
raise ValidationError(
278+
f"Field '{field_name}' must be a dict for nested InputFilter "
279+
f"validation, got {type(value).__name__}."
280+
)
281+
282+
nested_filter = input_filter_class()
283+
nested_filter.data = value
284+
285+
try:
286+
validated_nested_data = nested_filter.validate_data(value)
287+
if isinstance(validated_nested_data, dict):
288+
return validated_nested_data
289+
if hasattr(validated_nested_data, "__dict__"):
290+
return validated_nested_data.__dict__
291+
return validated_nested_data
292+
except ValidationError as e:
293+
# Re-raise with context about which field failed
294+
raise ValidationError(
295+
f"Nested validation failed for field '{field_name}': {e!s}"
296+
) from e
297+
246298
@staticmethod
247299
def get_field_value(
248300
field_name: str,

0 commit comments

Comments
 (0)