From 42f49ed8b174f37e7b706dfa20009b0ffdff6b71 Mon Sep 17 00:00:00 2001 From: Jonathan Rios Date: Tue, 30 Jan 2024 16:33:48 +0100 Subject: [PATCH] LITE-29415 Library pt2 --- connect_extension_utils/api/errors.py | 94 +++++++++++++++++++++++++++ connect_extension_utils/api/views.py | 40 ++++++++++++ pyproject.toml | 12 +++- sonar-project.properties | 2 + tests/api/test_errors.py | 62 ++++++++++++++++++ tests/api/test_views.py | 35 ++++++++++ tests/conftest.py | 9 +++ 7 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 connect_extension_utils/api/views.py create mode 100644 tests/api/test_errors.py create mode 100644 tests/api/test_views.py diff --git a/connect_extension_utils/api/errors.py b/connect_extension_utils/api/errors.py index e69de29..7da9cfc 100644 --- a/connect_extension_utils/api/errors.py +++ b/connect_extension_utils/api/errors.py @@ -0,0 +1,94 @@ +from typing import Any, Dict, Union + +from fastapi import status + +from connect.client import ClientError + + +''' +Error utilities for EaaS extensions. + +By default eass runner injects the `connect.eaas.core.utils.client_error_exception_handler` +function to the FastAPI app initialization as exception handler. +This function catch all errors raised in the context of the WebApplication that are +an instance of the `connecet.client.ClientError` class and coerce them into JSON format +(`fastapi.responses.JSONResponse`) if the error instance has the attribute `error_code` setted. + +`connect_extension_utils.api.errors` is a wrapper around this behaviour that facilitates the use of +prefixed error responses. +''' + + +class Error: + STATUS_CODE = status.HTTP_400_BAD_REQUEST + + def __init__(self, message, error_code): + self.message = message + self.error_code = error_code + + def __call__(self, **kwds: Dict[str, Any]) -> ClientError: + format_kwargs = kwds.get('format_kwargs', {}) + + message = self.message.format(**format_kwargs) + errors = kwds.get('errors') + + if not errors: + errors = [message or 'Unexpected error.'] + if not isinstance(errors, list): + errors = [errors] + + return ClientError( + message=message, + status_code=self.STATUS_CODE, + error_code=self.error_code, + errors=errors, + ) + + +class ExtensionErrorMeta(type): + PREFIX = 'EXT' + ERRORS = {} + + def __getattr__(cls, __name: str) -> Union[Error, AttributeError]: + valid_dict = {cls.PREFIX: cls.ERRORS} + try: + prefix, code = __name.split('_') + error = valid_dict[prefix][int(code)] + except (KeyError, ValueError): + raise AttributeError(f"type object '{cls.__name__}' has no attribute '{__name}'") + return Error(message=error, error_code=__name) + + +class ExtensionErrorBase(metaclass=ExtensionErrorMeta): + ''' + Base Error class to group a set of validation (`fastapi.status.HTTP_400_BAD_REQUEST`) + errors base on a prefix. By default the `PREFIX` value is `EXT`, but it can be overwritten. + Also a list of `errors` can be provided. + + Usage: + + ``` + # Define a custom error class + class MyError(ExtensionErrorBase) + PREFIX = "EXT" + ERRORS = { + 1: "Some error", + 2: "Some {template} error.", + 3: "Not found", + + } + + # raise the error + raise MyError.EXT_001() + raise MyError.EXT_002(format_kwargs={"template": "foo"}) + ``` + ''' + + +class Http404(ClientError): + def __init__(self, obj_id, **kwargs): + message = "Object `{obj_id}` not found.".format(obj_id=obj_id) + status_code = status.HTTP_404_NOT_FOUND + error_code = 'NFND_000' + errors = [message] + super().__init__(message, status_code, error_code, errors, **kwargs) diff --git a/connect_extension_utils/api/views.py b/connect_extension_utils/api/views.py new file mode 100644 index 0000000..bfd3e5b --- /dev/null +++ b/connect_extension_utils/api/views.py @@ -0,0 +1,40 @@ +import jwt + +from connect_extension_utils.api.errors import Http404 + + +def get_user_data_from_auth_token(token): + ''' + Helper function to fill the Events fields of ceartian object, base on + the token that be access through `request.headers['connect-auth']` f.e.: + ``` + created.by.id + created.by.name + ``` + :param str token: + :return: Python dict containing id and name of user making the request + :rtype: dict[str, str] + ''' + payload = jwt.decode(token, options={"verify_signature": False}) + return { + 'id': payload['u']['oid'], + 'name': payload['u']['name'], + } + + +def get_object_or_404(db, model, filters, object_id): + ''' + Wrapper to use `Http404`response error class within a WebApplication + view handler function. + + :param sqlalchemy.ormSession db: + :param connect_extension_utils.db.models.Model model: + :param tuple[bool] filters: + :param str object_id: + :return: A db model instance or a HTTP 404 error. + :rtype: Union[Type[connect_extension_utils.db.models.Model], Http404] + ''' + obj = db.query(model).filter(*filters).one_or_none() + if not obj: + raise Http404(obj_id=object_id) + return obj diff --git a/pyproject.toml b/pyproject.toml index 086d41e..9208951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,15 @@ addopts = "--cov=connect_extension_utils --cov-report=term-missing:skip-covered branch = true [tool.coverage.report] -omit = [] +omit = [ + "*/migrations/*", + "*/config/*", + "*/settings/*", + "*/manage.py", + "*/wsgi.py", + "*/urls.py", + "*__init__.py", +] exclude_lines = [ "pragma: no cover", @@ -76,7 +84,7 @@ exclude = [ show_source = true max_line_length = 100 max_cognitive_complexity = 15 -ignore = ["FI1", "W503", "B008", "I100"] +ignore = ["FI1", "W503", "B008", "I100", "I201"] [tool.isort] diff --git a/sonar-project.properties b/sonar-project.properties index da11328..51a7744 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,7 +4,9 @@ sonar.organization=cloudbluesonarcube sonar.language=py +sonar.sources=connect_extension_utils sonar.tests=tests +sonar.inclusions=connect_extension_utils/** sonar.exclusions=tests/** sonar.python.coverage.reportPaths=./coverage.xml diff --git a/tests/api/test_errors.py b/tests/api/test_errors.py new file mode 100644 index 0000000..e98f621 --- /dev/null +++ b/tests/api/test_errors.py @@ -0,0 +1,62 @@ +import json + +import pytest + +from connect.client import ClientError +from connect.eaas.core.utils import client_error_exception_handler +from connect_extension_utils.api.errors import ExtensionErrorBase, Http404 + + +class MyError(ExtensionErrorBase): + PREFIX = 'BAD' + + ERRORS = { + 0: "Some error.", + 1: "Error with param: {param}.", + 3: "", + } + + +def test_extension_error(): + error = MyError.BAD_000() + assert isinstance(error, ClientError) + assert error.status_code == 400 + + json_error = client_error_exception_handler(None, error).body.decode() + + assert json_error == '{"error_code":"BAD_000","errors":["Some error."]}' + + +def test_error_with_param(): + error = MyError.BAD_001(format_kwargs={'param': 'Foo'}) + assert isinstance(error, ClientError) + assert error.status_code == 400 + + json_error = client_error_exception_handler(None, error).body.decode() + + assert json_error == '{"error_code":"BAD_001","errors":["Error with param: Foo."]}' + + +@pytest.mark.parametrize( + 'errors,expected', + (('Error', 'Error'), (None, 'Unexpected error.')), +) +def test_default_error_message(errors, expected): + error = MyError.BAD_003(errors=errors) + assert isinstance(error, ClientError) + assert error.status_code == 400 + + json_error = client_error_exception_handler(None, error).body.decode() + + assert json.loads(json_error) == {"error_code": "BAD_003", "errors": [f'{expected}']} + + +def test_404_not_found_error(): + not_found = Http404('EXT-123') + + assert isinstance(not_found, ClientError) + assert not_found.status_code == 404 + + json_error = client_error_exception_handler(None, not_found).body.decode() + + assert json_error == '{"error_code":"NFND_000","errors":["Object `EXT-123` not found."]}' diff --git a/tests/api/test_views.py b/tests/api/test_views.py new file mode 100644 index 0000000..7cada2e --- /dev/null +++ b/tests/api/test_views.py @@ -0,0 +1,35 @@ +import pytest + +from connect.client import ClientError +from connect_extension_utils.api.views import get_object_or_404, get_user_data_from_auth_token + + +def test_get_user_data_from_auth_token(connect_auth_header): + result = get_user_data_from_auth_token(connect_auth_header) + + assert result == {'id': 'SU-295-689-628', 'name': 'Neri'} + + +def test_get_object_or_404_success(dbsession, my_model_factory): + obj = my_model_factory() + + obj_from_db = get_object_or_404( + dbsession, + my_model_factory._meta.model, + (my_model_factory._meta.model.id == obj.id,), + obj.id, + ) + + assert obj_from_db == obj + + +def test_get_object_or_404_not_found(dbsession, my_model_factory): + with pytest.raises(ClientError) as ex: + get_object_or_404( + dbsession, + my_model_factory._meta.model, + (my_model_factory._meta.model.id == '000',), + '000', + ) + assert ex.value.error_code == 'NFND_000' + assert ex.value.message == 'Object `000` not found.' diff --git a/tests/conftest.py b/tests/conftest.py index c1fc800..b712548 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,15 @@ def mock_settings_env_vars(): yield +@pytest.fixture +def connect_auth_header(): + """Connect-Auth header for the user fixture ('SU-295-689-628', 'Neri')""" + return ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1Ijp7Im9pZCI6IlNVLTI5NS02ODktN" + "jI4IiwibmFtZSI6Ik5lcmkifX0.U_T6vuXnD293hcWNTJZ9QBViteNv8JXUL2gM0BezQ-k" + ) + + register(MyModelFactory) register(RelatedModelFactory) register(TransactionalModelFactory)