mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-07-15 10:42:24 +03:00
feat: Add MaxFileSizeValidator and MinFileSizeValidator
Add two new validators for enforcing file size constraints on uploaded files: - MaxFileSizeValidator: Ensures files do not exceed a maximum size (in bytes) - MinFileSizeValidator: Ensures files meet a minimum size (in bytes) Both validators: - Support custom error messages and codes - Are deconstructible for migrations - Include comprehensive unit and integration tests - Work with FileField and ImageField - Follow DRF conventions and patterns
This commit is contained in:
parent
2ae8c117da
commit
6c9e2266cd
|
@ -170,6 +170,74 @@ If you want the date field to be entirely hidden from the user, then use `Hidden
|
|||
|
||||
---
|
||||
|
||||
## MaxFileSizeValidator and MinFileSizeValidator
|
||||
|
||||
These validators can be used to enforce file size constraints on uploaded files. They are especially useful for `FileField` and `ImageField` in serializers.
|
||||
|
||||
### MaxFileSizeValidator
|
||||
|
||||
Ensures that the uploaded file does not exceed a maximum size (in bytes).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
* `max_size` (*required*) — The maximum file size in bytes.
|
||||
* `message` — Custom error message. May use `{max_size}` in the string.
|
||||
* `code` — Custom error code. Default is `'max_file_size'`.
|
||||
|
||||
### MinFileSizeValidator
|
||||
|
||||
Ensures that the uploaded file meets a minimum size (in bytes).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
* `min_size` (*required*) — The minimum file size in bytes.
|
||||
* `message` — Custom error message. May use `{min_size}` in the string.
|
||||
* `code` — Custom error code. Default is `'min_file_size'`.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Basic usage with FileField
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import MaxFileSizeValidator, MinFileSizeValidator
|
||||
|
||||
class FileUploadSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(validators=[
|
||||
MaxFileSizeValidator(1024 * 1024), # 1MB max
|
||||
MinFileSizeValidator(1024), # 1KB min
|
||||
])
|
||||
|
||||
#### Usage with ImageField
|
||||
|
||||
class ImageUploadSerializer(serializers.Serializer):
|
||||
image = serializers.ImageField(validators=[
|
||||
MaxFileSizeValidator(5 * 1024 * 1024), # 5MB max
|
||||
MinFileSizeValidator(1024), # 1KB min
|
||||
])
|
||||
|
||||
#### Custom error messages and codes
|
||||
|
||||
class CustomFileSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(validators=[
|
||||
MaxFileSizeValidator(
|
||||
max_size=1024 * 1024,
|
||||
message="File size cannot exceed {max_size} bytes",
|
||||
code='file_too_large'
|
||||
),
|
||||
MinFileSizeValidator(
|
||||
min_size=1024,
|
||||
message="File must be at least {min_size} bytes",
|
||||
code='file_too_small'
|
||||
),
|
||||
])
|
||||
|
||||
### Error Codes
|
||||
|
||||
* `max_file_size` — Default error code for MaxFileSizeValidator
|
||||
* `min_file_size` — Default error code for MinFileSizeValidator
|
||||
|
||||
---
|
||||
|
||||
# Advanced field defaults
|
||||
|
||||
Validators that are applied across multiple fields in the serializer can sometimes require a field input that should not be provided by the API client, but that *is* available as input to the validator.
|
||||
|
|
|
@ -9,6 +9,7 @@ object creation, and makes it possible to switch between using the implicit
|
|||
from django.core.exceptions import FieldError
|
||||
from django.db import DataError
|
||||
from django.db.models import Exists
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
@ -344,3 +345,73 @@ class UniqueForYearValidator(BaseUniqueForValidator):
|
|||
filter_kwargs[field_name] = value
|
||||
filter_kwargs['%s__year' % date_field_name] = date.year
|
||||
return qs_filter(queryset, **filter_kwargs)
|
||||
|
||||
|
||||
@deconstructible
|
||||
class MaxFileSizeValidator:
|
||||
"""
|
||||
Validator that ensures uploaded files do not exceed a maximum size.
|
||||
|
||||
Should be applied to individual file fields on the serializer.
|
||||
"""
|
||||
message = _('File size must not exceed {max_size} bytes.')
|
||||
code = 'max_file_size'
|
||||
|
||||
def __init__(self, max_size, message=None, code=None):
|
||||
self.max_size = max_size
|
||||
self.message = message or self.message
|
||||
self.code = code or self.code
|
||||
|
||||
def __call__(self, value):
|
||||
if hasattr(value, 'size'):
|
||||
if value.size > self.max_size:
|
||||
message = self.message.format(max_size=self.max_size)
|
||||
raise ValidationError(message, code=self.code)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return NotImplemented
|
||||
return (self.message == other.message
|
||||
and self.code == other.code
|
||||
and self.max_size == other.max_size)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s(max_size=%s)>' % (
|
||||
self.__class__.__name__,
|
||||
smart_repr(self.max_size)
|
||||
)
|
||||
|
||||
|
||||
@deconstructible
|
||||
class MinFileSizeValidator:
|
||||
"""
|
||||
Validator that ensures uploaded files meet a minimum size.
|
||||
|
||||
Should be applied to individual file fields on the serializer.
|
||||
"""
|
||||
message = _('File size must be at least {min_size} bytes.')
|
||||
code = 'min_file_size'
|
||||
|
||||
def __init__(self, min_size, message=None, code=None):
|
||||
self.min_size = min_size
|
||||
self.message = message or self.message
|
||||
self.code = code or self.code
|
||||
|
||||
def __call__(self, value):
|
||||
if hasattr(value, 'size'):
|
||||
if value.size < self.min_size:
|
||||
message = self.message.format(min_size=self.min_size)
|
||||
raise ValidationError(message, code=self.code)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, self.__class__):
|
||||
return NotImplemented
|
||||
return (self.message == other.message
|
||||
and self.code == other.code
|
||||
and self.min_size == other.min_size)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s(min_size=%s)>' % (
|
||||
self.__class__.__name__,
|
||||
smart_repr(self.min_size)
|
||||
)
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import datetime
|
||||
import io
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django import VERSION as django_version
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import DataError, models
|
||||
from django.test import TestCase
|
||||
from PIL import Image
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.validators import (
|
||||
BaseUniqueForValidator, UniqueTogetherValidator, UniqueValidator, qs_exists
|
||||
BaseUniqueForValidator, MaxFileSizeValidator, MinFileSizeValidator,
|
||||
UniqueTogetherValidator, UniqueValidator, qs_exists
|
||||
)
|
||||
|
||||
|
||||
|
@ -972,3 +976,281 @@ class ValidatorsTests(TestCase):
|
|||
assert validator == validator2
|
||||
validator2.date_field = "bar2"
|
||||
assert validator != validator2
|
||||
|
||||
|
||||
# Tests for `MaxFileSizeValidator` and `MinFileSizeValidator`
|
||||
# -----------------------------------------------------------
|
||||
|
||||
class TestFileSizeValidators(TestCase):
|
||||
class DummyFile:
|
||||
def __init__(self, size):
|
||||
self.size = size
|
||||
|
||||
def test_max_file_size_validator_pass(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
file = self.DummyFile(1024)
|
||||
# Should not raise
|
||||
validator(file)
|
||||
|
||||
def test_max_file_size_validator_fail(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
file = self.DummyFile(1025)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert 'File size must not exceed 1024 bytes.' in str(exc.value)
|
||||
assert exc.value.get_codes() == ['max_file_size']
|
||||
|
||||
def test_min_file_size_validator_pass(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
file = self.DummyFile(200)
|
||||
# Should not raise
|
||||
validator(file)
|
||||
|
||||
def test_min_file_size_validator_fail(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
file = self.DummyFile(50)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert 'File size must be at least 100 bytes.' in str(exc.value)
|
||||
assert exc.value.get_codes() == ['min_file_size']
|
||||
|
||||
def test_max_file_size_validator_exact_size(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
file = self.DummyFile(1024)
|
||||
# Should not raise for exact size
|
||||
validator(file)
|
||||
|
||||
def test_min_file_size_validator_exact_size(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
file = self.DummyFile(100)
|
||||
# Should not raise for exact size
|
||||
validator(file)
|
||||
|
||||
def test_max_file_size_validator_zero_size(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
file = self.DummyFile(0)
|
||||
# Should not raise for zero size
|
||||
validator(file)
|
||||
|
||||
def test_min_file_size_validator_zero_size(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
file = self.DummyFile(0)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert 'File size must be at least 100 bytes.' in str(exc.value)
|
||||
|
||||
def test_max_file_size_validator_no_size_attribute(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
# Object without size attribute should not raise
|
||||
validator("not a file")
|
||||
|
||||
def test_min_file_size_validator_no_size_attribute(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
# Object without size attribute should not raise
|
||||
validator("not a file")
|
||||
|
||||
def test_max_file_size_validator_custom_message(self):
|
||||
validator = MaxFileSizeValidator(1024, message="File too big: {max_size}")
|
||||
file = self.DummyFile(1025)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert 'File too big: 1024' in str(exc.value)
|
||||
|
||||
def test_min_file_size_validator_custom_message(self):
|
||||
validator = MinFileSizeValidator(100, message="File too small: {min_size}")
|
||||
file = self.DummyFile(50)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert 'File too small: 100' in str(exc.value)
|
||||
|
||||
def test_max_file_size_validator_custom_code(self):
|
||||
validator = MaxFileSizeValidator(1024, code='custom_max_size')
|
||||
file = self.DummyFile(1025)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert exc.value.get_codes() == ['custom_max_size']
|
||||
|
||||
def test_min_file_size_validator_custom_code(self):
|
||||
validator = MinFileSizeValidator(100, code='custom_min_size')
|
||||
file = self.DummyFile(50)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validator(file)
|
||||
assert exc.value.get_codes() == ['custom_min_size']
|
||||
|
||||
def test_max_file_size_validator_equality(self):
|
||||
validator1 = MaxFileSizeValidator(1024)
|
||||
validator2 = MaxFileSizeValidator(1024)
|
||||
validator3 = MaxFileSizeValidator(2048)
|
||||
validator4 = MaxFileSizeValidator(1024, message="Custom")
|
||||
|
||||
assert validator1 == validator2
|
||||
assert validator1 != validator3
|
||||
assert validator1 != validator4
|
||||
|
||||
def test_min_file_size_validator_equality(self):
|
||||
validator1 = MinFileSizeValidator(100)
|
||||
validator2 = MinFileSizeValidator(100)
|
||||
validator3 = MinFileSizeValidator(200)
|
||||
validator4 = MinFileSizeValidator(100, message="Custom")
|
||||
|
||||
assert validator1 == validator2
|
||||
assert validator1 != validator3
|
||||
assert validator1 != validator4
|
||||
|
||||
def test_max_file_size_validator_repr(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
assert repr(validator) == '<MaxFileSizeValidator(max_size=1024)>'
|
||||
|
||||
def test_min_file_size_validator_repr(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
assert repr(validator) == '<MinFileSizeValidator(min_size=100)>'
|
||||
|
||||
def test_max_file_size_validator_boundary_conditions(self):
|
||||
validator = MaxFileSizeValidator(1024)
|
||||
# One byte over
|
||||
file = self.DummyFile(1025)
|
||||
with pytest.raises(ValidationError):
|
||||
validator(file)
|
||||
# One byte under
|
||||
file = self.DummyFile(1023)
|
||||
validator(file) # Should not raise
|
||||
|
||||
def test_min_file_size_validator_boundary_conditions(self):
|
||||
validator = MinFileSizeValidator(100)
|
||||
# One byte under
|
||||
file = self.DummyFile(99)
|
||||
with pytest.raises(ValidationError):
|
||||
validator(file)
|
||||
# One byte over
|
||||
file = self.DummyFile(101)
|
||||
validator(file) # Should not raise
|
||||
|
||||
|
||||
class TestFileSizeValidatorIntegration(TestCase):
|
||||
def test_filefield_max_and_min_size(self):
|
||||
class FileSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(validators=[
|
||||
MaxFileSizeValidator(10),
|
||||
MinFileSizeValidator(5),
|
||||
])
|
||||
|
||||
# File of size 7 (should pass)
|
||||
file = SimpleUploadedFile('test.txt', b'1234567')
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
# File of size 4 (too small)
|
||||
file = SimpleUploadedFile('test.txt', b'1234')
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'min_file_size' in serializer.errors['file'][0].code
|
||||
|
||||
# File of size 12 (too large)
|
||||
file = SimpleUploadedFile('test.txt', b'123456789012')
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'max_file_size' in serializer.errors['file'][0].code
|
||||
|
||||
def test_filefield_max_size_only(self):
|
||||
class FileSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(validators=[
|
||||
MaxFileSizeValidator(1024),
|
||||
])
|
||||
|
||||
# File under limit (should pass)
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 512)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
# File over limit
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 2048)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'max_file_size' in serializer.errors['file'][0].code
|
||||
|
||||
def test_filefield_min_size_only(self):
|
||||
class FileSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(validators=[
|
||||
MinFileSizeValidator(100),
|
||||
])
|
||||
|
||||
# File over minimum (should pass)
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 200)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
# File under minimum
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 50)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'min_file_size' in serializer.errors['file'][0].code
|
||||
|
||||
def test_imagefield_max_size(self):
|
||||
class ImageSerializer(serializers.Serializer):
|
||||
image = serializers.ImageField(validators=[MaxFileSizeValidator(1024)])
|
||||
|
||||
# Create a small image in memory
|
||||
img = Image.new('RGB', (10, 10), color='red')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
image_bytes = buf.read()
|
||||
image_file = SimpleUploadedFile('test.png', image_bytes, content_type='image/png')
|
||||
serializer = ImageSerializer(data={'image': image_file})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
# Create a valid image, then pad it to exceed the size limit
|
||||
big_bytes = image_bytes + b'0' * (1025 - len(image_bytes))
|
||||
big_image_file = SimpleUploadedFile('big.png', big_bytes, content_type='image/png')
|
||||
serializer = ImageSerializer(data={'image': big_image_file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'max_file_size' in serializer.errors['image'][0].code
|
||||
|
||||
def test_imagefield_min_size(self):
|
||||
class ImageSerializer(serializers.Serializer):
|
||||
image = serializers.ImageField(validators=[MinFileSizeValidator(100)])
|
||||
|
||||
# Create a large image in memory
|
||||
img = Image.new('RGB', (50, 50), color='blue')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
image_bytes = buf.read()
|
||||
image_file = SimpleUploadedFile('test.png', image_bytes, content_type='image/png')
|
||||
serializer = ImageSerializer(data={'image': image_file})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
# Create a small image
|
||||
small_img = Image.new('RGB', (5, 5), color='green')
|
||||
small_buf = io.BytesIO()
|
||||
small_img.save(small_buf, format='PNG')
|
||||
small_buf.seek(0)
|
||||
small_image_bytes = small_buf.read()
|
||||
small_image_file = SimpleUploadedFile('small.png', small_image_bytes, content_type='image/png')
|
||||
serializer = ImageSerializer(data={'image': small_image_file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'min_file_size' in serializer.errors['image'][0].code
|
||||
|
||||
def test_multiple_validators_on_same_field(self):
|
||||
class FileSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(validators=[
|
||||
MaxFileSizeValidator(1000),
|
||||
MinFileSizeValidator(100),
|
||||
])
|
||||
|
||||
# Valid file
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 500)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
# Too small
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 50)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'min_file_size' in serializer.errors['file'][0].code
|
||||
|
||||
# Too large
|
||||
file = SimpleUploadedFile('test.txt', b'x' * 1500)
|
||||
serializer = FileSerializer(data={'file': file})
|
||||
assert not serializer.is_valid()
|
||||
assert 'max_file_size' in serializer.errors['file'][0].code
|
||||
|
|
Loading…
Reference in New Issue
Block a user