diff --git a/src/openforms/formio/components/custom.py b/src/openforms/formio/components/custom.py index 23e1d65376..653fc18de5 100644 --- a/src/openforms/formio/components/custom.py +++ b/src/openforms/formio/components/custom.py @@ -1,9 +1,12 @@ import logging +from datetime import date from typing import Protocol +from django.core.validators import MaxValueValidator, MinValueValidator from django.utils.html import format_html from django.utils.translation import gettext as _ +from rest_framework import serializers from rest_framework.request import Request from openforms.authentication.constants import AuthAttribute @@ -50,6 +53,32 @@ def mutate_config_dynamically( """ mutate_min_max_validation(component, data) + def build_serializer_field( + self, component: DateComponent + ) -> serializers.DateField | serializers.ListField: + """ + Accept date values. + + Additional validation is taken from the datePicker configuration, which is also + set dynamically through our own backend (see :meth:`mutate_config_dynamically`). + """ + # relevant validators: required, datePicker.minDate and datePicker.maxDdate + multiple = component.get("multiple", False) + validate = component.get("validate", {}) + required = validate.get("required", False) + date_picker = component.get("datePicker") or {} + validators = [] + if min_date := date_picker.get("minDate"): + validators.append(MinValueValidator(date.fromisoformat(min_date))) + if max_date := date_picker.get("maxDate"): + validators.append(MaxValueValidator(date.fromisoformat(max_date))) + base = serializers.DateField( + required=required, + allow_null=not required, + validators=validators, + ) + return serializers.ListField(child=base) if multiple else base + @register("datetime") class Datetime(BasePlugin): @@ -192,6 +221,33 @@ def mutate_config_dynamically( ] +@register("bsn") +class BSN(BasePlugin): + formatter = TextFieldFormatter + + def build_serializer_field( + self, component: Component + ) -> serializers.CharField | serializers.ListField: + multiple = component.get("multiple", False) + validate = component.get("validate", {}) + required = validate.get("required", False) + + if validate.get("plugins", []): + raise NotImplementedError("Plugin validators not supported yet.") + + # dynamically add in more kwargs based on the component configuration + extra = {} + # maxLength because of the usage in appointments, even though our form builder + # does not expose it. See `openforms.appointments.contrib.qmatic.constants`. + if (max_length := validate.get("maxLength")) is not None: + extra["max_length"] = max_length + + base = serializers.CharField( + required=required, allow_blank=not required, allow_null=False, **extra + ) + return serializers.ListField(child=base) if multiple else base + + @register("addressNL") class AddressNL(BasePlugin): diff --git a/src/openforms/formio/components/vanilla.py b/src/openforms/formio/components/vanilla.py index 8391b6c7d8..cde4355ca0 100644 --- a/src/openforms/formio/components/vanilla.py +++ b/src/openforms/formio/components/vanilla.py @@ -7,6 +7,10 @@ from typing import TYPE_CHECKING +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers from rest_framework.request import Request from rest_framework.reverse import reverse @@ -35,8 +39,11 @@ TimeFormatter, ) from ..registry import BasePlugin, register +from ..serializers import build_serializer from ..typing import ( + Component, ContentComponent, + EditGridComponent, FileComponent, RadioComponent, SelectBoxesComponent, @@ -62,11 +69,64 @@ class Default(BasePlugin): class TextField(BasePlugin[TextFieldComponent]): formatter = TextFieldFormatter + def build_serializer_field( + self, component: TextFieldComponent + ) -> serializers.CharField | serializers.ListField: + multiple = component.get("multiple", False) + validate = component.get("validate", {}) + required = validate.get("required", False) + + if validate.get("plugins", []): + raise NotImplementedError("Plugin validators not supported yet.") + + # dynamically add in more kwargs based on the component configuration + extra = {} + if (max_length := validate.get("maxLength")) is not None: + extra["max_length"] = max_length + + # adding in the validator is more explicit than changing to serialiers.RegexField, + # which essentially does the same. + validators = [] + if pattern := validate.get("pattern"): + validators.append( + RegexValidator( + pattern, + message=_("This value does not match the required pattern."), + ) + ) + if validators: + extra["validators"] = validators + + base = serializers.CharField( + required=required, allow_blank=not required, allow_null=False, **extra + ) + return serializers.ListField(child=base) if multiple else base + @register("email") class Email(BasePlugin): formatter = EmailFormatter + def build_serializer_field( + self, component: Component + ) -> serializers.EmailField | serializers.ListField: + multiple = component.get("multiple", False) + validate = component.get("validate", {}) + required = validate.get("required", False) + + if validate.get("plugins", []): + raise NotImplementedError("Plugin validators not supported yet.") + + # dynamically add in more kwargs based on the component configuration + extra = {} + if (max_length := validate.get("maxLength")) is not None: + extra["max_length"] = max_length + + base = serializers.EmailField( + required=required, allow_blank=not required, allow_null=False, **extra + ) + return serializers.ListField(child=base) if multiple else base + @register("time") class Time(BasePlugin): @@ -77,6 +137,41 @@ class Time(BasePlugin): class PhoneNumber(BasePlugin): formatter = PhoneNumberFormatter + def build_serializer_field( + self, component: Component + ) -> serializers.CharField | serializers.ListField: + multiple = component.get("multiple", False) + validate = component.get("validate", {}) + required = validate.get("required", False) + + if validate.get("plugins", []): + raise NotImplementedError("Plugin validators not supported yet.") + + # dynamically add in more kwargs based on the component configuration + extra = {} + # maxLength because of the usage in appointments, even though our form builder + # does not expose it. See `openforms.appointments.contrib.qmatic.constants`. + if (max_length := validate.get("maxLength")) is not None: + extra["max_length"] = max_length + + # adding in the validator is more explicit than changing to serialiers.RegexField, + # which essentially does the same. + validators = [] + if pattern := validate.get("pattern"): + validators.append( + RegexValidator( + pattern, + message=_("This value does not match the required pattern."), + ) + ) + if validators: + extra["validators"] = validators + + base = serializers.CharField( + required=required, allow_blank=not required, allow_null=False, **extra + ) + return serializers.ListField(child=base) if multiple else base + @register("file") class File(BasePlugin[FileComponent]): @@ -112,6 +207,30 @@ class TextArea(BasePlugin): class Number(BasePlugin): formatter = NumberFormatter + def build_serializer_field( + self, component: Component + ) -> serializers.FloatField | serializers.ListField: + # new builder no longer exposes this, but existing forms may have multiple set + multiple = component.get("multiple", False) + validate = component.get("validate", {}) + required = validate.get("required", False) + + if validate.get("plugins", []): + raise NotImplementedError("Plugin validators not supported yet.") + + extra = {} + if max_value := validate.get("max"): + extra["max_value"] = max_value + if min_value := validate.get("min"): + extra["min_value"] = min_value + + base = serializers.FloatField( + required=required, + allow_null=not required, + **extra, + ) + return serializers.ListField(child=base) if multiple else base + @register("password") class Password(BasePlugin): @@ -182,6 +301,26 @@ def localize(self, component: RadioComponent, language_code: str, enabled: bool) return translate_options(options, language_code, enabled) + def build_serializer_field( + self, component: RadioComponent + ) -> serializers.ChoiceField: + """ + Convert a radio component to a serializer field. + + A radio component allows only a single value to be selected, but selecting a + value may not be required. The available choices are taken from the ``values`` + key, which may be set dynamically (see :meth:`mutate_config_dynamically`). + """ + validate = component.get("validate", {}) + required = validate.get("required", False) + choices = [(value["value"], value["label"]) for value in component["values"]] + return serializers.ChoiceField( + choices=choices, + required=required, + allow_blank=not required, + allow_null=not required, + ) + @register("signature") class Signature(BasePlugin): @@ -210,3 +349,28 @@ def rewrite_for_request(component: ContentComponent, request: Request): security risk. """ component["html"] = post_process_html(component["html"], request) + + +@register("editgrid") +class EditGrid(BasePlugin[EditGridComponent]): + def build_serializer_field( + self, component: EditGridComponent + ) -> serializers.ListField: + validate = component.get("validate", {}) + required = validate.get("required", False) + nested = build_serializer( + components=component.get("components", []), + # XXX: check out type annotations here, there's some co/contra variance + # in play + register=self.registry, + ) + kwargs = {} + if (max_length := validate.get("maxLength")) is not None: + kwargs["max_length"] = max_length + return serializers.ListField( + child=nested, + required=required, + allow_null=not required, + allow_empty=False, + **kwargs, + ) diff --git a/src/openforms/formio/registry.py b/src/openforms/formio/registry.py index bf344ed882..b8606a7d5e 100644 --- a/src/openforms/formio/registry.py +++ b/src/openforms/formio/registry.py @@ -12,10 +12,12 @@ the public API better defined and smaller. """ +import warnings from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar from django.utils.translation import gettext as _ +from rest_framework import serializers from rest_framework.request import Request from openforms.plugins.plugin import AbstractBasePlugin @@ -78,6 +80,26 @@ def mutate_config_dynamically( def localize(self, component: ComponentT, language_code: str, enabled: bool): pass # noop by default, specific component types can extend the base behaviour + def build_serializer_field(self, component: ComponentT) -> serializers.Field: + # the default implementation is a compatibility shim while we transition to + # the new backend validation mechanism. + warnings.warn( + "Relying on the default/implicit JSONField for component type " + f"{component['type']} is deprecated. Instead, define the " + "'build_serializer_field' method on the specific component plugin.", + DeprecationWarning, + ) + + required = ( + validate.get("required", False) + if (validate := component.get("validate")) + else False + ) + + # Allow anything that is valid JSON, taking into account the 'required' + # validation which is common for most components. + return serializers.JSONField(required=required, allow_null=True) + class ComponentRegistry(BaseRegistry[BasePlugin]): module = "formio_components" @@ -171,6 +193,20 @@ def localize_component( if generic_translations: del component["openForms"]["translations"] # type: ignore + def build_serializer_field(self, component: Component) -> serializers.Field: + """ + Translate a given component into a single serializer field, suitable for + input validation. + """ + # if the component known in registry -> use the component plugin, otherwise + # fall back to the special 'default' plugin which implements the current + # behaviour of accepting any JSON value. + if (component_type := component["type"]) not in self: + component_type = "default" + + component_plugin = self[component_type] + return component_plugin.build_serializer_field(component) + # Sentinel to provide the default registry. You can easily instantiate another # :class:`Registry` object to use as dependency injection in tests. diff --git a/src/openforms/formio/serializers.py b/src/openforms/formio/serializers.py new file mode 100644 index 0000000000..115f4ca654 --- /dev/null +++ b/src/openforms/formio/serializers.py @@ -0,0 +1,71 @@ +""" +Dynamically build serializers for Formio component trees. + +The reference implementation for the validation behaviour is the JS implementation of +Formio.js 4.13.x: +https://github.com/formio/formio.js/blob/4.13.x/src/validator/Validator.js. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias + +from glom import assign +from rest_framework import serializers + +from .typing import Component +from .utils import iter_components + +if TYPE_CHECKING: + from .registry import ComponentRegistry + + +FieldOrNestedFields: TypeAlias = serializers.Field | dict[str, "FieldOrNestedFields"] + + +def dict_to_serializer( + fields: dict[str, FieldOrNestedFields], **kwargs +) -> serializers.Serializer: + """ + Recursively convert a mapping of field names to a serializer instance. + + Keys are the names of the serializer fields to use, while the values can be + serializer field instances or nested mappings. Nested mappings result in nested + serializers. + """ + serializer = serializers.Serializer(**kwargs) + + for bit, field in fields.items(): + match field: + case dict() as nested_fields: + # we do not pass **kwwargs to nested serializers, as this should only + # be provided to the top-level serializer. The context/data is then + # shared through all children by DRF. + serializer.fields[bit] = dict_to_serializer(nested_fields) + # treat default case as a serializer field + case _: + serializer.fields[bit] = field + + return serializer + + +def build_serializer( + components: list[Component], register: ComponentRegistry, **kwargs +) -> serializers.Serializer: + """ + Translate a sequence of Formio.js component definitions into a serializer. + + This recursively builds up the serializer fields for each (nested) component and + puts them into a serializer instance ready for validation. + """ + fields: dict[str, FieldOrNestedFields] = {} + + # TODO: check that editgrid nested components are not yielded here! + for component in iter_components( + {"components": components}, recurse_into_editgrid=False + ): + field = register.build_serializer_field(component) + assign(obj=fields, path=component["key"], val=field, missing=dict) + + serializer = dict_to_serializer(fields, **kwargs) + return serializer diff --git a/src/openforms/formio/service.py b/src/openforms/formio/service.py index 2eced8944d..e03733cf4f 100644 --- a/src/openforms/formio/service.py +++ b/src/openforms/formio/service.py @@ -25,7 +25,8 @@ rewrite_formio_components, rewrite_formio_components_for_request, ) -from .registry import register +from .registry import ComponentRegistry, register +from .serializers import build_serializer as _build_serializer from .typing import Component from .utils import iter_components, iterate_data_with_components, recursive_apply from .validation import validate_formio_data @@ -42,6 +43,7 @@ "iterate_data_with_components", "validate_formio_data", "recursive_apply", + "build_serializer", ] @@ -105,3 +107,15 @@ def get_dynamic_configuration( # as checkboxes/dropdowns/radios/... inject_prefill(config_wrapper, submission) return config_wrapper + + +def build_serializer( + components: list[Component], _register: ComponentRegistry | None = None, **kwargs +): + """ + Translate a sequence of Formio.js component definitions into a serializer. + + This recursively builds up the serializer fields for each (nested) component and + puts them into a serializer instance ready for validation. + """ + return _build_serializer(components, register=_register or register, **kwargs) diff --git a/src/openforms/formio/tests/search_strategies.py b/src/openforms/formio/tests/search_strategies.py index 2cbf0e5daa..c9f7ea57c0 100644 --- a/src/openforms/formio/tests/search_strategies.py +++ b/src/openforms/formio/tests/search_strategies.py @@ -29,7 +29,7 @@ def formio_key(): data structure empty strings are used as keys: ``{"foo": {"": {"": {"bar": $value}}}}`` - See :func:`openforms.forms.models.form_variable.variable_key_validator` for the + See :func:`openforms.formio.validators.variable_key_validator` for the validator implementation. This strategy differs slightly from the validator - it will generate keys with a diff --git a/src/openforms/formio/tests/test_search_strategies.py b/src/openforms/formio/tests/test_search_strategies.py index 25e09b7476..7435ab68c3 100644 --- a/src/openforms/formio/tests/test_search_strategies.py +++ b/src/openforms/formio/tests/test_search_strategies.py @@ -3,8 +3,6 @@ from hypothesis import given, settings -from openforms.forms.models.form_variable import variable_key_validator - from ..typing import ( ColumnsComponent, Component, @@ -13,6 +11,7 @@ SelectBoxesComponent, SelectComponent, ) +from ..validators import variable_key_validator from .search_strategies import ( address_nl_component, bsn_component, diff --git a/src/openforms/formio/tests/test_validation.py b/src/openforms/formio/tests/test_validation.py index 74ab9668ae..bfc35631e4 100644 --- a/src/openforms/formio/tests/test_validation.py +++ b/src/openforms/formio/tests/test_validation.py @@ -2,36 +2,33 @@ from rest_framework.serializers import ValidationError -from ..validation import validate_formio_data +from openforms.typing import JSONObject, JSONValue +from ..service import build_serializer +from ..typing import Component, RadioComponent, TextFieldComponent -class FormioValidationTests(SimpleTestCase): - def test_textfield_required_validation(self): - component = {"type": "textfield", "key": "foo", "validate": {"required": True}} - invalid_values = [ - {}, - {"foo": ""}, - {"foo": None}, - ] +def validate_formio_data(components: list[Component], data: JSONValue) -> None: + serializer = build_serializer(components=components, data=data) + serializer.is_valid(raise_exception=True) - for data in invalid_values: - with self.subTest(data=data): - with self.assertRaises(ValidationError) as exc_detail: - validate_formio_data([component], data) - self.assertEqual(exc_detail.exception.detail["0"].code, "required") # type: ignore +class GenericValidationTests(SimpleTestCase): + """ + Test some generic validation behaviours using anecdotal examples. - def test_textfield_max_length(self): - component = {"type": "textfield", "key": "foo", "validate": {"maxLength": 3}} - - with self.assertRaises(ValidationError) as exc_detail: - validate_formio_data([component], {"foo": "barbaz"}) - - self.assertEqual(exc_detail.exception.detail["0"].code, "max_length") # type: ignore + Tests in this class are aimed towards some patterns, but use specific component + types as an easy-to-follow-and-debug example. Full coverage needs to be guaranteed + through the YAML-based tests or component-type specific test cases like below. + """ def test_component_without_validate_information(self): - component = {"type": "radio", "key": "radio", "values": []} + component: RadioComponent = { + "type": "radio", + "key": "radio", + "label": "Test", + "values": [], + } try: validate_formio_data([component], {"radio": ""}) @@ -39,3 +36,69 @@ def test_component_without_validate_information(self): raise self.failureException( "Expected component to pass validation" ) from exc + + def test_multiple_respected(self): + """ + Test that component ``multiple: true`` is correctly validated. + + Many components support multiple, but not all of them. The value data structure + changes for multiple, and the invalid param names should change accordingly. + """ + component: TextFieldComponent = { + "type": "textfield", + "key": "textMultiple", + "label": "Text multiple", + "multiple": True, + "validate": { + "required": True, + "maxLength": 3, + }, + } + data: JSONObject = { + "textMultiple": [ + "ok", + "not okay", + "", + ], + } + + with self.assertRaises(ValidationError) as exc_context: + validate_formio_data([component], data) + + detail = exc_context.exception.detail + assert isinstance(detail, dict) + assert "textMultiple" in detail + nested = detail["textMultiple"] + assert isinstance(nested, dict) + + self.assertEqual(list(nested.keys()), [1, 2]) + + with self.subTest("Error for item at index 1"): + self.assertEqual(len(errors := nested[1]), 1) + self.assertEqual(errors[0].code, "max_length") # type: ignore + + with self.subTest("Error for item at index 2"): + self.assertEqual(len(errors := nested[2]), 1) + self.assertEqual(errors[0].code, "blank") # type: ignore + + def test_key_with_nested_structure(self): + component: Component = { + "type": "textfield", + "key": "parent.nested", + "label": "Special key", + "validate": { + "maxLength": 2, + }, + } + data: JSONObject = {"parent": {"nested": "foo"}} + + with self.assertRaises(ValidationError) as exc_context: + validate_formio_data([component], data) + + detail = exc_context.exception.detail + assert isinstance(detail, dict) + assert "parent" in detail + self.assertIn("parent", detail) + self.assertIn("nested", detail["parent"]) + err_code = detail["parent"]["nested"][0].code + self.assertEqual(err_code, "max_length") diff --git a/src/openforms/formio/tests/validation/__init__.py b/src/openforms/formio/tests/validation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/formio/tests/validation/helpers.py b/src/openforms/formio/tests/validation/helpers.py new file mode 100644 index 0000000000..e1a00cec34 --- /dev/null +++ b/src/openforms/formio/tests/validation/helpers.py @@ -0,0 +1,21 @@ +from rest_framework.utils.serializer_helpers import ReturnDict + +from openforms.typing import JSONValue + +from ...service import build_serializer +from ...typing import Component + + +def validate_formio_data( + component: Component, data: JSONValue +) -> tuple[bool, ReturnDict]: + """ + Dynamically build the serializer, validate it and return the status. + """ + serializer = build_serializer(components=[component], data=data) + is_valid = serializer.is_valid(raise_exception=False) + return is_valid, serializer.errors + + +def extract_error(errors: ReturnDict, key: str, index: int = 0): + return errors[key][index] diff --git a/src/openforms/formio/tests/validation/test_bsn.py b/src/openforms/formio/tests/validation/test_bsn.py new file mode 100644 index 0000000000..9249c08f79 --- /dev/null +++ b/src/openforms/formio/tests/validation/test_bsn.py @@ -0,0 +1,51 @@ +from django.test import SimpleTestCase + +from openforms.typing import JSONValue + +from ...typing import Component +from .helpers import extract_error, validate_formio_data + + +class BSNValidationTests(SimpleTestCase): + + def test_bsn_field_required_validation(self): + component: Component = { + "type": "bsn", + "key": "foo", + "label": "Test", + "validate": {"required": True}, + } + + invalid_values = [ + ({}, "required"), + ({"foo": ""}, "blank"), + ({"foo": None}, "null"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) + + def test_maxlength(self): + component: Component = { + "type": "bsn", + "key": "foo", + "label": "Test", + "validate": {"required": True, "maxLength": 5}, + } + data: JSONValue = {"foo": "123456"} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + error = extract_error(errors, "foo") + self.assertEqual(error.code, "max_length") + + # TODO + # def test_elfproef(self): + # pass diff --git a/src/openforms/formio/tests/validation/test_date.py b/src/openforms/formio/tests/validation/test_date.py new file mode 100644 index 0000000000..9a89b52fdb --- /dev/null +++ b/src/openforms/formio/tests/validation/test_date.py @@ -0,0 +1,56 @@ +from django.test import SimpleTestCase + +from ...typing import DateComponent +from .helpers import extract_error, validate_formio_data + + +class DateFieldValidationTests(SimpleTestCase): + + def test_datefield_required_validation(self): + component: DateComponent = { + "type": "date", + "key": "foo", + "label": "Foo", + "validate": {"required": True}, + "datePicker": None, + } + + invalid_values = [ + ({}, "required"), + ({"foo": None}, "null"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) + + def test_min_max_date(self): + component: DateComponent = { + "type": "date", + "key": "foo", + "label": "Foo", + "validate": {"required": False}, + "datePicker": { + "minDate": "2024-03-10", + "maxDate": "2025-03-10", + }, + } + + invalid_values = [ + ({"foo": "2023-01-01"}, "min_value"), + ({"foo": "2025-12-30"}, "max_value"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) diff --git a/src/openforms/formio/tests/validation/test_editgrid.py b/src/openforms/formio/tests/validation/test_editgrid.py new file mode 100644 index 0000000000..a712fd3a1b --- /dev/null +++ b/src/openforms/formio/tests/validation/test_editgrid.py @@ -0,0 +1,163 @@ +from django.test import SimpleTestCase + +from openforms.typing import JSONObject + +from ...service import build_serializer +from ...typing import EditGridComponent +from .helpers import validate_formio_data + + +class EditGridValidationTests(SimpleTestCase): + """ + Test validations on the edit grid and its nested components. + """ + + def test_nested_fields_are_validated(self): + component: EditGridComponent = { + "type": "editgrid", + "key": "parent", + "label": "Repeating group", + "components": [ + { + "type": "textfield", + "key": "nested", + "label": "Nested text field", + "validate": { + "required": True, + }, + } + ], + } + data: JSONObject = { + "parent": [ + { + "nested": "foo", + }, + { + "nested": "", + }, + {}, + ], + } + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + + assert isinstance(errors, dict) + assert "parent" in errors + nested = errors["parent"] + assert isinstance(nested, dict) + + with self.subTest("Item at index 0"): + self.assertNotIn(0, nested) + + with self.subTest("Item at index 1"): + self.assertIn(1, nested) + _errors = nested[1] + self.assertIsInstance(_errors, dict) + self.assertIn("nested", _errors) + self.assertEqual(len(_errors["nested"]), 1) + self.assertEqual(_errors["nested"][0].code, "blank") + + with self.subTest("Item at index 2"): + self.assertIn(2, nested) + _errors = nested[2] + self.assertIsInstance(_errors, dict) + self.assertIn("nested", _errors) + self.assertEqual(len(_errors["nested"]), 1) + self.assertEqual(_errors["nested"][0].code, "required") + + def test_editgrids_own_validations(self): + component: EditGridComponent = { + "type": "editgrid", + "key": "parent", + "label": "Repeating group", + "components": [ + { + "type": "textfield", + "key": "nested", + "label": "Nested text field", + } + ], + "validate": { + "required": True, + "maxLength": 2, + }, + } + + with self.subTest("Required values missing"): + data: JSONObject = {} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + + assert isinstance(errors, dict) + assert "parent" in errors + nested = errors["parent"] + + self.assertIsInstance(nested, list) + err_code = nested[0].code + self.assertEqual(err_code, "required") + + with self.subTest("Max length exceeded"): + data: JSONObject = { + "parent": [ + {"nested": "foo"}, + {"nested": "bar"}, + {"nested": "bax"}, + ] + } + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + + assert isinstance(errors, dict) + assert "parent" in errors + nested = errors["parent"] + + self.assertIsInstance(nested, list) + err_code = nested[0].code + self.assertEqual(err_code, "max_length") + + def test_valid_configuration_nested_field_not_present_in_top_level_serializer(self): + """ + Test that the nested components in edit grid are not processed in a generic way. + + They are a blueprint for items in an array, so when iterating over all + components (recursively), they may not show up as standalone components. + """ + component: EditGridComponent = { + "type": "editgrid", + "key": "toplevel", + "label": "Repeating group", + "components": [ + { + "type": "textfield", + "key": "nested", + "label": "Required textfield", + "validate": {"required": True}, + } + ], + } + data: JSONObject = { + "toplevel": [{"nested": "i am valid"}], + } + + serializer = build_serializer(components=[component], data=data) + + with self.subTest("serializer validity"): + self.assertTrue(serializer.is_valid()) + + top_level_fields = serializer.fields + with self.subTest("top level fields"): + self.assertIn("toplevel", top_level_fields) + self.assertNotIn("nested", top_level_fields) + + with self.subTest("nested fields"): + nested_fields = top_level_fields["toplevel"].child.fields # type: ignore + + self.assertIn("nested", nested_fields) + self.assertNotIn("toplevel", nested_fields) diff --git a/src/openforms/formio/tests/validation/test_email.py b/src/openforms/formio/tests/validation/test_email.py new file mode 100644 index 0000000000..1b76df5dee --- /dev/null +++ b/src/openforms/formio/tests/validation/test_email.py @@ -0,0 +1,61 @@ +from django.test import SimpleTestCase + +from openforms.typing import JSONValue + +from ...typing import Component +from .helpers import extract_error, validate_formio_data + + +class EmailValidationTests(SimpleTestCase): + def test_email_required_validation(self): + component: Component = { + "type": "email", + "key": "foo", + "label": "Test", + "validate": {"required": True}, + } + + invalid_values = [ + ({}, "required"), + ({"foo": ""}, "blank"), + ({"foo": None}, "null"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) + + def test_email_pattern_validation(self): + component: Component = { + "type": "email", + "key": "foo", + "label": "Test", + } + data: JSONValue = {"foo": "invalid-email"} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, "invalid") + + def test_maxlength(self): + component: Component = { + "type": "email", + "key": "foo", + "label": "Test", + "validate": {"maxLength": 10}, + } + data: JSONValue = {"foo": "foobar@example.com"} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + error = extract_error(errors, "foo") + self.assertEqual(error.code, "max_length") diff --git a/src/openforms/formio/tests/validation/test_generic.py b/src/openforms/formio/tests/validation/test_generic.py new file mode 100644 index 0000000000..84e325e113 --- /dev/null +++ b/src/openforms/formio/tests/validation/test_generic.py @@ -0,0 +1,23 @@ +from django.test import SimpleTestCase + +from openforms.typing import JSONValue + +from ...typing import Component +from .helpers import validate_formio_data + + +class FallbackBehaviourTests(SimpleTestCase): + + def test_unknown_component_passthrough(self): + # TODO: this should *not* pass when all components are implemented, it's a + # temporary compatibility layer + component: Component = { + "type": "unknown-i-do-not-exist", + "key": "foo", + "label": "Fallback", + } + data: JSONValue = {"value": ["weird", {"data": "structure"}]} + + is_valid, _ = validate_formio_data(component, data) + + self.assertTrue(is_valid) diff --git a/src/openforms/formio/tests/validation/test_number.py b/src/openforms/formio/tests/validation/test_number.py new file mode 100644 index 0000000000..826000c738 --- /dev/null +++ b/src/openforms/formio/tests/validation/test_number.py @@ -0,0 +1,47 @@ +from django.test import SimpleTestCase + +from ...typing import Component +from .helpers import extract_error, validate_formio_data + + +class NumberValidationTests(SimpleTestCase): + def test_number_min_value(self): + component: Component = { + "type": "number", + "key": "foo", + "label": "Test", + "validate": { + "min": -3.5, + }, + } + + is_valid, errors = validate_formio_data(component, {"foo": -5.2}) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, "min_value") + + def test_number_min_value_with_non_required_value(self): + component: Component = { + "type": "number", + "key": "foo", + "label": "Test", + "validate": {"max": 10}, + } + + is_valid, _ = validate_formio_data(component, {}) + + self.assertTrue(is_valid) + + def test_zero_is_accepted(self): + component: Component = { + "type": "number", + "key": "foo", + "label": "Test", + "validate": {"required": True}, + } + + is_valid, _ = validate_formio_data(component, {"foo": 0}) + + self.assertTrue(is_valid) diff --git a/src/openforms/formio/tests/validation/test_phonenumber.py b/src/openforms/formio/tests/validation/test_phonenumber.py new file mode 100644 index 0000000000..963cb474e9 --- /dev/null +++ b/src/openforms/formio/tests/validation/test_phonenumber.py @@ -0,0 +1,69 @@ +from django.test import SimpleTestCase + +from openforms.typing import JSONValue + +from ...typing import Component +from .helpers import extract_error, validate_formio_data + + +class PhoneNumberValidationTests(SimpleTestCase): + def test_phonenumber_required_validation(self): + component: Component = { + "type": "phoneNumber", + "key": "foo", + "label": "Test", + "validate": {"required": True}, + } + + invalid_values = [ + ({}, "required"), + ({"foo": ""}, "blank"), + ({"foo": None}, "null"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) + + def test_pattern_validation(self): + component: Component = { + "type": "phoneNumber", + "key": "foo", + "label": "Test", + "validate": { + "required": True, + "pattern": r"06[ -]?[\d ]+", # only allow mobile numbers + }, + } + data: JSONValue = {"foo": "020-123 456"} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, "invalid") + + def test_phonenumber_maxlength(self): + component: Component = { + "type": "phoneNumber", + "key": "foo", + "label": "Test", + "validate": { + "required": True, + "maxLength": 8, + }, + } + data: JSONValue = {"foo": "020123456"} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, "max_length") diff --git a/src/openforms/formio/tests/validation/test_radio.py b/src/openforms/formio/tests/validation/test_radio.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/formio/tests/validation/test_textfield.py b/src/openforms/formio/tests/validation/test_textfield.py new file mode 100644 index 0000000000..e298f4f7e8 --- /dev/null +++ b/src/openforms/formio/tests/validation/test_textfield.py @@ -0,0 +1,81 @@ +from django.test import SimpleTestCase, tag + +from openforms.typing import JSONValue + +from ...typing import TextFieldComponent +from .helpers import extract_error, validate_formio_data + + +class TextFieldValidationTests(SimpleTestCase): + def test_textfield_required_validation(self): + component: TextFieldComponent = { + "type": "textfield", + "key": "foo", + "label": "Test", + "validate": {"required": True}, + } + + invalid_values = [ + ({}, "required"), + ({"foo": ""}, "blank"), + ({"foo": None}, "null"), + ] + + for data, error_code in invalid_values: + with self.subTest(data=data): + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, error_code) + + def test_textfield_max_length(self): + component: TextFieldComponent = { + "type": "textfield", + "key": "foo", + "label": "Test", + "validate": {"maxLength": 3}, + } + + is_valid, errors = validate_formio_data(component, {"foo": "barbaz"}) + + self.assertFalse(is_valid) + self.assertIn(component["key"], errors) + error = extract_error(errors, component["key"]) + self.assertEqual(error.code, "max_length") + + @tag("gh-3977") + def test_textfield_regex_housenumber(self): + component: TextFieldComponent = { + "type": "textfield", + "key": "houseNumber", + "label": "House number", + "validate": {"pattern": r"[0-9]{1,5}"}, + } + data: JSONValue = {"houseNumber": "
injection
"} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + error = extract_error(errors, "houseNumber") + self.assertEqual(error.code, "invalid") + + def test_multiple(self): + component: TextFieldComponent = { + "type": "textfield", + "key": "numbers", + "label": "House number", + "multiple": True, + "validate": {"pattern": r"\d+"}, + } + data: JSONValue = {"numbers": ["42", "notnumber"]} + + is_valid, errors = validate_formio_data(component, data) + + self.assertFalse(is_valid) + error = errors["numbers"][1][0] + self.assertEqual(error.code, "invalid") + + with self.subTest("valid item"): + self.assertNotIn(0, errors["numbers"]) diff --git a/src/openforms/formio/utils.py b/src/openforms/formio/utils.py index 7042c2ccf8..fa0726523e 100644 --- a/src/openforms/formio/utils.py +++ b/src/openforms/formio/utils.py @@ -25,7 +25,11 @@ def _is_column_component(component: ComponentLike) -> TypeGuard[ColumnsComponent @elasticapm.capture_span(span_type="app.formio.configuration") def iter_components( - configuration: ComponentLike, recursive=True, _is_root=True, _mark_root=False + configuration: ComponentLike, + recursive=True, + _is_root=True, + _mark_root=False, + recurse_into_editgrid: bool = True, ) -> Iterator[Component]: components = configuration.get("components", []) if _is_column_component(configuration) and recursive: @@ -40,6 +44,13 @@ def iter_components( component["_is_root"] = _is_root yield component if recursive: + # TODO: find a cleaner solution - currently just not yielding these is not + # an option because we have some special treatment for editgrid data which + # 'copies' the nested components for further processing. + # Ideally, with should be able to delegate this behaviour to the registered + # component classes, but that's a refactor too big for the current task(s). + if component.get("type") == "editgrid" and not recurse_into_editgrid: + continue yield from iter_components( configuration=component, recursive=recursive, _is_root=False )