From 9f2cf2c80577c66888a04caa6131bfd451bd8848 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:09:27 +0100 Subject: [PATCH 01/14] Create formatters --- drf_simple_api_errors/formatter.py | 214 +++++++++++++++++++++++++++++ drf_simple_api_errors/types.py | 36 +++++ 2 files changed, 250 insertions(+) create mode 100644 drf_simple_api_errors/formatter.py create mode 100644 drf_simple_api_errors/types.py diff --git a/drf_simple_api_errors/formatter.py b/drf_simple_api_errors/formatter.py new file mode 100644 index 0000000..8ead662 --- /dev/null +++ b/drf_simple_api_errors/formatter.py @@ -0,0 +1,214 @@ +import copy +import logging +from dataclasses import asdict, dataclass, field as dataclass_field +from typing import Dict, List, Optional + +from django.utils.translation import gettext_lazy as _ +from rest_framework import exceptions +from rest_framework.settings import api_settings as drf_api_settings + +from drf_simple_api_errors.settings import api_settings +from drf_simple_api_errors.types import APIErrorResponseDict +from drf_simple_api_errors.utils import camelize, flatten_dict + +logger = logging.getLogger(__name__) + + +@dataclass +class InvalidParam: + """ + A class representing an invalid parameter in the API error response. + + This is used within the `APIErrorResponse` class to represent + parameters that are invalid and have errors associated with them. + """ + + name: str + reason: List[str] + + +@dataclass +class APIErrorResponse: + """ + A class representing the API error response structure. + + It includes: + - title: A short, human-readable summary of the error + - detail: A more detailed explanation of the error, if any + - invalid_params: A list of invalid parameters, if any + """ + + title: str = dataclass_field(init=False) + detail: Optional[List[str]] = dataclass_field(default=None) + invalid_params: Optional[List[InvalidParam]] = dataclass_field(default=None) + + def to_dict(self) -> APIErrorResponseDict: + """Convert the APIErrorResponse instance to a dictionary.""" + response_dict = asdict(self) + + if api_settings.CAMELIZE: + for key in list(response_dict.keys()): + response_dict[camelize(key)] = response_dict.pop(key) + + return response_dict + + +def format_exc(exc: exceptions.APIException) -> APIErrorResponseDict: + """ + Format the exception into a structured API error response. + + Args: + exc (APIException): The exception to format. + Returns: + APIErrorResponseDict: + A structured dictionary representing the API error response. + """ + data = APIErrorResponse() + + # Set the API error response... + if isinstance(exc, exceptions.ValidationError): + # If the exception is a ValidationError, + # set the title to "Validation Error." + data.title = _("Validation error.") + else: + # If the exception is not a ValidationError, + # set the title to its default detail, e.g. "Not Found." + data.title = exc.default_detail + if _is_exc_detail_same_as_default_detail(exc): + # If the exception detail is the same as the default detail, + # we don't need to format it and return it as is, because + # it is not providing any additional information about the error. + return data.to_dict() + + # Extract the exception detail based on the type of exception. + # There are cases where the exc detail is a str, e.g. APIException("Error"), + # we will convert it to a list so that we can handle it uniformly. + exc_detail = exc.detail if not isinstance(exc.detail, str) else [exc.detail] + logger.debug("'exc_detail' is instance of %s" % type(exc_detail)) + # Create the API error response based on the exception detail... + if isinstance(exc_detail, dict): + return _format_exc_detail_dict(data, exc_detail) + elif isinstance(exc_detail, list): + # If the exception detail is a list, we will return all the errors + # in a single list. + return _format_exc_detail_list(data, exc_detail) + else: + return data.to_dict() + + +def _format_exc_detail_dict( + data: APIErrorResponse, exc_detail: Dict +) -> APIErrorResponseDict: + """ + Handle the exception detail as a dictionary. + + Args: + data (APIErrorResponse): The data dictionary to update. + exc_detail (dict): The exception detail dictionary. + + Returns: + APIErrorResponseDict: The updated `data` dictionary. + """ + # Start by flattening the exc dict. + # This is necessary as the exception detail can be nested and + # we want to flatten it to a single level dict as part of this library design. + exc_detail = flatten_dict(copy.deepcopy(exc_detail)) + + # Track the invalid params. + # This represents the fields that are invalid and have errors associated with them. + invalid_params = [] + # Track the non-field errors. + # This represents the errors that are not associated with any specific field. + # For example, this happens when an error is raised on the serializer level + # and not on the field level, e.g. in Serializer.validate() method. + non_field_errors = [] + # Now gather the errors by iterating over the exception detail. + for field, error in exc_detail.items(): + if field in [drf_api_settings.NON_FIELD_ERRORS_KEY, "__all__"]: + # We are first going to check if field represents a non-field error. + # These errors are usually general and not associated with any field. + if isinstance(error, list): + non_field_errors.extend(error) + else: + non_field_errors.append(error) + else: + # Otherwise, we will treat it as an invalid param. + # N.B. If the error is a string, we will convert it to a list + # to keep the consistency with the InvalidParamDict type. + invalid_param = InvalidParam( + name=field if not api_settings.CAMELIZE else camelize(field), + reason=error if isinstance(error, list) else [error], + ) + invalid_params.append(invalid_param) + + if invalid_params: + data.invalid_params = invalid_params + + if non_field_errors: + data.detail = non_field_errors + + return data.to_dict() + + +def _format_exc_detail_list( + data: APIErrorResponse, exc_detail: List +) -> APIErrorResponseDict: + """ + Handle the exception detail as a list. + + Args: + data (APIErrorResponse): The data dictionary to update. + exc_detail (list): The exception detail list. + + Returns: + APIErrorResponseDict: The updated `data` dictionary. + """ + detail = [] + + for error in exc_detail: + if isinstance(error, str): + detail.append(error) + elif isinstance(error, list): + detail.extend(error) + else: + # This is necessary as there is definitely something unexpected + # in the exc detail! + # This could be a potential bug in the code or a new feature in DRF/Django. + # Please report this to the maintainer if this ever happens + raise TypeError( + "Unexpected type for error in exception detail. " + "Expected str or list, got %s.", + type(error), + ) + + if detail: + data.detail = detail + + return data.to_dict() + + +def _is_exc_detail_same_as_default_detail(exc: exceptions.APIException) -> bool: + """ + Check if the exception detail is the same as the default detail. + + The default detail is the message that is the generic message + for the exception type. + For example, the default detail for `NotFound` is "Not found.", so + if the detail is the same as the default detail, we can assume that + the exception is not providing any additional information about the error and + we can ignore it. + + Args: + exc (APIException): The exception to check. + + Returns: + bool: + True if the exception detail is the same as the default detail, + False otherwise. + """ + return (isinstance(exc.detail, str) and exc.detail == exc.default_detail) or ( + isinstance(exc.detail, list) + and len(exc.detail) == 1 + and isinstance(exc.detail[0], str) + and exc.detail[0] == exc.default_detail + ) diff --git a/drf_simple_api_errors/types.py b/drf_simple_api_errors/types.py new file mode 100644 index 0000000..5914a55 --- /dev/null +++ b/drf_simple_api_errors/types.py @@ -0,0 +1,36 @@ +from typing import Dict, List, Optional, Tuple, TypedDict + +from rest_framework.request import Request +from rest_framework.views import APIView + + +class ExceptionHandlerContext(TypedDict): + """ + The base interface for the context of the exception handler. + + This is passed to the exception handler function and contains + information about the request and view. + """ + + view: APIView + args: Tuple + kwargs: Dict + request: Optional[Request] + + +class InvalidParamDict(TypedDict): + """The base interface for the invalid parameters in the API error response.""" + + name: str + reason: List[str] + + +class APIErrorResponseDict(TypedDict): + """ + The base interface for the API error response. + This is the response returned by the exception handler. + """ + + title: str + detail: Optional[List[str]] + invalid_params: Optional[List[InvalidParamDict]] From b3c620c3821d65cb87069db5783b0a115ff9df03 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:11:07 +0100 Subject: [PATCH 02/14] Refactor exception_handler to use formatter over handlers --- drf_simple_api_errors/exception_handler.py | 125 +++++++++++++++------ drf_simple_api_errors/utils.py | 2 +- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/drf_simple_api_errors/exception_handler.py b/drf_simple_api_errors/exception_handler.py index af27a52..689fe50 100644 --- a/drf_simple_api_errors/exception_handler.py +++ b/drf_simple_api_errors/exception_handler.py @@ -1,53 +1,120 @@ import logging +from typing import Dict, Union from django.core.exceptions import ( PermissionDenied, ValidationError as DjangoValidationError, ) from django.http import Http404 -from rest_framework import exceptions, status +from rest_framework import exceptions from rest_framework.response import Response from rest_framework.serializers import as_serializer_error from rest_framework.views import set_rollback -from .handlers import exc_detail_handler, is_exc_detail_same_as_default_detail -from .settings import api_settings +from drf_simple_api_errors import formatter +from drf_simple_api_errors.exceptions import ServerError +from drf_simple_api_errors.settings import api_settings +from drf_simple_api_errors.types import ExceptionHandlerContext logger = logging.getLogger(__name__) -def exception_handler(exc, context): +def exception_handler(exc: Exception, context: ExceptionHandlerContext) -> Response: """ - Returns the response that should be used for any given exception. + Custom exception handler for DRF. - By default this handles any REST framework `APIException`, and also - Django's built-in `ValidationError`, `Http404` and `PermissionDenied` exceptions. + This function handles exceptions and formats them into a structured API response, + including Django exceptions. It also applies any extra handlers defined in the + settings. - Any unhandled exceptions will log the exception message, and - will cause a 500 error response. + The function will not handle exceptions that are not instances of or + can be converted to DRF `APIException`. + + Args: + exc (Exception): The exception raised. + context (ExceptionHandlerContext): The context of the exception. + + Returns: + Response: The formatted API response. """ - if isinstance(exc, DjangoValidationError): - exc = exceptions.ValidationError(as_serializer_error(exc)) + # This allows for custom exception handling logic. + # If other kinds of exceptions are raised and should be handled, + # they can be added to the EXTRA_HANDLERS setting. + _apply_extra_handlers(exc) - if isinstance(exc, Http404): - exc = exceptions.NotFound() + # If the exception is not an instance of APIException, we can try to convert it + # to DRF APIException if it's a Django exception. + exc = _convert_django_exc_to_drf_api_exc(exc) + # If the exception is still not an instance of APIException, thus could be + # converted to one, we cannot handle it. + # This will result in a 500 error response without any detail. + # This is because it's not good practice to expose the details of + # unhandled exceptions to the client. + if not isinstance(exc, exceptions.APIException): + logger.debug("Server error", exc_info=True) + return ServerError - if isinstance(exc, PermissionDenied): - exc = exceptions.PermissionDenied() + # Get the API response headers from the exception. + headers = _get_response_headers(exc) + # Get the API response data from the exception. + # If the exception is an instance of APIException, we can handle it and + # will format it to a structured API response data. + data = formatter.format_exc(exc) + # Set the rollback flag to True, if the transaction is atomic. + set_rollback() + # Finally, return the API response \(◕ ◡ ◕\) + return Response(data, status=exc.status_code, headers=headers) + +def _apply_extra_handlers(exc: Exception): + """ + Apply any extra exception handlers defined in the settings. + + Args: + exc (Exception): The exception to handle. + """ extra_handlers = api_settings.EXTRA_HANDLERS if extra_handlers: for handler in extra_handlers: handler(exc) - # unhandled exceptions, which should raise a 500 error and log the exception - if not isinstance(exc, exceptions.APIException): - logger.exception(exc) - data = {"title": "Server error."} - return Response(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # from DRF +def _convert_django_exc_to_drf_api_exc( + exc: Exception, +) -> Union[exceptions.APIException, Exception]: + """ + Convert Django exceptions to DRF APIException, if possible. + + Args: + exc (Exception): The exception to convert. + + Returns: + exceptions.APIException | Exception: The converted exception or the original. + """ + if isinstance(exc, DjangoValidationError): + return exceptions.ValidationError(as_serializer_error(exc)) + + if isinstance(exc, Http404): + return exceptions.NotFound() + + if isinstance(exc, PermissionDenied): + return exceptions.PermissionDenied() + + return exc + + +def _get_response_headers(exc: exceptions.APIException) -> Dict: + """ + Get the response headers for the given exception. + + Args: + exc (exceptions.APIException): The exception to get headers for. + + Returns: + dict: A dictionary containing the response headers. + """ + # This is from DRF's default exception handler. # https://github.com/encode/django-rest-framework/blob/48a21aa0eb3a95d32456c2a927eff9552a04231e/rest_framework/views.py#L87-L91 headers = {} if getattr(exc, "auth_header", None): @@ -55,18 +122,4 @@ def exception_handler(exc, context): if getattr(exc, "wait", None): headers["Retry-After"] = "%d" % exc.wait - data = {} - if isinstance(exc.detail, (list, dict)) and isinstance( - exc, exceptions.ValidationError - ): - data["title"] = "Validation error." - exc_detail_handler(data, exc.detail) - else: - data["title"] = exc.default_detail - if not is_exc_detail_same_as_default_detail(exc): - exc_detail_handler( - data, [exc.detail] if isinstance(exc.detail, str) else exc.detail - ) - - set_rollback() - return Response(data, status=exc.status_code, headers=headers) + return headers diff --git a/drf_simple_api_errors/utils.py b/drf_simple_api_errors/utils.py index ea8f1f2..b7ce7ef 100644 --- a/drf_simple_api_errors/utils.py +++ b/drf_simple_api_errors/utils.py @@ -1,6 +1,6 @@ import re -from .settings import api_settings +from drf_simple_api_errors.settings import api_settings def camelize(field: str) -> str: From f19e68156ebfbd0a218efc4fba0084aedd99cae3 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:11:28 +0100 Subject: [PATCH 03/14] Remove handlers --- drf_simple_api_errors/handlers.py | 82 ------------------------------- 1 file changed, 82 deletions(-) delete mode 100644 drf_simple_api_errors/handlers.py diff --git a/drf_simple_api_errors/handlers.py b/drf_simple_api_errors/handlers.py deleted file mode 100644 index 07eea7a..0000000 --- a/drf_simple_api_errors/handlers.py +++ /dev/null @@ -1,82 +0,0 @@ -import copy -import logging -from typing import Dict, List, Union - -from rest_framework.exceptions import APIException -from rest_framework.settings import api_settings as drf_api_settings - -from .settings import api_settings -from .utils import camelize, flatten_dict - -logger = logging.getLogger(__name__) - - -def exc_detail_handler(data: Dict, exc_detail: Union[Dict, List]) -> Dict: - """ - Handle the exception detail and set it to the `data` dictionary. - - If the `exc_detail` is a dictionary, it will set to the `data` dictionary. - If the `exc_detail` is a list, it will be set to the `data` dictionary. - """ - logger.debug("`exc_detail` is instance of %s" % type(exc_detail)) - - if isinstance(exc_detail, dict): - __exc_detail_as_dict_handler(data, exc_detail) - elif isinstance(exc_detail, list): - __exc_detail_as_list_handler(data, exc_detail) - - return data - - -def __exc_detail_as_dict_handler(data: Dict, exc_detail: Dict): - """Handle the exception detail as a dictionary.""" - exc_detail = flatten_dict(copy.deepcopy(exc_detail)) - - invalid_params = [] - non_field_errors = [] - for field, error in exc_detail.items(): - error_detail = {} - - reason = error if not isinstance(error, list) or len(error) > 1 else error[0] - - if field in {drf_api_settings.NON_FIELD_ERRORS_KEY, "__all__"}: - if isinstance(reason, list): - non_field_errors.extend(reason) - else: - non_field_errors.append(reason) - else: - error_detail["name"] = ( - field if not api_settings.CAMELIZE else camelize(field) - ) - if isinstance(reason, list): - error_detail["reason"] = reason - else: - error_detail["reason"] = [reason] - - invalid_params.append(error_detail) - - if invalid_params: - data["invalid_params"] = invalid_params - - if non_field_errors: - data["detail"] = non_field_errors - - -def __exc_detail_as_list_handler(data: Dict, exc_detail: List): - """Handle the exception detail as a list.""" - detail = [] - for error in exc_detail: - detail.append(error if not isinstance(error, list) else error[0]) - - if detail: - data["detail"] = detail - - -def is_exc_detail_same_as_default_detail(exc: APIException) -> bool: - """Check if the exception detail is the same as the default detail.""" - return (isinstance(exc.detail, str) and exc.detail == exc.default_detail) or ( - isinstance(exc.detail, list) - and len(exc.detail) == 1 - and isinstance(exc.detail[0], str) - and exc.detail[0] == exc.default_detail - ) From 7c9de481687d76ef5cbf6bdd2cd4fb3714418edd Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:11:42 +0100 Subject: [PATCH 04/14] Update tests --- test_project/test_app/tests.py | 148 +++++++++++++++++++++++++++------ test_project/test_app/utils.py | 14 ++++ tox.ini | 5 +- 3 files changed, 141 insertions(+), 26 deletions(-) diff --git a/test_project/test_app/tests.py b/test_project/test_app/tests.py index 2c6e4db..e0eb395 100644 --- a/test_project/test_app/tests.py +++ b/test_project/test_app/tests.py @@ -8,71 +8,148 @@ from test_app.utils import ErrorTriggers, render_response -@pytest.mark.django_db -class TestErrors: - def test_django_http404_ok(self, mocker): - exc = Http404() - response = exception_handler(exc, mocker.Mock()) - - expected_response = {"title": "Not found."} - assert render_response(response.data) == expected_response - - def test_django_permission_denied_ok(self, mocker): - exc = PermissionDenied() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "You do not have permission to perform this action." - } - assert render_response(response.data) == expected_response +class TestDjangoExceptions: + """Test the exception handler for various Django exceptions.""" @pytest.mark.parametrize( - "error_message, expected_response", + "error_message, code, params, expected_response", [ + # Raising a single error message ( "Error message.", - {"title": "Validation error.", "detail": ["Error message."]}, + None, + None, + { + "title": "Validation error.", + "detail": ["Error message."], + "invalid_params": None, + }, ), ( - [f"Error message {i}." for i in range(2)], + "Error message: %(msg)s.", + "invalid", + {"msg": "ERROR"}, { "title": "Validation error.", - "detail": ["Error message 0.", "Error message 1."], + "detail": ["Error message: ERROR."], + "invalid_params": None, + }, + ), + # Raising multiple error messages + ( + [f"Error message {i+1}." for i in range(2)], + None, + None, + { + "title": "Validation error.", + "detail": ["Error message 1.", "Error message 2."], + "invalid_params": None, + }, + ), + ( + [ + ValidationError(f"Error message {i+1}.", code=f"error {i+1}") + for i in range(2) + ], + None, + None, + { + "title": "Validation error.", + "detail": ["Error message 1.", "Error message 2."], + "invalid_params": None, }, ), + # Raising a dictionary of error messages ( {"field": "Error message."}, + None, + None, { "title": "Validation error.", + "detail": None, "invalid_params": [{"name": "field", "reason": ["Error message."]}], }, ), + ( + {"field": [f"Error message {i+1}." for i in range(2)]}, + None, + None, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "field", + "reason": ["Error message 1.", "Error message 2."], + }, + ], + }, + ), ], ) - def test_django_validation_error_ok(self, error_message, expected_response, mocker): - exc = ValidationError(error_message) + def test_django_validation_error_ok( + self, error_message, code, params, expected_response, mocker + ): + """ + Test the exception handler for ValidationError exceptions. + """ + exc = ValidationError(error_message, code, params) + response = exception_handler(exc, mocker.Mock()) + + assert render_response(response.data) == expected_response + + def test_django_http404_ok(self, mocker): + """Test the exception handler for Http404 exceptions.""" + exc = Http404() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Not found.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_django_permission_denied_ok(self, mocker): + """Test the exception handler for PermissionDenied exceptions.""" + exc = PermissionDenied() response = exception_handler(exc, mocker.Mock()) + expected_response = { + "title": "You do not have permission to perform this action.", + "detail": None, + "invalid_params": None, + } assert render_response(response.data) == expected_response + +class TestAPIExceptions: + """Test the exception handler for various API exceptions.""" + @pytest.mark.parametrize( "error_message, expected_response", [ ( "Error message.", - {"title": "A server error occurred.", "detail": ["Error message."]}, + { + "title": "A server error occurred.", + "detail": ["Error message."], + "invalid_params": None, + }, ), ( [f"Error message {i}." for i in range(2)], { "title": "A server error occurred.", "detail": ["Error message 0.", "Error message 1."], + "invalid_params": None, }, ), ( {"field": "Error message."}, { "title": "A server error occurred.", + "detail": None, "invalid_params": [{"name": "field", "reason": ["Error message."]}], }, ), @@ -80,6 +157,7 @@ def test_django_validation_error_ok(self, error_message, expected_response, mock {"field1": {"field2": "Error message."}}, { "title": "A server error occurred.", + "detail": None, "invalid_params": [ {"name": "field1.field2", "reason": ["Error message."]} ], @@ -98,19 +176,25 @@ def test_drf_api_exception_ok(self, error_message, expected_response, mocker): [ ( "Error message.", - {"title": "Validation error.", "detail": ["Error message."]}, + { + "title": "Validation error.", + "detail": ["Error message."], + "invalid_params": None, + }, ), ( [f"Error message {i}." for i in range(2)], { "title": "Validation error.", "detail": ["Error message 0.", "Error message 1."], + "invalid_params": None, }, ), ( {"field": "Error message."}, { "title": "Validation error.", + "detail": None, "invalid_params": [{"name": "field", "reason": ["Error message."]}], }, ), @@ -118,6 +202,7 @@ def test_drf_api_exception_ok(self, error_message, expected_response, mocker): {"field1": {"field2": "Error message."}}, { "title": "Validation error.", + "detail": None, "invalid_params": [ {"name": "field1.field2", "reason": ["Error message."]} ], @@ -131,6 +216,7 @@ def test_drf_api_exception_ok(self, error_message, expected_response, mocker): }, { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "field1.field2.field3.field4.field5", @@ -146,6 +232,7 @@ def test_drf_api_exception_ok(self, error_message, expected_response, mocker): }, { "title": "Validation error.", + "detail": None, "invalid_params": [ {"name": "field1.field2", "reason": ["Error message."]}, {"name": "field3.field4", "reason": ["Error message."]}, @@ -159,6 +246,7 @@ def test_drf_api_exception_ok(self, error_message, expected_response, mocker): }, { "title": "Validation error.", + "detail": None, "invalid_params": [ {"name": "field1.field2", "reason": ["Error message."]}, {"name": "field3.field4.field5", "reason": ["Error message."]}, @@ -184,6 +272,7 @@ def test_field_required_error_ok(self, book_serializer, mocker): expected_response = { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "title", @@ -215,6 +304,7 @@ def test_field_validation_error_ok(self, book, book_serializer, faker, mocker): expected_response = { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "isbn10", @@ -239,6 +329,7 @@ def test_validation_error_ok(self, book_serializer, faker, mocker): expected_response = { "title": "Validation error.", "detail": [f"Title cannot be {ErrorTriggers.SERIALIZER_VALIDATION}"], + "invalid_params": None, } assert render_response(response.data) == expected_response @@ -262,6 +353,7 @@ def test_bad_choice_error_ok(self, book_model_serializer, faker, mocker, user): expected_response = { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "edition", @@ -290,6 +382,7 @@ def test_bad_one_to_one_relationship_error_ok( expected_response = { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "author", @@ -319,6 +412,7 @@ def test_bad_many_to_many_relationship_error_ok( expected_response = { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "libraries", @@ -345,6 +439,7 @@ def test_constraint_error_ok(self, book_model_serializer, faker, mocker, user): expected_response = { "title": "Validation error.", "detail": ["Pages cannot be more than 360."], + "invalid_params": None, } assert render_response(response.data) == expected_response @@ -356,6 +451,7 @@ def test_field_required_error_ok(self, book_model_serializer, mocker): expected_response = { "title": "Validation error.", + "detail": None, "invalid_params": [ { "name": "author", @@ -394,6 +490,7 @@ def test_method_error_ok(self, book_model_serializer, faker, mocker, user): expected_response = { "title": "Validation error.", "detail": [ErrorTriggers.SERIALIZER_METHOD.value], + "invalid_params": None, } assert render_response(response.data) == expected_response @@ -413,6 +510,7 @@ def test_validation_error_ok(self, book_model_serializer, faker, mocker, user): expected_response = { "title": "Validation error.", "detail": [f"Title cannot be {ErrorTriggers.SERIALIZER_VALIDATION}"], + "invalid_params": None, } assert render_response(response.data) == expected_response diff --git a/test_project/test_app/utils.py b/test_project/test_app/utils.py index 56ba383..05b5f7e 100644 --- a/test_project/test_app/utils.py +++ b/test_project/test_app/utils.py @@ -5,6 +5,8 @@ @unique class ErrorTriggers(Enum): + """Enum for error triggers used in tests and examples.""" + MODEL_CONSTRAINT = 361 MODEL_VALIDATION = "Model validation error!" SERIALIZER_METHOD = "Serializer method error!" @@ -15,5 +17,17 @@ def __str__(self) -> str: def render_response(data: dict) -> dict: + """ + Renders the response data from a `dict` to a DRF Response object. + + Args: + data (dict): The data to be rendered. + + Returns: + response.data (dict): The rendered response data from the DRF `Response` object. + """ + # This is needed in tests to ensure that + # the response data is in the same format as the DRF Response given by + # the exception handler. response = Response(data=data) return response.data diff --git a/tox.ini b/tox.ini index 9d531e3..d1477b8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310 +envlist = py38, py39, py310, py311, py312, py313 isolated_build = true [gh-actions] @@ -7,6 +7,9 @@ python = 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 [testenv] allowlist_externals = From f5f1c53b809d178481f25f0cf157559baf014aa8 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:11:53 +0100 Subject: [PATCH 05/14] Update README.md --- README.md | 70 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 6cfe51e..8488ef0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ A library for [Django Rest Framework](https://www.django-rest-framework.org/) returning **consistent, predictable and easy-to-parse API error messages**. -This library was built with [RFC7807](https://tools.ietf.org/html/rfc7807) guidelines in mind, but with a small twist: it defines a "problem detail" as a list, but it still serves as a way to include errors in a predictable and easy-to-parse format for any API consumer. Error messages are formatted using RFC7807 keywords and DRF exception data. +This library was built with [RFC7807](https://tools.ietf.org/html/rfc7807) guidelines in mind, but with a small twist: it defines a "problem detail" as a list instead of a string, but it still serves as a way to include errors in a human-readable and easy-to-parse format for any API consumer. +Error messages are formatted using RFC7807 keywords and DRF exception data. + +This library always returns errors in a consistent, predictable structure, making them easier to handle and parse, unlike standard DRF, where error response formats vary depending on the error source. ## What's different? @@ -17,7 +20,7 @@ Compared to other similar and popular libraries, this library: - Is based on RFC7807 guidelines - Aims to provide not only a standardized format for error details, but also human-readable error messages (perfect for both internal and public APIs) -- Transforms both `django.core.exceptions.ValidationError` and `rest_framework.errors.ValidationError` to API errors, so you don't have to handle error raised by services/domain logic, `clean()`, or other functions/methods +- Transforms both `django.core.exceptions.ValidationError` and `rest_framework.errors.ValidationError` to API errors, so you don't have to handle error raised by services/domain logic, `clean()`, etc. ## Table of Contents @@ -57,13 +60,13 @@ REST_FRAMEWORK = { ### Error structure overview -API error messages typically include the following keys: +API error messages will include the following keys: -- `"title"` (`str`): A brief summary that describes the problem type -- `"detail"` (`list[str] | None`): A list of specific explanations related to the problem -- `"invalid_params"` (`list[dict] | None`): A list of dict containing details about parameters that were invalid or malformed in the request. Each dict within this list provides: - - `"name"` (`str`): The name of the parameter that was found to be invalid - - `"reasons"` (`list[str]`): A list of strings describing the specific reasons why the parameter was considered invalid or malformed +- `"title"` (`str`): A brief summary that describes the problem type. +- `"detail"` (`list[str] | None`): A list of specific explanations related to the problem, if any. +- `"invalid_params"` (`list[dict] | None`): A list of dict containing details about parameters that were invalid or malformed in the request, if any. Each dict within this list provides: + - `"name"` (`str`): The name of the parameter that was found to be invalid. + - `"reasons"` (`list[str]`): A list of strings describing the specific reasons why the parameter was considered invalid or malformed. ```json { @@ -91,17 +94,18 @@ API error messages typically include the following keys: ```json { - "title": "Error message.", - "invalid_params": [ - { - "name": "field_name", - "reason": [ - "error", - ... - ] - }, - ... - ] + "title": "Error message.", + "details": null, + "invalid_params": [ + { + "name": "field_name", + "reason": [ + "error" + // ... + ] + } + // ... + ] } ``` @@ -111,9 +115,10 @@ API error messages typically include the following keys: { "title": "Error message.", "detail": [ - "error", - ... - ] + "error" + // ... + ], + "invalid_params": null } ``` @@ -121,7 +126,9 @@ API error messages typically include the following keys: ```json { - "title": "Error message." + "title": "Error message.", + "detail": null, + "invalid_params": null } ``` @@ -146,15 +153,16 @@ If `CAMELIZE` is set to `True`: ```json { "title": "Error message.", + "details": null, "invalidParams": [ { "name": "fieldName", "reason": [ - "error", - ... + "error" + // ... ] } - ... + // ... ] } ``` @@ -274,13 +282,19 @@ All the necessary commands are included in the `Makefile`. We are using `tox` and `poetry` to run tests in every supported Python version. -Run test with the commands below: +Run test during development with the commands below: ``` -make install +make install # only if necessary make test ``` +Finally, run `tox` to ensure the changes work for every supported python version: + +``` +tox -v +``` + ## Support Please [open an issue](https://github.com/gripep/drf-simple-api-errors/issues/new). From f8345bf26b840f5fac55e57bfe7ef416d6c37a38 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:23:37 +0100 Subject: [PATCH 06/14] Drop support form python 3.8 --- pyproject.toml | 4 ++-- tox.ini | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7af4b1d..5b31c6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,11 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", 'Programming Language :: Python', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Framework :: Django', "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", @@ -36,7 +36,7 @@ include = ["drf_simple_api_errors", "LICENSE.md"] [tool.poetry.dependencies] -python = ">=3.8.1,<4.0" +python = ">=3.9,<4.0" Django = ">=2.2" djangorestframework = ">=3.0" diff --git a/tox.ini b/tox.ini index d1477b8..cc90dc2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py38, py39, py310, py311, py312, py313 +envlist = py39, py310, py311, py312, py313 isolated_build = true [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311 From 9b35f4dc7d85d8e9a17137ec7cf8a53d7d3ca702 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:34:25 +0100 Subject: [PATCH 07/14] Release v2.0.0 --- CHANGELOG.md | 21 ++++++ drf_simple_api_errors/__init__.py | 2 +- poetry.lock | 110 ++++++++++++------------------ pyproject.toml | 2 +- tox.ini | 8 ++- 5 files changed, 71 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 416f010..cfc8976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,41 @@ ## [1.0.0] - 2024-04-26 +Changes: + - First release on PyPI ## [1.0.1] - 2024-04-30 +Changes: + - Upgrade dependencies ## [1.0.2] - 2024-09-08 +Changes: + - Add docstrings to handlers - Improve Makefile - Improve README ## [1.0.3] - 2025-03-16 +Changes: + - Update README - Improve tests - Fix DRF API settings initialization + +## [2.0.0] - 2025-07-11 + +Breaking changes: + +- The API error response now always includes the keys: `title`, `detail`, and `invalid_param`. The `title` key is always populated, while `detail` and `invalid_param` may be `null` depending on the error source. +- Drop support for python 3.8 + +Changes: + +- Improve code modularity and readability +- Update Makefile +- Update README diff --git a/drf_simple_api_errors/__init__.py b/drf_simple_api_errors/__init__.py index 951b713..a75c52d 100644 --- a/drf_simple_api_errors/__init__.py +++ b/drf_simple_api_errors/__init__.py @@ -1,4 +1,4 @@ from .exception_handler import exception_handler __all__ = ("exception_handler",) -__version__ = "1.0.3" +__version__ = "2.0.0" diff --git a/poetry.lock b/poetry.lock index d40d8ab..e430abf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,12 +1,12 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "asgiref" version = "3.8.1" description = "ASGI specs, helper code, and adapters" -category = "main" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, @@ -18,42 +18,13 @@ typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] -[[package]] -name = "backports-zoneinfo" -version = "0.2.1" -description = "Backport of the standard library zoneinfo module" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, - {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, - {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, - {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, - {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, -] - -[package.extras] -tzdata = ["tzdata"] - [[package]] name = "black" version = "24.4.2" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, @@ -90,7 +61,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -98,9 +69,9 @@ uvloop = ["uvloop (>=0.15.2)"] name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -113,9 +84,10 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -125,9 +97,9 @@ files = [ name = "coverage" version = "7.5.0" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"}, {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"}, @@ -187,15 +159,15 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "django" version = "4.2.11" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -category = "main" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "Django-4.2.11-py3-none-any.whl", hash = "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"}, {file = "Django-4.2.11.tar.gz", hash = "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4"}, @@ -203,7 +175,6 @@ files = [ [package.dependencies] asgiref = ">=3.6.0,<4" -"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} @@ -215,25 +186,25 @@ bcrypt = ["bcrypt"] name = "djangorestframework" version = "3.15.1" description = "Web APIs for Django, made easy." -category = "main" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "djangorestframework-3.15.1-py3-none-any.whl", hash = "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6"}, {file = "djangorestframework-3.15.1.tar.gz", hash = "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1"}, ] [package.dependencies] -"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} django = ">=3.0" [[package]] name = "exceptiongroup" version = "1.2.1" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, @@ -246,9 +217,9 @@ test = ["pytest (>=6)"] name = "factory-boy" version = "3.3.0" description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "factory_boy-3.3.0-py2.py3-none-any.whl", hash = "sha256:a2cdbdb63228177aa4f1c52f4b6d83fab2b8623bf602c7dedd7eb83c0f69c04c"}, {file = "factory_boy-3.3.0.tar.gz", hash = "sha256:bc76d97d1a65bbd9842a6d722882098eb549ec8ee1081f9fb2e8ff29f0c300f1"}, @@ -265,9 +236,9 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] name = "faker" version = "25.0.0" description = "Faker is a Python package that generates fake data for you." -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "Faker-25.0.0-py3-none-any.whl", hash = "sha256:e23a2b74888885c3d23a9237bacb823041291c03d609a39acb9ebe6c123f3986"}, {file = "Faker-25.0.0.tar.gz", hash = "sha256:87ef41e24b39a5be66ecd874af86f77eebd26782a2681200e86c5326340a95d3"}, @@ -280,9 +251,9 @@ python-dateutil = ">=2.4" name = "flake8" version = "7.0.0" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = ">=3.8.1" +groups = ["dev"] files = [ {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, @@ -297,9 +268,9 @@ pyflakes = ">=3.2.0,<3.3.0" name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -309,9 +280,9 @@ files = [ name = "isort" version = "5.13.2" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -324,9 +295,9 @@ colors = ["colorama (>=0.4.6)"] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -336,9 +307,9 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -348,9 +319,9 @@ files = [ name = "packaging" version = "24.0" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, @@ -360,9 +331,9 @@ files = [ name = "pathspec" version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -372,9 +343,9 @@ files = [ name = "platformdirs" version = "4.2.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, @@ -389,9 +360,9 @@ type = ["mypy (>=1.8)"] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -405,9 +376,9 @@ testing = ["pytest", "pytest-benchmark"] name = "pycodestyle" version = "2.11.1" description = "Python style guide checker" -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, @@ -417,9 +388,9 @@ files = [ name = "pyflakes" version = "3.2.0" description = "passive checker of Python programs" -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, @@ -429,9 +400,9 @@ files = [ name = "pytest" version = "8.2.0" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, @@ -452,9 +423,9 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments name = "pytest-cov" version = "5.0.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, @@ -471,9 +442,9 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] name = "pytest-django" version = "4.8.0" description = "A Django plugin for pytest." -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"}, {file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"}, @@ -490,9 +461,9 @@ testing = ["Django", "django-configurations (>=2.0)"] name = "pytest-mock" version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, @@ -508,9 +479,9 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "python-dateutil" version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -523,9 +494,9 @@ six = ">=1.5" name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["dev"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -535,9 +506,9 @@ files = [ name = "sqlparse" version = "0.5.0" description = "A non-validating SQL parser." -category = "main" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, @@ -551,9 +522,10 @@ doc = ["sphinx"] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -563,9 +535,10 @@ files = [ name = "typing-extensions" version = "4.11.0" description = "Backported and Experimental Type Hints for Python 3.8+" -category = "main" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" files = [ {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, @@ -575,15 +548,16 @@ files = [ name = "tzdata" version = "2024.1" description = "Provider of IANA time zone data" -category = "main" optional = false python-versions = ">=2" +groups = ["main"] +markers = "sys_platform == \"win32\"" files = [ {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] [metadata] -lock-version = "2.0" -python-versions = ">=3.8.1,<4.0" -content-hash = "482e22805e132d94ee0a867504dbcd9e2f311de7a169519fc5cd4e01d1e2c117" +lock-version = "2.1" +python-versions = ">=3.9,<4.0" +content-hash = "cb7a653834530ac06709627b7f3a63fe3e367baa4a4f934a0dd79cda7bf07c6a" diff --git a/pyproject.toml b/pyproject.toml index 5b31c6f..455af60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "drf-simple-api-errors" -version = "1.0.3" +version = "2.0.0" description = "A library for Django Rest Framework returning consistent and easy-to-parse API error messages." authors = ["Gian <30044863+gripep@users.noreply.github.com>"] license = "MIT" diff --git a/tox.ini b/tox.ini index cc90dc2..38b2dd6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,10 @@ [tox] -envlist = py39, py310, py311, py312, py313 -isolated_build = true +requires = + tox>=4 +envlist = + py39, py310, py311, py312, py313 +isolated_build = + true [gh-actions] python = From e0d3d7b585369121d7fe4b6f594b28be5bc63435 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 13:36:40 +0100 Subject: [PATCH 08/14] Update gh actions --- .github/workflows/build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0d6097e..182c21f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout source code @@ -28,7 +28,7 @@ jobs: python -m pip install tox tox-gh-actions - name: Run tox - run: tox + run: tox -v - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 From bd89b0ef9053fe8fe94803cbdac8a84f505eb369 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 15:21:28 +0100 Subject: [PATCH 09/14] Update utils.camelize to update string based in settings --- drf_simple_api_errors/formatter.py | 25 ++++++++++++++++------ drf_simple_api_errors/utils.py | 27 ++++++++++++++++++++---- test_project/test_app/tests.py | 33 +++++++++++++++++++++++------- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/drf_simple_api_errors/formatter.py b/drf_simple_api_errors/formatter.py index 8ead662..50d73d8 100644 --- a/drf_simple_api_errors/formatter.py +++ b/drf_simple_api_errors/formatter.py @@ -1,3 +1,17 @@ +""" +This module provides functionality to format API exceptions into a structured error response. + +It defines the `APIErrorResponse` class, which represents +the structure of the error response, and the `format_exc` function, which +formats exceptions into this structure according to the type of exception, its detail, +and any additional settings defined in the settings. + +Functions: + - `format_exc`: + Formats the given exception into a structured API error response. + Used by the exception handler to return a consistent error format. +""" + import copy import logging from dataclasses import asdict, dataclass, field as dataclass_field @@ -7,9 +21,9 @@ from rest_framework import exceptions from rest_framework.settings import api_settings as drf_api_settings +from drf_simple_api_errors import utils from drf_simple_api_errors.settings import api_settings from drf_simple_api_errors.types import APIErrorResponseDict -from drf_simple_api_errors.utils import camelize, flatten_dict logger = logging.getLogger(__name__) @@ -46,9 +60,8 @@ def to_dict(self) -> APIErrorResponseDict: """Convert the APIErrorResponse instance to a dictionary.""" response_dict = asdict(self) - if api_settings.CAMELIZE: - for key in list(response_dict.keys()): - response_dict[camelize(key)] = response_dict.pop(key) + for key in list(response_dict.keys()): + response_dict[utils.camelize(key)] = response_dict.pop(key) return response_dict @@ -112,7 +125,7 @@ def _format_exc_detail_dict( # Start by flattening the exc dict. # This is necessary as the exception detail can be nested and # we want to flatten it to a single level dict as part of this library design. - exc_detail = flatten_dict(copy.deepcopy(exc_detail)) + exc_detail = utils.flatten_dict(copy.deepcopy(exc_detail)) # Track the invalid params. # This represents the fields that are invalid and have errors associated with them. @@ -136,7 +149,7 @@ def _format_exc_detail_dict( # N.B. If the error is a string, we will convert it to a list # to keep the consistency with the InvalidParamDict type. invalid_param = InvalidParam( - name=field if not api_settings.CAMELIZE else camelize(field), + name=utils.camelize(field), reason=error if isinstance(error, list) else [error], ) invalid_params.append(invalid_param) diff --git a/drf_simple_api_errors/utils.py b/drf_simple_api_errors/utils.py index b7ce7ef..54507eb 100644 --- a/drf_simple_api_errors/utils.py +++ b/drf_simple_api_errors/utils.py @@ -3,8 +3,16 @@ from drf_simple_api_errors.settings import api_settings -def camelize(field: str) -> str: - """Convert a snake_case string to camelCase.""" +def camelize(s: str) -> str: + """ + Convert a snake_case string to camelCase according to + the CAMELIZE setting (default to `False`). + + Args: + s (str): The string to convert. + Returns: + str: The camelCase version of the string, or the original if CAMELIZE is `False`. + """ def underscore_to_camel(match: re.Match) -> str: group = match.group() @@ -13,14 +21,25 @@ def underscore_to_camel(match: re.Match) -> str: else: return group[1].upper() + if not api_settings.CAMELIZE: + return s + camelize_re = re.compile(r"[a-z0-9]?_[a-z0-9]") - return re.sub(camelize_re, underscore_to_camel, field) + return re.sub(camelize_re, underscore_to_camel, s) def flatten_dict(data: dict, parent_key: str = "") -> dict: """ Flatten a nested dictionary into a single-level dictionary according to - the specified FIELDS_SEPARATOR in your settings (default to '.'). + the specified FIELDS_SEPARATOR setting (default to `'.'`). + + Args: + data (dict): The dictionary to flatten. + parent_key (str): + The base key to prepend to each key in the flattened dictionary. + This is used for recursive calls to maintain the hierarchy. + Returns: + dict: A flattened dictionary with keys joined by the FIELDS_SEPARATOR. """ sep = api_settings.FIELDS_SEPARATOR diff --git a/test_project/test_app/tests.py b/test_project/test_app/tests.py index e0eb395..7492990 100644 --- a/test_project/test_app/tests.py +++ b/test_project/test_app/tests.py @@ -517,16 +517,35 @@ def test_validation_error_ok(self, book_model_serializer, faker, mocker, user): class TestUtils: @pytest.mark.parametrize( - "field_input, expected_output", + "field_input, expected_output, camelize_enabled", [ - ("", ""), - ("name", "name"), - ("first_name", "firstName"), - ("family_tree_name", "familyTreeName"), - ("very_long_last_name_and_first_name", "veryLongLastNameAndFirstName"), + ("", "", True), + ("name", "name", True), + ("first_name", "firstName", True), + ("family_tree_name", "familyTreeName", True), + ( + "very_long_last_name_and_first_name", + "veryLongLastNameAndFirstName", + True, + ), + ("", "", False), + ("name", "name", False), + ("first_name", "first_name", False), + ("family_tree_name", "family_tree_name", False), + ( + "very_long_last_name_and_first_name", + "very_long_last_name_and_first_name", + False, + ), ], ) - def test_camelize(self, field_input, expected_output): + def test_camelize_with_settings_set( + self, mocker, field_input, expected_output, camelize_enabled + ): + mocker.patch( + "drf_simple_api_errors.settings.api_settings.CAMELIZE", camelize_enabled + ) + assert utils.camelize(field_input) == expected_output @pytest.mark.parametrize( From 8488a635ec3bd205d4d66dd3a7e4b58dda4d7ca5 Mon Sep 17 00:00:00 2001 From: Gian Date: Fri, 11 Jul 2025 15:23:04 +0100 Subject: [PATCH 10/14] Update Makefile --- Makefile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Makefile b/Makefile index b6e5d0c..887ba33 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,6 @@ install: @poetry install --with dev -v -# Run poetry shell -shell: - @poetry shell - # Run formatters (black, isort) with poetry format: @poetry run isort drf_simple_api_errors test_project From 43a91de9915a2395cb0b20687f887b65f5a62781 Mon Sep 17 00:00:00 2001 From: Gian Date: Wed, 16 Jul 2025 19:08:48 +0100 Subject: [PATCH 11/14] Update drf_simple_api_errors modules --- drf_simple_api_errors/exception_handler.py | 76 ++---------------- drf_simple_api_errors/extra_handlers.py | 38 +++++++++ drf_simple_api_errors/formatter.py | 6 +- drf_simple_api_errors/handlers.py | 90 ++++++++++++++++++++++ drf_simple_api_errors/settings.py | 5 ++ drf_simple_api_errors/types.py | 4 + drf_simple_api_errors/utils.py | 12 ++- 7 files changed, 156 insertions(+), 75 deletions(-) create mode 100644 drf_simple_api_errors/extra_handlers.py create mode 100644 drf_simple_api_errors/handlers.py diff --git a/drf_simple_api_errors/exception_handler.py b/drf_simple_api_errors/exception_handler.py index 689fe50..ccbbbab 100644 --- a/drf_simple_api_errors/exception_handler.py +++ b/drf_simple_api_errors/exception_handler.py @@ -1,19 +1,11 @@ import logging -from typing import Dict, Union -from django.core.exceptions import ( - PermissionDenied, - ValidationError as DjangoValidationError, -) -from django.http import Http404 from rest_framework import exceptions from rest_framework.response import Response -from rest_framework.serializers import as_serializer_error from rest_framework.views import set_rollback -from drf_simple_api_errors import formatter +from drf_simple_api_errors import formatter, handlers from drf_simple_api_errors.exceptions import ServerError -from drf_simple_api_errors.settings import api_settings from drf_simple_api_errors.types import ExceptionHandlerContext logger = logging.getLogger(__name__) @@ -41,22 +33,22 @@ def exception_handler(exc: Exception, context: ExceptionHandlerContext) -> Respo # This allows for custom exception handling logic. # If other kinds of exceptions are raised and should be handled, # they can be added to the EXTRA_HANDLERS setting. - _apply_extra_handlers(exc) + handlers.apply_extra_handlers(exc) # If the exception is not an instance of APIException, we can try to convert it # to DRF APIException if it's a Django exception. - exc = _convert_django_exc_to_drf_api_exc(exc) + exc = handlers.convert_django_exc_to_drf_api_exc(exc) # If the exception is still not an instance of APIException, thus could be # converted to one, we cannot handle it. # This will result in a 500 error response without any detail. # This is because it's not good practice to expose the details of # unhandled exceptions to the client. if not isinstance(exc, exceptions.APIException): - logger.debug("Server error", exc_info=True) + logger.info("Server error (500) from unexpected exception.", exc_info=True) return ServerError # Get the API response headers from the exception. - headers = _get_response_headers(exc) + headers = handlers.get_response_headers(exc) # Get the API response data from the exception. # If the exception is an instance of APIException, we can handle it and # will format it to a structured API response data. @@ -65,61 +57,3 @@ def exception_handler(exc: Exception, context: ExceptionHandlerContext) -> Respo set_rollback() # Finally, return the API response \(◕ ◡ ◕\) return Response(data, status=exc.status_code, headers=headers) - - -def _apply_extra_handlers(exc: Exception): - """ - Apply any extra exception handlers defined in the settings. - - Args: - exc (Exception): The exception to handle. - """ - extra_handlers = api_settings.EXTRA_HANDLERS - if extra_handlers: - for handler in extra_handlers: - handler(exc) - - -def _convert_django_exc_to_drf_api_exc( - exc: Exception, -) -> Union[exceptions.APIException, Exception]: - """ - Convert Django exceptions to DRF APIException, if possible. - - Args: - exc (Exception): The exception to convert. - - Returns: - exceptions.APIException | Exception: The converted exception or the original. - """ - if isinstance(exc, DjangoValidationError): - return exceptions.ValidationError(as_serializer_error(exc)) - - if isinstance(exc, Http404): - return exceptions.NotFound() - - if isinstance(exc, PermissionDenied): - return exceptions.PermissionDenied() - - return exc - - -def _get_response_headers(exc: exceptions.APIException) -> Dict: - """ - Get the response headers for the given exception. - - Args: - exc (exceptions.APIException): The exception to get headers for. - - Returns: - dict: A dictionary containing the response headers. - """ - # This is from DRF's default exception handler. - # https://github.com/encode/django-rest-framework/blob/48a21aa0eb3a95d32456c2a927eff9552a04231e/rest_framework/views.py#L87-L91 - headers = {} - if getattr(exc, "auth_header", None): - headers["WWW-Authenticate"] = exc.auth_header - if getattr(exc, "wait", None): - headers["Retry-After"] = "%d" % exc.wait - - return headers diff --git a/drf_simple_api_errors/extra_handlers.py b/drf_simple_api_errors/extra_handlers.py new file mode 100644 index 0000000..89a17bc --- /dev/null +++ b/drf_simple_api_errors/extra_handlers.py @@ -0,0 +1,38 @@ +""" +This module contains custom error handlers for DRF exceptions. +It allows you to define additional handlers for specific exceptions +or to modify the behavior of existing exceptions. + +Functions: + - `set_default_detail_to_formatted_exc_default_code`: + Formats the `default_detail` for specific DRF exceptions + by setting it to a human-readable string based on the exception `default_code`. +""" + +from django.utils.translation import gettext_lazy as _ +from rest_framework import exceptions + + +def set_default_detail_to_formatted_exc_default_code( + exc: exceptions.APIException, +) -> exceptions.APIException: + """ + Formats the `default_detail` for specific DRF exceptions + by setting it to a human-readable string based on the exception `default_code`. + + This ensures that the `default_detail` is consistent and descriptive, such as + 'Method not allowed.' instead of using a template string, leaving the exception + `detail` to be more informative. + """ + exceptions_to_format = ( + exceptions.MethodNotAllowed, + exceptions.UnsupportedMediaType, + ) + + if not isinstance(exc, exceptions_to_format): + return exc + + # Compose the title based on the exception code. + title = exc.default_code.replace("_", " ").capitalize() + "." + exc.default_detail = _(title) + return exc diff --git a/drf_simple_api_errors/formatter.py b/drf_simple_api_errors/formatter.py index 50d73d8..eb7b9e9 100644 --- a/drf_simple_api_errors/formatter.py +++ b/drf_simple_api_errors/formatter.py @@ -1,5 +1,6 @@ """ -This module provides functionality to format API exceptions into a structured error response. +This module provides functionality to format API exceptions into a structured +error response. It defines the `APIErrorResponse` class, which represents the structure of the error response, and the `format_exc` function, which @@ -22,7 +23,6 @@ from rest_framework.settings import api_settings as drf_api_settings from drf_simple_api_errors import utils -from drf_simple_api_errors.settings import api_settings from drf_simple_api_errors.types import APIErrorResponseDict logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ class APIErrorResponse: - invalid_params: A list of invalid parameters, if any """ - title: str = dataclass_field(init=False) + title: str = dataclass_field(default="") detail: Optional[List[str]] = dataclass_field(default=None) invalid_params: Optional[List[InvalidParam]] = dataclass_field(default=None) diff --git a/drf_simple_api_errors/handlers.py b/drf_simple_api_errors/handlers.py new file mode 100644 index 0000000..0e19863 --- /dev/null +++ b/drf_simple_api_errors/handlers.py @@ -0,0 +1,90 @@ +""" +Handlers to deal with different types of exceptions and format them into +API error responses, and other utility functions. + +Functions: + - `apply_extra_handlers`: + Applies any extra exception handlers defined in the settings. + - `convert_django_exc_to_drf_api_exc`: + Converts Django exceptions to DRF APIException, if possible. + - `get_response_headers`: Gets the response headers for the given exception. +""" + +from typing import Dict, Union + +from django.core.exceptions import ( + PermissionDenied, + ValidationError as DjangoValidationError, +) +from django.http import Http404 +from rest_framework import exceptions +from rest_framework.serializers import as_serializer_error + +from drf_simple_api_errors import extra_handlers +from drf_simple_api_errors.settings import api_settings + + +def apply_extra_handlers(exc: Exception): + """ + Apply any extra exception handlers defined in the settings. + + Args: + exc (Exception): The exception to handle. + """ + # Get the default extra handlers and the ones defined in the settings. + # The default handlers are always applied to ensure that exceptions + # are formatted correctly. + default_extra_handlers = [ + extra_handlers.set_default_detail_to_formatted_exc_default_code + ] + settings_extra_handlers = api_settings.EXTRA_HANDLERS + + extra_handlers_to_apply = default_extra_handlers + settings_extra_handlers + if extra_handlers_to_apply: + for handler in extra_handlers_to_apply: + handler(exc) + + +def convert_django_exc_to_drf_api_exc( + exc: Exception, +) -> Union[exceptions.APIException, Exception]: + """ + Convert Django exceptions to DRF APIException, if possible. + + Args: + exc (Exception): The exception to convert. + + Returns: + exceptions.APIException | Exception: The converted exception or the original. + """ + if isinstance(exc, DjangoValidationError): + return exceptions.ValidationError(as_serializer_error(exc)) + + if isinstance(exc, Http404): + return exceptions.NotFound() + + if isinstance(exc, PermissionDenied): + return exceptions.PermissionDenied() + + return exc + + +def get_response_headers(exc: exceptions.APIException) -> Dict: + """ + Get the response headers for the given exception. + + Args: + exc (exceptions.APIException): The exception to get headers for. + + Returns: + dict: A dictionary containing the response headers. + """ + # This is from DRF's default exception handler. + # https://github.com/encode/django-rest-framework/blob/48a21aa0eb3a95d32456c2a927eff9552a04231e/rest_framework/views.py#L87-L91 + headers = {} + if getattr(exc, "auth_header", None): + headers["WWW-Authenticate"] = exc.auth_header + if getattr(exc, "wait", None): + headers["Retry-After"] = "%d" % exc.wait + + return headers diff --git a/drf_simple_api_errors/settings.py b/drf_simple_api_errors/settings.py index 134d171..0316b08 100644 --- a/drf_simple_api_errors/settings.py +++ b/drf_simple_api_errors/settings.py @@ -1,3 +1,8 @@ +""" +Settings for the DRF Simple API Errors package. +This module defines the default settings and user settings for the package. +""" + from django.conf import settings from rest_framework.settings import APISettings diff --git a/drf_simple_api_errors/types.py b/drf_simple_api_errors/types.py index 5914a55..1a76bba 100644 --- a/drf_simple_api_errors/types.py +++ b/drf_simple_api_errors/types.py @@ -1,3 +1,7 @@ +""" +Types for exception handler and its modules. +""" + from typing import Dict, List, Optional, Tuple, TypedDict from rest_framework.request import Request diff --git a/drf_simple_api_errors/utils.py b/drf_simple_api_errors/utils.py index 54507eb..fb5b083 100644 --- a/drf_simple_api_errors/utils.py +++ b/drf_simple_api_errors/utils.py @@ -1,3 +1,13 @@ +""" +Utility functions for handling API errors and formatting responses. + +Functions: + - `camelize`: + Converts a snake_case string to camelCase according to the CAMELIZE setting. + - `flatten_dict`: Flattens a nested dictionary into a single-level dictionary + according to the specified FIELDS_SEPARATOR setting. +""" + import re from drf_simple_api_errors.settings import api_settings @@ -11,7 +21,7 @@ def camelize(s: str) -> str: Args: s (str): The string to convert. Returns: - str: The camelCase version of the string, or the original if CAMELIZE is `False`. + str: The camelCase version of a string, or the original if CAMELIZE is `False`. """ def underscore_to_camel(match: re.Match) -> str: From 0a4d27a7927b4a1a95dfb673d965d1ba54ab82b8 Mon Sep 17 00:00:00 2001 From: Gian Date: Wed, 16 Jul 2025 19:16:48 +0100 Subject: [PATCH 12/14] Refactor tests --- Makefile | 14 +- {test_project => integration_tests}/manage.py | 2 +- .../settings}/__init__.py | 0 .../settings/django.py | 2 +- .../settings}/urls.py | 0 .../test_app/__init__.py | 0 .../test_app/apps.py | 0 .../test_app/conftest.py | 0 .../test_app/factories.py | 0 .../test_app/models.py | 0 integration_tests/test_app/tests.py | 260 ++++++++ .../test_app/utils.py | 0 pyproject.toml | 9 + test_project/pytest.ini | 12 - test_project/test_app/tests.py | 571 ------------------ tests/__init__.py | 0 tests/test_exception_handler.py | 401 ++++++++++++ tests/test_extra_handlers.py | 50 ++ tests/test_formatter.py | 235 +++++++ tests/test_hadlers.py | 123 ++++ tests/test_utils.py | 80 +++ tests/utils.py | 18 + 22 files changed, 1185 insertions(+), 592 deletions(-) rename {test_project => integration_tests}/manage.py (89%) rename {test_project/config => integration_tests/settings}/__init__.py (100%) rename test_project/config/settings.py => integration_tests/settings/django.py (98%) rename {test_project/config => integration_tests/settings}/urls.py (100%) rename {test_project => integration_tests}/test_app/__init__.py (100%) rename {test_project => integration_tests}/test_app/apps.py (100%) rename {test_project => integration_tests}/test_app/conftest.py (100%) rename {test_project => integration_tests}/test_app/factories.py (100%) rename {test_project => integration_tests}/test_app/models.py (100%) create mode 100644 integration_tests/test_app/tests.py rename {test_project => integration_tests}/test_app/utils.py (100%) delete mode 100644 test_project/pytest.ini delete mode 100644 test_project/test_app/tests.py create mode 100644 tests/__init__.py create mode 100644 tests/test_exception_handler.py create mode 100644 tests/test_extra_handlers.py create mode 100644 tests/test_formatter.py create mode 100644 tests/test_hadlers.py create mode 100644 tests/test_utils.py create mode 100644 tests/utils.py diff --git a/Makefile b/Makefile index 887ba33..40f8e34 100644 --- a/Makefile +++ b/Makefile @@ -6,19 +6,19 @@ install: # Run formatters (black, isort) with poetry format: - @poetry run isort drf_simple_api_errors test_project - @poetry run black drf_simple_api_errors test_project + @poetry run isort drf_simple_api_errors integration_tests tests + @poetry run black drf_simple_api_errors integration_tests tests # Check format (black, isort) and linting (flake8) lint: - @poetry run isort --check drf_simple_api_errors test_project - @poetry run black --check drf_simple_api_errors test_project --exclude migrations - @poetry run flake8 drf_simple_api_errors test_project --max-line-length 88 + @poetry run isort --check drf_simple_api_errors integration_tests tests + @poetry run black --check drf_simple_api_errors integration_tests tests --exclude migrations + @poetry run flake8 drf_simple_api_errors integration_tests tests --max-line-length 88 # Run unittests with poetry test: - @poetry run pytest test_project + @poetry run pytest # Run code coverage tests coverage with poetry test/cov: - @poetry run pytest test_project --cov=drf_simple_api_errors --cov-report xml:coverage.xml + @poetry run pytest --cov=drf_simple_api_errors --cov-report xml:coverage.xml diff --git a/test_project/manage.py b/integration_tests/manage.py similarity index 89% rename from test_project/manage.py rename to integration_tests/manage.py index d28672e..e053ba9 100755 --- a/test_project/manage.py +++ b/integration_tests/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.django") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/test_project/config/__init__.py b/integration_tests/settings/__init__.py similarity index 100% rename from test_project/config/__init__.py rename to integration_tests/settings/__init__.py diff --git a/test_project/config/settings.py b/integration_tests/settings/django.py similarity index 98% rename from test_project/config/settings.py rename to integration_tests/settings/django.py index 4579e59..e4caf01 100644 --- a/test_project/config/settings.py +++ b/integration_tests/settings/django.py @@ -39,7 +39,7 @@ ] -ROOT_URLCONF = "config.urls" +ROOT_URLCONF = "settings.urls" TEMPLATES = [ diff --git a/test_project/config/urls.py b/integration_tests/settings/urls.py similarity index 100% rename from test_project/config/urls.py rename to integration_tests/settings/urls.py diff --git a/test_project/test_app/__init__.py b/integration_tests/test_app/__init__.py similarity index 100% rename from test_project/test_app/__init__.py rename to integration_tests/test_app/__init__.py diff --git a/test_project/test_app/apps.py b/integration_tests/test_app/apps.py similarity index 100% rename from test_project/test_app/apps.py rename to integration_tests/test_app/apps.py diff --git a/test_project/test_app/conftest.py b/integration_tests/test_app/conftest.py similarity index 100% rename from test_project/test_app/conftest.py rename to integration_tests/test_app/conftest.py diff --git a/test_project/test_app/factories.py b/integration_tests/test_app/factories.py similarity index 100% rename from test_project/test_app/factories.py rename to integration_tests/test_app/factories.py diff --git a/test_project/test_app/models.py b/integration_tests/test_app/models.py similarity index 100% rename from test_project/test_app/models.py rename to integration_tests/test_app/models.py diff --git a/integration_tests/test_app/tests.py b/integration_tests/test_app/tests.py new file mode 100644 index 0000000..603d45e --- /dev/null +++ b/integration_tests/test_app/tests.py @@ -0,0 +1,260 @@ +from django.core.exceptions import ValidationError +from rest_framework import exceptions + +import pytest + +from drf_simple_api_errors import exception_handler +from test_app.utils import ErrorTriggers, render_response + + +@pytest.mark.django_db +class TestSerializerErrors: + def test_field_required_error_ok(self, book_serializer, mocker): + serializer = book_serializer(data={}) + with pytest.raises(exceptions.ValidationError): + exc = serializer.is_valid(raise_exception=True) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "title", + "reason": ["This field is required."], + }, + { + "name": "pages", + "reason": ["This field is required."], + }, + { + "name": "isbn10", + "reason": ["This field is required."], + }, + ], + } + assert render_response(response.data) == expected_response + + def test_field_validation_error_ok(self, book, book_serializer, faker, mocker): + data = { + "isbn10": book.isbn10, + "pages": faker.pyint(max_value=360), + "title": faker.word()[:32], # slice not to exceed max_length + } + + serializer = book_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + exc = serializer.is_valid(raise_exception=True) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "isbn10", + "reason": [f"Book with isbn10 {book.isbn10} already exists."], + } + ], + } + assert render_response(response.data) == expected_response + + def test_validation_error_ok(self, book_serializer, faker, mocker): + data = { + "isbn10": faker.unique.isbn10(), + "pages": faker.pyint(max_value=360), + "title": ErrorTriggers.SERIALIZER_VALIDATION.value, + } + + serializer = book_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + exc = serializer.is_valid(raise_exception=True) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": [f"Title cannot be {ErrorTriggers.SERIALIZER_VALIDATION}"], + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + +@pytest.mark.django_db +class TestModelSerializerErrors: + def test_bad_choice_error_ok(self, book_model_serializer, faker, mocker, user): + edition = faker.word()[:8] + data = { + "author": user.username, + "edition": edition, + "isbn10": faker.unique.isbn10(), + "pages": faker.pyint(max_value=360), + "title": faker.word()[:32], + } + + serializer = book_model_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + exc = serializer.is_valid(raise_exception=True) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "edition", + "reason": [f'"{edition}" is not a valid choice.'], + } + ], + } + assert render_response(response.data) == expected_response + + def test_bad_one_to_one_relationship_error_ok( + self, book_model_serializer, faker, mocker + ): + username = faker.user_name() + data = { + "author": username, + "isbn10": faker.unique.isbn10(), + "pages": ErrorTriggers.MODEL_CONSTRAINT.value, + "title": faker.word()[:32], + } + + serializer = book_model_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + serializer.is_valid(raise_exception=True) + exc = serializer.save() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "author", + "reason": [f"Object with username={username} does not exist."], + } + ], + } + assert render_response(response.data) == expected_response + + def test_bad_many_to_many_relationship_error_ok( + self, book_model_serializer, faker, mocker, user + ): + library_name1, library_name2 = faker.word()[:32], faker.word()[:32] + data = { + "author": user.username, + "isbn10": faker.unique.isbn10(), + "libraries": [library_name1, library_name2], + "pages": faker.pyint(max_value=360), + "title": faker.word()[:32], + } + + serializer = book_model_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + serializer.is_valid(raise_exception=True) + exc = serializer.save() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "libraries", + "reason": [f"Object with name={library_name1} does not exist."], + } + ], + } + assert render_response(response.data) == expected_response + + def test_constraint_error_ok(self, book_model_serializer, faker, mocker, user): + data = { + "author": user.username, + "isbn10": faker.unique.isbn10(), + "pages": ErrorTriggers.MODEL_CONSTRAINT.value, + "title": faker.word()[:32], + } + + serializer = book_model_serializer(data=data) + with pytest.raises(ValidationError): + serializer.is_valid(raise_exception=True) + exc = serializer.save() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": ["Pages cannot be more than 360."], + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_field_required_error_ok(self, book_model_serializer, mocker): + serializer = book_model_serializer(data={}) + with pytest.raises(exceptions.ValidationError): + exc = serializer.is_valid(raise_exception=True) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "author", + "reason": ["This field is required."], + }, + { + "name": "isbn10", + "reason": ["This field is required."], + }, + { + "name": "pages", + "reason": ["This field is required."], + }, + { + "name": "title", + "reason": ["This field is required."], + }, + ], + } + assert render_response(response.data) == expected_response + + def test_method_error_ok(self, book_model_serializer, faker, mocker, user): + data = { + "author": user.username, + "isbn10": faker.unique.isbn10(), + "pages": faker.pyint(max_value=360), + "title": ErrorTriggers.SERIALIZER_METHOD.value, + } + + serializer = book_model_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + serializer.is_valid(raise_exception=True) + exc = serializer.save() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": [ErrorTriggers.SERIALIZER_METHOD.value], + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_validation_error_ok(self, book_model_serializer, faker, mocker, user): + data = { + "author": user.username, + "isbn10": faker.unique.isbn10(), + "pages": faker.pyint(max_value=360), + "title": ErrorTriggers.SERIALIZER_VALIDATION.value, + } + + serializer = book_model_serializer(data=data) + with pytest.raises(exceptions.ValidationError): + exc = serializer.is_valid(raise_exception=True) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Validation error.", + "detail": [f"Title cannot be {ErrorTriggers.SERIALIZER_VALIDATION}"], + "invalid_params": None, + } + assert render_response(response.data) == expected_response diff --git a/test_project/test_app/utils.py b/integration_tests/test_app/utils.py similarity index 100% rename from test_project/test_app/utils.py rename to integration_tests/test_app/utils.py diff --git a/pyproject.toml b/pyproject.toml index 455af60..444c39e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,15 @@ sections = [ ] +[tool.pytest.ini_options] +addopts = ["-q", "-s", "-v", "--create-db", "--nomigrations"] +python_files = ["test*.py"] +testpaths = ["integration_tests", "tests"] +# pytest-django +DJANGO_SETTINGS_MODULE = "integration_tests.settings.django" +pythonpath = [".", "integration_tests", "tests"] + + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/test_project/pytest.ini b/test_project/pytest.ini deleted file mode 100644 index a52ebe8..0000000 --- a/test_project/pytest.ini +++ /dev/null @@ -1,12 +0,0 @@ -[pytest] -addopts = - -q - -s - -v - --create-db - --nomigrations -norecursedirs = .cache .git config tmp* -python_files = tests.py -testpaths = test_app - -DJANGO_SETTINGS_MODULE = config.settings diff --git a/test_project/test_app/tests.py b/test_project/test_app/tests.py deleted file mode 100644 index 7492990..0000000 --- a/test_project/test_app/tests.py +++ /dev/null @@ -1,571 +0,0 @@ -from django.core.exceptions import PermissionDenied, ValidationError -from django.http import Http404 -from rest_framework import exceptions - -import pytest - -from drf_simple_api_errors import exception_handler, utils -from test_app.utils import ErrorTriggers, render_response - - -class TestDjangoExceptions: - """Test the exception handler for various Django exceptions.""" - - @pytest.mark.parametrize( - "error_message, code, params, expected_response", - [ - # Raising a single error message - ( - "Error message.", - None, - None, - { - "title": "Validation error.", - "detail": ["Error message."], - "invalid_params": None, - }, - ), - ( - "Error message: %(msg)s.", - "invalid", - {"msg": "ERROR"}, - { - "title": "Validation error.", - "detail": ["Error message: ERROR."], - "invalid_params": None, - }, - ), - # Raising multiple error messages - ( - [f"Error message {i+1}." for i in range(2)], - None, - None, - { - "title": "Validation error.", - "detail": ["Error message 1.", "Error message 2."], - "invalid_params": None, - }, - ), - ( - [ - ValidationError(f"Error message {i+1}.", code=f"error {i+1}") - for i in range(2) - ], - None, - None, - { - "title": "Validation error.", - "detail": ["Error message 1.", "Error message 2."], - "invalid_params": None, - }, - ), - # Raising a dictionary of error messages - ( - {"field": "Error message."}, - None, - None, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [{"name": "field", "reason": ["Error message."]}], - }, - ), - ( - {"field": [f"Error message {i+1}." for i in range(2)]}, - None, - None, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "field", - "reason": ["Error message 1.", "Error message 2."], - }, - ], - }, - ), - ], - ) - def test_django_validation_error_ok( - self, error_message, code, params, expected_response, mocker - ): - """ - Test the exception handler for ValidationError exceptions. - """ - exc = ValidationError(error_message, code, params) - response = exception_handler(exc, mocker.Mock()) - - assert render_response(response.data) == expected_response - - def test_django_http404_ok(self, mocker): - """Test the exception handler for Http404 exceptions.""" - exc = Http404() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Not found.", - "detail": None, - "invalid_params": None, - } - assert render_response(response.data) == expected_response - - def test_django_permission_denied_ok(self, mocker): - """Test the exception handler for PermissionDenied exceptions.""" - exc = PermissionDenied() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "You do not have permission to perform this action.", - "detail": None, - "invalid_params": None, - } - assert render_response(response.data) == expected_response - - -class TestAPIExceptions: - """Test the exception handler for various API exceptions.""" - - @pytest.mark.parametrize( - "error_message, expected_response", - [ - ( - "Error message.", - { - "title": "A server error occurred.", - "detail": ["Error message."], - "invalid_params": None, - }, - ), - ( - [f"Error message {i}." for i in range(2)], - { - "title": "A server error occurred.", - "detail": ["Error message 0.", "Error message 1."], - "invalid_params": None, - }, - ), - ( - {"field": "Error message."}, - { - "title": "A server error occurred.", - "detail": None, - "invalid_params": [{"name": "field", "reason": ["Error message."]}], - }, - ), - ( - {"field1": {"field2": "Error message."}}, - { - "title": "A server error occurred.", - "detail": None, - "invalid_params": [ - {"name": "field1.field2", "reason": ["Error message."]} - ], - }, - ), - ], - ) - def test_drf_api_exception_ok(self, error_message, expected_response, mocker): - exc = exceptions.APIException(error_message) - response = exception_handler(exc, mocker.Mock()) - - assert render_response(response.data) == expected_response - - @pytest.mark.parametrize( - "error_message, expected_response", - [ - ( - "Error message.", - { - "title": "Validation error.", - "detail": ["Error message."], - "invalid_params": None, - }, - ), - ( - [f"Error message {i}." for i in range(2)], - { - "title": "Validation error.", - "detail": ["Error message 0.", "Error message 1."], - "invalid_params": None, - }, - ), - ( - {"field": "Error message."}, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [{"name": "field", "reason": ["Error message."]}], - }, - ), - ( - {"field1": {"field2": "Error message."}}, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - {"name": "field1.field2", "reason": ["Error message."]} - ], - }, - ), - ( - { - "field1": { - "field2": {"field3": {"field4": {"field5": "Error message."}}} - } - }, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "field1.field2.field3.field4.field5", - "reason": ["Error message."], - } - ], - }, - ), - ( - { - "field1": {"field2": "Error message."}, - "field3": {"field4": "Error message."}, - }, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - {"name": "field1.field2", "reason": ["Error message."]}, - {"name": "field3.field4", "reason": ["Error message."]}, - ], - }, - ), - ( - { - "field1": {"field2": "Error message."}, - "field3": {"field4": {"field5": "Error message."}}, - }, - { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - {"name": "field1.field2", "reason": ["Error message."]}, - {"name": "field3.field4.field5", "reason": ["Error message."]}, - ], - }, - ), - ], - ) - def test_drf_validation_error_ok(self, error_message, expected_response, mocker): - exc = exceptions.ValidationError(error_message) - response = exception_handler(exc, mocker.Mock()) - - assert render_response(response.data) == expected_response - - -@pytest.mark.django_db -class TestSerializerErrors: - def test_field_required_error_ok(self, book_serializer, mocker): - serializer = book_serializer(data={}) - with pytest.raises(exceptions.ValidationError): - exc = serializer.is_valid(raise_exception=True) - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "title", - "reason": ["This field is required."], - }, - { - "name": "pages", - "reason": ["This field is required."], - }, - { - "name": "isbn10", - "reason": ["This field is required."], - }, - ], - } - assert render_response(response.data) == expected_response - - def test_field_validation_error_ok(self, book, book_serializer, faker, mocker): - data = { - "isbn10": book.isbn10, - "pages": faker.pyint(max_value=360), - "title": faker.word()[:32], # slice not to exceed max_length - } - - serializer = book_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - exc = serializer.is_valid(raise_exception=True) - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "isbn10", - "reason": [f"Book with isbn10 {book.isbn10} already exists."], - } - ], - } - assert render_response(response.data) == expected_response - - def test_validation_error_ok(self, book_serializer, faker, mocker): - data = { - "isbn10": faker.unique.isbn10(), - "pages": faker.pyint(max_value=360), - "title": ErrorTriggers.SERIALIZER_VALIDATION.value, - } - - serializer = book_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - exc = serializer.is_valid(raise_exception=True) - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": [f"Title cannot be {ErrorTriggers.SERIALIZER_VALIDATION}"], - "invalid_params": None, - } - assert render_response(response.data) == expected_response - - -@pytest.mark.django_db -class TestModelSerializerErrors: - def test_bad_choice_error_ok(self, book_model_serializer, faker, mocker, user): - edition = faker.word()[:8] - data = { - "author": user.username, - "edition": edition, - "isbn10": faker.unique.isbn10(), - "pages": faker.pyint(max_value=360), - "title": faker.word()[:32], - } - - serializer = book_model_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - exc = serializer.is_valid(raise_exception=True) - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "edition", - "reason": [f'"{edition}" is not a valid choice.'], - } - ], - } - assert render_response(response.data) == expected_response - - def test_bad_one_to_one_relationship_error_ok( - self, book_model_serializer, faker, mocker - ): - username = faker.user_name() - data = { - "author": username, - "isbn10": faker.unique.isbn10(), - "pages": ErrorTriggers.MODEL_CONSTRAINT.value, - "title": faker.word()[:32], - } - - serializer = book_model_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - serializer.is_valid(raise_exception=True) - exc = serializer.save() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "author", - "reason": [f"Object with username={username} does not exist."], - } - ], - } - assert render_response(response.data) == expected_response - - def test_bad_many_to_many_relationship_error_ok( - self, book_model_serializer, faker, mocker, user - ): - library_name1, library_name2 = faker.word()[:32], faker.word()[:32] - data = { - "author": user.username, - "isbn10": faker.unique.isbn10(), - "libraries": [library_name1, library_name2], - "pages": faker.pyint(max_value=360), - "title": faker.word()[:32], - } - - serializer = book_model_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - serializer.is_valid(raise_exception=True) - exc = serializer.save() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "libraries", - "reason": [f"Object with name={library_name1} does not exist."], - } - ], - } - assert render_response(response.data) == expected_response - - def test_constraint_error_ok(self, book_model_serializer, faker, mocker, user): - data = { - "author": user.username, - "isbn10": faker.unique.isbn10(), - "pages": ErrorTriggers.MODEL_CONSTRAINT.value, - "title": faker.word()[:32], - } - - serializer = book_model_serializer(data=data) - with pytest.raises(ValidationError): - serializer.is_valid(raise_exception=True) - exc = serializer.save() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": ["Pages cannot be more than 360."], - "invalid_params": None, - } - assert render_response(response.data) == expected_response - - def test_field_required_error_ok(self, book_model_serializer, mocker): - serializer = book_model_serializer(data={}) - with pytest.raises(exceptions.ValidationError): - exc = serializer.is_valid(raise_exception=True) - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": None, - "invalid_params": [ - { - "name": "author", - "reason": ["This field is required."], - }, - { - "name": "isbn10", - "reason": ["This field is required."], - }, - { - "name": "pages", - "reason": ["This field is required."], - }, - { - "name": "title", - "reason": ["This field is required."], - }, - ], - } - assert render_response(response.data) == expected_response - - def test_method_error_ok(self, book_model_serializer, faker, mocker, user): - data = { - "author": user.username, - "isbn10": faker.unique.isbn10(), - "pages": faker.pyint(max_value=360), - "title": ErrorTriggers.SERIALIZER_METHOD.value, - } - - serializer = book_model_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - serializer.is_valid(raise_exception=True) - exc = serializer.save() - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": [ErrorTriggers.SERIALIZER_METHOD.value], - "invalid_params": None, - } - assert render_response(response.data) == expected_response - - def test_validation_error_ok(self, book_model_serializer, faker, mocker, user): - data = { - "author": user.username, - "isbn10": faker.unique.isbn10(), - "pages": faker.pyint(max_value=360), - "title": ErrorTriggers.SERIALIZER_VALIDATION.value, - } - - serializer = book_model_serializer(data=data) - with pytest.raises(exceptions.ValidationError): - exc = serializer.is_valid(raise_exception=True) - response = exception_handler(exc, mocker.Mock()) - - expected_response = { - "title": "Validation error.", - "detail": [f"Title cannot be {ErrorTriggers.SERIALIZER_VALIDATION}"], - "invalid_params": None, - } - assert render_response(response.data) == expected_response - - -class TestUtils: - @pytest.mark.parametrize( - "field_input, expected_output, camelize_enabled", - [ - ("", "", True), - ("name", "name", True), - ("first_name", "firstName", True), - ("family_tree_name", "familyTreeName", True), - ( - "very_long_last_name_and_first_name", - "veryLongLastNameAndFirstName", - True, - ), - ("", "", False), - ("name", "name", False), - ("first_name", "first_name", False), - ("family_tree_name", "family_tree_name", False), - ( - "very_long_last_name_and_first_name", - "very_long_last_name_and_first_name", - False, - ), - ], - ) - def test_camelize_with_settings_set( - self, mocker, field_input, expected_output, camelize_enabled - ): - mocker.patch( - "drf_simple_api_errors.settings.api_settings.CAMELIZE", camelize_enabled - ) - - assert utils.camelize(field_input) == expected_output - - @pytest.mark.parametrize( - "original_dict, flattened_dict", - [ - ({"key": "value"}, {"key": "value"}), - ({"key": {"subkey": "value"}}, {"key.subkey": "value"}), - ( - {"key": {"subkey": {"subsubkey": "value"}}}, - {"key.subkey.subsubkey": "value"}, - ), - ( - {"key": {"subkey": "value", "subkey2": "value2"}}, - {"key.subkey": "value", "key.subkey2": "value2"}, - ), - ( - {"key": {"subkey": {"subsubkey": "value", "subsubkey2": "value2"}}}, - {"key.subkey.subsubkey": "value", "key.subkey.subsubkey2": "value2"}, - ), - ], - ) - def test_flatten_dict(self, original_dict, flattened_dict): - assert utils.flatten_dict(original_dict) == flattened_dict diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_exception_handler.py b/tests/test_exception_handler.py new file mode 100644 index 0000000..f12fda8 --- /dev/null +++ b/tests/test_exception_handler.py @@ -0,0 +1,401 @@ +from django.core import exceptions as django_exceptions +from django.http import Http404 +from rest_framework import exceptions as drf_exceptions + +import pytest + +from drf_simple_api_errors.exception_handler import exception_handler +from tests.utils import render_response + + +class TestExceptionHandler: + """ + Tests for the DRF exception handler response formatting + for various exceptions. + """ + + @pytest.mark.parametrize( + "error_message, code, params, expected_response", + [ + # Raising a single error message + ( + "Error message.", + None, + None, + { + "title": "Validation error.", + "detail": ["Error message."], + "invalid_params": None, + }, + ), + ( + "Error message: %(msg)s.", + "invalid", + {"msg": "ERROR"}, + { + "title": "Validation error.", + "detail": ["Error message: ERROR."], + "invalid_params": None, + }, + ), + # Raising multiple error messages + ( + [f"Error message {i+1}." for i in range(2)], + None, + None, + { + "title": "Validation error.", + "detail": ["Error message 1.", "Error message 2."], + "invalid_params": None, + }, + ), + ( + [ + django_exceptions.ValidationError( + f"Error message {i+1}.", code=f"error {i+1}" + ) + for i in range(2) + ], + None, + None, + { + "title": "Validation error.", + "detail": ["Error message 1.", "Error message 2."], + "invalid_params": None, + }, + ), + # Raising a dictionary of error messages + ( + {"field": "Error message."}, + None, + None, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [{"name": "field", "reason": ["Error message."]}], + }, + ), + ( + {"field": [f"Error message {i+1}." for i in range(2)]}, + None, + None, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "field", + "reason": ["Error message 1.", "Error message 2."], + }, + ], + }, + ), + ], + ) + def test_django_validation_error( + self, error_message, code, params, expected_response, mocker + ): + """ + Test the exception handler for can handle Django ValidationError + exceptions and formats them into a structured API response. + """ + exc = django_exceptions.ValidationError(error_message, code, params) + response = exception_handler(exc, mocker.Mock()) + + assert render_response(response.data) == expected_response + + def test_django_http404(self, mocker): + """Test the exception handler for Http404 exceptions.""" + exc = Http404() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Not found.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_django_permission_denied(self, mocker): + """Test the exception handler for PermissionDenied exceptions.""" + exc = django_exceptions.PermissionDenied() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "You do not have permission to perform this action.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + @pytest.mark.parametrize( + "error_message, expected_response", + [ + ( + "Error message.", + { + "title": "A server error occurred.", + "detail": ["Error message."], + "invalid_params": None, + }, + ), + ( + [f"Error message {i}." for i in range(2)], + { + "title": "A server error occurred.", + "detail": ["Error message 0.", "Error message 1."], + "invalid_params": None, + }, + ), + ( + {"field": "Error message."}, + { + "title": "A server error occurred.", + "detail": None, + "invalid_params": [{"name": "field", "reason": ["Error message."]}], + }, + ), + ( + {"field1": {"field2": "Error message."}}, + { + "title": "A server error occurred.", + "detail": None, + "invalid_params": [ + {"name": "field1.field2", "reason": ["Error message."]} + ], + }, + ), + ], + ) + def test_drf_api_exception(self, error_message, expected_response, mocker): + exc = drf_exceptions.APIException(error_message) + response = exception_handler(exc, mocker.Mock()) + + assert render_response(response.data) == expected_response + + @pytest.mark.parametrize( + "error_message, expected_response", + [ + ( + "Error message.", + { + "title": "Validation error.", + "detail": ["Error message."], + "invalid_params": None, + }, + ), + ( + [f"Error message {i}." for i in range(2)], + { + "title": "Validation error.", + "detail": ["Error message 0.", "Error message 1."], + "invalid_params": None, + }, + ), + ( + {"field": "Error message."}, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [{"name": "field", "reason": ["Error message."]}], + }, + ), + ( + {"field1": {"field2": "Error message."}}, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + {"name": "field1.field2", "reason": ["Error message."]} + ], + }, + ), + ( + { + "field1": { + "field2": {"field3": {"field4": {"field5": "Error message."}}} + } + }, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + { + "name": "field1.field2.field3.field4.field5", + "reason": ["Error message."], + } + ], + }, + ), + ( + { + "field1": {"field2": "Error message."}, + "field3": {"field4": "Error message."}, + }, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + {"name": "field1.field2", "reason": ["Error message."]}, + {"name": "field3.field4", "reason": ["Error message."]}, + ], + }, + ), + ( + { + "field1": {"field2": "Error message."}, + "field3": {"field4": {"field5": "Error message."}}, + }, + { + "title": "Validation error.", + "detail": None, + "invalid_params": [ + {"name": "field1.field2", "reason": ["Error message."]}, + {"name": "field3.field4.field5", "reason": ["Error message."]}, + ], + }, + ), + ], + ) + def test_drf_validation_error(self, error_message, expected_response, mocker): + exc = drf_exceptions.ValidationError(error_message) + response = exception_handler(exc, mocker.Mock()) + + assert render_response(response.data) == expected_response + + def test_drf_parser_error(self, mocker): + """ + Test the exception handler for DRF ParserError exceptions. + """ + exc = drf_exceptions.ParseError() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Malformed request.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + @pytest.mark.parametrize( + "exception, expected_response", + [ + ( + drf_exceptions.NotAuthenticated(), + { + "title": "Authentication credentials were not provided.", + "detail": None, + "invalid_params": None, + }, + ), + ( + drf_exceptions.AuthenticationFailed(), + { + "title": "Incorrect authentication credentials.", + "detail": None, + "invalid_params": None, + }, + ), + ], + ) + def test_drf_authentication_exceptions(self, exception, expected_response, mocker): + """ + Test the exception handler for DRF authentication exceptions. + """ + response = exception_handler(exception, mocker.Mock()) + + assert render_response(response.data) == expected_response + + def test_drf_permission_denied(self, mocker): + """ + Test the exception handler for DRF PermissionDenied exceptions. + """ + exc = drf_exceptions.PermissionDenied() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "You do not have permission to perform this action.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_drf_not_found(self, mocker): + """ + Test the exception handler for DRF NotFound exceptions. + """ + exc = drf_exceptions.NotFound() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Not found.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_drf_method_not_allowed(self, mocker): + """ + Test the exception handler for DRF MethodNotAllowed exceptions. + """ + method = "GET" + exc = drf_exceptions.MethodNotAllowed(method) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Method not allowed.", + "detail": [f'Method "{method}" not allowed.'], + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_drf_not_acceptable(self, mocker): + """ + Test the exception handler for DRF NotAcceptable exceptions. + """ + exc = drf_exceptions.NotAcceptable() + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Could not satisfy the request Accept header.", + "detail": None, + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + def test_drf_unsupported_media_type(self, mocker): + """ + Test the exception handler for DRF UnsupportedMediaType exceptions. + """ + media_type = "application/json" + exc = drf_exceptions.UnsupportedMediaType(media_type) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Unsupported media type.", + "detail": [f'Unsupported media type "{media_type}" in request.'], + "invalid_params": None, + } + assert render_response(response.data) == expected_response + + @pytest.mark.parametrize( + "wait, expected_detail", + [ + (None, None), + (1, ["Request was throttled. Expected available in 1 second."]), + (60, ["Request was throttled. Expected available in 60 seconds."]), + ], + ) + def test_drf_throttled(self, mocker, wait, expected_detail): + """ + Test the exception handler for DRF Throttled exceptions. + """ + exc = drf_exceptions.Throttled(wait=wait) + response = exception_handler(exc, mocker.Mock()) + + expected_response = { + "title": "Request was throttled.", + "detail": expected_detail, + "invalid_params": None, + } + assert render_response(response.data) == expected_response diff --git a/tests/test_extra_handlers.py b/tests/test_extra_handlers.py new file mode 100644 index 0000000..7ca529b --- /dev/null +++ b/tests/test_extra_handlers.py @@ -0,0 +1,50 @@ +from rest_framework import exceptions as drf_exceptions + +import pytest + +from drf_simple_api_errors import extra_handlers + + +class TestSetDefaultDetailHandler: + """ + Test the set_default_detail_handler function from extra_handlers. + Check this function sets the default detail of an exception to the formatted + exception's default code. + """ + + @pytest.mark.parametrize( + "exc, expected_default_detail", + [ + (drf_exceptions.MethodNotAllowed("GET"), "Method not allowed."), + ( + drf_exceptions.UnsupportedMediaType("application/json"), + "Unsupported media type.", + ), + ], + ) + def test_set_default_detail_correctly(self, exc, expected_default_detail): + """ + Test that the handler sets the default detail to a human-readable string + based on the exception's default code. + """ + extra_handlers.set_default_detail_to_formatted_exc_default_code(exc) + assert exc.default_detail == expected_default_detail + assert exc.detail != expected_default_detail + + @pytest.mark.parametrize( + "exc", + [ + drf_exceptions.ValidationError("This is a validation error."), + drf_exceptions.PermissionDenied(), + drf_exceptions.NotFound(), + ], + ) + def test_set_default_detail_no_change_for_other_exceptions(self, exc): + """ + Test that the handler does not change the default detail for exceptions + that are not MethodNotAllowed or UnsupportedMediaType. + """ + original_default_detail = exc.default_detail + extra_handlers.set_default_detail_to_formatted_exc_default_code(exc) + + assert exc.default_detail == original_default_detail diff --git a/tests/test_formatter.py b/tests/test_formatter.py new file mode 100644 index 0000000..0f844a3 --- /dev/null +++ b/tests/test_formatter.py @@ -0,0 +1,235 @@ +from rest_framework import exceptions as drf_exceptions + +import pytest + +from drf_simple_api_errors import formatter + + +class TestAPIErrorResponse: + def test_init(self): + """ + Test the initialization of APIErrorResponse. + + This checks that the default values are set correctly. + In our use case, all the fields are eventually set when the + exception is evaluated. + """ + response = formatter.APIErrorResponse() + + assert response.title == "" + assert response.detail is None + assert response.invalid_params is None + + def test_to_dict(self): + """Test the conversion of APIErrorResponse to dictionary.""" + response = formatter.APIErrorResponse( + title="Error Title", + detail=["Detail 1", "Detail 2"], + invalid_params=[ + formatter.InvalidParam(name="param1", reason=["Invalid type"]), + formatter.InvalidParam( + name="param2", reason=["Required field missing"] + ), + ], + ) + + expected_dict = { + "title": "Error Title", + "detail": ["Detail 1", "Detail 2"], + "invalid_params": [ + {"name": "param1", "reason": ["Invalid type"]}, + {"name": "param2", "reason": ["Required field missing"]}, + ], + } + assert response.to_dict() == expected_dict + + def test_to_dict_if_camelize_is_true(self, mocker): + """ + Test the conversion of APIErrorResponse to dictionary when + camelize setting is True. + """ + mocker.patch("drf_simple_api_errors.settings.api_settings.CAMELIZE", True) + + response = formatter.APIErrorResponse( + title="Error Title", + detail=["Detail 1", "Detail 2"], + invalid_params=[ + formatter.InvalidParam(name="param1", reason=["Invalid type"]), + formatter.InvalidParam( + name="param2", reason=["Required field missing"] + ), + ], + ) + + expected_dict = { + "title": "Error Title", + "detail": ["Detail 1", "Detail 2"], + "invalidParams": [ + {"name": "param1", "reason": ["Invalid type"]}, + {"name": "param2", "reason": ["Required field missing"]}, + ], + } + assert response.to_dict() == expected_dict + + +class TestFormatExc: + def test_title_set_when_validation_error(self): + """ + Test that the title is set to "Validation Error" for ValidationError exceptions. + """ + exc = drf_exceptions.ValidationError() + + data = formatter.format_exc(exc) + assert data["title"] == "Validation error." + + @pytest.mark.parametrize( + "exc, expected_title", + [ + (drf_exceptions.NotFound(), "Not found."), + # Exc detail is populated but title is unchanged + ( + drf_exceptions.AuthenticationFailed("Bad credentials."), + "Incorrect authentication credentials.", + ), + ], + ) + def test_title_set_with_exception_title(self, exc, expected_title): + """ + Test that the title is set correctly for various exceptions. + """ + data = formatter.format_exc(exc) + assert data["title"] == expected_title + + def test_title_set_when_exception_detail_is_the_same_as_default_detail(self): + """ + Test that when the exception detail is the same as the default detail, + only the title is set and the detail is `None`. + """ + exc = drf_exceptions.NotFound("Not found.") + + data = formatter.format_exc(exc) + assert data["title"] == "Not found." + assert data["detail"] is None + + @pytest.mark.parametrize( + "exc, expected_invalid_params", + [ + ( + drf_exceptions.ValidationError({"field": ["This field is required."]}), + [{"name": "field", "reason": ["This field is required."]}], + ), + # The field error detail is not a list, so it should be converted to a list. + ( + drf_exceptions.ValidationError({"field": "This field is required."}), + [{"name": "field", "reason": ["This field is required."]}], + ), + # Multiple fields with errors + ( + drf_exceptions.ValidationError( + {"field1": ["This field is required."], "field2": "Invalid value."} + ), + [ + {"name": "field1", "reason": ["This field is required."]}, + {"name": "field2", "reason": ["Invalid value."]}, + ], + ), + # Nested fields with errors + ( + drf_exceptions.ValidationError( + { + "field1": {"subfield": ["This subfield is required."]}, + "field2": "Invalid value.", + } + ), + [ + { + "name": "field1.subfield", + "reason": ["This subfield is required."], + }, + {"name": "field2", "reason": ["Invalid value."]}, + ], + ), + ], + ) + def test_exc_detail_is_dict_with_invalid_params(self, exc, expected_invalid_params): + """ + Test that the exception detail is correctly formatted + when it contains invalid parameters. + The detail should be `None` and invalid_params should be populated. + """ + data = formatter.format_exc(exc) + assert data["detail"] is None + assert data["invalid_params"] == expected_invalid_params + + @pytest.mark.parametrize( + "non_field_errors_key", ["non_field_errors", "nonFieldErrors", "__all__"] + ) + def test_exc_detail_is_dict_with_non_field_errors( + self, mocker, non_field_errors_key + ): + """ + Test that when the exception detail is a dict with non-field errors, + the detail is set to the error messages and invalid_params is None. + """ + if non_field_errors_key != "__all__": + # Mock the non_field_errors_key setting if it's not "__all__" + # The key "__all__" is normally by ModelSerializer and + # is not part of the DRF settings. + mocker.patch( + "rest_framework.settings.api_settings.NON_FIELD_ERRORS_KEY", + non_field_errors_key, + ) + + exc = drf_exceptions.ValidationError( + {non_field_errors_key: ["This is a non-field error."]} + ) + + data = formatter.format_exc(exc) + assert data["detail"] == ["This is a non-field error."] + assert data["invalid_params"] is None + + @pytest.mark.parametrize( + "exc_detail", + [ + ["This is a non-field error."], + "This is a non-field error.", + ], + ) + def test_exc_detail_is_dict_with_non_field_errors_formats(self, mocker, exc_detail): + """ + Test that when the non field errors are provided in different formats, + the detail is set to the error messages list and invalid_params is `None`. + """ + exc = drf_exceptions.ValidationError({"non_field_errors": exc_detail}) + + data = formatter.format_exc(exc) + assert data["detail"] == ["This is a non-field error."] + assert data["invalid_params"] is None + + @pytest.mark.parametrize( + "exc_detail", + [ + "This is a non-field error.", + ["This is a non-field error."], + ], + ) + def test_exc_detail_is_list_formats(self, exc_detail): + """ + Test that when the exception detail is a list or a string, + the detail is set to the error messages list and invalid_params is `None`. + """ + exc = drf_exceptions.ValidationError(exc_detail) + + data = formatter.format_exc(exc) + assert data["detail"] == ["This is a non-field error."] + assert data["invalid_params"] is None + + def test_format_exc_detail_is_list_error_when_unexpected_type(self): + """ + Test that when the exception detail is an unexpected type, + it raises a TypeError. + """ + with pytest.raises(TypeError): + formatter.format_exc( + drf_exceptions.ValidationError([{"question": "What are you doing?"}]) + ) diff --git a/tests/test_hadlers.py b/tests/test_hadlers.py new file mode 100644 index 0000000..f7a6f7a --- /dev/null +++ b/tests/test_hadlers.py @@ -0,0 +1,123 @@ +from django.core import exceptions as django_exceptions +from django.http import Http404 +from rest_framework import exceptions as drf_exceptions + +import pytest + +from drf_simple_api_errors import handlers + + +class TestApplyExtraHandlers: + """Test cases for the apply_extra_handlers function in handlers module.""" + + def test_extra_handlers_called(self, mocker): + """ + Test that extra handlers are called with the exception. + + Testing with multiple handlers to ensure all are invoked, and this gives + confidence one or more than two handlers can be used correctly. + """ + mock_extra_handler = mocker.MagicMock() + mock_another_extra_handler = mocker.MagicMock() + + mocker.patch( + "drf_simple_api_errors.settings.api_settings.EXTRA_HANDLERS", + [mock_extra_handler, mock_another_extra_handler], + ) + + exc = mocker.MagicMock() + handlers.apply_extra_handlers(exc) + + mock_extra_handler.assert_called_once_with(exc) + mock_another_extra_handler.assert_called_once_with(exc) + + def test_no_extra_handlers(self, mocker): + """Test that no extra handlers are called when EXTRA_HANDLERS is empty.""" + mock_extra_handler = mocker.MagicMock() + + exc = mocker.MagicMock() + handlers.apply_extra_handlers(exc) + + mock_extra_handler.assert_not_called() + + +class TestConvertDjangoExcToDRFAPIExc: + """ + Test cases for the convert_django_exc_to_drf_api_exc function in handlers module. + """ + + def test_converts_django_validation_error(self): + """ + Test that a Django ValidationError is converted to a DRF ValidationError. + + Trusting that rest_framework.serializers.as_serializer_error + transfers the error message correctly, so + not testing the content of the message. + """ + exc = django_exceptions.ValidationError("Oh no, my spaghetti!") + + new_exc = handlers.convert_django_exc_to_drf_api_exc(exc) + assert isinstance(new_exc, drf_exceptions.ValidationError) + + def test_converts_django_http404(self): + """Test that a Django Http404 is converted to a DRF NotFound exception.""" + exc = Http404() + + new_exc = handlers.convert_django_exc_to_drf_api_exc(exc) + assert isinstance(new_exc, drf_exceptions.NotFound) + + def test_converts_django_permission_denied(self): + """ + Test that a Django PermissionDenied is converted to a DRF PermissionDenied. + """ + exc = django_exceptions.PermissionDenied("No spaghetti for you!") + + new_exc = handlers.convert_django_exc_to_drf_api_exc(exc) + assert isinstance(new_exc, drf_exceptions.PermissionDenied) + + def test_unknown_exception_not_converted(self): + """ + Test that an unknown exception is not converted and remains the same type. + This ensures that the function does not alter exceptions that + are not recognized. + """ + exc_class = Exception + + exc = exc_class() + + new_exc = handlers.convert_django_exc_to_drf_api_exc(exc) + assert isinstance(new_exc, exc_class) + + +class TestGetResponseHeaders: + """Test cases for the get_response_headers function in handlers module.""" + + @pytest.mark.parametrize( + "attr,value,header_name", + [ + ("auth_header", "", "WWW-Authenticate"), + ("wait", 10, "Retry-After"), + ], + ) + def test_set_response_headers(self, mocker, attr, value, header_name): + """ + Test that the response headers are set correctly based on + the exception attributes. + """ + exc = drf_exceptions.APIException("Spaghetti error") + setattr(exc, attr, value) + + headers = handlers.get_response_headers(exc) + + assert headers[header_name] == str(value) + + def test_no_headers_when_no_attr(self): + """ + Test that no headers are set when the exception does not have + the relevant attributes. + """ + exc = drf_exceptions.APIException("Spaghetti error") + + headers = handlers.get_response_headers(exc) + + assert not headers diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..dd60f41 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,80 @@ +import pytest + +from drf_simple_api_errors import utils + + +class TestCamelize: + """Test cases for the camelize function in utils module.""" + + @pytest.mark.parametrize( + "field_input, expected_output", + [ + ("", ""), + ("name", "name"), + ("first_name", "firstName"), + ("family_tree_name", "familyTreeName"), + ("very_long_last_name_and_first_name", "veryLongLastNameAndFirstName"), + ], + ) + def test_with_camelize_settings_true(self, mocker, field_input, expected_output): + """Test conversion to camelCase when CAMELIZE setting is True.""" + mocker.patch("drf_simple_api_errors.settings.api_settings.CAMELIZE", True) + + assert utils.camelize(field_input) == expected_output + + @pytest.mark.parametrize( + "field_input, expected_output", + [ + ("", ""), + ("name", "name"), + ("first_name", "first_name"), + ("family_tree_name", "family_tree_name"), + ( + "very_long_last_name_and_first_name", + "very_long_last_name_and_first_name", + ), + ], + ) + def test_with_camelize_settings_false(self, field_input, expected_output): + """Test no conversion to camelCase when CAMELIZE setting is False (default).""" + assert utils.camelize(field_input) == expected_output + + +class TestFlattenDict: + """Test cases for the flatten_dict function in utils module.""" + + @pytest.mark.parametrize( + "original_dict, flattened_dict", + [ + ({"key": "value"}, {"key": "value"}), + ({"key": {"subkey": "value"}}, {"key.subkey": "value"}), + ( + {"key": {"subkey": {"subsubkey": "value"}}}, + {"key.subkey.subsubkey": "value"}, + ), + ( + {"key": {"subkey": "value", "subkey2": "value2"}}, + {"key.subkey": "value", "key.subkey2": "value2"}, + ), + ( + {"key": {"subkey": {"subsubkey": "value", "subsubkey2": "value2"}}}, + {"key.subkey.subsubkey": "value", "key.subkey.subsubkey2": "value2"}, + ), + ], + ) + def test_with_default_field_separator(self, original_dict, flattened_dict): + """Test flattening a dictionary with default field separator.""" + assert utils.flatten_dict(original_dict) == flattened_dict + + def test_with_different_field_separator_settings(self, mocker): + """Test flattening a dictionary with a custom field separator.""" + new_separator = "_" + mocker.patch( + "drf_simple_api_errors.settings.api_settings.FIELDS_SEPARATOR", + new_separator, + ) + + original_dict = {"key": {"subkey": "value"}} + + expected_result = {f"key{new_separator}subkey": "value"} + assert utils.flatten_dict(original_dict) == expected_result diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3f16829 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,18 @@ +from rest_framework.response import Response + + +def render_response(data: dict) -> dict: + """ + Renders the response data from a `dict` to a DRF Response object. + + Args: + data (dict): The data to be rendered. + + Returns: + response.data (dict): The rendered response data from the DRF `Response` object. + """ + # This is needed in tests to ensure that + # the response data is in the same format as the DRF Response given by + # the exception handler. + response = Response(data=data) + return response.data From 33a1acf09c71cfd2a3022f55471a7a1592bd901d Mon Sep 17 00:00:00 2001 From: Gian Date: Wed, 16 Jul 2025 19:16:59 +0100 Subject: [PATCH 13/14] Update README.md --- CHANGELOG.md | 10 ++++--- README.md | 80 +++++----------------------------------------------- 2 files changed, 13 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc8976..395c8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Changes: Changes: -- Add docstrings to handlers +- Add docstrings - Improve Makefile - Improve README @@ -24,19 +24,21 @@ Changes: Changes: -- Update README -- Improve tests - Fix DRF API settings initialization +- Improve tests +- Update README ## [2.0.0] - 2025-07-11 Breaking changes: -- The API error response now always includes the keys: `title`, `detail`, and `invalid_param`. The `title` key is always populated, while `detail` and `invalid_param` may be `null` depending on the error source. +- The API error response now **always** includes the keys: `title`, `detail`, and `invalid_param`. The `title` key is always populated, while `detail` and `invalid_param` may be `null` depending on the error source. - Drop support for python 3.8 Changes: - Improve code modularity and readability +- Split tests in unittest and integration tests +- Improve test coverage - Update Makefile - Update README diff --git a/README.md b/README.md index 8488ef0..2c35714 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A library for [Django Rest Framework](https://www.django-rest-framework.org/) re This library was built with [RFC7807](https://tools.ietf.org/html/rfc7807) guidelines in mind, but with a small twist: it defines a "problem detail" as a list instead of a string, but it still serves as a way to include errors in a human-readable and easy-to-parse format for any API consumer. Error messages are formatted using RFC7807 keywords and DRF exception data. -This library always returns errors in a consistent, predictable structure, making them easier to handle and parse, unlike standard DRF, where error response formats vary depending on the error source. +Unlike standard DRF, where the error response format varies depending on the error source, this library always returns errors in a consistent, predictable structure to make them easier to handle and parse. ## What's different? @@ -134,7 +134,7 @@ API error messages will include the following keys: ## Settings -Default available settings: +Default settings: ```python DRF_SIMPLE_API_ERRORS = { @@ -171,89 +171,23 @@ If `CAMELIZE` is set to `True`: Support for exceptions that differ from the standard structure of the Django Rest Framework. -For instance, you may want to specify you own exception: +For example, if you need to customize how a specific exception is handled or want to format an existing exception differently, you can create your own handler. -```python -class AuthenticationFailed(exceptions.AuthenticationFailed): - def __init__(self, detail=None, code=None): - """ - Builds a detail dictionary for the error to give more information - to API users. - """ - detail_dict = {"detail": self.default_detail, "code": self.default_code} - - if isinstance(detail, dict): - detail_dict.update(detail) - elif detail is not None: - detail_dict["detail"] = detail - - if code is not None: - detail_dict["code"] = code - - super().__init__(detail_dict) -``` - -Use exception in code: - -```python -def my_func(): - raise AuthenticationFailed( - { - "detail": _("Error message."), - "messages": [ - { - "metadata": "metadata_data", - "type": "type_name", - "message": "error message", - } - ], - } - ) -``` - -This will result in: - -```python -AuthenticationFailed( - { - "detail": "Error message.", - "messages": [ - { - "metadata": "metadata_data", - "type": "type_name", - "message": "error message", - } - ], - } -) -``` - -You can handle this by creating a `handlers.py` file and specifying an handler for your use case: - -```python -def handle_exc_custom_authentication_failed(exc): - from path.to.my.exceptions import AuthenticationFailed - - if isinstance(exc, AuthenticationFailed): - try: - exc.detail = exc.detail["messages"][0]["message"] - except (KeyError, IndexError): - exc.detail = exc.detail["detail"] - - return exc -``` +To customize error handling for your project, simply create a new file (for example, `extra_handlers.py`) and define your own handler functions. This approach lets you tailor error responses to fit your specific needs. Then add it to the `EXTRA_HANDLERS` list in this package settings: ```python DRF_SIMPLE_API_ERRORS = { "EXTRA_HANDLERS": [ - "path.to.my.handlers.handle_exc_custom_authentication_failed", + "path.to.my.extra_handlers.custom_handler", # ... ] } ``` +For reference, this library uses the same pattern for its own extra handlers [here](drf_simple_api_errors/extra_handlers.py). + - #### FIELDS_SEPARATOR Support for nested dicts containing multiple fields to be flattened. From b5df2c0ef2e0d3bf533d77f393099bca6327721c Mon Sep 17 00:00:00 2001 From: Gian Date: Wed, 16 Jul 2025 19:23:46 +0100 Subject: [PATCH 14/14] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c35714..e610452 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A library for [Django Rest Framework](https://www.django-rest-framework.org/) re This library was built with [RFC7807](https://tools.ietf.org/html/rfc7807) guidelines in mind, but with a small twist: it defines a "problem detail" as a list instead of a string, but it still serves as a way to include errors in a human-readable and easy-to-parse format for any API consumer. Error messages are formatted using RFC7807 keywords and DRF exception data. -Unlike standard DRF, where the error response format varies depending on the error source, this library always returns errors in a consistent, predictable structure to make them easier to handle and parse. +Unlike standard DRF, where the error response format varies depending on the error source, this library always returns errors in a consistent and predictable structure. ## What's different?