Skip to content

Commit

Permalink
LITE-29415 Library pt2
Browse files Browse the repository at this point in the history
  • Loading branch information
jonatrios committed Jan 30, 2024
1 parent 3d84155 commit dfb42ed
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 0 deletions.
93 changes: 93 additions & 0 deletions connect_extension_utils/api/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from typing import Any, Dict, Union

from connect.client import ClientError
from fastapi import status


'''
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)
40 changes: 40 additions & 0 deletions connect_extension_utils/api/views.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions tests/api/test_errors.py
Original file line number Diff line number Diff line change
@@ -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."]}'
38 changes: 38 additions & 0 deletions tests/api/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest

from connect.client import ClientError
from connect_extension_utils.api.views import (
get_user_data_from_auth_token,
get_object_or_404,
)


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.'
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit dfb42ed

Please sign in to comment.