From 270fa430747763f5dd5d608e2b06629356c7a051 Mon Sep 17 00:00:00 2001 From: OlhaZahoruiko Date: Mon, 26 Jan 2026 10:47:56 +0100 Subject: [PATCH] :sparkles: [maykinmedia/commonground-api-common#142] Use exception handler registry from commonground-api-common --- requirements/base.txt | 2 +- requirements/ci.txt | 2 +- requirements/dev.txt | 2 +- src/objects/conf/api.py | 2 +- src/objects/tests/v2/test_auth_fields.py | 32 +++-- src/objects/tests/v2/test_filters.py | 119 ++++++++++++------ src/objects/tests/v2/test_object_api.py | 35 +++++- .../tests/v2/test_object_api_fields.py | 12 +- src/objects/tests/v2/test_ordering.py | 9 +- src/objects/tests/v2/test_validation.py | 100 +++++++++------ src/objects/utils/apps.py | 3 + .../utils/tests/test_exception_handler.py | 53 -------- src/objects/utils/views.py | 70 ++++------- 13 files changed, 245 insertions(+), 196 deletions(-) delete mode 100644 src/objects/utils/tests/test_exception_handler.py diff --git a/requirements/base.txt b/requirements/base.txt index b81d89b4..3b6d7694 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -60,7 +60,7 @@ click-plugins==1.1.1 # via celery click-repl==0.3.0 # via celery -commonground-api-common==2.10.7 +commonground-api-common==2.11.0 # via # -r requirements/base.in # open-api-framework diff --git a/requirements/ci.txt b/requirements/ci.txt index 73fc1a1b..65fe6794 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -111,7 +111,7 @@ click-repl==0.3.0 # celery codecov==2.1.13 # via -r requirements/test-tools.in -commonground-api-common==2.10.7 +commonground-api-common==2.11.0 # via # -c requirements/base.txt # -r requirements/base.txt diff --git a/requirements/dev.txt b/requirements/dev.txt index 05effdb7..ba7d4f33 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -139,7 +139,7 @@ codecov==2.1.13 # via # -c requirements/ci.txt # -r requirements/ci.txt -commonground-api-common==2.10.7 +commonground-api-common==2.11.0 # via # -c requirements/ci.txt # -r requirements/ci.txt diff --git a/src/objects/conf/api.py b/src/objects/conf/api.py index b7a7e09b..65c7f9d8 100644 --- a/src/objects/conf/api.py +++ b/src/objects/conf/api.py @@ -14,7 +14,7 @@ "DEFAULT_VERSION": "v2", # NOT to be confused with API_VERSION - it's the major version part "ALLOWED_VERSIONS": ("v2",), "VERSION_PARAM": "version", - "EXCEPTION_HANDLER": "objects.utils.views.exception_handler", + "EXCEPTION_HANDLER": "vng_api_common.exception_handling.exception_handler", # test "TEST_REQUEST_DEFAULT_FORMAT": "json", } diff --git a/src/objects/tests/v2/test_auth_fields.py b/src/objects/tests/v2/test_auth_fields.py index 38dc5351..d9698c7d 100644 --- a/src/objects/tests/v2/test_auth_fields.py +++ b/src/objects/tests/v2/test_auth_fields.py @@ -4,6 +4,7 @@ from rest_framework import status from rest_framework.test import APITestCase +from vng_api_common.tests import get_validation_errors from objects.core.tests.factories import ( ObjectFactory, @@ -109,9 +110,12 @@ def test_retrieve_incorrect_auth_fields(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "") + self.assertEqual( - response.json(), - ["Fields in the configured authorization are absent in the data: 'some'"], + error["reason"], + "Fields in the configured authorization are absent in the data: 'some'", ) def test_retrieve_query_fields_not_allowed(self): @@ -304,7 +308,9 @@ def test_list_incorrect_auth_fields(self): fields={"1": ["url", "uuid", "record"]}, ) ObjectRecordFactory.create( - object__object_type=self.object_type, data={"name": "some"}, version=1 + object__object_type=self.object_type, + data={"name": "some"}, + version=1, ) ObjectRecordFactory.create( object__object_type=self.other_object_type, @@ -315,9 +321,12 @@ def test_list_incorrect_auth_fields(self): response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json(), - ["Fields in the configured authorization are absent in the data: 'some'"], + + error = get_validation_errors(response, "") + + self.assertIn( + "Fields in the configured authorization are absent in the data: 'some'", + error["reason"], ) def test_retrieve_query_fields_not_allowed(self): @@ -336,7 +345,9 @@ def test_retrieve_query_fields_not_allowed(self): fields={"1": ["url", "uuid", "record"]}, ) ObjectRecordFactory.create( - object__object_type=self.object_type, data={"name": "some"}, version=1 + object__object_type=self.object_type, + data={"name": "some"}, + version=1, ) ObjectRecordFactory.create( object__object_type=self.other_object_type, @@ -347,9 +358,12 @@ def test_retrieve_query_fields_not_allowed(self): response = self.client.get(self.url, {"fields": "uuid"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual( - response.json(), - ["'fields' query parameter has invalid or unauthorized values: 'uuid'"], + data["invalid_params"][0]["reason"], + "'fields' query parameter has invalid or unauthorized values: 'uuid'", ) def test_list_no_allowed_fields(self): diff --git a/src/objects/tests/v2/test_filters.py b/src/objects/tests/v2/test_filters.py index 61f59faa..56185a98 100644 --- a/src/objects/tests/v2/test_filters.py +++ b/src/objects/tests/v2/test_filters.py @@ -7,6 +7,7 @@ from furl import furl from rest_framework import status from rest_framework.test import APITestCase +from vng_api_common.tests import get_validation_errors from objects.core.tests.factories import ( ObjectFactory, @@ -66,7 +67,11 @@ def test_filter_invalid_objecttype(self): response = self.client.get(self.url, {"type": "invalid-objecttype-url"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], ["Invalid value."]) + + error = get_validation_errors(response, "type") + + self.assertEqual(error["reason"], "Invalid value.") + self.assertEqual(error["code"], "invalid") def test_filter_unknown_objecttype(self): objecttype_url = ( @@ -75,11 +80,18 @@ def test_filter_unknown_objecttype(self): response = self.client.get(self.url, {"type": objecttype_url}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "type") + self.assertEqual( - response.json()["type"], - [ - f"Select a valid object type. {objecttype_url} is not one of the available choices." - ], + error, + { + "name": "type", + "code": "invalid_choice", + "reason": ( + f"Select a valid object type. {objecttype_url} is not one of the available choices." + ), + }, ) def test_filter_too_long_object_type(self): @@ -87,7 +99,11 @@ def test_filter_too_long_object_type(self): response = self.client.get(self.url, {"type": object_type_long}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["type"], ["The value has too many characters"]) + + error = get_validation_errors(response, "type") + + self.assertEqual(error["code"], "max_length") + self.assertEqual(error["reason"], "The value has too many characters") class FilterDataAttrsTests(TokenAuthMixin, APITestCase): @@ -218,8 +234,12 @@ def test_filter_lte_not_numerical(self): response = self.client.get(self.url, {"data_attrs": "diameter__lt__value"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "") + self.assertEqual( - response.json(), ["Operator `lt` supports only dates and/or numeric values"] + error["reason"], + "Operator `lt` supports only dates and/or numeric values", ) def test_filter_lte_date(self): @@ -255,20 +275,27 @@ def test_filter_lte_date(self): def test_filter_invalid_operator(self): response = self.client.get(self.url, {"data_attrs": "diameter__not__value"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json(), ["Comparison operator `not` is unknown"]) + + error = get_validation_errors(response, "") + + self.assertEqual( + error["reason"], + "Comparison operator `not` is unknown", + ) + self.assertEqual(error["code"], "invalid-data-attrs-query") def test_filter_invalid_param(self): response = self.client.get(self.url, {"data_attrs": "diameter__exact"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "") + self.assertEqual( - response.json(), - [ - "Filter expression 'diameter__exact' doesn't have the shape 'key__operator__value'" - ], + error["reason"], + "Filter expression 'diameter__exact' doesn't have the shape 'key__operator__value'", ) + self.assertEqual(error["code"], "invalid-data-attrs-query") def test_filter_nested_attr(self): record = ObjectRecordFactory.create( @@ -570,8 +597,11 @@ def test_filter_lte_not_numerical(self): response = self.client.get(self.url, {"data_attr": "diameter__lt__value"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "") + self.assertEqual( - response.json(), ["Operator `lt` supports only dates and/or numeric values"] + "Operator `lt` supports only dates and/or numeric values", error["reason"] ) def test_filter_lte_date(self): @@ -609,17 +639,24 @@ def test_filter_invalid_operator(self): response = self.client.get(self.url, {"data_attr": "diameter__not__value"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json(), ["Comparison operator `not` is unknown"]) + + error = get_validation_errors(response, "") + + self.assertEqual( + error["reason"], + "Comparison operator `not` is unknown", + ) def test_filter_invalid_param(self): response = self.client.get(self.url, {"data_attr": "diameter__exact"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "") + self.assertEqual( - response.json(), - [ - "Filter expression 'diameter__exact' doesn't have the shape 'key__operator__value'" - ], + error["reason"], + "Filter expression 'diameter__exact' doesn't have the shape 'key__operator__value'", ) def test_filter_nested_attr(self): @@ -819,18 +856,21 @@ def test_filter_two_icontains_with_comma(self): def test_filter_comma_separated_invalid(self): response = self.client.get( - self.url, {"data_attr": "dimensions__diameter__exact__4,name__exact__demo"} + self.url, + {"data_attr": "dimensions__diameter__exact__4,name__exact__demo"}, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "") + self.assertEqual( - response.json(), - [ - "Filter expression 'dimensions__diameter__exact__4,name__exact__demo' " - "must have the shape 'key__operator__value', commas can only be present in " - "the 'value'" - ], + error["reason"], + "Filter expression 'dimensions__diameter__exact__4,name__exact__demo' " + "must have the shape 'key__operator__value', commas can only be present in " + "the 'value'", ) + self.assertEqual(error["code"], "invalid-data-attr-query") class FilterDateTests(TokenAuthMixin, APITestCase): @@ -941,6 +981,8 @@ def test_filter_registration_date_list(self): response = self.client.get(url, {"registrationDate": "2020-07-01"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json()["results"] self.assertEqual(len(data), 1) @@ -952,17 +994,18 @@ def test_filter_registration_date_list(self): def test_filter_on_both_date_and_registration_date(self): url = reverse_lazy("object-list") - response = self.client.get( - url, {"date": "2020-07-01", "registrationDate": "2020-08-01"} + url, + {"date": "2020-07-01", "registrationDate": "2020-08-01"}, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual( - response.json(), - [ - "'date' and 'registrationDate' parameters can't be used in the same request" - ], + data["invalid_params"][0]["reason"], + "'date' and 'registrationDate' parameters can't be used in the same request", ) @@ -1033,13 +1076,19 @@ def test_filter_db_error(self, mock_query): response = self.client.get(self.url, {"data_icontains": "some"}) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + data = response.json() + + data.pop("instance", None) + self.assertEqual( - response.json(), + data, { - "code": "error", + "code": "search-not-supported", "title": "Internal Server Error", "status": 500, "detail": "This search operation is not supported by the underlying data store.", + "type": "http://testserver/ref/fouten/ProgrammingError/", }, ) diff --git a/src/objects/tests/v2/test_object_api.py b/src/objects/tests/v2/test_object_api.py index 7f1bd279..79197377 100644 --- a/src/objects/tests/v2/test_object_api.py +++ b/src/objects/tests/v2/test_object_api.py @@ -6,6 +6,7 @@ from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase +from vng_api_common.tests import get_validation_errors from objects.core.models import Object, Reference from objects.core.tests.factories import ( @@ -869,7 +870,21 @@ def test_list_incorrect_date(self): response = self.client.get(self.url, {"date": "2024-31-08"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json(), {"date": ["Enter a valid date."]}) + + data = response.json() + + self.assertEqual(data["status"], 400) + self.assertEqual(data["code"], "invalid") + self.assertEqual(data["title"], "Invalid input.") + error = get_validation_errors(response, "date") + self.assertEqual( + error["name"], + "date", + ) + self.assertEqual( + error["reason"], + "Enter a valid date.", + ) def test_list_available_for_registration_date(self): with self.subTest("filter on old name"): @@ -903,4 +918,20 @@ def test_list_incorrect_registration_date(self): response = self.client.get(self.url, {"registrationDate": "2024-31-08"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json(), {"registrationDate": ["Enter a valid date."]}) + + data = response.json() + + self.assertEqual(data["status"], 400) + self.assertEqual(data["code"], "invalid") + self.assertEqual(data["title"], "Invalid input.") + + error = get_validation_errors(response, "registrationDate") + + self.assertEqual( + error["name"], + "registrationDate", + ) + self.assertEqual( + error["reason"], + "Enter a valid date.", + ) diff --git a/src/objects/tests/v2/test_object_api_fields.py b/src/objects/tests/v2/test_object_api_fields.py index e6addd58..f7528e27 100644 --- a/src/objects/tests/v2/test_object_api_fields.py +++ b/src/objects/tests/v2/test_object_api_fields.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.test import APITestCase +from vng_api_common.tests import get_validation_errors from objects.core.tests.factories import ( ObjectFactory, @@ -106,9 +107,12 @@ def test_fields_invalid(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) data = response.json() + self.assertEqual(data["status"], 400) + self.assertEqual(data["code"], "invalid") + self.assertEqual(data["title"], "Invalid input.") + + error = get_validation_errors(response, "") self.assertEqual( - data, - [ - "'fields' query parameter has invalid or unauthorized values: 'someField'" - ], + error["reason"], + "'fields' query parameter has invalid or unauthorized values: 'someField'", ) diff --git a/src/objects/tests/v2/test_ordering.py b/src/objects/tests/v2/test_ordering.py index cc4d950d..16c7aa2e 100644 --- a/src/objects/tests/v2/test_ordering.py +++ b/src/objects/tests/v2/test_ordering.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.test import APITestCase +from vng_api_common.tests import get_validation_errors from objects.core.tests.factories import ObjectRecordFactory, ObjectTypeFactory from objects.token.constants import PermissionModes @@ -140,11 +141,13 @@ def test_not_allowed_field(self): ) response = self.client.get(self.url, {"ordering": "record__data__length"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + ordering_error = get_validation_errors(response, "") + self.assertEqual( - response.json(), - ["You are not allowed to sort on following fields: record__data__length"], + ordering_error["reason"], + "You are not allowed to sort on following fields: record__data__length", ) def test_allowed_field(self): diff --git a/src/objects/tests/v2/test_validation.py b/src/objects/tests/v2/test_validation.py index cfe22645..fdca9630 100644 --- a/src/objects/tests/v2/test_validation.py +++ b/src/objects/tests/v2/test_validation.py @@ -8,6 +8,7 @@ from freezegun import freeze_time from rest_framework import status from rest_framework.test import APITestCase +from vng_api_common.tests import get_validation_errors from objects.core.models import Object from objects.core.tests.factories import ObjectRecordFactory, ObjectTypeFactory @@ -151,7 +152,17 @@ def test_create_object_with_invalid_length(self, m): self.assertEqual(Object.objects.count(), 0) data = response.json() - self.assertEqual(data["type"], ["The value has too many characters"]) + + self.assertEqual(data["status"], 400) + self.assertEqual(Object.objects.count(), 0) + + type_error = get_validation_errors(response, "type") + + self.assertEqual(type_error["code"], "max_length") + self.assertEqual( + type_error["reason"], + "The value has too many characters", + ) def test_create_object_no_version(self, m): mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") @@ -172,9 +183,11 @@ def test_create_object_no_version(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Object.objects.count(), 0) - data = response.json() + error = get_validation_errors(response, "nonFieldErrors") + self.assertEqual( - data["non_field_errors"], ["Object type version can not be retrieved."] + error["reason"], + "Object type version can not be retrieved.", ) def test_create_object_objecttype_request_error(self, m): @@ -196,9 +209,11 @@ def test_create_object_objecttype_request_error(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Object.objects.count(), 0) - data = response.json() + error = get_validation_errors(response, "nonFieldErrors") + self.assertEqual( - data["non_field_errors"], ["Object type version can not be retrieved."] + error["reason"], + "Object type version can not be retrieved.", ) def test_create_object_objecttype_with_no_jsonSchema(self, m): @@ -224,12 +239,11 @@ def test_create_object_objecttype_with_no_jsonSchema(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Object.objects.count(), 0) - data = response.json() + error = get_validation_errors(response, "nonFieldErrors") + self.assertEqual( - data["non_field_errors"], - [ - f"{self.object_type.versions_url} does not appear to be a valid objecttype." - ], + error["reason"], + f"{self.object_type.versions_url} does not appear to be a valid objecttype.", ) def test_create_object_schema_invalid(self, m): @@ -254,9 +268,11 @@ def test_create_object_schema_invalid(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Object.objects.count(), 0) - data = response.json() + error = get_validation_errors(response, "nonFieldErrors") + self.assertEqual( - data["non_field_errors"], ["'diameter' is a required property"] + error["reason"], + "'diameter' is a required property", ) def test_create_object_without_record_invalid(self, m): @@ -296,10 +312,11 @@ def test_create_object_correction_invalid(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Object.objects.exclude(id=record.object.id).count(), 0) - data = response.json() + error = get_validation_errors(response, "record.correctionFor") + self.assertEqual( - data["record"]["correctionFor"], - [f"Object with index={record.index} does not exist."], + error["reason"], + f"Object with index={record.index} does not exist.", ) def test_create_object_geometry_not_allowed(self, m): @@ -330,9 +347,12 @@ def test_create_object_geometry_not_allowed(self, m): response = self.client.post(url, data, **GEO_WRITE_KWARGS) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "nonFieldErrors") + self.assertEqual( - response.json()["non_field_errors"], - ["This object type doesn't support geometry"], + error["reason"], + "This object type doesn't support geometry", ) def test_create_object_with_geometry_without_allowGeometry(self, m): @@ -442,10 +462,11 @@ def test_update_object_with_correction_invalid(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - data = response.json() + error = get_validation_errors(response, "record.correctionFor") + self.assertEqual( - data["record"]["correctionFor"], - ["Object with index=5 does not exist."], + error["reason"], + "Object with index=5 does not exist.", ) def test_update_object_type_invalid(self, m): @@ -463,22 +484,18 @@ def test_update_object_type_invalid(self, m): data={"plantDate": "2020-04-12", "diameter": 30}, version=1, ) - object = initial_record.object + obj = initial_record.object - url = reverse("object-detail", args=[object.uuid]) - data = { - "type": self.object_type.url, - } + url = reverse("object-detail", args=[obj.uuid]) + data = {"type": self.object_type.url} response = self.client.patch(url, data, **GEO_WRITE_KWARGS) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - data = response.json() - self.assertEqual( - data["type"], - ["This field can't be changed"], - ) + type_error = get_validation_errors(response, "type") + + self.assertEqual(type_error["reason"], "This field can't be changed") def test_update_uuid_invalid(self, m): mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") @@ -496,8 +513,12 @@ def test_update_uuid_invalid(self, m): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - data = response.json() - self.assertEqual(data["uuid"], ["This field can't be changed"]) + error = get_validation_errors(response, "uuid") + + self.assertEqual( + error["reason"], + "This field can't be changed", + ) def test_update_geometry_not_allowed(self, m): mock_service_oas_get(m, OBJECT_TYPES_API, "objecttypes") @@ -531,9 +552,12 @@ def test_update_geometry_not_allowed(self, m): response = self.client.patch(url, data, **GEO_WRITE_KWARGS) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + error = get_validation_errors(response, "nonFieldErrors") + self.assertEqual( - response.json()["non_field_errors"], - ["This object type doesn't support geometry"], + error["reason"], + "This object type doesn't support geometry", ) def test_create_object_with_duplicate_uuid_returns_400(self, m): @@ -560,6 +584,8 @@ def test_create_object_with_duplicate_uuid_returns_400(self, m): response = self.client.post(url, data, **GEO_WRITE_KWARGS) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["uuid"], ["An object with this UUID already exists."] - ) + + error = get_validation_errors(response, "uuid") + + self.assertEqual(error["reason"], "An object with this UUID already exists.") + self.assertEqual(error["code"], "unique") diff --git a/src/objects/utils/apps.py b/src/objects/utils/apps.py index 0f914f11..4c0b8993 100644 --- a/src/objects/utils/apps.py +++ b/src/objects/utils/apps.py @@ -30,3 +30,6 @@ def ready(self): field_mapping = ModelSerializer.serializer_field_mapping field_mapping[models.JSONField] = JSONObjectField + from .views import register_handlers + + register_handlers() diff --git a/src/objects/utils/tests/test_exception_handler.py b/src/objects/utils/tests/test_exception_handler.py deleted file mode 100644 index c933cccb..00000000 --- a/src/objects/utils/tests/test_exception_handler.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging # noqa: TID251 -from unittest.mock import patch - -import sentry_sdk -from rest_framework.test import APITestCase -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.transport import Transport - -from ..views import exception_handler - - -class InMemoryTransport(Transport): - """ - Mock transport class to test if Sentry works - """ - - def __init__(self, options): - self.envelopes = [] - - def capture_envelope(self, envelope): - self.envelopes.append(envelope) - - -class ExceptionHandlerTests(APITestCase): - @patch.dict("os.environ", {"DEBUG": "no"}) - def test_error_is_forwarded_to_sentry(self): - transport = InMemoryTransport({}) - sentry_sdk.init( - dsn="https://12345@sentry.local/1234", - transport=transport, - integrations=[ - LoggingIntegration( - level=logging.INFO, - # Avoid sending logger.exception calls to Sentry - event_level=None, - ), - ], - ) - assert len(transport.envelopes) == 0 - - exc = Exception("Something went wrong") - - result = exception_handler(exc, context={}) - - self.assertIsNotNone(result) - - # Error should be forwarded to sentry - assert len(transport.envelopes) == 1 - - event = transport.envelopes[0] - assert event.items[0].payload.json["level"] == "error" - exception = event.items[0].payload.json["exception"]["values"][-1] - assert exception["value"] == "Something went wrong" diff --git a/src/objects/utils/views.py b/src/objects/utils/views.py index b65d3b10..65f58b8e 100644 --- a/src/objects/utils/views.py +++ b/src/objects/utils/views.py @@ -1,59 +1,31 @@ -from django.db.utils import DatabaseError from django.utils.translation import gettext_lazy as _ -import sentry_sdk -import structlog -from open_api_framework.conf.utils import config from rest_framework import status +from rest_framework.exceptions import ErrorDetail from rest_framework.response import Response -from rest_framework.views import exception_handler as drf_exception_handler +from vng_api_common.exception_handling import register_exception_handler -logger = structlog.stdlib.get_logger(__name__) -DEFAULT_CODE = "invalid" -DEFAULT_DETAIL = _("Invalid input.") - - -def exception_handler(exc, context): +def jsonpath_database_error_handler(exc, context): """ - Transform 5xx errors into DSO-compliant shape. + Handle DatabaseError raised when PostgreSQL cannot execute jsonpath queries. """ - response = drf_exception_handler(exc, context) - if not response: - if config("DEBUG", default=False): - return None - - data = { - "code": "error", - "title": "Internal Server Error", - "status": status.HTTP_500_INTERNAL_SERVER_ERROR, - "detail": _("A server error has occurred."), - } - event = "api.uncaught_exception" - - if isinstance(exc, DatabaseError) and "jsonpath" in exc.args[0]: - # provide user-friendly response if data_icontains was used but DB couldn't process it - data["detail"] = ( - "This search operation is not supported by the underlying data store." - ) - event = "api.database_exception" - - # make sure the exception still ends up in Sentry - sentry_sdk.capture_exception(exc) - - response = Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR, data=data) - logger.exception(event, exc_info=exc) - - return response - - # exception logger event - logger.exception( - "api.handled_exception", - title=getattr(exc, "default_detail", DEFAULT_DETAIL).strip("'"), - code=getattr(exc, "default_code", DEFAULT_CODE), - status=getattr(response, "status_code", status.HTTP_400_BAD_REQUEST), - data=getattr(response, "data", {}), - exc_info=False, + + if "jsonpath" not in str(exc): + return None + + exc.detail = ErrorDetail( + _("This search operation is not supported by the underlying data store."), + code="search-not-supported", ) + exc.default_detail = _("Internal Server Error") + return Response( + data={"detail": exc.detail}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +def register_handlers(): + from django.db.utils import Error - return response + register_exception_handler(Error, jsonpath_database_error_handler)