Skip to content

Commit 8bc7bb4

Browse files
authored
Merge pull request #66 from LeanderCS/inputfilter-field
Add nested input_filter for field descriptor
2 parents dbeabaa + b31ec14 commit 8bc7bb4

File tree

19 files changed

+777
-67
lines changed

19 files changed

+777
-67
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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,58 @@ 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+
**Error Handling:**
418+
419+
If nested validation fails, the error will include context about which field failed:
420+
421+
.. code-block:: python
422+
423+
# If user.name is missing:
424+
# ValidationError: {'user': "Nested validation failed for field 'user': {'name': \"Field 'name' is required.\"}"}
425+
426+
**Important Notes:**
427+
428+
- The field value must be a dictionary, otherwise a validation error is raised
429+
- If the field is optional (``required=False``) and the value is ``None``, nested validation is skipped
430+
- All filters and validators from the nested InputFilter are applied
431+
- The nested InputFilter can also have its own nested fields, allowing unlimited nesting depth
432+
381433
Advanced Field Patterns
382434
-----------------------
383435

docs/source/options/global_decorators.rst

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -344,29 +344,3 @@ Combining Global Decorators
344344
345345
# Model association
346346
model(User)
347-
348-
Hierarchical Configuration
349-
~~~~~~~~~~~~~~~~~~~~~~~~~~
350-
351-
.. code-block:: python
352-
353-
class BaseUserFilter(InputFilter):
354-
# Base global configuration
355-
global_filter(StringTrimFilter())
356-
global_validator(IsStringValidator())
357-
358-
class StandardUserFilter(BaseUserFilter):
359-
username = field(required=True)
360-
email = field(required=True)
361-
362-
# Additional processing
363-
global_validator(NotEmptyValidator())
364-
365-
class AdminUserFilter(StandardUserFilter):
366-
role = field(required=True, default="admin")
367-
permissions = field(required=False, default=[])
368-
369-
# Admin-specific validation
370-
global_validator(SecurityValidator())
371-
condition(AdminPermissionCondition())
372-
# errors will contain both field-level and condition-level errors

flask_inputfilter/_input_filter.pyx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ cdef class InputFilter:
288288
attr_value.external_api,
289289
attr_value.copy,
290290
attr_value.computed,
291+
attr_value.input_filter,
291292
)
292293

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

flask_inputfilter/declarative/_field_descriptor.pxd

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ cdef class FieldDescriptor:
99
object fallback
1010
list[BaseFilter] filters
1111
list[BaseValidator] validators
12-
list steps
12+
list[BaseFilter | BaseValidator] steps
1313
ExternalApiConfig external_api
1414
str copy
1515
object computed
16+
object input_filter
17+
1618
cdef public:
1719
str name
1820

flask_inputfilter/declarative/_field_descriptor.pyx

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

6+
from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, ExternalApiConfig
67

78
cdef class FieldDescriptor:
89
"""
@@ -18,14 +19,23 @@ cdef class FieldDescriptor:
1819
- **default** (*Any*): Default value if field is missing.
1920
- **fallback** (*Any*): Fallback value if validation fails.
2021
- **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.
22+
- **validators** (*Optional[list[BaseValidator]]*): List of validators
23+
to apply.
24+
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of
25+
combined filters and validators.
26+
- **external_api** (*Optional[ExternalApiConfig]*): External API
27+
configuration.
28+
- **copy** (*Optional[str]*): Field to copy value from if this field
29+
is missing.
30+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
31+
that computes the field value from validated data.
32+
- **input_filter** (*Optional[type]*): An InputFilter class for
33+
nested validation.
2534
2635
**Expected Behavior:**
2736
28-
Automatically registers field configuration during class creation and provides
37+
Automatically registers field configuration during class creation and
38+
provides
2939
attribute access to validated field values.
3040
"""
3141

