Skip to content

Commit 1368491

Browse files
committed
Add first implementation
1 parent dbeabaa commit 1368491

File tree

15 files changed

+525
-4
lines changed

15 files changed

+525
-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: 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 for
57+
nested validation. When specified, the field value (must be a dict) will
58+
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[type]*): An InputFilter class for nested
39+
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 for nested
85+
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)

flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ from typing import Any
99

1010
from flask_inputfilter.exceptions import ValidationError
1111

12+
from .._input_filter cimport InputFilter
1213
from flask_inputfilter.mixins.cimports cimport ExternalApiMixin
1314
from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, FieldModel
1415

@@ -325,12 +326,57 @@ cdef class ValidationMixin:
325326
value
326327
)
327328

329+
if field_info.input_filter is not None and value is not None:
330+
value = ValidationMixin.apply_nested_input_filter(
331+
field_name,
332+
field_info.input_filter,
333+
value
334+
)
335+
328336
validated_data[field_name] = value
329337
except ValidationError as e:
330338
errors[field_name] = str(e)
331339

332340
return validated_data, errors
333341

342+
@staticmethod
343+
cdef dict apply_nested_input_filter(
344+
str field_name,
345+
InputFilter input_filter_class,
346+
object value
347+
):
348+
"""
349+
Apply nested InputFilter validation to a field value.
350+
351+
**Parameters:**
352+
353+
- **field_name** (*str*): The name of the field being validated.
354+
- **input_filter_class** (*type*): The InputFilter class to use for
355+
validation.
356+
- **value** (*Any*): The value to validate (must be a dict).
357+
358+
**Returns:**
359+
360+
- (*dict[str, Any]*): The validated nested data as a dictionary.
361+
362+
**Raises:**
363+
364+
- **ValidationError**: If the value is not a dict or if nested
365+
validation fails.
366+
"""
367+
if not isinstance(value, dict):
368+
raise ValidationError(
369+
f"Field '{field_name}' must be a dict for nested InputFilter "
370+
f"validation, got {type(value).__name__}."
371+
)
372+
373+
try:
374+
return input_filter_class().validate_data(value)
375+
except ValidationError as e:
376+
raise ValidationError(
377+
f"Nested validation failed for field '{field_name}': {str(e)}"
378+
) from e
379+
334380
@staticmethod
335381
cdef inline object get_field_value(
336382
str field_name,
@@ -372,4 +418,4 @@ cdef class ValidationMixin:
372418
field_info.fallback,
373419
validated_data
374420
)
375-
return data.get(field_name)
421+
return data.get(field_name)

0 commit comments

Comments
 (0)