Integration wth drf-spectacular

Configuration

Set the default schema class to the one provided by the package

REST_FRAMEWORK = {
    # other settings
    "DEFAULT_SCHEMA_CLASS": "drf_standardized_errors.openapi.AutoSchema"
}

or on a view basis (especially if you’re introducing this to a versioned API)

from drf_standardized_errors.openapi import AutoSchema
from rest_framework.views import APIView

class MyAPIView(APIView):
    schema = AutoSchema()

Next, add the following to drf_spectacular setting ENUM_NAME_OVERRIDES. This will avoid multiple warnings raised by drf-spectacular due to the same set of error codes appearing in multiple operations.

SPECTACULAR_SETTINGS = {
    # other settings
    "ENUM_NAME_OVERRIDES": {
        "ValidationErrorEnum": "drf_standardized_errors.openapi_serializers.ValidationErrorEnum.choices",
        "ClientErrorEnum": "drf_standardized_errors.openapi_serializers.ClientErrorEnum.choices",
        "ServerErrorEnum": "drf_standardized_errors.openapi_serializers.ServerErrorEnum.choices",
        "ErrorCode401Enum": "drf_standardized_errors.openapi_serializers.ErrorCode401Enum.choices",
        "ErrorCode403Enum": "drf_standardized_errors.openapi_serializers.ErrorCode403Enum.choices",
        "ErrorCode404Enum": "drf_standardized_errors.openapi_serializers.ErrorCode404Enum.choices",
        "ErrorCode405Enum": "drf_standardized_errors.openapi_serializers.ErrorCode405Enum.choices",
        "ErrorCode406Enum": "drf_standardized_errors.openapi_serializers.ErrorCode406Enum.choices",
        "ErrorCode415Enum": "drf_standardized_errors.openapi_serializers.ErrorCode415Enum.choices",
        "ErrorCode429Enum": "drf_standardized_errors.openapi_serializers.ErrorCode429Enum.choices",
        "ErrorCode500Enum": "drf_standardized_errors.openapi_serializers.ErrorCode500Enum.choices",
    },
}

Last, if you’re not overriding the postprocessing hook setting from drf-spectacular, set it to

SPECTACULAR_SETTINGS = {
    # other settings
    "POSTPROCESSING_HOOKS": ["drf_standardized_errors.openapi_hooks.postprocess_schema_enums"]
}

But if you’re already overriding it, make sure to replace the enums postprocessing hook from drf-spectacular with the one from this package. The hook will avoid raising warnings for dynamically created error code enums per field.

That’s it, now error responses will be automatically generated for each operation in your schema. Here’s an example of how it will look in swagger UI.

Notes

  • The implementation covers all the status codes returned by DRF which are: 400, 401, 403, 404, 405, 406, 415, 429 and 500. More info about each status code and the corresponding exception can be found here.

  • The main goal of the current implementation is to generate a precise schema definition for validation errors. That means documenting all possible error codes on a field-basis. That will help API consumers know in advance all possible errors returned, so they can change the error messages based on the error code or execute specific logic for a certain error code.

  • The implementation includes support for django-filter when it is used. That means validation error responses are generated for list views using the DjangoFilterBackend and specifying a filterset_class or filterset_fields.

  • For validation errors, error codes for each serializer field are collected from the corresponding error_messages attribute of that field. So, for this package to collect custom error codes, it’s a good idea to follow the DRF-way of defining and raising validation errors. Below is a sample serializer definition that would result in adding unknown_email_domain to the possible error codes raised by the email field and invalid_date_range to the list of codes associated with non_field_errors of the serializer. What’s important is that custom error codes are added to the default_error_messages of the corresponding serializer or serializer field. Note that you can also override __init__ and add the error codes to self.error_messages directly.

from rest_framework.fields import empty
from rest_framework import serializers


