Skip to content

Commit f2e42bd

Browse files
committed
Add first implementation
1 parent dbeabaa commit f2e42bd

File tree

14 files changed

+675
-2
lines changed

14 files changed

+675
-2
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+
[Unreleased]
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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Example demonstrating nested InputFilter validation."""
2+
3+
from flask_inputfilter import InputFilter
4+
from flask_inputfilter.declarative import field
5+
from flask_inputfilter.filters import ToIntegerFilter, StringTrimFilter
6+
from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator
7+
8+
9+
# Define nested InputFilters
10+
class AddressFilter(InputFilter):
11+
"""Filter for address data."""
12+
13+
city: str = field(
14+
required=True,
15+
filters=[StringTrimFilter()],
16+
validators=[IsStringValidator()],
17+
)
18+
zipcode: str = field(
19+
required=True,
20+
filters=[StringTrimFilter()],
21+
validators=[IsStringValidator()],
22+
)
23+
24+
25+
class UserFilter(InputFilter):
26+
"""Filter for user data."""
27+
28+
id: int = field(
29+
required=True,
30+
filters=[ToIntegerFilter()],
31+
validators=[IsIntegerValidator()],
32+
)
33+
name: str = field(
34+
required=True,
35+
filters=[StringTrimFilter()],
36+
validators=[IsStringValidator()],
37+
)
38+
address: dict = field(required=True, input_filter=AddressFilter)
39+
40+
41+
class OrderFilter(InputFilter):
42+
"""Filter for order data with nested user."""
43+
44+
quantity: int = field(
45+
required=True,
46+
filters=[ToIntegerFilter()],
47+
validators=[IsIntegerValidator()],
48+
)
49+
user: dict = field(required=True, input_filter=UserFilter)
50+
51+
52+
if __name__ == "__main__":
53+
# Example 1: Valid nested data
54+
print("Example 1: Valid nested data")
55+
print("-" * 50)
56+
57+
order_filter = OrderFilter()
58+
order_filter.data = {
59+
"quantity": "10",
60+
"user": {
61+
"id": "123",
62+
"name": " John Doe ",
63+
"address": {"city": " New York ", "zipcode": "10001"},
64+
},
65+
}
66+
67+
try:
68+
validated_data = order_filter.validate_data()
69+
print("✓ Validation successful!")
70+
print(f"Quantity: {validated_data['quantity']}")
71+
print(f"User ID: {validated_data['user']['id']}")
72+
print(f"User Name: {validated_data['user']['name']}")
73+
print(f"City: {validated_data['user']['address']['city']}")
74+
print(f"Zipcode: {validated_data['user']['address']['zipcode']}")
75+
except Exception as e:
76+
print(f"✗ Validation failed: {e}")
77+
78+
print("\n")
79+
80+
# Example 2: Missing required field in nested data
81+
print("Example 2: Missing required field in nested data")
82+
print("-" * 50)
83+
84+
order_filter2 = OrderFilter()
85+
order_filter2.data = {
86+
"quantity": 5,
87+
"user": {
88+
"id": 456,
89+
# name is missing
90+
"address": {"city": "Los Angeles", "zipcode": "90001"},
91+
},
92+
}
93+
94+
try:
95+
validated_data = order_filter2.validate_data()
96+
print("✓ Validation successful!")
97+
except Exception as e:
98+
print(f"✗ Validation failed (expected): {e}")
99+
100+
print("\n")
101+
102+
# Example 3: Invalid nested data type
103+
print("Example 3: Invalid nested data type")
104+
print("-" * 50)
105+
106+
order_filter3 = OrderFilter()
107+
order_filter3.data = {"quantity": 3, "user": "not_a_dict"}
108+
109+
try:
110+
validated_data = order_filter3.validate_data()
111+
print("✓ Validation successful!")
112+
except Exception as e:
113+
print(f"✗ Validation failed (expected): {e}")
114+
115+
print("\n")
116+
117+
# Example 4: Using is_valid() method
118+
print("Example 4: Using is_valid() method")
119+
print("-" * 50)
120+
121+
order_filter4 = OrderFilter()
122+
order_filter4.data = {
123+
"quantity": 7,
124+
"user": {
125+
"id": 789,
126+
"name": "Jane Smith",
127+
"address": {"city": "Chicago", "zipcode": "60601"},
128+
},
129+
}
130+
131+
if order_filter4.is_valid():
132+
print("✓ Data is valid!")
133+
print(f"Validated quantity: {order_filter4.quantity}")
134+
print(f"Validated user name: {order_filter4.user['name']}")
135+
else:
136+
print(f"✗ Data is invalid: {order_filter4.errors}")

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)

0 commit comments

Comments
 (0)