Resolve the form field class before applying schema constraints

to_form_field() applied each constraint based on the property's declared type,
but an enum or a string format redirects the property to a field class which
accepts a narrower set of keyword arguments. A string with "format": "date" and
a minLength resolved to a DateField and raised TypeError on construction, taking
down the ModuleType form with it. The enum cases failed the same way.

Resolve field_class up front and gate each constraint on what that class accepts.
EmailField, URLField and UUIDField subclass CharField, so they keep their length
bounds, and FloatField subclasses IntegerField, so both keep their value bounds.
Validators are accepted by every field class, so pattern and multipleOf now apply
regardless of which class the property resolved to.
This commit is contained in:
Jason Novinger 2026-09-14 12:12:02 -05:00
parent 2bed744ec2
commit 5402cc8c7e
2 changed files with 120 additions and 10 deletions

View File

@ -87,6 +87,7 @@ class JSONSchemaProperty:
"""
Instantiate and return a Django form field suitable for editing the property's value.
"""
field_class = self.field_class
field_kwargs = {
'label': self.title or title(name),
'help_text': render_markdown(self.description),
@ -111,22 +112,26 @@ class JSONSchemaProperty:
# String validation
if self.type == PropertyTypeEnum.STRING.value:
if self.minLength is not None:
field_kwargs['min_length'] = self.minLength
if self.maxLength is not None:
field_kwargs['max_length'] = self.maxLength
# it's safe to check against CharField here because the other
# CharField-derived fields are ruled out by the "is a string check" above
if issubclass(field_class, forms.CharField):
if self.minLength is not None and not self.enum:
field_kwargs['min_length'] = self.minLength
if self.maxLength is not None and not self.enum:
field_kwargs['max_length'] = self.maxLength
if self.pattern is not None:
field_kwargs['validators'] = [
RegexValidator(regex=self.pattern)
]
# Integer/number validation
elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value) and not self.enum:
field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
if self.minimum is not None:
field_kwargs['min_value'] = self.minimum
if self.maximum is not None:
field_kwargs['max_value'] = self.maximum
elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value):
if issubclass(field_class, forms.IntegerField):
field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
if self.minimum is not None:
field_kwargs['min_value'] = self.minimum
if self.maximum is not None:
field_kwargs['max_value'] = self.maximum
if self.multipleOf:
field_kwargs['validators'] = [
MultipleOfValidator(multiple=self.multipleOf)

View File

@ -1,9 +1,11 @@
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.test import TestCase
from utilities.jsonschema import JSONSchemaProperty
from utilities.validators import MultipleOfValidator
class JSONSchemaPropertyTestCase(TestCase):
@ -122,12 +124,115 @@ class JSONSchemaPropertyTestCase(TestCase):
self.assertEqual(list(field.choices), [(None, ''), (1, 1), (2, 2)])
def test_numeric_enum_with_multiple_of_builds_choice_field(self):
"""An enum suppresses the numeric bounds but retains the multipleOf validator.
Field.__init__() accepts validators, so a MultipleOfValidator remains applicable to a
ChoiceField even though min_value and max_value are not.
"""
prop = JSONSchemaProperty(type='integer', title='Slots', enum=[2, 4], multipleOf=2)
field = prop.to_form_field('slots')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(list(field.choices), [(None, ''), (2, 2), (4, 4)])
self.assertEqual(len(field.validators), 1)
self.assertIsInstance(field.validators[0], MultipleOfValidator)
def test_string_enum_with_min_length_builds_choice_field(self):
"""A string property carrying both an enum and a length bound resolves to a ChoiceField.
ChoiceField accepts neither min_length nor max_length, so the length bounds must not be
passed through when an enum is present.
"""
prop = JSONSchemaProperty(type='string', title='Media', enum=['a', 'bb'], minLength=1)
field = prop.to_form_field('media')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(list(field.choices), [(None, ''), ('a', 'a'), ('bb', 'bb')])
def test_string_enum_with_max_length_builds_choice_field(self):
prop = JSONSchemaProperty(type='string', title='Media', enum=['a', 'bb'], maxLength=2)
field = prop.to_form_field('media')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(list(field.choices), [(None, ''), ('a', 'a'), ('bb', 'bb')])
def test_string_enum_retains_pattern_validator(self):
"""Dropping the length bounds for an enum must not also drop the pattern validator.
Field.__init__() accepts validators, so a RegexValidator remains applicable to a
ChoiceField even though min_length and max_length are not.
"""
prop = JSONSchemaProperty(
type='string', title='Media', enum=['a', 'bb'], minLength=1, pattern='^[ab]+$'
)
field = prop.to_form_field('media')
self.assertIsInstance(field, forms.ChoiceField)
self.assertEqual(len(field.validators), 1)
self.assertIsInstance(field.validators[0], RegexValidator)
self.assertEqual(field.validators[0].regex.pattern, '^[ab]+$')
def test_string_bounds_are_applied_without_an_enum(self):
prop = JSONSchemaProperty(type='string', title='Media', minLength=1, maxLength=4)
field = prop.to_form_field('media')
self.assertIsInstance(field, forms.CharField)
self.assertEqual(field.min_length, 1)
self.assertEqual(field.max_length, 4)
with self.assertRaises(ValidationError):
field.clean('toolong')
def test_string_format_with_length_bound_builds_format_field(self):
"""A string format resolves to a field class which accepts no length bounds.
DateField, TimeField and DateTimeField do not subclass CharField, so passing minLength
or maxLength to one raises TypeError.
"""
for string_format, expected_class in (
('date', forms.DateField),
('time', forms.TimeField),
('datetime', forms.DateTimeField),
):
with self.subTest(format=string_format):
prop = JSONSchemaProperty(
type='string', title='Timestamp', format=string_format, minLength=10, maxLength=30
)
field = prop.to_form_field('timestamp')
self.assertIsInstance(field, expected_class)
def test_string_format_retains_pattern_validator(self):
prop = JSONSchemaProperty(type='string', title='Timestamp', format='date', pattern='^x$')
field = prop.to_form_field('timestamp')
self.assertIsInstance(field, forms.DateField)
self.assertEqual(len(field.validators), 1)
self.assertIsInstance(field.validators[0], RegexValidator)
def test_charfield_derived_format_retains_length_bounds(self):
"""EmailField, URLField and UUIDField subclass CharField, so they keep their bounds."""
for string_format, expected_class in (
('email', forms.EmailField),
('uri', forms.URLField),
('uuid', forms.UUIDField),
):
with self.subTest(format=string_format):
prop = JSONSchemaProperty(
type='string', title='Contact', format=string_format, minLength=5, maxLength=40
)
field = prop.to_form_field('contact')
self.assertIsInstance(field, expected_class)
self.assertEqual(field.min_length, 5)
self.assertEqual(field.max_length, 40)
class JSONSchemaPropertyDescriptionSanitizationTestCase(TestCase):