From d3095e7d9b53d3417e5a4296077e697be3107111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Thu, 16 Jan 2025 16:38:17 +0100 Subject: [PATCH] feat(api): standardize error responses This is companion to #13541 to make API error response more consistent. We now rely on drf-standardized-erros to do the processing. It also has drf-spectacular integration, so the error states are now documented in OpenAPI. --- docs/changes.rst | 2 ++ pyproject.toml | 3 ++- uv.lock | 21 +++++++++++++++++++++ weblate/api/spectacular.py | 13 ++++++++++++- weblate/api/views.py | 28 +++++++++++++++------------- weblate/settings_docker.py | 7 +++++-- weblate/settings_example.py | 7 +++++-- 7 files changed, 62 insertions(+), 19 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 7d43c17043fb..dd0acc492eae 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -28,6 +28,8 @@ Not yet released. Please follow :ref:`generic-upgrade-instructions` in order to perform update. +* There are several changes in :file:`settings_example.py`, most notable is changes in ``REST_FRAMEWORK`` and ``DRF_STANDARDIZED_ERRORS``, please adjust your settings accordingly. + **Contributors** .. include:: changes/contributors/5.10.rst diff --git a/pyproject.toml b/pyproject.toml index 97154d62415a..42e0ecdbc40c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,8 @@ dependencies = [ "unidecode>=1.3.8,<1.4", "user-agents>=2.0,<2.3", "weblate-language-data>=2024.14", - "weblate-schemas==2024.2" + "weblate-schemas==2024.2", + "drf-standardized-errors[openapi]>=0.14.1,<0.15" ] description = "A web-based continuous localization system with tight version control integration" keywords = [ diff --git a/uv.lock b/uv.lock index 6b2ce8ed0052..d3806ab6a2af 100644 --- a/uv.lock +++ b/uv.lock @@ -1210,6 +1210,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/b4/250f5c3073835f04997c2ff489cbba51de5e29b4e1deb96d949859c0bd72/drf_spectacular_sidecar-2024.12.1-py3-none-any.whl", hash = "sha256:e30821d150d29294f3be2018aab31b55cd724158e9e690b51a215264751aa8c7", size = 2405338 }, ] +[[package]] +name = "drf-standardized-errors" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/cc/fd5b8cbc66c361125cba0497573a5ecac94521a715267d7db4d113257a73/drf_standardized_errors-0.14.1.tar.gz", hash = "sha256:0610dcd0096b75365102d276022a22e59a1f8db8825bb0bff05e1b7194ba145d", size = 58730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/70/589efc32d6f268576e2f3c2a595ef19a305c5d5acbfd26d10ebd45278778/drf_standardized_errors-0.14.1-py3-none-any.whl", hash = "sha256:4941e0f81be94eb0904549999cf221988a5b0f524041c3877530e24f70328ed8", size = 25512 }, +] + +[package.optional-dependencies] +openapi = [ + { name = "drf-spectacular" }, + { name = "inflection" }, +] + [[package]] name = "elementpath" version = "4.7.0" @@ -4202,6 +4221,7 @@ dependencies = [ { name = "django-redis" }, { name = "djangorestframework" }, { name = "drf-spectacular", extra = ["sidecar"] }, + { name = "drf-standardized-errors", extra = ["openapi"] }, { name = "filelock" }, { name = "fluent-syntax" }, { name = "gitpython" }, @@ -4443,6 +4463,7 @@ requires-dist = [ { name = "djangorestframework", specifier = ">=3.15.2,<3.16" }, { name = "djangosaml2idp", marker = "extra == 'saml2idp'", specifier = "==0.7.2" }, { name = "drf-spectacular", extras = ["sidecar"], specifier = ">=0.27.2,<0.29" }, + { name = "drf-standardized-errors", extras = ["openapi"], specifier = ">=0.14.1,<0.15" }, { name = "filelock", specifier = ">=3.16.1,<4" }, { name = "fluent-syntax", specifier = ">=0.18.1,<0.20" }, { name = "git-review", marker = "extra == 'gerrit'", specifier = ">=2.4.0,<2.5.0" }, diff --git a/weblate/api/spectacular.py b/weblate/api/spectacular.py index 20ba2181902c..39938a2de1dd 100644 --- a/weblate/api/spectacular.py +++ b/weblate/api/spectacular.py @@ -74,9 +74,20 @@ def get_spectacular_settings( # Flatten enum definitions "ENUM_NAME_OVERRIDES": { "ColorEnum": "weblate.utils.colors.ColorChoices.choices", + "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", }, "POSTPROCESSING_HOOKS": [ - "drf_spectacular.hooks.postprocess_schema_enums", + "drf_standardized_errors.openapi_hooks.postprocess_schema_enums", "weblate.api.docs.add_middleware_headers", ], "EXTERNAL_DOCS": { diff --git a/weblate/api/views.py b/weblate/api/views.py index e66229bae211..a8b3a06d5e91 100644 --- a/weblate/api/views.py +++ b/weblate/api/views.py @@ -22,12 +22,13 @@ from django.shortcuts import get_object_or_404 from django.utils.datastructures import MultiValueDictKeyError from django.utils.html import format_html -from django.utils.translation import gettext +from django.utils.translation import gettext, gettext_lazy from django_filters import rest_framework as filters from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view +from drf_standardized_errors.handler import ExceptionHandler from rest_framework import parsers, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import APIException, ValidationError from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, UpdateModelMixin from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer @@ -42,7 +43,7 @@ HTTP_500_INTERNAL_SERVER_ERROR, ) from rest_framework.utils import formatting -from rest_framework.views import APIView, exception_handler +from rest_framework.views import APIView from rest_framework.viewsets import ViewSet from weblate.accounts.models import Subscription @@ -146,18 +147,19 @@ """ -def weblate_exception_handler(exc, context): - # Call REST framework's default exception handler first, - # to get the standard error response. - response = exception_handler(exc, context) +class LockedError(APIException): + status_code = HTTP_423_LOCKED + default_detail = gettext_lazy( + "Could not obtain repository lock to perform the operation." + ) + default_code = "repository-locked" - if response is None and isinstance(exc, WeblateLockTimeoutError): - return Response( - data={"error": "Could not obtain repository lock to delete the string."}, - status=HTTP_423_LOCKED, - ) - return response +class WeblateExceptionHandler(ExceptionHandler): + def convert_known_exceptions(self, exc: Exception) -> Exception: + if isinstance(exc, WeblateLockTimeoutError): + return LockedError() + return super().convert_known_exceptions(exc) def get_view_description(view, html=False): diff --git a/weblate/settings_docker.py b/weblate/settings_docker.py index b0534ea0b9a7..ae863ce72999 100644 --- a/weblate/settings_docker.py +++ b/weblate/settings_docker.py @@ -1297,9 +1297,12 @@ "DEFAULT_PAGINATION_CLASS": "weblate.api.pagination.StandardPagination", "PAGE_SIZE": 50, "VIEW_DESCRIPTION_FUNCTION": "weblate.api.views.get_view_description", - "EXCEPTION_HANDLER": "weblate.api.views.weblate_exception_handler", + "EXCEPTION_HANDLER": "drf_standardized_errors.handler.exception_handler", "UNAUTHENTICATED_USER": "weblate.auth.models.get_anonymous", - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_standardized_errors.openapi.AutoSchema", +} +DRF_STANDARDIZED_ERRORS = { + "EXCEPTION_HANDLER_CLASS": "weblate.api.views.WeblateExceptionHandler" } SPECTACULAR_SETTINGS = get_spectacular_settings(INSTALLED_APPS, SITE_URL, SITE_TITLE) diff --git a/weblate/settings_example.py b/weblate/settings_example.py index ad7d9309235c..c1144df695d7 100644 --- a/weblate/settings_example.py +++ b/weblate/settings_example.py @@ -870,9 +870,12 @@ "DEFAULT_PAGINATION_CLASS": "weblate.api.pagination.StandardPagination", "PAGE_SIZE": 50, "VIEW_DESCRIPTION_FUNCTION": "weblate.api.views.get_view_description", - "EXCEPTION_HANDLER": "weblate.api.views.weblate_exception_handler", + "EXCEPTION_HANDLER": "drf_standardized_errors.handler.exception_handler", "UNAUTHENTICATED_USER": "weblate.auth.models.get_anonymous", - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_standardized_errors.openapi.AutoSchema", +} +DRF_STANDARDIZED_ERRORS = { + "EXCEPTION_HANDLER_CLASS": "weblate.api.views.WeblateExceptionHandler" } SPECTACULAR_SETTINGS = get_spectacular_settings(INSTALLED_APPS, SITE_URL, SITE_TITLE)