Skip to content

Commit 9f2cf2c

Browse files
committed
Create formatters
1 parent 6aad2f0 commit 9f2cf2c

File tree

2 files changed

+250
-0
lines changed

2 files changed

+250
-0
lines changed

drf_simple_api_errors/formatter.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import copy
2+
import logging
3+
from dataclasses import asdict, dataclass, field as dataclass_field
4+
from typing import Dict, List, Optional
5+
6+
from django.utils.translation import gettext_lazy as _
7+
from rest_framework import exceptions
8+
from rest_framework.settings import api_settings as drf_api_settings
9+
10+
from drf_simple_api_errors.settings import api_settings
11+
from drf_simple_api_errors.types import APIErrorResponseDict
12+
from drf_simple_api_errors.utils import camelize, flatten_dict
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@dataclass
18+
class InvalidParam:
19+
"""
20+
A class representing an invalid parameter in the API error response.
21+
22+
This is used within the `APIErrorResponse` class to represent
23+
parameters that are invalid and have errors associated with them.
24+
"""
25+
26+
name: str
27+
reason: List[str]
28+
29+
30+
@dataclass
31+
class APIErrorResponse:
32+
"""
33+
A class representing the API error response structure.
34+
35+
It includes:
36+
- title: A short, human-readable summary of the error
37+
- detail: A more detailed explanation of the error, if any
38+
- invalid_params: A list of invalid parameters, if any
39+
"""
40+
41+
title: str = dataclass_field(init=False)
42+
detail: Optional[List[str]] = dataclass_field(default=None)
43+
invalid_params: Optional[List[InvalidParam]] = dataclass_field(default=None)
44+
45+
def to_dict(self) -> APIErrorResponseDict:
46+
"""Convert the APIErrorResponse instance to a dictionary."""
47+
response_dict = asdict(self)
48+
49+
if api_settings.CAMELIZE:
50+
for key in list(response_dict.keys()):
51+
response_dict[camelize(key)] = response_dict.pop(key)
52+
53+
return response_dict
54+
55+
56+
def format_exc(exc: exceptions.APIException) -> APIErrorResponseDict:
57+
"""
58+
Format the exception into a structured API error response.
59+
60+
Args:
61+
exc (APIException): The exception to format.
62+
Returns:
63+
APIErrorResponseDict:
64+
A structured dictionary representing the API error response.
65+
"""
66+
data = APIErrorResponse()
67+
68+
# Set the API error response...
69+
if isinstance(exc, exceptions.ValidationError):
70+
# If the exception is a ValidationError,
71+
# set the title to "Validation Error."
72+
data.title = _("Validation error.")
73+
else:
74+
# If the exception is not a ValidationError,
75+
# set the title to its default detail, e.g. "Not Found."
76+
data.title = exc.default_detail
77+
if _is_exc_detail_same_as_default_detail(exc):
78+
# If the exception detail is the same as the default detail,
79+
# we don't need to format it and return it as is, because
80+
# it is not providing any additional information about the error.
81+
return data.to_dict()
82+
83+
# Extract the exception detail based on the type of exception.
84+
# There are cases where the exc detail is a str, e.g. APIException("Error"),
85+
# we will convert it to a list so that we can handle it uniformly.
86+
exc_detail = exc.detail if not isinstance(exc.detail, str) else [exc.detail]
87+
logger.debug("'exc_detail' is instance of %s" % type(exc_detail))
88+
# Create the API error response based on the exception detail...
89+
if isinstance(exc_detail, dict):
90+
return _format_exc_detail_dict(data, exc_detail)
91+
elif isinstance(exc_detail, list):
92+
# If the exception detail is a list, we will return all the errors
93+
# in a single list.
94+
return _format_exc_detail_list(data, exc_detail)
95+
else:
96+
return data.to_dict()
97+
98+
99+
def _format_exc_detail_dict(
100+
data: APIErrorResponse, exc_detail: Dict
101+
) -> APIErrorResponseDict:
102+
"""
103+
Handle the exception detail as a dictionary.
104+
105+
Args:
106+
data (APIErrorResponse): The data dictionary to update.
107+
exc_detail (dict): The exception detail dictionary.
108+
109+
Returns:
110+
APIErrorResponseDict: The updated `data` dictionary.
111+
"""
112+
# Start by flattening the exc dict.
113+
# This is necessary as the exception detail can be nested and
114+
# we want to flatten it to a single level dict as part of this library design.
115+
exc_detail = flatten_dict(copy.deepcopy(exc_detail))
116+
117+
# Track the invalid params.
118+
# This represents the fields that are invalid and have errors associated with them.
119+
invalid_params = []
120+
# Track the non-field errors.
121+
# This represents the errors that are not associated with any specific field.
122+
# For example, this happens when an error is raised on the serializer level
123+
# and not on the field level, e.g. in Serializer.validate() method.
124+
non_field_errors = []
125+
# Now gather the errors by iterating over the exception detail.
126+
for field, error in exc_detail.items():
127+
if field in [drf_api_settings.NON_FIELD_ERRORS_KEY, "__all__"]:
128+
# We are first going to check if field represents a non-field error.
129+
# These errors are usually general and not associated with any field.
130+
if isinstance(error, list):
131+
non_field_errors.extend(error)
132+
else:
133+
non_field_errors.append(error)
134+
else:
135+
# Otherwise, we will treat it as an invalid param.
136+
# N.B. If the error is a string, we will convert it to a list
137+
# to keep the consistency with the InvalidParamDict type.
138+
invalid_param = InvalidParam(
139+
name=field if not api_settings.CAMELIZE else camelize(field),
140+
reason=error if isinstance(error, list) else [error],
141+
)
142+
invalid_params.append(invalid_param)
143+
144+
if invalid_params:
145+
data.invalid_params = invalid_params
146+
147+
if non_field_errors:
148+
data.detail = non_field_errors
149+
150+
return data.to_dict()
151+
152+
153+
def _format_exc_detail_list(
154+
data: APIErrorResponse, exc_detail: List
155+
) -> APIErrorResponseDict:
156+
"""
157+
Handle the exception detail as a list.
158+
159+
Args:
160+
data (APIErrorResponse): The data dictionary to update.
161+
exc_detail (list): The exception detail list.
162+
163+
Returns:
164+
APIErrorResponseDict: The updated `data` dictionary.
165+
"""
166+
detail = []
167+
168+
for error in exc_detail:
169+
if isinstance(error, str):
170+
detail.append(error)
171+
elif isinstance(error, list):
172+
detail.extend(error)
173+
else:
174+
# This is necessary as there is definitely something unexpected
175+
# in the exc detail!
176+
# This could be a potential bug in the code or a new feature in DRF/Django.
177+
# Please report this to the maintainer if this ever happens
178+
raise TypeError(
179+
"Unexpected type for error in exception detail. "
180+
"Expected str or list, got %s.",
181+
type(error),
182+
)
183+
184+
if detail:
185+
data.detail = detail
186+
187+
return data.to_dict()
188+
189+
190+
def _is_exc_detail_same_as_default_detail(exc: exceptions.APIException) -> bool:
191+
"""
192+
Check if the exception detail is the same as the default detail.
193+
194+
The default detail is the message that is the generic message
195+
for the exception type.
196+
For example, the default detail for `NotFound` is "Not found.", so
197+
if the detail is the same as the default detail, we can assume that
198+
the exception is not providing any additional information about the error and
199+
we can ignore it.
200+
201+
Args:
202+
exc (APIException): The exception to check.
203+
204+
Returns:
205+
bool:
206+
True if the exception detail is the same as the default detail,
207+
False otherwise.
208+
"""
209+
return (isinstance(exc.detail, str) and exc.detail == exc.default_detail) or (
210+
isinstance(exc.detail, list)
211+
and len(exc.detail) == 1
212+
and isinstance(exc.detail[0], str)
213+
and exc.detail[0] == exc.default_detail
214+
)

drf_simple_api_errors/types.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from typing import Dict, List, Optional, Tuple, TypedDict
2+
3+
from rest_framework.request import Request
4+
from rest_framework.views import APIView
5+
6+
7+
class ExceptionHandlerContext(TypedDict):
8+
"""
9+
The base interface for the context of the exception handler.
10+
11+
This is passed to the exception handler function and contains
12+
information about the request and view.
13+
"""
14+
15+
view: APIView
16+
args: Tuple
17+
kwargs: Dict
18+
request: Optional[Request]
19+
20+
21+
class InvalidParamDict(TypedDict):
22+
"""The base interface for the invalid parameters in the API error response."""
23+
24+
name: str
25+
reason: List[str]
26+
27+
28+
class APIErrorResponseDict(TypedDict):
29+
"""
30+
The base interface for the API error response.
31+
This is the response returned by the exception handler.
32+
"""
33+
34+
title: str
35+
detail: Optional[List[str]]
36+
invalid_params: Optional[List[InvalidParamDict]]

0 commit comments

Comments
 (0)