Skip to content

Commit fe7038a

Browse files
committed
Add first implementation
1 parent dbeabaa commit fe7038a

File tree

15 files changed

+878
-15
lines changed

15 files changed

+878
-15
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# cython: language_level=3
22

3+
from .._input_filter cimport InputFilter
34
from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, ExternalApiConfig
45

56
cdef class FieldDescriptor:
@@ -9,10 +10,11 @@ cdef class FieldDescriptor:
910
object fallback
1011
list[BaseFilter] filters
1112
list[BaseValidator] validators
12-
list steps
13+
list[BaseFilter | BaseValidator] steps
1314
ExternalApiConfig external_api
1415
str copy
1516
object computed
17+
InputFilter input_filter
1618
cdef public:
1719
str name
1820

flask_inputfilter/declarative/_field_descriptor.pyx

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# cython: wraparound=False
44
# cython: cdivision=True
55

6+
from .._input_filter cimport InputFilter
7+
from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, ExternalApiConfig
68

79
cdef class FieldDescriptor:
810
"""
@@ -18,14 +20,23 @@ cdef class FieldDescriptor:
1820
- **default** (*Any*): Default value if field is missing.
1921
- **fallback** (*Any*): Fallback value if validation fails.
2022
- **filters** (*Optional[list[BaseFilter]]*): List of filters to apply.
21-
- **validators** (*Optional[list[BaseValidator]]*): List of validators to apply.
22-
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of combined filters and validators.
23-
- **external_api** (*Optional[ExternalApiConfig]*): External API configuration.
24-
- **copy** (*Optional[str]*): Field to copy value from if this field is missing.
23+
- **validators** (*Optional[list[BaseValidator]]*): List of validators
24+
to apply.
25+
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of
26+
combined filters and validators.
27+
- **external_api** (*Optional[ExternalApiConfig]*): External API
28+
configuration.
29+
- **copy** (*Optional[str]*): Field to copy value from if this field
30+
is missing.
31+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
32+
that computes the field value from validated data.
33+
- **input_filter** (*Optional[InputFilter]*): An InputFilter class for
34+
nested validation.
2535
2636
**Expected Behavior:**
2737
28-
Automatically registers field configuration during class creation and provides
38+
Automatically registers field configuration during class creation and
39+
provides
2940
attribute access to validated field values.
3041
"""
3142

@@ -34,12 +45,13 @@ cdef class FieldDescriptor:
3445
bint required = False,
3546
object default = None,
3647
object fallback = None,
37-
list filters = None,
38-
list validators = None,
39-
list steps = None,
40-
object external_api = None,
48+
list[BaseFilter] filters = None,
49+
list[BaseValidator] validators = None,
50+
list[BaseFilter | BaseValidator] steps = None,
51+
ExternalApiConfig external_api = None,
4152
str copy = None,
4253
object computed = None,
54+
InputFilter input_filter = None,
4355
) -> None:
4456
"""
4557
Initialize a field descriptor.
@@ -62,6 +74,8 @@ cdef class FieldDescriptor:
6274
from.
6375
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
6476
that computes the field value from validated data.
77+
- **input_filter** (*Optional[InputFilter]*): An InputFilter class
78+
for nested validation.
6579
"""
6680
self.required = required
6781
self._default = default
@@ -72,6 +86,7 @@ cdef class FieldDescriptor:
7286
self.external_api = external_api
7387
self.copy = copy
7488
self.computed = computed
89+
self.input_filter = input_filter
7590
self.name = None
7691
7792
@property
@@ -147,6 +162,11 @@ cdef class FieldDescriptor:
147162
f"required={self.required}, "
148163
f"default={self.default!r}, "
149164
f"filters={len(self.filters)}, "
150-
f"validators={len(self.validators)}"
165+
f"validators={len(self.validators)}, "
166+
f"steps={len(self.steps)}, "
167+
f"external_api={self.external_api!r}, "
168+
f"copy={self.copy!r}, "
169+
f"computed={self.computed!r}, "
170+
f"input_filter={self.input_filter!r}"
151171
f")"
152172
)

flask_inputfilter/declarative/field.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from flask_inputfilter.declarative import FieldDescriptor
66

77
if TYPE_CHECKING:
8+
from flask_inputfilter import InputFilter
89
from flask_inputfilter.models import (
910
BaseFilter,
1011
BaseValidator,
@@ -23,6 +24,7 @@ def field(
2324
external_api: Optional[ExternalApiConfig] = None,
2425
copy: Optional[str] = None,
2526
computed: Optional[Any] = None,
27+
input_filter: Optional[InputFilter] = None,
2628
) -> FieldDescriptor:
2729
"""
2830
Create a field descriptor for declarative field definition.
@@ -51,6 +53,9 @@ def field(
5153
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
5254
that computes the field value from validated data.
5355
Default: None.
56+
- **input_filter** (*Optional[InputFilter]*): An InputFilter class to use
57+
for nested validation. When specified, the field value (must be a dict)
58+
will be validated against the nested InputFilter's rules. Default: None.
5459
5560
**Returns:**
5661
@@ -78,4 +83,5 @@ class UserInputFilter(InputFilter):
7883
external_api=external_api,
7984
copy=copy,
8085
computed=computed,
86+
input_filter=input_filter,
8187
)

flask_inputfilter/declarative/field_descriptor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import TYPE_CHECKING, Any, Optional, Union
44

55
if TYPE_CHECKING:
6+
from flask_inputfilter import InputFilter
67
from flask_inputfilter.models import (
78
BaseFilter,
89
BaseValidator,
@@ -34,6 +35,8 @@ class FieldDescriptor:
3435
is missing.
3536
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
3637
that computes the field value from validated data.
38+
- **input_filter** (*Optional[InputFilter]*): An InputFilter class for
39+
nested validation.
3740
3841
**Expected Behavior:**
3942
@@ -53,6 +56,7 @@ def __init__(
5356
external_api: Optional[ExternalApiConfig] = None,
5457
copy: Optional[str] = None,
5558
computed: Optional[Any] = None,
59+
input_filter: Optional[InputFilter] = None,
5660
) -> None:
5761
"""
5862
Initialize a field descriptor.
@@ -77,6 +81,8 @@ def __init__(
7781
from.
7882
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A
7983
callable that computes the field value from validated data.
84+
- **input_filter** (*Optional[InputFilter]*): An InputFilter class
85+
for nested validation.
8086
"""
8187
self.required = required
8288
self.default = default
@@ -87,6 +93,7 @@ def __init__(
8793
self.external_api = external_api
8894
self.copy = copy
8995
self.computed = computed
96+
self.input_filter = input_filter
9097
self.name: Optional[str] = None
9198

9299
def __set_name__(self, owner: type, name: str) -> None:
@@ -146,5 +153,6 @@ def __repr__(self) -> str:
146153
f"external_api={self.external_api!r}, "
147154
f"copy={self.copy!r}, "
148155
f"computed={self.computed!r}, "
156+
f"input_filter={self.input_filter!r}"
149157
f")"
150158
)

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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any
22

3+
from .._input_filter cimport InputFilter
34
from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, FieldModel, BaseCondition
45

56

@@ -29,4 +30,11 @@ cdef class ValidationMixin:
2930
)
3031

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

0 commit comments

Comments
 (0)