Чиста архітектура в Python: покрокова демонстрація. Частина 3

Зміст
Сценарії (частина 2)
Git tag:Step06

Тепер, коли ми реалізували об'єкти запиту і відповіді, додаємо їх. Поміщаємо в файл
<font color="#900">tests/use_cases/test_storageroom_list_use_case.py</font>
наступний код:

import pytest
from unittest import mock

from rentomatic.domain.storageroom import StorageRoom
from rentomatic.use_cases import request_objects as ro
from rentomatic.use_cases import storageroom_use_cases as uc


@pytest.fixture
def domain_storagerooms():
storageroom_1 = StorageRoom(
code='f853578c-fc0f-4e65-81b8-566c5dffa35a',
size=215,
price=39,
longitude='-0.09998975',
latitude='51.75436293',
)

storageroom_2 = StorageRoom(
code='fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a',
size=405,
price=66,
longitude='0.18228006',
latitude='51.74640997',
)

storageroom_3 = StorageRoom(
code='913694c6-435a-4366-ba0d-da5334a611b2',
size=56,
price=60,
longitude='0.27891577',
latitude='51.45994069',
)

storageroom_4 = StorageRoom(
code='eed76e77-55c1-41ce-985d-ca49bf6c0585',
size=93,
price=48,
longitude='0.33894476',
latitude='51.39916678',
)

return [storageroom_1, storageroom_2, storageroom_3, storageroom_4]


def test_storageroom_list_without_parameters(domain_storagerooms):
repo = mock.Mock()
repo.list.return_value = domain_storagerooms

storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
request_object = ro.StorageRoomListRequestObject.from_dict({})

response_object = storageroom_list_use_case.execute(request_object)

assert bool(response_object) is True
repo.list.assert_called_with()

assert response_object.value == domain_storagerooms

Нова версія файлу
<font color="#900">rentomatic/use_case/storageroom_use_cases.py</font>
тепер виглядає наступним чином:

from rentomatic.shared import response_object as ro


class StorageRoomListUseCase(object):

def __init__(self, repo):
self.repo = repo

def execute(self, request_object):
storage_rooms = self.repo.list()
return ro.ResponseSuccess (storage_rooms)

Давайте розглянемо, чого ж ми досягли з цієї чистої архітектурою. У нас є дуже легка модель, сериализуемая в JSON і повністю незалежна від інших частин системи. Так само в коді міститься сценарій, в якому сховище, надане даними API, витягує всі моделі і повертає їх усередині структурованого об'єкта.

Правда, ми втратили деякі об'єкти. Наприклад, ми не реалізували який-небудь негативний відповідь або валидированный входить об'єкт запиту.

Для того, щоб можна було розглянути ці втрачені частини архітектури, давайте змінимо поточний сценарій, щоб він приймав параметр
<font color="#900">filters</font>
, який представляє деякі фільтри, що застосовуються до отримання списку моделей. При передачі цього параметра можуть виникати помилки, тому нам доведеться реалізувати перевірку для вхідного об'єкта запиту.

Запити і валідація
Git tag:Step07

Я хочу додати параметр
<font color="#900">filters</font>
для запиту. Завдяки цьому параметру викликає сторона може додавати різні фільтри, вказавши ім'я і значення для кожного (наприклад,
<font color="#900">{ 'price_lt': 100} </font>
для отримання всіх результатів з ціною менше, ніж 100).

Перше, де ми починаємо наші правки, це тест. Нова версія файлу
<font color="#900">tests/use_cases/test_storageroom_list_request_objects.py</font>
виглядає так:

import pytest

from rentomatic.use_cases import request_objects as ro


def test_valid_request_object_cannot_be_used():
with pytest.raises(NotImplementedError):
ro.ValidRequestObject.from_dict({})


def test_build_storageroom_list_request_object_without_parameters():
req = ro.StorageRoomListRequestObject()

assert req.filters is None
assert bool(req) is True


def test_build_file_list_request_object_from_empty_dict():
req = ro.StorageRoomListRequestObject.from_dict({})

assert req.filters is None
assert bool(req) is True


