diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index 49c8ddec..03698d61 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -592,6 +592,7 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): return append_meta(build_array_type(component.ref), meta) if component else None else: schema = self._map_serializer_field(field.child, direction) + self._insert_field_validators(field.child, schema) # remove automatically attached but redundant title if is_trivial_string_variation(field.field_name, schema.get('title')): schema.pop('title', None) @@ -636,27 +637,32 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): content = {**build_basic_type(OpenApiTypes.STR), 'format': 'decimal'} if field.max_whole_digits: content['pattern'] = ( - f'^\\d{{0,{field.max_whole_digits}}}' - f'(\\.\\d{{0,{field.decimal_places}}})?$' + fr'^\d{{0,{field.max_whole_digits}}}' + fr'(?:\.\d{{0,{field.decimal_places}}})?$' ) else: content = build_basic_type(OpenApiTypes.DECIMAL) if field.max_whole_digits: - content['maximum'] = int(field.max_whole_digits * '9') + 1 - content['minimum'] = -content['maximum'] - self._map_min_max(field, content) + value = 10 ** field.max_whole_digits + content.update({ + 'maximum': value, + 'minimum': -value, + 'exclusiveMaximum': True, + 'exclusiveMinimum': True, + }) + self._insert_min_max(field, content) return append_meta(content, meta) if isinstance(field, serializers.FloatField): content = build_basic_type(OpenApiTypes.FLOAT) - self._map_min_max(field, content) + self._insert_min_max(field, content) return append_meta(content, meta) if isinstance(field, serializers.IntegerField): content = build_basic_type(OpenApiTypes.INT) - self._map_min_max(field, content) - # 2147483647 is max for int32_size, so we use int64 for format - if int(content.get('maximum', 0)) > 2147483647 or int(content.get('minimum', 0)) > 2147483647: + self._insert_min_max(field, content) + # Use int64 for format if value outside the 32-bit signed integer range [-2,147,483,648 to 2,147,483,647]. + if not all(-2147483648 <= int(content.get(key, 0)) <= 2147483647 for key in ('maximum', 'minimum')): content['format'] = 'int64' return append_meta(content, meta) @@ -682,6 +688,7 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): content = build_basic_type(OpenApiTypes.OBJECT) if not isinstance(field.child, _UnvalidatedField): content['additionalProperties'] = self._map_serializer_field(field.child, direction) + self._insert_field_validators(field.child, content['additionalProperties']) return append_meta(content, meta) if isinstance(field, serializers.CharField): @@ -722,11 +729,15 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): warn(f'could not resolve serializer field "{field}". Defaulting to "string"') return append_meta(build_basic_type(OpenApiTypes.STR), meta) - def _map_min_max(self, field, content): - if field.max_value: + def _insert_min_max(self, field, content): + if field.max_value is not None: content['maximum'] = field.max_value - if field.min_value: + if 'exclusiveMaximum' in content: + del content['exclusiveMaximum'] + if field.min_value is not None: content['minimum'] = field.min_value + if 'exclusiveMinimum' in content: + del content['exclusiveMinimum'] def _map_serializer(self, serializer, direction, bypass_extensions=False): serializer = force_instance(serializer) @@ -817,7 +828,7 @@ def _map_basic_serializer(self, serializer, direction): if add_to_required: required.add(field.field_name) - self._map_field_validators(field, schema) + self._insert_field_validators(field, schema) if field.field_name in get_override(serializer, 'deprecate_fields', []): schema['deprecated'] = True @@ -833,38 +844,62 @@ def _map_basic_serializer(self, serializer, direction): description=get_doc(serializer.__class__), ) - def _map_field_validators(self, field, schema): + def _insert_field_validators(self, field, schema): + schema_type = schema.get('type') + + def update_constraint(schema, key, function, value, *, exclusive=False): + if callable(value): + value = value() + current_value = schema.get(key) + if current_value is not None: + new_value = function(current_value, value) + else: + new_value = value + schema[key] = new_value + if key in ('maximum', 'minimum'): + exclusive_key = f'exclusive{key.title()}' + if exclusive: + if new_value != current_value: + schema[exclusive_key] = True + elif exclusive_key in schema: + del schema[exclusive_key] + for v in field.validators: - if isinstance(v, validators.EmailValidator): - schema['format'] = 'email' - elif isinstance(v, validators.URLValidator): - schema['format'] = 'uri' - elif isinstance(v, validators.RegexValidator): - pattern = v.regex.pattern.encode('ascii', 'backslashreplace').decode() - pattern = pattern.replace(r'\x', r'\u00') # unify escaping - pattern = pattern.replace(r'\Z', '$').replace(r'\A', '^') # ECMA anchors - schema['pattern'] = pattern - elif isinstance(v, validators.MaxLengthValidator): - attr_name = 'maxLength' - if isinstance(field, serializers.ListField): - attr_name = 'maxItems' - schema[attr_name] = v.limit_value() if callable(v.limit_value) else v.limit_value - elif isinstance(v, validators.MinLengthValidator): - attr_name = 'minLength' - if isinstance(field, serializers.ListField): - attr_name = 'minItems' - schema[attr_name] = v.limit_value() if callable(v.limit_value) else v.limit_value - elif isinstance(v, validators.MaxValueValidator): - schema['maximum'] = v.limit_value() if callable(v.limit_value) else v.limit_value - elif isinstance(v, validators.MinValueValidator): - schema['minimum'] = v.limit_value() if callable(v.limit_value) else v.limit_value - elif isinstance(v, validators.DecimalValidator): - if v.max_digits: - digits = v.max_digits - if v.decimal_places is not None and v.decimal_places > 0: - digits -= v.decimal_places - schema['maximum'] = int(digits * '9') + 1 - schema['minimum'] = -schema['maximum'] + if schema_type == 'string': + if isinstance(v, validators.EmailValidator): + schema['format'] = 'email' + elif isinstance(v, validators.URLValidator): + schema['format'] = 'uri' + elif isinstance(v, validators.RegexValidator): + pattern = v.regex.pattern.encode('ascii', 'backslashreplace').decode() + pattern = pattern.replace(r'\x', r'\u00') # unify escaping + pattern = pattern.replace(r'\Z', '$').replace(r'\A', '^') # ECMA anchors + schema['pattern'] = pattern + elif isinstance(v, validators.MaxLengthValidator): + update_constraint(schema, 'maxLength', min, v.limit_value) + elif isinstance(v, validators.MinLengthValidator): + update_constraint(schema, 'minLength', max, v.limit_value) + elif isinstance(v, validators.FileExtensionValidator) and v.allowed_extensions: + schema['pattern'] = '(?:%s)$' % '|'.join([re.escape(extn) for extn in v.allowed_extensions]) + elif schema_type in ('integer', 'number'): + if isinstance(v, validators.MaxValueValidator): + update_constraint(schema, 'maximum', min, v.limit_value) + elif isinstance(v, validators.MinValueValidator): + update_constraint(schema, 'minimum', max, v.limit_value) + elif isinstance(v, validators.DecimalValidator) and v.max_digits: + value = 10 ** (v.max_digits - (v.decimal_places or 0)) + update_constraint(schema, 'maximum', min, value, exclusive=True) + update_constraint(schema, 'minimum', max, -value, exclusive=True) + elif schema_type == 'array': + if isinstance(v, validators.MaxLengthValidator): + update_constraint(schema, 'maxItems', min, v.limit_value) + elif isinstance(v, validators.MinLengthValidator): + update_constraint(schema, 'minItems', max, v.limit_value) + elif schema_type == 'object': + if isinstance(v, validators.MaxLengthValidator): + update_constraint(schema, 'maxProperties', min, v.limit_value) + elif isinstance(v, validators.MinLengthValidator): + update_constraint(schema, 'minProperties', max, v.limit_value) def _map_response_type_hint(self, method): hint = get_override(method, 'field') or get_type_hints(method).get('return') diff --git a/tests/test_fields.yml b/tests/test_fields.yml index 4855dd68..79bf1a67 100644 --- a/tests/test_fields.yml +++ b/tests/test_fields.yml @@ -99,6 +99,8 @@ components: format: double maximum: 1000 minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true field_method_float: type: number format: float @@ -242,7 +244,7 @@ components: field_decimal: type: string format: decimal - pattern: ^\d{0,3}(\.\d{0,3})?$ + pattern: ^\d{0,3}(?:\.\d{0,3})?$ field_file: type: string format: uri diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 2b09a47c..339829a5 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -1347,7 +1347,7 @@ class XViewset(viewsets.ReadOnlyModelViewSet): def test_manual_decimal_validator(): # manually test this validator as it is not part of the default workflow class XSerializer(serializers.Serializer): - field = serializers.CharField( + field = serializers.FloatField( validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)] ) @@ -1385,8 +1385,17 @@ def view_func(request): pass # pragma: no cover schema = generate_schema('/x/', view_function=view_func) - field = schema['components']['schemas']['X']['properties']['field'] - assert field['minimum'] and field['maximum'] + assert schema['components']['schemas']['X']['properties']['field'] == { + 'type': 'number', + 'format': 'double', + 'maximum': Decimal('100.00'), + 'minimum': Decimal('1'), + } + assert schema['components']['schemas']['X']['properties']['field_coerced'] == { + 'type': 'string', + 'format': 'decimal', + 'pattern': r'^\d{0,3}(?:\.\d{0,2})?$', + } schema_yml = OpenApiYamlRenderer().render(schema, renderer_context={}) assert b'maximum: 100.00\n' in schema_yml @@ -2424,3 +2433,50 @@ class HexConverter(StringConverter): assert schema['paths']['/c/{var}/']['get']['parameters'][0]['schema'] == { 'type': 'string', 'pattern': '[a-f0-9]+' } + + +@pytest.mark.parametrize('kwargs,expected', [ + ( + {'max_value': -2147483648}, + {'type': 'integer', 'maximum': -2147483648}, + ), + ( + {'max_value': -2147483649}, + {'type': 'integer', 'maximum': -2147483649, 'format': 'int64'}, + ), + ( + {'max_value': 2147483647}, + {'type': 'integer', 'maximum': 2147483647}, + ), + ( + {'max_value': 2147483648}, + {'type': 'integer', 'maximum': 2147483648, 'format': 'int64'}, + ), + ( + {'min_value': -2147483648}, + {'type': 'integer', 'minimum': -2147483648}, + ), + ( + {'min_value': -2147483649}, + {'type': 'integer', 'minimum': -2147483649, 'format': 'int64'}, + ), + ( + {'min_value': 2147483647}, + {'type': 'integer', 'minimum': 2147483647}, + ), + ( + {'min_value': 2147483648}, + {'type': 'integer', 'minimum': 2147483648, 'format': 'int64'}, + ), +]) +def test_int64_detection(kwargs, expected, no_warnings): + class XSerializer(serializers.Serializer): + field = serializers.IntegerField(**kwargs) + + @extend_schema(request=XSerializer, responses=XSerializer) + @api_view(['GET']) + def view_func(request, format=None): + pass # pragma: no cover + + schema = generate_schema('x', view_function=view_func) + assert schema['components']['schemas']['X']['properties']['field'] == expected diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 00000000..0f244444 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,392 @@ +import sys +from datetime import timedelta +from unittest import mock + +import pytest +from django.contrib.auth import validators as auth_validators +from django.contrib.postgres import validators as postgres_validators +from django.core import validators +from django.urls import path +from rest_framework import serializers +from rest_framework.decorators import api_view + +from drf_spectacular.utils import extend_schema +from tests import assert_schema, generate_schema + + +@mock.patch('rest_framework.settings.api_settings.COERCE_DECIMAL_TO_STRING', False) +def test_validators(): + + class XSerializer(serializers.Serializer): + # Note that these fields intentionally use basic field types to ensure that we detect from the validator only. + + # The following only apply for `string` type: + char_email = serializers.CharField(validators=[validators.EmailValidator()]) + char_url = serializers.CharField(validators=[validators.URLValidator()]) + char_regex = serializers.CharField(validators=[validators.RegexValidator(r'\w+')]) + char_max_length = serializers.CharField(validators=[validators.MaxLengthValidator(200)]) + char_min_length = serializers.CharField(validators=[validators.MinLengthValidator(100)]) + + # The following only apply for `integer` and `number` types: + float_max_value = serializers.FloatField(validators=[validators.MaxValueValidator(200.0)]) + float_min_value = serializers.FloatField(validators=[validators.MinValueValidator(100.0)]) + float_decimal = serializers.FloatField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + integer_max_value = serializers.IntegerField(validators=[validators.MaxValueValidator(200)]) + integer_min_value = serializers.IntegerField(validators=[validators.MinValueValidator(100)]) + integer_decimal = serializers.FloatField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + decimal_max_value = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.MaxValueValidator(200)], + ) + decimal_min_value = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.MinValueValidator(100)], + ) + decimal_decimal = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + + # The following only apply for `array` type: + list_max_length = serializers.ListField(validators=[validators.MaxLengthValidator(200)]) + list_min_length = serializers.ListField(validators=[validators.MinLengthValidator(100)]) + + # The following only apply for `object` type: + dict_max_length = serializers.DictField(validators=[validators.MaxLengthValidator(200)]) + dict_min_length = serializers.DictField(validators=[validators.MinLengthValidator(100)]) + + # Explicit test for rest_framework.fields.DurationField: + age = serializers.DurationField(validators=[ + validators.RegexValidator(r'^P\d+Y$'), + validators.MaxLengthValidator(5), + validators.MinLengthValidator(3), + ]) + + # Tests for additional subclasses already handled by their superclass: + array_max_length = serializers.ListField(validators=[postgres_validators.ArrayMaxLengthValidator(200)]) + array_min_length = serializers.ListField(validators=[postgres_validators.ArrayMinLengthValidator(100)]) + ascii_username = serializers.CharField(validators=[auth_validators.ASCIIUsernameValidator()]) + unicode_username = serializers.CharField(validators=[auth_validators.UnicodeUsernameValidator()]) + file_extension = serializers.CharField(validators=[validators.FileExtensionValidator(['.jpg', '.png'])]) + integer_string = serializers.CharField(validators=[validators.integer_validator]) + integer_list = serializers.CharField(validators=[validators.validate_comma_separated_integer_list]) + + class YSerializer(serializers.Serializer): + # These validators are unsupported for the `string` type: + char_max_value = serializers.CharField(validators=[validators.MaxValueValidator(200)]) + char_min_value = serializers.CharField(validators=[validators.MinValueValidator(100)]) + char_decimal = serializers.CharField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + + # These validators are unsupported for the `integer` and `number` types: + float_email = serializers.FloatField(validators=[validators.EmailValidator()]) + float_url = serializers.FloatField(validators=[validators.URLValidator()]) + float_regex = serializers.FloatField(validators=[validators.RegexValidator(r'\w+')]) + float_max_length = serializers.FloatField(validators=[validators.MaxLengthValidator(200)]) + float_min_length = serializers.FloatField(validators=[validators.MinLengthValidator(100)]) + integer_email = serializers.IntegerField(validators=[validators.EmailValidator()]) + integer_url = serializers.IntegerField(validators=[validators.URLValidator()]) + integer_regex = serializers.IntegerField(validators=[validators.RegexValidator(r'\w+')]) + integer_max_length = serializers.IntegerField(validators=[validators.MaxLengthValidator(200)]) + integer_min_length = serializers.IntegerField(validators=[validators.MinLengthValidator(100)]) + decimal_email = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.EmailValidator()], + ) + decimal_url = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.URLValidator()], + ) + decimal_regex = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.RegexValidator(r'\w+')], + ) + decimal_max_length = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.MaxLengthValidator(200)], + ) + decimal_min_length = serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.MinLengthValidator(100)], + ) + + # These validators are unsupported for the `array` type: + list_email = serializers.ListField(validators=[validators.EmailValidator()]) + list_url = serializers.ListField(validators=[validators.URLValidator()]) + list_regex = serializers.ListField(validators=[validators.RegexValidator(r'\w+')]) + list_max_value = serializers.ListField(validators=[validators.MaxValueValidator(200)]) + list_min_value = serializers.ListField(validators=[validators.MinValueValidator(100)]) + list_decimal = serializers.ListField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + + # These validators are unsupported for the `object` type: + dict_email = serializers.DictField(validators=[validators.EmailValidator()]) + dict_url = serializers.DictField(validators=[validators.URLValidator()]) + dict_regex = serializers.DictField(validators=[validators.RegexValidator(r'\w+')]) + dict_max_value = serializers.DictField(validators=[validators.MaxValueValidator(200)]) + dict_min_value = serializers.DictField(validators=[validators.MinValueValidator(100)]) + dict_decimal = serializers.DictField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + + # These validators are unsupported for the `boolean` type: + boolean_email = serializers.BooleanField(validators=[validators.EmailValidator()]) + boolean_url = serializers.BooleanField(validators=[validators.URLValidator()]) + boolean_regex = serializers.BooleanField(validators=[validators.RegexValidator(r'\w+')]) + boolean_max_length = serializers.BooleanField(validators=[validators.MaxLengthValidator(200)]) + boolean_min_length = serializers.BooleanField(validators=[validators.MinLengthValidator(100)]) + boolean_max_value = serializers.BooleanField(validators=[validators.MaxValueValidator(200)]) + boolean_min_value = serializers.BooleanField(validators=[validators.MinValueValidator(100)]) + boolean_decimal = serializers.BooleanField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + + # Explicit test for rest_framework.fields.DurationField: + duration_max_value = serializers.DurationField(validators=[validators.MaxValueValidator(200)]) + duration_min_value = serializers.DurationField(validators=[validators.MinValueValidator(100)]) + duration_decimal = serializers.DurationField( + validators=[validators.DecimalValidator(max_digits=4, decimal_places=2)], + ) + + @extend_schema(request=XSerializer, responses=XSerializer) + @api_view(['POST']) + def view_func_x(request, format=None): + pass # pragma: no cover + + @extend_schema(request=YSerializer, responses=YSerializer) + @api_view(['POST']) + def view_func_y(request, format=None): + pass # pragma: no cover + + schema = generate_schema(None, patterns=[path('x', view_func_x), path('y', view_func_y)]) + + if sys.version_info < (3, 7): + # In Python < 3.7, re.escape() escapes more characters than necessary. + field = schema['components']['schemas']['X']['properties']['integer_list'] + field['pattern'] = field['pattern'].replace(r'\,', ',') + + assert_schema(schema, 'tests/test_validators.yml') + + +def test_nested_validators(): + class XSerializer(serializers.Serializer): + list_field = serializers.ListField( + child=serializers.IntegerField( + validators=[validators.MaxValueValidator(999)], + ), + validators=[validators.MaxLengthValidator(5)], + ) + dict_field = serializers.DictField( + child=serializers.IntegerField( + validators=[validators.MaxValueValidator(999)], + ), + ) + + @extend_schema(request=XSerializer, responses=XSerializer) + @api_view(['POST']) + def view_func(request, format=None): + pass # pragma: no cover + + schema = generate_schema('x', view_function=view_func) + properties = schema['components']['schemas']['X']['properties'] + assert properties['list_field']['maxItems'] == 5 + assert properties['list_field']['items']['maximum'] == 999 + assert properties['dict_field']['additionalProperties']['maximum'] == 999 + + +@pytest.mark.parametrize('instance,expected', [ + ( + serializers.DictField(validators=[validators.MaxLengthValidator(150), validators.MaxLengthValidator(200)]), + {'type': 'object', 'additionalProperties': {}, 'maxProperties': 150}, + ), + ( + serializers.DictField(validators=[validators.MinLengthValidator(150), validators.MinLengthValidator(100)]), + {'type': 'object', 'additionalProperties': {}, 'minProperties': 150}, + ), + ( + serializers.ListField(max_length=150, validators=[validators.MaxLengthValidator(200)]), + {'type': 'array', 'items': {}, 'maxItems': 150}, + ), + ( + serializers.ListField(min_length=150, validators=[validators.MinLengthValidator(100)]), + {'type': 'array', 'items': {}, 'minItems': 150}, + ), + ( + serializers.ListField(max_length=200, validators=[validators.MaxLengthValidator(150)]), + {'type': 'array', 'items': {}, 'maxItems': 150}, + ), + ( + serializers.ListField(min_length=100, validators=[validators.MinLengthValidator(150)]), + {'type': 'array', 'items': {}, 'minItems': 150}, + ), + ( + serializers.ListField(validators=[validators.MaxLengthValidator(150), validators.MaxLengthValidator(200)]), + {'type': 'array', 'items': {}, 'maxItems': 150}, + ), + ( + serializers.ListField(validators=[validators.MinLengthValidator(150), validators.MinLengthValidator(100)]), + {'type': 'array', 'items': {}, 'minItems': 150}, + ), + ( + serializers.CharField(max_length=150, validators=[validators.MaxLengthValidator(200)]), + {'type': 'string', 'maxLength': 150}, + ), + ( + serializers.CharField(min_length=150, validators=[validators.MinLengthValidator(100)]), + {'type': 'string', 'minLength': 150}, + ), + ( + serializers.CharField(max_length=200, validators=[validators.MaxLengthValidator(150)]), + {'type': 'string', 'maxLength': 150}, + ), + ( + serializers.CharField(min_length=100, validators=[validators.MinLengthValidator(150)]), + {'type': 'string', 'minLength': 150}, + ), + ( + serializers.CharField(validators=[validators.MaxLengthValidator(150), validators.MaxLengthValidator(200)]), + {'type': 'string', 'maxLength': 150}, + ), + ( + serializers.CharField(validators=[validators.MinLengthValidator(150), validators.MinLengthValidator(100)]), + {'type': 'string', 'minLength': 150}, + ), + ( + serializers.IntegerField(max_value=150, validators=[validators.MaxValueValidator(200)]), + {'type': 'integer', 'maximum': 150}, + ), + ( + serializers.IntegerField(min_value=150, validators=[validators.MinValueValidator(100)]), + {'type': 'integer', 'minimum': 150}, + ), + ( + serializers.IntegerField(max_value=200, validators=[validators.MaxValueValidator(150)]), + {'type': 'integer', 'maximum': 150}, + ), + ( + serializers.IntegerField(min_value=100, validators=[validators.MinValueValidator(150)]), + {'type': 'integer', 'minimum': 150}, + ), + ( + serializers.IntegerField(validators=[validators.MaxValueValidator(150), validators.MaxValueValidator(200)]), + {'type': 'integer', 'maximum': 150}, + ), + ( + serializers.IntegerField(validators=[validators.MinValueValidator(150), validators.MinValueValidator(100)]), + {'type': 'integer', 'minimum': 150}, + ), + ( + serializers.DecimalField(max_digits=3, decimal_places=1, validators=[validators.MaxValueValidator(50)]), + {'type': 'number', 'format': 'double', 'maximum': 50, 'minimum': -100, 'exclusiveMinimum': True}, + ), + ( + serializers.DecimalField(max_digits=3, decimal_places=1, validators=[validators.MinValueValidator(-50)]), + {'type': 'number', 'format': 'double', 'maximum': 100, 'minimum': -50, 'exclusiveMaximum': True}, + ), + ( + serializers.DecimalField(max_digits=3, decimal_places=1, validators=[validators.MaxValueValidator(150)]), + {'type': 'number', 'format': 'double', 'maximum': 100, 'minimum': -100, 'exclusiveMinimum': True}, + ), + ( + serializers.DecimalField(max_digits=3, decimal_places=1, validators=[validators.MinValueValidator(-150)]), + {'type': 'number', 'format': 'double', 'maximum': 100, 'minimum': -100, 'exclusiveMaximum': True}, + ), + ( + serializers.DecimalField( + max_digits=4, + decimal_places=1, + validators=[validators.DecimalValidator(max_digits=3, decimal_places=1)], + ), + { + 'type': 'number', 'format': 'double', 'maximum': 100, 'minimum': -100, + 'exclusiveMaximum': True, 'exclusiveMinimum': True, + }, + ), + ( + serializers.DecimalField( + max_digits=3, + decimal_places=1, + validators=[validators.DecimalValidator(max_digits=4, decimal_places=1)], + ), + { + 'type': 'number', 'format': 'double', 'maximum': 100, 'minimum': -100, + 'exclusiveMaximum': True, 'exclusiveMinimum': True, + }, + ), + ( + serializers.DecimalField( + max_digits=3, + decimal_places=1, + validators=[validators.DecimalValidator(max_digits=2, decimal_places=1), validators.MaxValueValidator(5)], + ), + {'type': 'number', 'format': 'double', 'maximum': 5, 'minimum': -10, 'exclusiveMinimum': True}, + ), + ( + serializers.DecimalField( + max_digits=3, + decimal_places=1, + validators=[validators.DecimalValidator(max_digits=2, decimal_places=1), validators.MinValueValidator(-5)], + ), + {'type': 'number', 'format': 'double', 'maximum': 10, 'minimum': -5, 'exclusiveMaximum': True}, + ), +]) +@mock.patch('rest_framework.settings.api_settings.COERCE_DECIMAL_TO_STRING', False) +def test_validation_constrained(instance, expected): + class XSerializer(serializers.Serializer): + field = instance + + @extend_schema(request=XSerializer, responses=XSerializer) + @api_view(['POST']) + def view_func(request, format=None): + pass # pragma: no cover + + schema = generate_schema('x', view_function=view_func) + assert schema['components']['schemas']['X']['properties']['field'] == expected + + +def test_timedelta_in_validator(): + class XSerializer(serializers.Serializer): + field = serializers.DurationField( + validators=[validators.MaxValueValidator(timedelta(seconds=3600))], + ) + + @extend_schema(request=XSerializer, responses=XSerializer) + @api_view(['POST']) + def view_func(request, format=None): + pass # pragma: no cover + + # `DurationField` values and `timedelta` serialize to `string` type so `maximum` is invalid. + schema = generate_schema('x', view_function=view_func) + assert 'maximum' not in schema['components']['schemas']['X']['properties']['field'] + + +@pytest.mark.parametrize('pattern,expected', [ + (r'\xff', r'\u00ff'), # Unify escape characters. + (r'\Ato\Z', r'^to$'), # Switch to ECMA anchors. +]) +def test_regex_validator_tweaks(pattern, expected): + class XSerializer(serializers.Serializer): + field = serializers.CharField(validators=[validators.RegexValidator(pattern)]) + + @extend_schema(request=XSerializer, responses=XSerializer) + @api_view(['POST']) + def view_func(request, format=None): + pass # pragma: no cover + + schema = generate_schema('x', view_function=view_func) + field = schema['components']['schemas']['X']['properties']['field'] + assert field['pattern'] == expected diff --git a/tests/test_validators.yml b/tests/test_validators.yml new file mode 100644 index 00000000..b7bae48f --- /dev/null +++ b/tests/test_validators.yml @@ -0,0 +1,376 @@ +openapi: 3.0.3 +info: + title: '' + version: 0.0.0 +paths: + /x: + post: + operationId: x_create + tags: + - x + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/X' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/X' + multipart/form-data: + schema: + $ref: '#/components/schemas/X' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/X' + description: '' + /y: + post: + operationId: y_create + tags: + - y + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Y' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Y' + multipart/form-data: + schema: + $ref: '#/components/schemas/Y' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Y' + description: '' +components: + schemas: + X: + type: object + properties: + char_email: + type: string + format: email + char_url: + type: string + format: uri + char_regex: + type: string + pattern: \w+ + char_max_length: + type: string + maxLength: 200 + char_min_length: + type: string + minLength: 100 + float_max_value: + type: number + format: float + maximum: 200.0 + float_min_value: + type: number + format: float + minimum: 100.0 + float_decimal: + type: number + format: float + maximum: 100 + exclusiveMaximum: true + minimum: -100 + exclusiveMinimum: true + integer_max_value: + type: integer + maximum: 200 + integer_min_value: + type: integer + minimum: 100 + integer_decimal: + type: number + format: float + maximum: 100 + exclusiveMaximum: true + minimum: -100 + exclusiveMinimum: true + decimal_max_value: + type: number + format: double + maximum: 200 + minimum: -1000 + exclusiveMinimum: true + decimal_min_value: + type: number + format: double + maximum: 1000 + minimum: 100 + exclusiveMaximum: true + decimal_decimal: + type: number + format: double + maximum: 100 + minimum: -100 + exclusiveMaximum: true + exclusiveMinimum: true + list_max_length: + type: array + items: {} + maxItems: 200 + list_min_length: + type: array + items: {} + minItems: 100 + dict_max_length: + type: object + additionalProperties: {} + maxProperties: 200 + dict_min_length: + type: object + additionalProperties: {} + minProperties: 100 + age: + type: string + pattern: ^P\d+Y$ + maxLength: 5 + minLength: 3 + array_max_length: + type: array + items: {} + maxItems: 200 + array_min_length: + type: array + items: {} + minItems: 100 + ascii_username: + type: string + pattern: ^[\w.@+-]+$ + unicode_username: + type: string + pattern: ^[\w.@+-]+$ + file_extension: + type: string + pattern: (?:\.jpg|\.png)$ + integer_string: + type: string + pattern: ^-?\d+$ + integer_list: + type: string + pattern: ^\d+(?:,\d+)*$ + required: + - age + - array_max_length + - array_min_length + - ascii_username + - char_email + - char_max_length + - char_min_length + - char_regex + - char_url + - decimal_decimal + - decimal_max_value + - decimal_min_value + - dict_max_length + - dict_min_length + - file_extension + - float_decimal + - float_max_value + - float_min_value + - integer_decimal + - integer_list + - integer_max_value + - integer_min_value + - integer_string + - list_max_length + - list_min_length + - unicode_username + Y: + type: object + properties: + char_max_value: + type: string + char_min_value: + type: string + char_decimal: + type: string + float_email: + type: number + format: float + float_url: + type: number + format: float + float_regex: + type: number + format: float + float_max_length: + type: number + format: float + float_min_length: + type: number + format: float + integer_email: + type: integer + integer_url: + type: integer + integer_regex: + type: integer + integer_max_length: + type: integer + integer_min_length: + type: integer + decimal_email: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + decimal_url: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + decimal_regex: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + decimal_max_length: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + decimal_min_length: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + list_email: + type: array + items: {} + list_url: + type: array + items: {} + list_regex: + type: array + items: {} + list_max_value: + type: array + items: {} + list_min_value: + type: array + items: {} + list_decimal: + type: array + items: {} + dict_email: + type: object + additionalProperties: {} + dict_url: + type: object + additionalProperties: {} + dict_regex: + type: object + additionalProperties: {} + dict_max_value: + type: object + additionalProperties: {} + dict_min_value: + type: object + additionalProperties: {} + dict_decimal: + type: object + additionalProperties: {} + boolean_email: + type: boolean + boolean_url: + type: boolean + boolean_regex: + type: boolean + boolean_max_length: + type: boolean + boolean_min_length: + type: boolean + boolean_max_value: + type: boolean + boolean_min_value: + type: boolean + boolean_decimal: + type: boolean + duration_max_value: + type: string + duration_min_value: + type: string + duration_decimal: + type: string + required: + - boolean_decimal + - boolean_email + - boolean_max_length + - boolean_max_value + - boolean_min_length + - boolean_min_value + - boolean_regex + - boolean_url + - char_decimal + - char_max_value + - char_min_value + - decimal_email + - decimal_max_length + - decimal_min_length + - decimal_regex + - decimal_url + - dict_decimal + - dict_email + - dict_max_value + - dict_min_value + - dict_regex + - dict_url + - duration_decimal + - duration_max_value + - duration_min_value + - float_email + - float_max_length + - float_min_length + - float_regex + - float_url + - integer_email + - integer_max_length + - integer_min_length + - integer_regex + - integer_url + - list_decimal + - list_email + - list_max_value + - list_min_value + - list_regex + - list_url + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid