Skip to content

Commit 48a5840

Browse files
Merge pull request #35 from MEHRSHAD-MIRSHEKARY/fix/exception-handler
Fix/exception handler
2 parents 9985703 + f374319 commit 48a5840

File tree

7 files changed

+64
-20
lines changed

7 files changed

+64
-20
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ Here are the default settings that are automatically applied:
300300
# settings.py
301301
302302
RESPONSE_SHAPER_DEBUG_MODE = False
303+
RESPONSE_SHAPER_RETURN_ERROR_AS_DICT = True
303304
RESPONSE_SHAPER_EXCLUDED_PATHS = ["/admin/", "/schema/swagger-ui/", "/schema/redoc/", "/schema/"]
304305
RESPONSE_SHAPER_SUCCESS_HANDLER = ""
305306
RESPONSE_SHAPER_ERROR_HANDLER = ""
@@ -313,6 +314,24 @@ RESPONSE_SHAPER_ERROR_HANDLER = ""
313314
- **Description**: When set to `True`, disables response shaping for debugging purposes.
314315
- **Default**: `False`
315316

317+
`RESPONSE_SHAPER_RETURN_ERROR_AS_DICT`
318+
--------------------------------------
319+
320+
- **Type**: `bool`
321+
- **Description**: Controls the format of dict error messages extracted by the ExceptionHandler. When `True`, errors with **nested dictionary** structure are returned as a dictionary containing the innermost key-value pair from nested error structures. When `False`, only the innermost error message is returned as a string. This applies to error responses shaped by the handler, particularly for validation.
322+
- **Default**: `True`
323+
324+
**Example**:
325+
```python
326+
# With RESPONSE_SHAPER_RETURN_ERROR_AS_DICT = True
327+
error_input = {"field": {"detail": {"code": "invalid"}}}
328+
# Result: {"code": "invalid"}
329+
330+
# With RESPONSE_SHAPER_RETURN_ERROR_AS_DICT = False
331+
error_input = {"field": {"detail": {"code": "invalid"}}}
332+
# Result: "invalid"
333+
```
334+
316335
`RESPONSE_SHAPER_EXCLUDED_PATHS`
317336
--------------------------------
318337

response_shaper/exceptions.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
)
2727
from django.http import JsonResponse
2828

29+
from response_shaper.settings.conf import response_shaper_config
30+
2931

3032
class ExceptionHandler:
3133
"""Handles exception responses consistently across the application.
@@ -165,28 +167,38 @@ def extract_first_error(error_data: Any) -> Union[Any, Dict]:
165167
"""Extract the first error message from various data structures (dict,
166168
list, string). Stops at the first error encountered.
167169
168-
This method is useful for extracting the first error message from
169-
complex error data structures, such as those returned by Django's
170-
validation framework.
170+
Behavior is controlled by settings.EXTRACT_ERROR_AS_DICT:
171+
- If True: Returns the innermost dict with a string value
172+
- If False: Returns the first string value
171173
172174
Args:
173175
error_data (Any): The error data structure, which can be a string,
174176
list, or dictionary.
175177
176178
Returns:
177-
Union[str, dict]: The extracted error message or structure. If the
178-
input is a list, it returns the first element. If the input is
179-
a dictionary, it returns the first key-value pair. If the input
180-
is a string, it returns the string itself.
179+
Union[str, dict]: The extracted error message or structure based on settings.
181180
182181
"""
183182
if isinstance(error_data, str):
184183
return error_data
185184
if isinstance(error_data, list) and error_data:
186185
return ExceptionHandler.extract_first_error(error_data[0])
187186
if isinstance(error_data, dict):
188-
for key, value in error_data.items():
189-
return {key: ExceptionHandler.extract_first_error(value)}
187+
if not error_data:
188+
return str(error_data)
189+
first_key = next(iter(error_data))
190+
first_value = error_data[first_key]
191+
192+
# Recursively process the value
193+
extracted = ExceptionHandler.extract_first_error(first_value)
194+
195+
if response_shaper_config.return_dict_error:
196+
# If the extracted result is a string, return it as a single key-value dict
197+
if isinstance(extracted, str):
198+
return {first_key: extracted}
199+
# If it's already a dict (from deeper recursion), return it as-is
200+
return extracted
201+
return extracted
190202
return str(error_data)
191203

192204
@staticmethod

response_shaper/settings/check.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ def check_response_shaper_settings(app_configs: Any, **kwargs: Any) -> List[Erro
3232
response_shaper_config.debug, "RESPONSE_SHAPER_DEBUG_MODE"
3333
)
3434
)
35+
errors.extend(
36+
validate_boolean_setting(
37+
response_shaper_config.return_dict_error,
38+
"RESPONSE_SHAPER_RETURN_ERROR_AS_DICT",
39+
)
40+
)
3541

3642
# Validate optional excluded path settings
3743
errors.extend(

response_shaper/settings/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ class ResponseShaperConfig:
1414
def __init__(self) -> None:
1515
self.config_prefix = "RESPONSE_SHAPER_"
1616
self.debug = self.get_setting(f"{self.config_prefix}DEBUG_MODE", False)
17+
self.return_dict_error = self.get_setting(
18+
f"{self.config_prefix}RETURN_ERROR_AS_DICT", True
19+
)
1720
self.excluded_paths = self.get_setting(
1821
f"{self.config_prefix}EXCLUDED_PATHS",
1922
["/admin/", "/schema/swagger-ui/", "/schema/redoc/", "/schema/"],

response_shaper/tests/test_exceptions.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,12 @@ def test_extract_first_error(self):
125125
)
126126

127127
# Test with a dictionary
128-
assert ExceptionHandler.extract_first_error({"field": ["Invalid value"]}) == {
129-
"field": "Invalid value"
130-
}
128+
assert ExceptionHandler.extract_first_error({"field": ["Invalid value"]}) == {"field": "Invalid value"}
131129

132130
# Test with nested structures
133131
assert ExceptionHandler.extract_first_error(
134132
{"field": [{"nested": "Invalid value"}]}
135-
) == {"field": {"nested": "Invalid value"}}
133+
) == {"nested": "Invalid value"}
136134

137135
def test_get_detailed_error_info(self):
138136
"""Test the _get_detailed_error_info method in debug mode."""

response_shaper/tests/test_middleware.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def custom_error_handler(response):
290290
response.data = {"key": "value"}
291291
response = middleware._default_error_handler(response)
292292
response_data = self.parse_json_response(response)
293-
assert response_data["error"] == {"key": "value"}
293+
assert response_data["error"] == {'key': 'value'}
294294

295295
def test_process_object_does_not_exist_exception(
296296
self, request_factory: RequestFactory, get_response: Callable
@@ -356,17 +356,19 @@ def test_extract_dict_of_errors(self) -> None:
356356
"""
357357
Test that the middleware correctly extracts the first error from a dictionary of errors.
358358
"""
359+
response_shaper_config.return_dict_error = False
359360
errors = {"field1": "Field1 error", "field2": "Field2 error"}
360361
result = ExceptionHandler.extract_first_error(errors)
361-
assert result == {"field1": "Field1 error"}
362+
assert result == "Field1 error"
362363

363364
def test_extract_nested_dict_of_errors(self) -> None:
364365
"""
365366
Test that the middleware correctly extracts the first error from a nested dictionary of errors.
366367
"""
368+
response_shaper_config.return_dict_error = True
367369
errors = {"field1": {"subfield": "Subfield error"}, "field2": "Field2 error"}
368370
result = ExceptionHandler.extract_first_error(errors)
369-
assert result == {"field1": {"subfield": "Subfield error"}}
371+
assert result == {"subfield": "Subfield error"}
370372

371373
def test_extract_empty_list(self) -> None:
372374
"""
@@ -396,7 +398,7 @@ def test_extract_complex_structure(self) -> None:
396398
"field2": "Field2 error",
397399
}
398400
result = ExceptionHandler.extract_first_error(errors)
399-
assert result == {"field1": {"subfield": "Subfield error"}}
401+
assert result == {"subfield": "Subfield error"}
400402

401403
def test_skip_non_json_content_type(
402404
self, request_factory: RequestFactory, get_response: Callable

response_shaper/tests/test_settings.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def test_valid_settings(self, mock_config: MagicMock) -> None:
2626
"""
2727
# Mock valid configuration values
2828
mock_config.debug = True
29+
mock_config.return_dict_error = True
2930
mock_config.excluded_paths = ["/admin/"]
3031
mock_config.success_handler = "myapp.handlers.custom_success_handler"
3132
mock_config.error_handler = "myapp.handlers.custom_error_handler"
@@ -47,25 +48,26 @@ def test_invalid_settings(self, mock_config: MagicMock) -> None:
4748
"""
4849
# Mock invalid settings
4950
mock_config.debug = "invalid_bool" # must be bool
51+
mock_config.return_dict_error = "invalid_bool" # must be bool
5052
mock_config.excluded_paths = {} # must be a list
5153
mock_config.success_handler = "myapp.handlers.custom_success_handler"
5254
mock_config.error_handler = "myapp.handlers.custom_error_handler"
5355

5456
# Run check and assert there is an error
5557
errors = check_response_shaper_settings(None)
56-
assert len(errors) == 2
58+
assert len(errors) == 3
5759
assert errors[0].id == "response_shaper.E001.RESPONSE_SHAPER_DEBUG_MODE"
5860
assert "should be a boolean value" in errors[0].msg
5961

6062
mock_config.excluded_paths = [1, 2]
6163
errors = check_response_shaper_settings(None)
6264

63-
assert len(errors) == 2
65+
assert len(errors) == 3
6466

6567
mock_config.excluded_paths = ["admin"] # path without /
6668
errors = check_response_shaper_settings(None)
6769

68-
assert len(errors) == 2
70+
assert len(errors) == 3
6971

7072
@patch("response_shaper.settings.check.response_shaper_config")
7173
def test_invalid_class_path_settings(self, mock_config: MagicMock) -> None:
@@ -80,6 +82,7 @@ def test_invalid_class_path_settings(self, mock_config: MagicMock) -> None:
8082
"""
8183
# Mock invalid class path settings
8284
mock_config.debug = True
85+
mock_config.return_dict_error = True
8386
mock_config.excluded_paths = ["/admin/"]
8487
mock_config.success_handler = 123 # invalid type
8588
mock_config.error_handler = 456 # invalid type
@@ -104,6 +107,7 @@ def test_missing_optional_settings(self, mock_config: MagicMock) -> None:
104107
"""
105108
# Mock valid optional settings (None or empty)
106109
mock_config.debug = True
110+
mock_config.return_dict_error = True
107111
mock_config.excluded_paths = ["/admin/"]
108112
mock_config.success_handler = None # Optional and valid
109113
mock_config.error_handler = "" # Empty string is valid

0 commit comments

Comments
 (0)