class CustomDomainEmailField(serializers.EmailField):
    default_error_messages = {"unknown_email_domain": "The email domain is invalid."}

    def run_validation(self, data=empty):
        data = super().run_validation(data)
        if data and not data.endswith("custom-domain.com"):
            self.fail("unknown_email_domain")
        return data


class CustomSerializer(serializers.Serializer):
    default_error_messages = {
        "invalid_date_range": "The end date should be after the start date."
    }
    name = serializers.CharField()
    email = CustomDomainEmailField()
    start_date = serializers.DateField()
    end_date = serializers.DateField()

    def validate(self, attrs):
        start_date = attrs.get("start_date")
        end_date = attrs.get("end_date")
        if start_date and end_date and end_date < start_date:
            self.fail("invalid_date_range")

        return attrs

Tips and Tricks

Hide error responses that show in every operation

By default, the error response for all supported status codes will be added to the schema. Some of these status codes actually appear in every operation: 500 (server error) and 405 (method not allowed). Others can also appear in every operation under certain conditions:

  • If all operations require authentication, then 401 will appear in each one of them

  • If all endpoints are throttled, then the same will happen.

  • Also, 406 (not acceptable) will show if you’re using the default content negotiator and so on.

In that case, it is recommended to hide those error responses from the schema and leverage the schema description attribute to cover them.

Let’s take the example of an API where all endpoints require authentication and accept/return json only. With that we have:

  • 500 (server error) and 405 (method not allowed) in every operation (default package behavior)

  • 401 (unauthorized) almost in every operation (aside from login/signup)

  • 406 (not acceptable) appearing in every operation since the API returns json only and API consumers can populate the “Accept” header with a value other than “application/json”.

  • 415 (unsupported media type) since every API consumers can send request content that is not json.

Now that we identified the error responses that will be in every operation, we can add notes about them to the API description. Since the description can become a bit long, let’s add that to a markdown file (instead of adding it to a python file). Also, that means it will be easier to maintain. Here’s a sample markdown file (You can copy the content from GitHub). Then, the file contents need to be set as the API description.

# settings.py
with open("/absolute/path/to/openapi_sample_description.md") as f:
    description = f.read()

SPECTACULAR_SETTINGS = {
    "TITLE": "Awesome API",
    "DESCRIPTION": description,
    # other settings
}

Now that the details for errors that show in all operations is part to the docs, we can remove them from the list of errors that appear in the API schema. This should make the list of error responses

DRF_STANDARDIZED_ERRORS = {
    "ALLOWED_ERROR_STATUS_CODES": ["400", "403", "404", "429"]
}

Note that you can limit the list of status codes even more under other circumstances. If the API uses URL versioning, then 404 will appear in every operation. Also, if you’re providing a public API and throttling all endpoints to avoid abuse, or as part of the business model, then 429 is better removed and notes about it added to the API description.

Hide parse error responses

The 400 status code covers both validation errors and parse errors. But, since parse errors usually appear for every operation, you might want to hide them while still showing validation errors. Doing that requires overriding the default behavior of the error responses generation in the AutoSchema class:

from drf_standardized_errors.handler import exception_handler as standardized_errors_handler
from drf_standardized_errors.openapi import AutoSchema

class CustomAutoSchema(AutoSchema):
    def _should_add_error_response(self, responses: dict, status_code: str) -> bool:
        if (
            status_code == "400"
            and status_code not in responses
            and self.view.get_exception_handler() is standardized_errors_handler
        ):
            # no need to account for parse errors when deciding if we should add
            # the 400 error response
            return self._should_add_validation_error_response()
        else:
            return super()._should_add_error_response(responses, status_code)

    def _get_http400_serializer(self):
        # removed all logic related to having parse errors
        return self._get_serializer_for_validation_error_response()

After that, update the DEFAULT_SCHEMA_CLASS setting

REST_FRAMEWORK = {
    # other settings
    "DEFAULT_SCHEMA_CLASS": "path.to.CustomAutoSchema"
}