def test_build_storageroom_list_request_object_with_empty_filters():
req = ro.StorageRoomListRequestObject(filters={})

assert req.filters == {}
assert bool(req) is True


def test_build_storageroom_list_request_object_from_dict_with_empty_filters():
req = ro.StorageRoomListRequestObject.from_dict({'filters': {}})

assert req.filters == {}
assert bool(req) is True


def test_build_storageroom_list_request_object_with_filters():
req = ro.StorageRoomListRequestObject(filters={'a': 1, 'b': 2})

assert req.filters == {'a': 1, 'b': 2}
assert bool(req) is True


def test_build_storageroom_list_request_object_from_dict_with_filters():
req = ro.StorageRoomListRequestObject.from_dict({'filters': {'a': 1, 'b': 2}})

assert req.filters == {'a': 1, 'b': 2}
assert bool(req) is True


def test_build_storageroom_list_request_object_from_dict_with_invalid_filters():
req = ro.StorageRoomListRequestObject.from_dict({'filters': 5})

assert req.has_errors()
assert req.errors[0]['parameter'] == 'filters'
assert bool(req) is False

Перевіряємо
<font color="#900">assert req.filters is None</font>
для початкових двох тестів, а потім я додав ще 5 тестів для перевірки, чи можуть фільтри бути уточнені, і перевірити поведінку об'єкта з неприпустимим параметром фільтра.

Для того, щоб тести пройшли, необхідно змінити наш клас
<font color="#900">StorageRoomListRequestObject</font>
. Природно, є безліч можливих рішень, які ви можете придумати, і я рекомендую вам спробувати знайти свій власний. Тут описано те рішення, яке я зазвичай використовують сам. Файл
<font color="#900">rentomatic/use_cases/request_object.py</font>
тепер виглядає як

import collections


class InvalidRequestObject(object):

def __init__(self):
self.errors = []

def add_error(self, parameter, message):
self.errors.append({'parameter': parameter, 'message': message})

def has_errors(self):
return len(self.errors) > 0

def __nonzero__(self):
return False

__bool__ = __nonzero__


class ValidRequestObject(object):

@classmethod
def from_dict(cls, adict):
raise NotImplementedError

def __nonzero__(self):
return True

__bool__ = __nonzero__


class StorageRoomListRequestObject(ValidRequestObject):

def __init__(self, filters=None):
self.filters = filters

@classmethod
def from_dict(cls, adict):
invalid_req = InvalidRequestObject()

if 'filters' in adict and not isinstance(adict['filters'], collections.Mapping):
invalid_req.add_error('filters', 'Is not iterable')

if invalid_req.has_errors():
return invalid_req

return StorageRoomListRequestObject(filters=adict.get('filters', None))


Давайте я поясню цей новий код.

По-перше, були введені два допоміжні об'єкти,
<font color="#900">ValidRequestObject</font>
та
<font color="#900">InvalidRequestObject</font>
. Вони відрізняються один від одного тому, що неправильний запит повинен містити помилки валідації, але при цьому вони обидва повинні перетворюватися до булеву значенням.

По-друге,
<font color="#900">StorageRoomListRequestObject</font>
приймає опціональний параметр
<font color="#900">filters</font>
в момент створення. У методі
<font color="#900">__init __ ()</font>
, немає ніяких перевірок на валідність, так, як він вважається внутрішнім методом, який викликається вже після того, як параметри вже були підтверджені.

У підсумку, метод
<font color="#900">from_dict()</font>
перевіряє наявність параметра
<font color="#900">filters</font>
. Я використовую абстрактний клас
<font color="#900">collections.Mapping</font>
для перевірки, що вхідні параметри є словниками, і що повертається примірник об'єктів
<font color="#900">InvalidRequestObject</font>
або
<font color="#900">ValidRequestObject</font>
.

Раз вже ми тепер можемо повідомити про наявність поганих або хороших запитів, нам необхідно ввести новий тип відповіді для управління поганими відповідями або помилками в сценарії.

Відповіді і провали
Git tag:Step08

