BTHLABS-61: Service layer refactoring

A journey to fix `ValidationError` in Pocket imports turned service
layer refactoring :D
This commit is contained in:
2025-10-12 18:37:32 +00:00
parent ac7a8dd90e
commit 8b86145519
45 changed files with 1023 additions and 337 deletions

View File

@@ -8,7 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/
# COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/
VOLUME ["/srv/node_modules", "/srv/venv"]

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import enum
class BackendServiceErrorCode(enum.Enum):
INTERNAL = -32000
NOT_FOUND = -32001
ACCESS_DENIED = -32002
INVALID = -32003

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_soa.constants import BackendServiceErrorCode
if typing.TYPE_CHECKING:
from django.core.exceptions import ValidationError
VALIDATION_CODE_INVALID = 'invalid'
def get_validation_error_data(validation_error: typing.Any) -> typing.Any: # Heh
if hasattr(validation_error, 'error_dict') is True:
return {
field: [
get_validation_error_data(inner_error)
for inner_error
in inner_errors
]
for field, inner_errors in validation_error.error_dict.items()
}
elif hasattr(validation_error, 'error_list') is True and len(validation_error.error_list) > 1:
return [
get_validation_error_data(inner_error)
for inner_error
in validation_error.error_list
]
elif hasattr(validation_error, 'code') is True:
return validation_error.code
elif isinstance(validation_error, (tuple, list)) is True:
return [
get_validation_error_data(inner_error)
for inner_error
in validation_error
]
elif isinstance(validation_error, dict) is True:
return {
field: [
get_validation_error_data(inner_error)
for inner_error
in inner_errors
]
for field, inner_errors in validation_error.items()
}
else:
return VALIDATION_CODE_INVALID
class BackendServiceError(Exception):
CODE = BackendServiceErrorCode.INTERNAL
def __init__(self, message: str, *args):
super().__init__(message, *args)
self.message = message
self.data: typing.Any = None
if len(args) > 0:
self.data = args[0]
class InternalError(BackendServiceError):
pass
class NotFound(BackendServiceError):
CODE = BackendServiceErrorCode.NOT_FOUND
class AccessDenied(BackendServiceError):
CODE = BackendServiceErrorCode.ACCESS_DENIED
class Invalid(BackendServiceError):
CODE = BackendServiceErrorCode.INVALID
@classmethod
def from_django_validation_error(cls: type[typing.Self],
exception: ValidationError,
message: str | None = None,
) -> typing.Self:
data = get_validation_error_data(exception)
result = cls(message or 'Invalid', data)
result.__cause__ = exception
return result

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from .backend import BackendServiceError
class SOAError(Exception):
def __init__(self, code: int, message: str, *args):
super().__init__(code, message, *args)
self.code = code
self.message = message
self.data: typing.Any = None
if len(args) > 0:
self.data = args[0]
@classmethod
def from_backend_error(cls: type[typing.Self], exception: BackendServiceError) -> typing.Self:
result = cls(
exception.CODE.value,
exception.message,
exception.data,
)
result.__cause__ = exception
return result

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import uuid
from hotpocket_backend.apps.accounts.services import (
@@ -11,6 +12,7 @@ from hotpocket_soa.dto.accounts import (
AccessTokenOut,
AccessTokensQuery,
)
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@@ -19,22 +21,18 @@ class AccessTokensService(ProxyService):
class AccessTokensServiceError(SOAError):
pass
class AccessTokenNotFound(AccessTokensServiceError):
class NotFound(AccessTokensServiceError):
pass
class AccessTokenAccessDenied(AccessTokensServiceError):
class AccessDenied(AccessTokensServiceError):
pass
def __init__(self):
super().__init__()
self.backend_access_tokens_service = BackendAccessTokensService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccessTokensServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.AccessTokensServiceError
def create(self,
*,
@@ -69,16 +67,14 @@ class AccessTokensService(ProxyService):
)
if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True:
raise self.AccessTokenNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def get_by_key(self,
*,
@@ -96,16 +92,14 @@ class AccessTokensService(ProxyService):
)
if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` key=`{key}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True:
raise self.AccessTokenNotFound(f'account_uuid=`{account_uuid}` pk=`{key}`') from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def search(self,
*,
@@ -124,23 +118,29 @@ class AccessTokensService(ProxyService):
]
def delete(self, *, access_token: AccessTokenOut) -> bool:
return self.call(
self.backend_access_tokens_service,
'delete',
pk=access_token.pk,
)
try:
return self.call(
self.backend_access_tokens_service,
'delete',
pk=access_token.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update_meta(self,
*,
access_token: AccessTokenOut,
update: AccessTokenMetaUpdateIn,
) -> AccessTokenOut:
return AccessTokenOut.model_validate(
self.call(
self.backend_access_tokens_service,
'update_meta',
pk=access_token.pk,
update=update,
),
from_attributes=True,
)
try:
return AccessTokenOut.model_validate(
self.call(
self.backend_access_tokens_service,
'update_meta',
pk=access_token.pk,
update=update,
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@@ -7,6 +7,7 @@ from hotpocket_backend.apps.accounts.services import (
AccountsService as BackendAccountsService,
)
from hotpocket_soa.dto.accounts import AccountOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@@ -15,19 +16,15 @@ class AccountsService(ProxyService):
class AccountsServiceError(SOAError):
pass
class AccountNotFound(AccountsServiceError):
class NotFound(AccountsServiceError):
pass
def __init__(self):
super().__init__()
self.backend_accounts_service = BackendAccountsService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccountsServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.AccountsServiceError
def get(self, *, pk: uuid.UUID) -> AccountOut:
try:
@@ -41,8 +38,5 @@ class AccountsService(ProxyService):
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAccountsService.AccountNotFound) is True:
raise self.AccountNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import datetime
import http
import uuid
from hotpocket_backend.apps.saves.services import (
@@ -14,6 +15,7 @@ from hotpocket_soa.dto.associations import (
AssociationWithTargetOut,
)
from hotpocket_soa.dto.saves import SaveOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@@ -22,22 +24,18 @@ class AssociationsService(ProxyService):
class AssociationsServiceError(SOAError):
pass
class AssociationNotFound(AssociationsServiceError):
class NotFound(AssociationsServiceError):
pass
class AssociationAccessDenied(AssociationsServiceError):
class AccessDenied(AssociationsServiceError):
pass
def __init__(self):
super().__init__()
self.backend_associations_service = BackendAssociationsService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AssociationsServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.AssociationsServiceError
def create(self,
*,
@@ -81,19 +79,19 @@ class AssociationsService(ProxyService):
)
if allow_archived is False and result.archived_at is not None:
raise self.AssociationNotFound(f'pk=`{pk}`')
raise self.NotFound(
http.HTTPStatus.NOT_FOUND.value, f'pk=`{pk}`',
)
if account_uuid is not None and result.account_uuid != account_uuid:
raise self.AssociationAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True:
raise self.AssociationNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def search(self,
*,
@@ -112,50 +110,65 @@ class AssociationsService(ProxyService):
]
def archive(self, *, association: AssociationOut) -> bool:
return self.call(
self.backend_associations_service,
'archive',
pk=association.pk,
)
try:
return self.call(
self.backend_associations_service,
'archive',
pk=association.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def star(self, *, association: AssociationOut) -> AssociationOut:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'star',
pk=association.pk,
),
from_attributes=True,
)
try:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'star',
pk=association.pk,
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def unstar(self, *, association: AssociationOut) -> AssociationOut:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'unstar',
pk=association.pk,
),
from_attributes=True,
)
try:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'unstar',
pk=association.pk,
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update(self,
*,
association: AssociationOut,
update: AssociationUpdateIn,
) -> AssociationOut:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'update',
pk=association.pk,
update=update,
),
from_attributes=True,
)
try:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'update',
pk=association.pk,
update=update,
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def delete(self, *, association: AssociationOut) -> bool:
return self.call(
self.backend_associations_service,
'delete',
pk=association.pk,
)
try:
return self.call(
self.backend_associations_service,
'delete',
pk=association.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import uuid
from hotpocket_backend.apps.accounts.services import (
AuthKeysService as BackendAuthKeysService,
)
from hotpocket_soa.dto.accounts import AuthKeyOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@@ -15,23 +17,16 @@ class AuthKeysService(ProxyService):
class AuthKeysServiceError(SOAError):
pass
class AuthKeyNotFound(AuthKeysServiceError):
class NotFound(AuthKeysServiceError):
pass
class AuthKeyAccessDenied(AuthKeysServiceError):
class AccessDenied(AuthKeysServiceError):
pass
def __init__(self):
super().__init__()
self.backend_auth_keys_service = BackendAuthKeysService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AuthKeysServiceError(*new_exception_args)
def _check_auth_key_access(self,
auth_key: AuthKeyOut,
account_uuid: uuid.UUID | None,
@@ -70,16 +65,14 @@ class AuthKeysService(ProxyService):
)
if self._check_auth_key_access(result, account_uuid) is False:
raise self.AuthKeyAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True:
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def get_by_key(self,
*,
@@ -97,13 +90,11 @@ class AuthKeysService(ProxyService):
)
if self._check_auth_key_access(result, account_uuid) is False:
raise self.AuthKeyAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` key=`{key}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True:
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@@ -1,16 +1,66 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import functools
import http
import types
import typing
class SOAError(Exception):
pass
from hotpocket_soa.exceptions.backend import BackendServiceError
from hotpocket_soa.exceptions.frontend import SOAError
class Service:
def wrap_exception(self, exception: Exception) -> Exception:
return SOAError(exception.args[0])
def __getattribute__(self, name: str) -> typing.Any:
result = super().__getattribute__(name)
is_service_method = all((
name.startswith('_') is False,
hasattr(Service, name) is False,
isinstance(result, types.MethodType),
getattr(result, '__self__', None) is self,
))
if is_service_method is True:
@functools.wraps(result)
def wrapped_result(*args, **kwargs):
try:
return result(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception
return wrapped_result
return result
def get_error_class(self) -> type[SOAError]:
return SOAError
def wrap_exception(self, exception: Exception) -> SOAError:
error_class = self.get_error_class()
if isinstance(exception, error_class) is True:
return typing.cast(SOAError, exception)
result = error_class(
http.HTTPStatus.IM_A_TEAPOT.value,
'SOA Error',
*exception.args,
)
if isinstance(exception, BackendServiceError) is True:
baskend_error = typing.cast(BackendServiceError, exception)
result = error_class(
baskend_error.CODE.value,
baskend_error.message,
baskend_error.data,
*baskend_error.args,
)
result.__cause__ = exception
return result
def call(self, *args, **kwargs) -> typing.Any:
raise NotImplementedError('TODO')
class ProxyService(Service):
@@ -18,7 +68,4 @@ class ProxyService(Service):
handler = getattr(service, method, None)
assert handler is not None, f'Unknown method: method=`{method}`'
try:
return handler(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception
return handler(*args, **kwargs)

View File

@@ -15,12 +15,8 @@ class BotService(ProxyService):
super().__init__()
self.backend_associations_service = BackendBotService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.BotServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.BotServiceError
def is_netloc_banned(self, *, url: str) -> bool:
return self.call(

View File

@@ -18,17 +18,13 @@ class SaveProcessorService(ProxyService):
class SaveProcessorServiceError(SOAError):
pass
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SaveProcessorServiceError(*new_exception_args)
def __init__(self):
super().__init__()
self.backend_save_processor_service = BackendSaveProcessorService()
def get_error_class(self) -> type[SOAError]:
return self.SaveProcessorServiceError
def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut:
result = AsyncResultOut.model_validate(
self.call(

View File

@@ -7,6 +7,7 @@ from hotpocket_backend.apps.saves.services import (
SavesService as BackendSavesService,
)
from hotpocket_soa.dto.saves import SaveIn, SaveOut
from hotpocket_soa.exceptions.backend import Invalid, NotFound
from .base import ProxyService, SOAError
@@ -15,30 +16,34 @@ class SavesService(ProxyService):
class SavesServiceError(SOAError):
pass
class SaveNotFound(SavesServiceError):
class NotFound(SavesServiceError):
pass
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SavesServiceError(*new_exception_args)
class Invalid(SavesServiceError):
pass
def __init__(self):
super().__init__()
self.backend_saves_service = BackendSavesService()
def get_error_class(self) -> type[SOAError]:
return self.SavesServiceError
def create(self, *, account_uuid: uuid.UUID, save: SaveIn) -> SaveOut:
return SaveOut.model_validate(
self.call(
self.backend_saves_service,
'create',
account_uuid=account_uuid,
save=save,
),
from_attributes=True,
)
try:
return SaveOut.model_validate(
self.call(
self.backend_saves_service,
'create',
account_uuid=account_uuid,
save=save,
),
from_attributes=True,
)
except Invalid as exception:
raise self.Invalid(
exception.CODE.value, exception.message, exception.data,
)
def get(self, *, pk: uuid.UUID) -> SaveOut:
try:
@@ -52,8 +57,5 @@ class SavesService(ProxyService):
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True:
raise self.SaveNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)