Already using a custom AutoSchema class

If you’re already overriding the AutoSchema class provided by drf-spectacular, be sure to inherit from the AutoSchema class provided by this package instead. Also, if you’re overriding get_examples and/or _get_response_bodies, be sure to call super.

Custom status code

This goes hand-in-hand with handling non-DRF exceptions. So, let’s assume you have defined a custom exception that could be raised in any operation:

from rest_framework.exceptions import APIException

class ServiceUnavailable(APIException):
    status_code = 503
    default_detail = 'Service temporarily unavailable, try again later.'
    default_code = 'service_unavailable'

Next, you’ll need to add the corresponding status code to the settings and define a serializer class that represents the response returned.

# serializers.py
from django.db import models
from rest_framework import serializers
from drf_standardized_errors.openapi_serializers import ServerErrorEnum

class ErrorCode503Enum(models.TextChoices):
    SERVICE_UNAVAILABLE = "service_unavailable"

class Error503Serializer(serializers.Serializer):
    code = serializers.ChoiceField(choices=ErrorCode503Enum.choices)
    detail = serializers.CharField()
    attr = serializers.CharField(allow_null=True)

class ErrorResponse503Serializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=ServerErrorEnum.choices)
    errors = Error503Serializer(many=True)
# settings.py
DRF_STANDARDIZED_ERRORS = {
    "ALLOWED_ERROR_STATUS_CODES": ["400", "403", "404", "429", "503"],
    "ERROR_SCHEMAS": {"503": "path.to.ErrorResponse503Serializer"}
}
SPECTACULAR_SETTINGS = {
    # other settings
    "ENUM_NAME_OVERRIDES": {
        # to avoid warnings raised by drf-spectacular, add the next line
        "ErrorCode503Enum": "path.to.ErrorCode503Enum.choices",
    },
}

If the status code only appears in specific operations, you can create your own AutoSchema that inherits from the one provided by this package and then override AutoSchema._should_add_error_response to define the criteria that controls the addition of the error response to the operation. For example, adding the 503 response only if operation method is GET looks like this:

from drf_standardized_errors.openapi import AutoSchema

class CustomAutoSchema(AutoSchema):
    def _should_add_error_response(self, responses: dict, status_code: str) -> bool:
        if status_code == "503":
            return self.method == "GET"
        else:
            return super()._should_add_error_response(responses, status_code)

Don’t forget to update the DEFAULT_SCHEMA_CLASS to point to the CustomAutoSchema in this case

REST_FRAMEWORK = {
    # other settings
    "DEFAULT_SCHEMA_CLASS": "path.to.CustomAutoSchema"
}

Custom error format

This entry covers the changes required if you change the default error response format. The main idea is that you need to provide serializers that describe each error status code in ALLOWED_ERROR_STATUS_CODES. Also, you should provide examples for each status code or make sure that the default examples do not show up.

Let’s continue from the example in the Customization section about changing the error response format. The standardized error response looks like this:

{
    "type": "string",
    "code": "string",
    "message": "string",
    "field_name": "string"
}

Now, let’s say you want an accurate error response based on the status code. That means you want the schema to show which specific types, codes and field names to expect based on the status code. Also, to avoid the example becoming too long, the ALLOWED_ERROR_STATUS_CODES will be set only to ["400", "403", "404"]. That’s because the work for other status codes will be similar to 403 and 404. However, error response generation for 400 is complicated compared to others and that’s why it’s in the list.

Let’s start with the easy ones (403 and 404):

from drf_standardized_errors.openapi_serializers import ClientErrorEnum, ErrorCode403Enum, ErrorCode404Enum
from rest_framework import serializers


class ErrorResponse403Serializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=ClientErrorEnum.choices)
    code = serializers.ChoiceField(choices=ErrorCode403Enum.choices)
    message = serializers.CharField()
    field_name = serializers.CharField(allow_null=True)