Що станеться, якщо в сценарії виникає помилка? Сценарії можуть зіткнутися з великою кількістю помилок: не тільки помилки валідації, про яких мыговорили в попередньому розділі, але і бізнес помилки або з шару сховища. Якою б не була помилка, сценарій повинен завжди повертати об'єкт з відомою структурою (відповідь), тому нам потрібен новий об'єкт, який забезпечує хорошу підтримку для різних типів провалів.

Як і з запитами, немає єдиного вірного способу подання такого об'єкта, і наступний код є лише одним з можливих рішень.

Перше, що потрібно зробити, це розширити файл
<font color="#900">tests/shared/test_response_object.py</font>
, додавши, тести для провальних випадків.

import pytest

from rentomatic.shared import response_object as res
from rentomatic.use_cases import request_objects as req


@pytest.fixture
def response_value():
return {'key': ['value1', 'value2']}


@pytest.fixture
def response_type():
return 'ResponseError'


@pytest.fixture
def response_message():
return 'This is a response error'

Це шаблонний код, заснований на фикстурах
<font color="#900">pytest</font>
, які ми будемо використовувати в наступних тестах.

def test_response_success_is_true(response_value):
assert bool(res.ResponseSuccess(response_value)) is True


def test_response_failure_is_false(response_type, response_message):
assert bool(res.ResponseFailure(response_type, response_message)) is False

Два базових тесту для перевірки того, що колишній
<font color="#900">ResponseSuccess</font>
і новий
<font color="#900">ResponseFailure</font>
поводяться погоджено при перетворенні в булеве значення.

def test_response_success_contains_value(response_value):
response = res.ResponseSuccess(response_value)

assert response.value == response_value

Об'єкт
<font color="#900">ResponseSuccess</font>
містить результат виклику в атрибуті
value
.

def test_response_failure_has_type_and_message(response_type, response_message):
response = res.ResponseFailure(response_type, response_message)

assert response.type == response_type
assert response.message == response_message


def test_response_failure_contains_value(response_type, response_message):
response = res.ResponseFailure(response_type, response_message)

assert response.value == {'type': response_type, 'message': response_message}

Ці два тести гарантують, що об'єкт
<font color="#900">ResponseFailure</font>
забезпечує той же інтерфейс, що і при успіху, і що у цього об'єкта є параметри
<font color="#900">type</font>
та
<font color="#900">message</font>
.

def test_response_failure_initialization_with_exception():
response = res.ResponseFailure(response_type, Exception('Just an error message'))

assert bool(response) is False
assert response.type == response_type
assert response.message == "Exception: Just an error message"


def test_response_failure_from_invalid_request_object():
response = res.ResponseFailure.build_from_invalid_request_object(req.InvalidRequestObject())

assert bool(response) is False


def test_response_failure_from_invalid_request_object_with_errors():
request_object = req.InvalidRequestObject()
request_object.add_error('path', 'Is mandatory')
request_object.add_error('path', "can't be blank")

response = res.ResponseFailure.build_from_invalid_request_object(request_object)

assert bool(response) is False
assert response.type == res.ResponseFailure.PARAMETERS_ERROR
assert response.message == "path: Is mandatory\npath: can't be blank"

Іноді необхідно створити відповіді від Python-винятків, які можуть відбутися в сценарії, тому ми перевіряємо, що об'єкти
<font color="#900">ResponseFailure</font>
можна ініціалізувати з винятком.

