diff --git a/poetry.lock b/poetry.lock index 024a53e..649bd0b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1356,12 +1356,12 @@ files = [ google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" grpcio = [ - {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] grpcio-status = [ - {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] proto-plus = ">=1.22.3,<2.0.0dev" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" @@ -1451,8 +1451,8 @@ grpcio-status = ">=1.33.2" opentelemetry-api = {version = ">=1.27.0", markers = "python_version >= \"3.8\""} opentelemetry-sdk = {version = ">=1.27.0", markers = "python_version >= \"3.8\""} proto-plus = [ - {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""}, + {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" @@ -1776,13 +1776,13 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -1790,7 +1790,6 @@ anyio = "*" certifi = "*" httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] @@ -2997,9 +2996,9 @@ files = [ [package.dependencies] lxml = {version = ">=4.9.2", optional = true, markers = "extra == \"xml\""} numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] odfpy = {version = ">=1.4.1", optional = true, markers = "extra == \"excel\""} openpyxl = {version = ">=3.1.0", optional = true, markers = "extra == \"excel\""} @@ -3563,8 +3562,8 @@ files = [ annotated-types = ">=0.6.0" pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -4167,13 +4166,13 @@ cffi = {version = "*", markers = "implementation_name == \"pypy\""} [[package]] name = "redis" -version = "5.2.0" +version = "5.2.1" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" files = [ - {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, - {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, + {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, + {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, ] [package.dependencies] @@ -4896,18 +4895,19 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7 [[package]] name = "testcontainers" -version = "4.8.2" +version = "4.9.0" description = "Python library for throwaway instances of anything that can run in a Docker container" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "testcontainers-4.8.2-py3-none-any.whl", hash = "sha256:9e19af077cd96e1957c13ee466f1f32905bc6c5bc1bc98643eb18be1a989bfb0"}, - {file = "testcontainers-4.8.2.tar.gz", hash = "sha256:dd4a6a2ea09e3c3ecd39e180b6548105929d0bb78d665ce9919cb3f8c98f9853"}, + {file = "testcontainers-4.9.0-py3-none-any.whl", hash = "sha256:c6fee929990972c40bf6b91b7072c94064ff3649b405a14fde0274c8b2479d32"}, + {file = "testcontainers-4.9.0.tar.gz", hash = "sha256:2cd6af070109ff68c1ab5389dc89c86c2dc3ab30a21ca734b2cb8f0f80ad479e"}, ] [package.dependencies] docker = "*" httpx = {version = "*", optional = true, markers = "extra == \"aws\" or extra == \"generic\" or extra == \"test-module-import\""} +python-dotenv = "*" redis = {version = "*", optional = true, markers = "extra == \"generic\" or extra == \"redis\""} typing-extensions = "*" urllib3 = "*" diff --git a/src/dapla_metadata/variable_definitions/exceptions.py b/src/dapla_metadata/variable_definitions/exceptions.py new file mode 100644 index 0000000..1f9039f --- /dev/null +++ b/src/dapla_metadata/variable_definitions/exceptions.py @@ -0,0 +1,69 @@ +"""Vardef client exceptions.""" + +import json +from functools import wraps + +from dapla_metadata.variable_definitions.generated.vardef_client.exceptions import ( + OpenApiException, +) + + +class VardefClientException(OpenApiException): + """Custom exception to represent errors encountered in the Vardef client. + + This exception extracts and formats error details from a JSON response body + provided by the Vardef API, enabling more descriptive error messages. + If the response body cannot be parsed as JSON or lacks expected keys, + default values are used to provide meaningful feedback. + """ + + def __init__(self, response_body: str) -> None: + """Initialize the exception with a JSON-formatted response body. + + Args: + response_body (str): The JSON string containing error details + from the Vardef API response. + + Attributes: + status (str): The status code from the response, or "Unknown status" + if not available or the response is invalid. + detail (str || list): A detailed error message from the response, or + "No detail provided" if not provided. If "Constraint violation" + the detail is a list with field and message. + response_body (str): The raw response body string, stored for + debugging purposes. + """ + self.detail: str | list + try: + data = json.loads(response_body) + self.status = data.get("status", "Unknown status") + if data.get("title") == "Constraint Violation": + violations = data.get("violations", []) + self.detail = [ + { + "field": violation.get("field", "Unknown field"), + "message": violation.get("message", "No message provided"), + } + for violation in violations + ] + else: + self.detail = data.get("detail", "No detail provided") + self.response_body = response_body + except (json.JSONDecodeError, TypeError): + self.status = "Unknown" + self.detail = "Could not decode error response from API" + data = None + super().__init__(f"Status {self.status}: {self.detail}") + + +def vardef_exception_handler(method): # noqa: ANN201, ANN001 + """Decorator for handling exceptions in Vardef.""" + + @wraps(method) + def _impl(self, *method_args, **method_kwargs): # noqa: ANN001, ANN002, ANN003 + try: + return method(self, *method_args, **method_kwargs) + except OpenApiException as e: + raise VardefClientException(e.body) from e + + return _impl diff --git a/src/dapla_metadata/variable_definitions/vardef.py b/src/dapla_metadata/variable_definitions/vardef.py index cac8d79..94c02ff 100644 --- a/src/dapla_metadata/variable_definitions/vardef.py +++ b/src/dapla_metadata/variable_definitions/vardef.py @@ -2,6 +2,7 @@ from dapla_metadata.variable_definitions import config from dapla_metadata.variable_definitions._client import VardefClient +from dapla_metadata.variable_definitions.exceptions import vardef_exception_handler from dapla_metadata.variable_definitions.generated.vardef_client.api.data_migration_api import ( DataMigrationApi, ) @@ -113,6 +114,7 @@ def migrate_from_vardok(cls, vardok_id: str) -> VariableDefinition: ) @classmethod + @vardef_exception_handler def list_variable_definitions( cls, date_of_validity: date | None = None, @@ -141,6 +143,7 @@ def list_variable_definitions( ] @classmethod + @vardef_exception_handler def get_variable_definition( cls, variable_definition_id: str, diff --git a/tests/utils/constants.py b/tests/utils/constants.py index 963b751..6fd1814 100644 --- a/tests/utils/constants.py +++ b/tests/utils/constants.py @@ -7,3 +7,81 @@ DAPLA_REGION = "DAPLA_REGION" DAPLA_GROUP_CONTEXT = "DAPLA_GROUP_CONTEXT" DAPLA_SERVICE = "DAPLA_SERVICE" +NOT_FOUND_STATUS = 404 +BAD_REQUEST_STATUS = 400 +CONSTRAINT_VIOLATION_BODY = """{ + "cause": null, + "suppressed": [ + ], + "detail": null, + "instance": null, + "parameters": { + }, + "type": "https://zalando.github.io/problem/constraint-violation", + "title": "Constraint Violation", + "status": 400, + "violations": [ + { + "field": "updateVariableDefinitionById.updateDraft.owner.team", + "message": "Invalid Dapla team" + }, + { + "field": "updateVariableDefinitionById.updateDraft.owner.team", + "message": "must not be empty" + } + ] +}""" +CONSTRAINT_VIOLATION_BODY_MISSING_MESSAGES = """{ + "cause": null, + "suppressed": [ + ], + "detail": null, + "instance": null, + "parameters": { + }, + "type": "https://zalando.github.io/problem/constraint-violation", + "title": "Constraint Violation", + "status": 400, + "violations": [ + { + "field": "updateVariableDefinitionById.updateDraft.owner.team" + }, + { + "field": "updateVariableDefinitionById.updateDraft.owner.team" + } + ] +}""" + +CONSTRAINT_VIOLATION_BODY_MISSING_VIOLATIONS = """{ + "cause": null, + "suppressed": [ + ], + "detail": null, + "instance": null, + "parameters": { + }, + "type": "https://zalando.github.io/problem/constraint-violation", + "title": "Constraint Violation", + "status": 400, + "violations": [] +}""" +CONSTRAINT_VIOLATION_BODY_MISSING_FIELD = """{ + "cause": null, + "suppressed": [ + ], + "detail": null, + "instance": null, + "parameters": { + }, + "type": "https://zalando.github.io/problem/constraint-violation", + "title": "Constraint Violation", + "status": 400, + "violations": [ + { + "message": "Invalid Dapla team" + }, + { + "message": "must not be empty" + } + ] +}""" diff --git a/tests/variable_definitions/conftest.py b/tests/variable_definitions/conftest.py index 65cdf42..cacec1c 100644 --- a/tests/variable_definitions/conftest.py +++ b/tests/variable_definitions/conftest.py @@ -73,7 +73,7 @@ def draft(language_string_type, contact) -> Draft: short_name="test", definition=language_string_type, classification_reference="91", - unit_types=["a", "b"], + unit_types=["01"], subject_fields=["a", "b"], contains_sensitive_personal_information=True, measurement_type="test", diff --git a/tests/variable_definitions/test_vardef.py b/tests/variable_definitions/test_vardef.py index 153812f..ea5dddf 100644 --- a/tests/variable_definitions/test_vardef.py +++ b/tests/variable_definitions/test_vardef.py @@ -2,6 +2,7 @@ from dapla_metadata._shared.config import DAPLA_GROUP_CONTEXT from dapla_metadata.variable_definitions._client import VardefClient +from dapla_metadata.variable_definitions.exceptions import VardefClientException from dapla_metadata.variable_definitions.generated.vardef_client.configuration import ( Configuration, ) @@ -16,6 +17,7 @@ ) from dapla_metadata.variable_definitions.vardef import Vardef from dapla_metadata.variable_definitions.variable_definition import VariableDefinition +from tests.utils.constants import NOT_FOUND_STATUS from tests.utils.constants import VARDEF_EXAMPLE_ACTIVE_GROUP from tests.utils.constants import VARDEF_EXAMPLE_DATE from tests.utils.constants import VARDEF_EXAMPLE_DEFINITION_ID @@ -45,6 +47,17 @@ def test_get_variable_definition(client_configuration: Configuration): assert landbak.classification_reference == "91" +def test_get_variable_definition_invalid_id(client_configuration: Configuration): + VardefClient.set_config(client_configuration) + with pytest.raises(VardefClientException) as e: + Vardef.get_variable_definition( + variable_definition_id="invalid id", + ) + assert e.value.status == NOT_FOUND_STATUS + assert e.value.detail == "Not found" + assert str(e.value) == "Status 404: Not found" + + def test_list_patches(client_configuration: Configuration): VardefClient.set_config(client_configuration) landbak = Vardef.get_variable_definition( diff --git a/tests/variable_definitions/vardef_client/test_exceptions.py b/tests/variable_definitions/vardef_client/test_exceptions.py new file mode 100644 index 0000000..1f81928 --- /dev/null +++ b/tests/variable_definitions/vardef_client/test_exceptions.py @@ -0,0 +1,83 @@ +"""Tests for Vardef client exception handling.""" + +from dapla_metadata.variable_definitions.exceptions import VardefClientException +from tests.utils.constants import BAD_REQUEST_STATUS +from tests.utils.constants import CONSTRAINT_VIOLATION_BODY +from tests.utils.constants import CONSTRAINT_VIOLATION_BODY_MISSING_FIELD +from tests.utils.constants import CONSTRAINT_VIOLATION_BODY_MISSING_MESSAGES +from tests.utils.constants import CONSTRAINT_VIOLATION_BODY_MISSING_VIOLATIONS +from tests.utils.constants import NOT_FOUND_STATUS + + +def test_valid_response_body(): + response_body = '{"status": 400, "detail": "Bad Request"}' + exc = VardefClientException(response_body) + assert exc.status == BAD_REQUEST_STATUS + assert exc.detail == "Bad Request" + assert str(exc) == "Status 400: Bad Request" + + +def test_respons_empty_status(): + response_body = '{"status": , "detail": "Bad Request"}' + exc = VardefClientException(response_body) + assert exc.status == "Unknown" + + +def tests_no_status(): + response_body = '{"detail": "Bad Request"}' + exc = VardefClientException(response_body) + assert exc.status == "Unknown status" + assert exc.detail == "Bad Request" + + +def test_invalid_json(): + response_body = "Not a JSON string" + exc = VardefClientException(response_body) + assert exc.status == "Unknown" + assert exc.detail == "Could not decode error response from API" + assert str(exc) == "Status Unknown: Could not decode error response from API" + + +def test_missing_keys(): + response_body = '{"status": 404}' + exc = VardefClientException(response_body) + assert exc.status == NOT_FOUND_STATUS + assert exc.detail == "No detail provided" + assert str(exc) == "Status 404: No detail provided" + + +def test_constraint_violation(): + response_body = CONSTRAINT_VIOLATION_BODY + exc = VardefClientException(response_body) + assert exc.status == BAD_REQUEST_STATUS + assert exc.detail[0]["message"] == "Invalid Dapla team" + assert ( + str(exc) == "Status 400: [" + "{'field': 'updateVariableDefinitionById.updateDraft.owner.team', 'message': 'Invalid Dapla team'}, " + "{'field': 'updateVariableDefinitionById.updateDraft.owner.team', 'message': 'must not be empty'}]" + ) + + +def test_constraint_violation_missing_messages(): + response_body = CONSTRAINT_VIOLATION_BODY_MISSING_MESSAGES + exc = VardefClientException(response_body) + assert exc.status == BAD_REQUEST_STATUS + assert exc.detail[0]["message"] == "No message provided" + + +def test_constraint_violation_empty_violations(): + response_body = CONSTRAINT_VIOLATION_BODY_MISSING_VIOLATIONS + exc = VardefClientException(response_body) + assert exc.status == BAD_REQUEST_STATUS + assert str(exc) == "Status 400: []" + + +def test_constraint_violation_empty_field(): + response_body = CONSTRAINT_VIOLATION_BODY_MISSING_FIELD + exc = VardefClientException(response_body) + assert exc.status == BAD_REQUEST_STATUS + assert str(exc) == ( + "Status 400: [" + "{'field': 'Unknown field', 'message': 'Invalid Dapla team'}, " + "{'field': 'Unknown field', 'message': 'must not be empty'}]" + )