class ErrorResponse404Serializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=ClientErrorEnum.choices)
    code = serializers.ChoiceField(choices=ErrorCode404Enum.choices)
    message = serializers.CharField()
    field_name = serializers.CharField(allow_null=True)

Next, let’s update the settings

DRF_STANDARDIZED_ERRORS = {
    "ALLOWED_ERROR_STATUS_CODES": ["400", "403", "404"],
    "ERROR_SCHEMAS": {
        "403": "path.to.ErrorResponse403Serializer",
        "404": "path.to.ErrorResponse404Serializer",
    }
}

Now, let’s move to 400. This status code represents parsing errors as well as validation errors and validation errors are dynamic based on the serializer in the corresponding operation. So, we need to create our own AutoSchema class that returns the correct error response serializer based on the operation.

from drf_spectacular.utils import PolymorphicProxySerializer
from drf_standardized_errors.openapi_serializers import ClientErrorEnum, ParseErrorCodeEnum, ValidationErrorEnum
from drf_standardized_errors.openapi import AutoSchema
from drf_standardized_errors.settings import package_settings
from inflection import camelize
from rest_framework import serializers


class ParseErrorResponseSerializer(serializers.Serializer):
    type = serializers.ChoiceField(choices=ClientErrorEnum.choices)
    code = serializers.ChoiceField(choices=ParseErrorCodeEnum.choices)
    message = serializers.CharField()
    field_name = serializers.CharField(allow_null=True)


class CustomAutoSchema(AutoSchema):
    def _get_http400_serializer(self):
        operation_id = self.get_operation_id()
        component_name = f"{camelize(operation_id)}ErrorResponse400"

        http400_serializers = []
        if self._should_add_validation_error_response():
            fields_with_error_codes = self._determine_fields_with_error_codes()
            error_serializers = [
                get_serializer_for_validation_error_response(
                    operation_id, field.name, field.error_codes
                )
                for field in fields_with_error_codes
            ]
            http400_serializers.extend(error_serializers)
        if self._should_add_parse_error_response():
            http400_serializers.append(ParseErrorResponseSerializer)

        return PolymorphicProxySerializer(
            component_name=component_name,
            serializers=http400_serializers,
            resource_type_field_name="field_name",
        )


def get_serializer_for_validation_error_response(operation_id, field, error_codes):
    field_choices = [(field, field)]
    error_code_choices = sorted(zip(error_codes, error_codes))

    camelcase_operation_id = camelize(operation_id)
    attr_with_underscores = field.replace(package_settings.NESTED_FIELD_SEPARATOR, "_")
    camelcase_attr = camelize(attr_with_underscores)
    suffix = package_settings.ERROR_COMPONENT_NAME_SUFFIX
    component_name = f"{camelcase_operation_id}{camelcase_attr}{suffix}"

    class ValidationErrorSerializer(serializers.Serializer):
        type = serializers.ChoiceField(choices=ValidationErrorEnum.choices)
        code = serializers.ChoiceField(choices=error_code_choices)
        message = serializers.CharField()
        field_name = serializers.ChoiceField(choices=field_choices)

        class Meta:
            ref_name = component_name

    return ValidationErrorSerializer

What remains is removing the default examples from the AutoSchema class or generating new ones that match the new error response output. Removing the default examples is easy and can be done by overriding get_examples and returning an empty list which leaves example generation up to the OpenAPI UI used (swagger UI, redoc, …). But, if you’re picky about the examples and want to show that the field_name attribute is always null for errors other than validation errors, you can provide examples. Therefore, let’s go with generating new examples for 403 and 404.

from drf_standardized_errors.openapi import AutoSchema
from rest_framework import exceptions
from drf_spectacular.utils import OpenApiExample


class CustomAutoSchema(AutoSchema):
    def get_examples(self):
        errors = [exceptions.PermissionDenied(), exceptions.NotFound()]
        return [get_example_from_exception(error) for error in errors]