І останнє, у нас є тести для методу
<font color="#900">build_from_invalid_request_object()</font>
, що автоматизують ініціалізацію відповіді від неприпустимого запиту. Якщо запит містить помилки (пам'ятаємо, запит перевіряє себе), ми повинні передати їх у відповідному повідомленні.

Останній тест використовує атрибут класу для класифікації помилки. Клас
<font color="#900">ResponseFailure</font>
буде містити три зумовлених помилки, які можуть виникнути при виконанні сценарію:
<font color="#900">RESOURCE_ERROR</font>
,
<font color="#900">PARAMETERS_ERROR</font>
та
<font color="#900">SYSTEM_ERROR</font>
. Подібним поділом ми намагаємося охопити різні види помилок, які можуть виникнути при роботі з зовнішньою системою через API.
<font color="#900">RESOURCE_ERROR</font>
містить помилки, пов'язані з ресурсами, що містяться у сховищі, наприклад, коли ви не можете знайти запис за її унікальному ідентифікатору.
<font color="#900">PARAMETERS_ERROR</font>
описує помилки, що виникають при неправильних або пропущених параметри запиту.
<font color="#900">SYSTEM_ERROR</font>
охоплює помилки, що відбуваються в базовій системі на рівні операційної системи, такі як збій у роботі файлової системи або помилка підключення до мережі під час вибірки даних з бази даних.

Сценарій відповідальний за взаємодію з різними помилками, що виникають у Python-коді, і перетворює їх один з трьох тільки що описаних типів повідомлення, що має опис даної помилки.

Давайте напишемо клас
<font color="#900">ResponseFailure</font>
, який дозволяє тестів успішно виконуватися. Створимо його в
<font color="#900">rentomatic/shared/response_object.py</font>


class ResponseFailure(object):
RESOURCE_ERROR = 'RESOURCE_ERROR'
PARAMETERS_ERROR = 'PARAMETERS_ERROR'
SYSTEM_ERROR = 'SYSTEM_ERROR'

def __init__(self, type_, message):
self.type = type_
self.message = self._format_message(message)

def _format_message(self, msg):
if isinstance(msg, Exception):
return "{}: {}".format(msg.__class__.__name__, "{}".format(msg))
return msg

З допомогою методу
<font color="#900">_format_message()</font>
ми дозволяємо класу приймати як рядковий повідомлення, так і Python-виняток, що дуже зручно при роботі з зовнішніми бібліотеками, які можуть викликати невідомі або не цікавлять нас винятку.

@property
def value(self):
return {'type': self.type, 'message': self.message}

Це властивість робить клас погодженим з API
<font color="#900">ResponseSuccess</font>
, надаючи атрибут
<font color="#900">value</font>
, який є словником.

def __bool__(self):
return False


@classmethod
def build_from_invalid_request_object(cls, invalid_request_object):
message = "\n".join(["{}: {}".format(err['parameter'], err['message'])
for err in invalid_request_object.errors])
return cls(cls.PARAMETERS_ERROR, message)

Як було пояснено вище, тип
<font color="#900">PARAMETERS_ERROR</font>
охоплює всі ті помилки, які відбуваються при неправильному наборі переданих параметрів, тобто деякі параметри містять помилки або пропущені.

Оскільки нам часто доводиться створювати відповіді з провалом, корисно мати допоміжні методи. Додаю три тесту для функцій-будівних у файлі
<font color="#900">tests/shared/test_response_object.py</font>


def test_response_failure_build_resource_error():
response = res.ResponseFailure.build_resource_error("test message")

assert bool(response) is False
assert response.type == res.ResponseFailure.RESOURCE_ERROR
assert response.message == "test message"


def test_response_failure_build_parameters_error():
response = res.ResponseFailure.build_parameters_error("test message")

assert bool(response) is False
assert response.type == res.ResponseFailure.PARAMETERS_ERROR
assert response.message == "test message"


def test_response_failure_build_system_error():
response = res.ResponseFailure.build_system_error("test message")

assert bool(response) is False
assert response.type == res.ResponseFailure.SYSTEM_ERROR
assert response.message == "test message"

Ми додали відповідні методи в класі і додали використання нового методу
<font color="#900">build_parameters_error()</font>
в методі
<font color="#900">build_from_invalid_request_object()</font>
. У файлі
<font color="#900">rentomatic/shared/response_object.py</font>
тепер повинен знаходитися такий код

@classmethod
def build_resource_error(cls, message=None):
return cls(cls.RESOURCE_ERROR, message)

@classmethod
def build_system_error(cls, message=None):
return cls(cls.SYSTEM_ERROR, message)

@classmethod
def build_parameters_error(cls, message=None):
return cls(cls.PARAMETERS_ERROR, message)

@classmethod
def build_from_invalid_request_object(cls, invalid_request_object):
message = "\n".join(["{}: {}".format(err['parameter'], err['message'])
for err in invalid_request_object.errors])
return cls.build_parameters_error(message)

далі буде...
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.