@@ -34,12 +44,13 @@ cdef class FieldDescriptor:
3444
bint required = False,
3545
object default = None,
3646
object fallback = None,
37-
list filters = None,
38-
list validators = None,
39-
list steps = None,
40-
object external_api = None,
47+
list[BaseFilter] filters = None,
48+
list[BaseValidator] validators = None,
49+
list[BaseFilter | BaseValidator] steps = None,
50+
ExternalApiConfig external_api = None,
4151
str copy = None,
4252
object computed = None,
53+
object input_filter = None,
4354
) -> None:
4455
"""
4556
Initialize a field descriptor.
@@ -62,6 +73,8 @@ cdef class FieldDescriptor:
6273
from.
6374
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
6475
that computes the field value from validated data.
76+
- **input_filter** (*Optional[type]*): An InputFilter class
77+
for nested validation.
6578
"""
6679
self.required = required
6780
self._default = default
@@ -72,6 +85,7 @@ cdef class FieldDescriptor:
7285
self.external_api = external_api
7386
self.copy = copy
7487
self.computed = computed
88+
self.input_filter = input_filter
7589
self.name = None
7690
7791
@property
@@ -147,6 +161,11 @@ cdef class FieldDescriptor:
147161
f"required={self.required}, "
148162
f"default={self.default!r}, "
149163
f"filters={len(self.filters)}, "
150-
f"validators={len(self.validators)}"
164+
f"validators={len(self.validators)}, "
165+
f"steps={len(self.steps)}, "
166+
f"external_api={self.external_api!r}, "
167+
f"copy={self.copy!r}, "
168+
f"computed={self.computed!r}, "
169+
f"input_filter={self.input_filter!r}"
151170
f")"
152171
)

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
56+
for nested validation. When specified, the field value (must be a dict)
57+
will 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: 5 additions & 24 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
38+
nested validation.
3739
3840
**Expected Behavior:**
3941
@@ -53,31 +55,8 @@ 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:
57-
"""
58-
Initialize a field descriptor.
59-
60-
**Parameters:**
61-
62-
- **required** (*bool*): Whether the field is required.
63-
- **default** (*Any*): The default value of the field.
64-
- **fallback** (*Any*): The fallback value of the field, if
65-
validations fail or field is None, although it is required.
66-
- **filters** (*Optional[list[BaseFilter]]*): The filters to apply to
67-
the field value.
68-
- **validators** (*Optional[list[BaseValidator]]*): The validators to
69-
apply to the field value.
70-
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*):
71-
Allows
72-
to apply multiple filters and validators in a specific order.
73-
- **external_api** (*Optional[ExternalApiConfig]*): Configuration
74-
for an
75-
external API call.
76-
- **copy** (*Optional[str]*): The name of the field to copy the value
77-
from.
78-
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A
79-
callable that computes the field value from validated data.
80-
"""
8160
self.required = required
8261
self.default = default
8362
self.fallback = fallback
@@ -87,6 +66,7 @@ def __init__(
8766
self.external_api = external_api
8867
self.copy = copy
8968
self.computed = computed
69+
self.input_filter = input_filter
9070
self.name: Optional[str] = None
9171

9272
def __set_name__(self, owner: type, name: str) -> None:
@@ -146,5 +126,6 @@ def __repr__(self) -> str:
146126
f"external_api={self.external_api!r}, "
147127
f"copy={self.copy!r}, "
148128
f"computed={self.computed!r}, "
129+
f"input_filter={self.input_filter!r}"
149130
f")"
150131
)

flask_inputfilter/declarative/field_descriptor.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class FieldDescriptor:
3131
configuration.
3232
- **copy** (*Optional[str]*): Field to copy value from if this field
3333
is missing.
34+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A
35+
callable that computes the field value from validated data.
36+
- **input_filter** (*Optional[type]*): An InputFilter class
37+
for nested validation.
3438
3539
**Expected Behavior:**
3640
@@ -49,6 +53,7 @@ class FieldDescriptor:
4953
copy: Optional[str]
5054
name: Optional[str]
5155
computed: Optional[Any]
56+
input_filter: Optional[type]
5257

5358
def __init__(
5459
self,
@@ -61,6 +66,7 @@ class FieldDescriptor:
6166
external_api: Optional[ExternalApiConfig] = None,
6267
copy: Optional[str] = None,
6368
computed: Optional[Any] = None,
69+
input_filter: Optional[type] = None,
6470
) -> None: ...
6571
def __set_name__(self, owner: type, name: str) -> None: ...
6672
def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any: ...

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)