Use a custom dictionary class to safely skip substitution errors in ValidationError messages.

"%" substitution requires all keys to be matched during substitution, so if a ValidationError happens
to include a %(foo)s style variable not met by parameters, it will throw a KeyError. In Field.run_validators
there is also an accumulation of errors that are wrapped by a final ValidationError which can then
throw TypeError if any of the sub-errors contain replaceable substrings.

This patch implements a subclassed dict which simply returns the key's name for any missing keys. The end
result for the logic in exceptions.py is that the final message is an exact copy of the original message
with only found parameters replaced and the rest left untouched.

Signed-off-by: James Tanner <tanner.jc@gmail.com>
This commit is contained in:
James Tanner 2024-03-19 14:46:01 -04:00
parent 77ef27f18f
commit 93b677cbb6
No known key found for this signature in database
GPG Key ID: EB4645E850012701
2 changed files with 26 additions and 1 deletions

View File

@ -15,6 +15,11 @@ from rest_framework import status
from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList
class SafeReplacerDict(dict):
def __missing__(self, key):
return key
def _get_error_details(data, default_code=None):
"""
Descend into a nested data structure, forcing any
@ -144,7 +149,7 @@ class ValidationError(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_detail = _('Invalid input.')
default_code = 'invalid'
default_params = {}
default_params = SafeReplacerDict()
def __init__(self, detail=None, code=None, params=None):
if detail is None:
@ -157,6 +162,7 @@ class ValidationError(APIException):
# For validation failures, we may collect many errors together,
# so the details should always be coerced to a list if not already.
if isinstance(detail, str):
#import pdb; pdb.set_trace()
detail = [detail % params]
elif isinstance(detail, ValidationError):
detail = detail.detail
@ -166,6 +172,7 @@ class ValidationError(APIException):
if isinstance(detail_item, ValidationError):
final_detail += detail_item.detail
else:
#import pdb; pdb.set_trace()
final_detail += [detail_item % params if isinstance(detail_item, str) else detail_item]
detail = final_detail
elif not isinstance(detail, dict) and not isinstance(detail, list):

View File

@ -1,3 +1,4 @@
import pytest
from django.test import TestCase
from rest_framework import serializers, status
@ -195,3 +196,20 @@ class TestValidationErrorWithDjangoStyle(TestCase):
assert str(error.detail[1]) == 'Invalid value: 43'
assert str(error.detail[2]) == 'Invalid value: 44'
assert str(error.detail[3]) == 'Invalid value: 45'
def test_validation_error_without_params(self):
"""Ensure that substitutable errors can be emitted without params."""
# mimic the logic in fields.Field.run_validators by saving the exception
# detail into a list which will then be the detail for a new ValidationError.
# this should not throw a KeyError or a TypeError even though
# the string has a substitutable substring ...
errors = []
try:
raise ValidationError('%(user)s')
except ValidationError as exc:
errors.extend(exc.detail)
# ensure it raises the correct exception type as an input to a new ValidationError
with pytest.raises(ValidationError):
raise ValidationError(errors)