Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Changelog
All notable changes to this project will be documented in this file.


[0.7.5] - 2025-10-17
--------------------

Added
^^^^^
- **Nested InputFilter Support**: New feature to validate nested dictionary
structures using InputFilters. Use the ``input_filter`` parameter in
``field()`` to specify an InputFilter class for nested validation. This
enables composition of complex validation structures with multiple levels
of nesting. See :doc:`Field Decorator documentation <options/field_decorator>`
for more details.


[0.7.4] - 2025-10-08
--------------------

Expand Down
52 changes: 52 additions & 0 deletions docs/source/options/field_decorator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,58 @@ Computed fields are:
computed=lambda data: data['subtotal'] + data.get('tax', 0)
)

input_filter
~~~~~~~~~~~~

**Type**: ``type``
**Default**: ``None``

Specify an InputFilter class to use for nested validation. When this parameter is
provided, the field value must be a dictionary and will be validated against the
nested InputFilter's rules.

This allows you to compose complex validation structures by nesting InputFilters
within each other, enabling validation of nested objects and hierarchical data
structures.

**Key Features:**

- Validates nested dictionary structures
- Applies all filters and validators from the nested InputFilter
- Supports multiple levels of nesting
- Provides clear error messages with field context

.. code-block:: python

from flask_inputfilter import InputFilter
from flask_inputfilter.declarative import field
from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator

class UserInputFilter(InputFilter):
id: int = field(required=True, validators=[IsIntegerValidator()])
name: str = field(required=True, validators=[IsStringValidator()])
email: str = field(required=True, validators=[IsStringValidator()])

class OrderInputFilter(InputFilter):
quantity: int = field(required=True, validators=[IsIntegerValidator()])
user: dict = field(required=True, input_filter=UserInputFilter)

**Error Handling:**

If nested validation fails, the error will include context about which field failed:

.. code-block:: python

# If user.name is missing:
# ValidationError: {'user': "Nested validation failed for field 'user': {'name': \"Field 'name' is required.\"}"}

**Important Notes:**

- The field value must be a dictionary, otherwise a validation error is raised
- If the field is optional (``required=False``) and the value is ``None``, nested validation is skipped
- All filters and validators from the nested InputFilter are applied
- The nested InputFilter can also have its own nested fields, allowing unlimited nesting depth

Advanced Field Patterns
-----------------------

Expand Down
26 changes: 0 additions & 26 deletions docs/source/options/global_decorators.rst
Original file line number Diff line number Diff line change
Expand Up @@ -344,29 +344,3 @@ Combining Global Decorators

# Model association
model(User)

Hierarchical Configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: python

class BaseUserFilter(InputFilter):
# Base global configuration
global_filter(StringTrimFilter())
global_validator(IsStringValidator())

class StandardUserFilter(BaseUserFilter):
username = field(required=True)
email = field(required=True)

# Additional processing
global_validator(NotEmptyValidator())

class AdminUserFilter(StandardUserFilter):
role = field(required=True, default="admin")
permissions = field(required=False, default=[])

# Admin-specific validation
global_validator(SecurityValidator())
condition(AdminPermissionCondition())
# errors will contain both field-level and condition-level errors
1 change: 1 addition & 0 deletions flask_inputfilter/_input_filter.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ cdef class InputFilter:
attr_value.external_api,
attr_value.copy,
attr_value.computed,
attr_value.input_filter,
)

conditions = getattr(base_cls, "_conditions", None)
Expand Down
4 changes: 3 additions & 1 deletion flask_inputfilter/declarative/_field_descriptor.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ cdef class FieldDescriptor:
object fallback
list[BaseFilter] filters
list[BaseValidator] validators
list steps
list[BaseFilter | BaseValidator] steps
ExternalApiConfig external_api
str copy
object computed
object input_filter

cdef public:
str name

Expand Down
39 changes: 29 additions & 10 deletions flask_inputfilter/declarative/_field_descriptor.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# cython: wraparound=False
# cython: cdivision=True

from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, ExternalApiConfig

cdef class FieldDescriptor:
"""
Expand All @@ -18,14 +19,23 @@ cdef class FieldDescriptor:
- **default** (*Any*): Default value if field is missing.
- **fallback** (*Any*): Fallback value if validation fails.
- **filters** (*Optional[list[BaseFilter]]*): List of filters to apply.
- **validators** (*Optional[list[BaseValidator]]*): List of validators to apply.
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of combined filters and validators.
- **external_api** (*Optional[ExternalApiConfig]*): External API configuration.
- **copy** (*Optional[str]*): Field to copy value from if this field is missing.
- **validators** (*Optional[list[BaseValidator]]*): List of validators
to apply.
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*): List of
combined filters and validators.
- **external_api** (*Optional[ExternalApiConfig]*): External API
configuration.
- **copy** (*Optional[str]*): Field to copy value from if this field
is missing.
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
that computes the field value from validated data.
- **input_filter** (*Optional[type]*): An InputFilter class for
nested validation.

**Expected Behavior:**

Automatically registers field configuration during class creation and provides
Automatically registers field configuration during class creation and
provides
attribute access to validated field values.
"""