def get_example_from_exception(exc: exceptions.APIException):
    return OpenApiExample(
        exc.__class__.__name__,
        value={
            "type": "client_error",
            "code": exc.get_codes(),
            "message": exc.detail,
            "field_name": None,
        },
        response_only=True,
        status_codes=[str(exc.status_code)],
    )

Customize error codes on an operation basis

Determining error codes on a field-basis assumes the developer will follow the example in the last item in Notes. However, there are certain situations where that does not happen:

  • When using serializers provided by third-party packages and the package does not add the error codes to the error_messages attribute.

  • When using a custom form for a filterset class and that form has a clean method that includes validation between multiple fields (for example, when having a start/end date or min/max price fields)

  • When raising a ValidationError inside a view directly.

In these cases, we can use the @extend_validation_errors decorator to add extra error codes for a field to specific actions, methods and/or versions. Here’s one example: we have a filterset class with start_date and end_date fields and the filterset class uses a custom form that checks that the end_date is greater than or equal to the start_date in the Form.clean method. The @extend_validation_errors can be used on the viewset to add the specific error code to the correct field in your API schema (in this case, __all__ is set as the field name because django sets it as the field name for errors raised in Form.clean).

from rest_framework.viewsets import ModelViewSet
from django import forms
from django.contrib.auth import get_user_model
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, DateFilter
from drf_standardized_errors.openapi_validation_errors import extend_validation_errors

User = get_user_model()


class UserForm(forms.Form):
    start_date = forms.DateField()
    end_date = forms.DateField()
    
    def clean(self):
        cleaned_data = super().clean()
        start_date = cleaned_data.get("start_date")
        end_date = cleaned_data.get("end_date")
        if start_date and end_date and end_date < start_date:
            msg = "The end should be greater than or equal to the start date."
            raise forms.ValidationError(msg, code="invalid_date_range")
        
        return cleaned_data


class UserFilterSet(FilterSet):
    start_date = DateFilter(field_name="date_joined", lookup_expr="gte")
    end_date = DateFilter(field_name="date_joined", lookup_expr="lte")
    
    class Meta:
        model = User
        fields = ["start_date", "end_date"]
        form = UserForm


@extend_validation_errors(["invalid_date_range"], field_name="__all__", actions=["list"], methods=["get"])
class UserViewSet(ModelViewSet):
    queryset = User.objects.all()
    serializer_class = ...
    filter_backends = (DjangoFilterBackend,)
    filterset_class = UserFilterSet

Few notes about the decorator:

  • It can be applied to a view class, viewset class or view function decorated with @api_view.

  • It can be applied multiple times to the same view.

  • It adds extra error codes to the ones already collected by drf-standardized-errors for a specific field.

  • If it is applied to a parent view, the added error codes will automatically be added to the child view.

  • Error codes added on a child view, override ones added in a parent view for a specific field, method, action and version.

drf_standardized_errors.openapi_validation_errors.extend_validation_errors(error_codes: List[str], field_name: str | None = None, actions: List[str] | None = None, methods: List[str] | None = None, versions: List[str] | None = None) Callable[[V], V]

A view/viewset decorator for adding extra error codes to validation errors. This decorator does not override error codes already collected by drf-standardized-errors.

Parameters:
  • error_codes – list of error codes to add.

  • field_name – name of serializer or form field to which the error codes will be added. It can be set to "non_field_errors" when the error codes correspond to validation inside Serializer.validate or "__all__" when they correspond to validation inside Form.clean. It can also be left as None when the validation is not linked to any serializer or form (for example, raising serializers.ValidationError inside the view or viewset directly).

  • actions – can be set when decorating a viewset. Limits the added error codes to the specified actions. Defaults to adding the error codes to all actions.

  • methods – Limits the added error codes to the specified methods (get, post, …). Defaults to adding the error codes regardless of the method.

  • versions – Limits the added error codes to the specified versions. Defaults to adding the error codes regardless of the version.