Skip to content

Commit 93b677c

Browse files
committed
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 <[email protected]>
1 parent 77ef27f commit 93b677c

File tree

2 files changed

+26
-1
lines changed

2 files changed

+26
-1
lines changed

rest_framework/exceptions.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList
1616

1717

18+
class SafeReplacerDict(dict):
19+
def __missing__(self, key):
20+
return key
21+
22+
1823
def _get_error_details(data, default_code=None):
1924
"""
2025
Descend into a nested data structure, forcing any
@@ -144,7 +149,7 @@ class ValidationError(APIException):
144149
status_code = status.HTTP_400_BAD_REQUEST
145150
default_detail = _('Invalid input.')
146151
default_code = 'invalid'
147-
default_params = {}
152+
default_params = SafeReplacerDict()
148153

149154
def __init__(self, detail=None, code=None, params=None):
150155
if detail is None:
@@ -157,6 +162,7 @@ def __init__(self, detail=None, code=None, params=None):
157162
# For validation failures, we may collect many errors together,
158163
# so the details should always be coerced to a list if not already.
159164
if isinstance(detail, str):
165+
#import pdb; pdb.set_trace()
160166
detail = [detail % params]
161167
elif isinstance(detail, ValidationError):
162168
detail = detail.detail
@@ -166,6 +172,7 @@ def __init__(self, detail=None, code=None, params=None):
166172
if isinstance(detail_item, ValidationError):
167173
final_detail += detail_item.detail
168174
else:
175+
#import pdb; pdb.set_trace()
169176
final_detail += [detail_item % params if isinstance(detail_item, str) else detail_item]
170177
detail = final_detail
171178
elif not isinstance(detail, dict) and not isinstance(detail, list):

tests/test_validation_error.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from django.test import TestCase
23

34
from rest_framework import serializers, status
@@ -195,3 +196,20 @@ def test_validation_error_details_validation_errors_nested_list(self):
195196
assert str(error.detail[1]) == 'Invalid value: 43'
196197
assert str(error.detail[2]) == 'Invalid value: 44'
197198
assert str(error.detail[3]) == 'Invalid value: 45'
199+
200+
def test_validation_error_without_params(self):
201+
"""Ensure that substitutable errors can be emitted without params."""
202+
203+
# mimic the logic in fields.Field.run_validators by saving the exception
204+
# detail into a list which will then be the detail for a new ValidationError.
205+
# this should not throw a KeyError or a TypeError even though
206+
# the string has a substitutable substring ...
207+
errors = []
208+
try:
209+
raise ValidationError('%(user)s')
210+
except ValidationError as exc:
211+
errors.extend(exc.detail)
212+
213+
# ensure it raises the correct exception type as an input to a new ValidationError
214+
with pytest.raises(ValidationError):
215+
raise ValidationError(errors)

0 commit comments

Comments
 (0)