Expand All @@ -34,12 +44,13 @@ cdef class FieldDescriptor:
bint required = False,
object default = None,
object fallback = None,
list filters = None,
list validators = None,
list steps = None,
object external_api = None,
list[BaseFilter] filters = None,
list[BaseValidator] validators = None,
list[BaseFilter | BaseValidator] steps = None,
ExternalApiConfig external_api = None,
str copy = None,
object computed = None,
object input_filter = None,
) -> None:
"""
Initialize a field descriptor.
Expand All @@ -62,6 +73,8 @@ cdef class FieldDescriptor:
from.
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
that computes the field value from validated data.
- **input_filter** (*Optional[type]*): An InputFilter class
for nested validation.
"""
self.required = required
self._default = default
Expand All @@ -72,6 +85,7 @@ cdef class FieldDescriptor:
self.external_api = external_api
self.copy = copy
self.computed = computed
self.input_filter = input_filter
self.name = None

@property
Expand Down Expand Up @@ -147,6 +161,11 @@ cdef class FieldDescriptor:
f"required={self.required}, "
f"default={self.default!r}, "
f"filters={len(self.filters)}, "
f"validators={len(self.validators)}"
f"validators={len(self.validators)}, "
f"steps={len(self.steps)}, "
f"external_api={self.external_api!r}, "
f"copy={self.copy!r}, "
f"computed={self.computed!r}, "
f"input_filter={self.input_filter!r}"
f")"
)
5 changes: 5 additions & 0 deletions flask_inputfilter/declarative/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def field(
external_api: Optional[ExternalApiConfig] = None,
copy: Optional[str] = None,
computed: Optional[Any] = None,
input_filter: Optional[type] = None,
) -> FieldDescriptor:
"""
Create a field descriptor for declarative field definition.
Expand Down Expand Up @@ -51,6 +52,9 @@ def field(
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
that computes the field value from validated data.
Default: None.
- **input_filter** (*Optional[type]*): An InputFilter class to use
for nested validation. When specified, the field value (must be a dict)
will be validated against the nested InputFilter's rules. Default: None.

**Returns:**

Expand Down Expand Up @@ -78,4 +82,5 @@ class UserInputFilter(InputFilter):
external_api=external_api,
copy=copy,
computed=computed,
input_filter=input_filter,
)
29 changes: 5 additions & 24 deletions flask_inputfilter/declarative/field_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class FieldDescriptor:
is missing.
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
that computes the field value from validated data.
- **input_filter** (*Optional[type]*): An InputFilter class for
nested validation.

**Expected Behavior:**

Expand All @@ -53,31 +55,8 @@ def __init__(
external_api: Optional[ExternalApiConfig] = None,
copy: Optional[str] = None,
computed: Optional[Any] = None,
input_filter: Optional[type] = None,
) -> None:
"""
Initialize a field descriptor.

**Parameters:**

- **required** (*bool*): Whether the field is required.
- **default** (*Any*): The default value of the field.
- **fallback** (*Any*): The fallback value of the field, if
validations fail or field is None, although it is required.
- **filters** (*Optional[list[BaseFilter]]*): The filters to apply to
the field value.
- **validators** (*Optional[list[BaseValidator]]*): The validators to
apply to the field value.
- **steps** (*Optional[list[Union[BaseFilter, BaseValidator]]]*):
Allows
to apply multiple filters and validators in a specific order.
- **external_api** (*Optional[ExternalApiConfig]*): Configuration
for an
external API call.
- **copy** (*Optional[str]*): The name of the field to copy the value
from.
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A
callable that computes the field value from validated data.
"""
self.required = required
self.default = default
self.fallback = fallback
Expand All @@ -87,6 +66,7 @@ def __init__(
self.external_api = external_api
self.copy = copy
self.computed = computed
self.input_filter = input_filter
self.name: Optional[str] = None

def __set_name__(self, owner: type, name: str) -> None:
Expand Down Expand Up @@ -146,5 +126,6 @@ def __repr__(self) -> str:
f"external_api={self.external_api!r}, "
f"copy={self.copy!r}, "
f"computed={self.computed!r}, "
f"input_filter={self.input_filter!r}"
f")"
)
6 changes: 6 additions & 0 deletions flask_inputfilter/declarative/field_descriptor.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class FieldDescriptor:
configuration.
- **copy** (*Optional[str]*): Field to copy value from if this field
is missing.
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A
callable that computes the field value from validated data.
- **input_filter** (*Optional[type]*): An InputFilter class
for nested validation.

**Expected Behavior:**

Expand All @@ -49,6 +53,7 @@ class FieldDescriptor:
copy: Optional[str]
name: Optional[str]
computed: Optional[Any]
input_filter: Optional[type]

def __init__(
self,
Expand All @@ -61,6 +66,7 @@ class FieldDescriptor:
external_api: Optional[ExternalApiConfig] = None,
copy: Optional[str] = None,
computed: Optional[Any] = None,
input_filter: Optional[type] = None,
) -> None: ...
def __set_name__(self, owner: type, name: str) -> None: ...
def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any: ...
Expand Down
1 change: 1 addition & 0 deletions flask_inputfilter/input_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def _register_decorator_components(self) -> None:
attr_value.external_api,
attr_value.copy,
attr_value.computed,
attr_value.input_filter,
)

conditions = getattr(base_cls, "_conditions", None)
Expand Down
14 changes: 13 additions & 1 deletion flask_inputfilter/mixins/validation_mixin/_validation_mixin.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,16 @@ cdef class ValidationMixin:
)

@staticmethod
cdef inline object get_field_value(str field_name, FieldModel field_info, dict[str, Any] data, dict[str, Any] validated_data)
cdef dict apply_nested_input_filter(
str field_name,
object input_filter_class,
object value
)

@staticmethod
cdef inline object get_field_value(
str field_name,
FieldModel field_info,
dict[str, Any] data,
dict[str, Any] validated_data
)
Loading