Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: fewer response models #146

Closed
wants to merge 11 commits into from
37 changes: 37 additions & 0 deletions api/src/common/entity_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Optional, Type

from pydantic import BaseModel


def filter_fields(
name: Optional[str] = None,
include: Optional[list[str]] = None,
exclude: Optional[list[str]] = None,
):
"""Return a decorator to filter model fields"""

def decorator(cls: Type[BaseModel]):
config = cls.Config()
to_include = getattr(config, "include", include)
to_exclude = getattr(config, "exclude", exclude)

if name:
cls.__name__ = name

include_ = set(cls.__fields__.keys())

if to_include is not None:
include_ &= set(to_include)

exclude_ = set()
if to_exclude is not None:
exclude_ = set(to_exclude)
if to_include and to_exclude and set(to_include) & set(to_exclude):
raise ValueError("include and exclude cannot contain the same fields")

for field in list(cls.__fields__):
if field not in include_ or field in exclude_:
del cls.__fields__[field]
return cls

return decorator
2 changes: 1 addition & 1 deletion api/src/data_providers/repositories/TodoRepository.py
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@


def to_dict(todo_item: TodoItem):
dict = todo_item.__dict__
dict = todo_item.dict()
dict["_id"] = todo_item.id
return dict

16 changes: 8 additions & 8 deletions api/src/entities/TodoItem.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
from dataclasses import asdict, dataclass, fields
from pydantic import BaseModel, Field

title_field = Field(
..., title="The title of the item", max_length=30, min_length=1, example="Read about clean architecture"
)

@dataclass(frozen=True)
class TodoItem:

class TodoItem(BaseModel):
id: str
user_id: str
title: str
title: str = title_field
is_completed: bool = False

def to_dict(self):
return asdict(self)

@classmethod
def from_dict(cls, dict_) -> "TodoItem":
class_fields = {f.name for f in fields(cls)}
class_fields = {field for field in cls.__fields__}
if "_id" in dict_:
dict_["id"] = dict_.pop("_id")
data = {k: v for k, v in dict_.items() if k in class_fields}
10 changes: 10 additions & 0 deletions api/src/features/todo/shared_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from common.entity_mapper import filter_fields
from entities.TodoItem import TodoItem


# An alternative solution is to use Pydantic BaseModel
# and to duplicate all the fields except user_id
@filter_fields(name="TodoItem")
class TodoItemResponseModel(TodoItem):
class Config:
exclude = ["user_id"]
29 changes: 13 additions & 16 deletions api/src/features/todo/todo_feature.py
Original file line number Diff line number Diff line change
@@ -11,30 +11,27 @@
TodoRepositoryInterface,
)

from .use_cases.add_todo import AddTodoRequest, AddTodoResponse, add_todo_use_case
from .use_cases.delete_todo_by_id import DeleteTodoByIdResponse, delete_todo_use_case
from .use_cases.get_todo_all import GetTodoAllResponse, get_todo_all_use_case
from .use_cases.get_todo_by_id import GetTodoByIdResponse, get_todo_by_id_use_case
from .use_cases.update_todo import (
UpdateTodoRequest,
UpdateTodoResponse,
update_todo_use_case,
)
from .shared_models import TodoItemResponseModel
from .use_cases.add_todo import AddTodoRequestModel, add_todo_use_case
from .use_cases.delete_todo_by_id import delete_todo_use_case
from .use_cases.get_todo_all import get_todo_all_use_case
from .use_cases.get_todo_by_id import get_todo_by_id_use_case
from .use_cases.update_todo import UpdateTodoRequest, update_todo_use_case

router = APIRouter(tags=["todos"], prefix="/todos")


@router.post("", operation_id="create", response_model=AddTodoResponse)
@router.post("", operation_id="create", response_model=TodoItemResponseModel)
@create_response(JSONResponse)
def add_todo(
data: AddTodoRequest,
data: AddTodoRequestModel,
user: User = Depends(auth_with_jwt),
todo_repository: TodoRepositoryInterface = Depends(get_todo_repository),
):
return add_todo_use_case(data=data, user_id=user.user_id, todo_repository=todo_repository).dict()


@router.get("/{id}", operation_id="get_by_id", response_model=GetTodoByIdResponse)
@router.get("/{id}", operation_id="get_by_id", response_model=TodoItemResponseModel)
@create_response(JSONResponse)
def get_todo_by_id(
id: str,
@@ -44,25 +41,25 @@ def get_todo_by_id(
return get_todo_by_id_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository).dict()


@router.delete("/{id}", operation_id="delete_by_id", response_model=DeleteTodoByIdResponse)
@router.delete("/{id}", operation_id="delete_by_id")
@create_response(JSONResponse)
def delete_todo_by_id(
id: str,
user: User = Depends(auth_with_jwt),
todo_repository: TodoRepositoryInterface = Depends(get_todo_repository),
):
return delete_todo_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository).dict()
return delete_todo_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository)


@router.get("", operation_id="get_all", response_model=List[GetTodoAllResponse])
@router.get("", operation_id="get_all", response_model=List[TodoItemResponseModel])
@create_response(JSONResponse)
def get_todo_all(
user: User = Depends(auth_with_jwt), todo_repository: TodoRepositoryInterface = Depends(get_todo_repository)
):
return [todo.dict() for todo in get_todo_all_use_case(user_id=user.user_id, todo_repository=todo_repository)]


@router.put("/{id}", operation_id="update_by_id", response_model=UpdateTodoResponse)
@router.put("/{id}", operation_id="update_by_id", response_model=TodoItemResponseModel)
@create_response(JSONResponse)
def update_todo(
id: str,
36 changes: 11 additions & 25 deletions api/src/features/todo/use_cases/add_todo.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,24 @@
import uuid

from pydantic import BaseModel, Field

from common.entity_mapper import filter_fields
from data_providers.repository_interfaces.TodoRepositoryInterface import (
TodoRepositoryInterface,
)
from entities.TodoItem import TodoItem
from features.todo.shared_models import TodoItemResponseModel


class AddTodoRequest(BaseModel):
title: str = Field(
...,
title="The title of the item",
max_length=300,
min_length=1,
example="Read about clean architecture",
)


class AddTodoResponse(BaseModel):
id: str = Field(example="vytxeTZskVKR7C7WgdSP3d")
title: str = Field(example="Read about clean architecture")
is_completed: bool = False

@staticmethod
def from_entity(todo_item: TodoItem) -> "AddTodoResponse":
return AddTodoResponse(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed)
# An alternative solution is to use Pydantic BaseModel
# and to duplicate the title and is_completed fields
@filter_fields(name="AddTodo")
class AddTodoRequestModel(TodoItem):
class Config:
include = ["title"]


def add_todo_use_case(
data: AddTodoRequest,
user_id: str,
todo_repository: TodoRepositoryInterface,
) -> AddTodoResponse:
data: AddTodoRequestModel, user_id: str, todo_repository: TodoRepositoryInterface
) -> TodoItemResponseModel:
todo_item = TodoItem(id=str(uuid.uuid4()), title=data.title, user_id=user_id)
todo_repository.create(todo_item)
return AddTodoResponse.from_entity(todo_item)
return TodoItemResponseModel.parse_obj(todo_item)
9 changes: 1 addition & 8 deletions api/src/features/todo/use_cases/delete_todo_by_id.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
from pydantic import BaseModel, Field

from common.exceptions import MissingPrivilegeException, NotFoundException
from data_providers.repository_interfaces.TodoRepositoryInterface import (
TodoRepositoryInterface,
)


class DeleteTodoByIdResponse(BaseModel):
success: bool = Field(...)


def delete_todo_use_case(id: str, user_id: str, todo_repository: TodoRepositoryInterface) -> DeleteTodoByIdResponse:
def delete_todo_use_case(id: str, user_id: str, todo_repository: TodoRepositoryInterface) -> None:
todo_item = todo_repository.get(id)
if todo_item is None:
raise NotFoundException
if todo_item.user_id != user_id:
raise MissingPrivilegeException
todo_repository.delete(id)
return DeleteTodoByIdResponse(success=True)
18 changes: 3 additions & 15 deletions api/src/features/todo/use_cases/get_todo_all.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,17 @@
from typing import List

from pydantic import BaseModel, Field

from data_providers.repository_interfaces.TodoRepositoryInterface import (
TodoRepositoryInterface,
)
from entities.TodoItem import TodoItem


class GetTodoAllResponse(BaseModel):
id: str = Field(...)
title: str = Field(...)
is_completed: bool

@staticmethod
def from_entity(todo_item: TodoItem):
return GetTodoAllResponse(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed)
from features.todo.shared_models import TodoItemResponseModel


def get_todo_all_use_case(
user_id: str,
todo_repository: TodoRepositoryInterface,
) -> List[GetTodoAllResponse]:
) -> List[TodoItemResponseModel]:
return [
GetTodoAllResponse.from_entity(todo_item)
TodoItemResponseModel.parse_obj(todo_item)
for todo_item in todo_repository.get_all()
if todo_item.user_id == user_id
]
20 changes: 3 additions & 17 deletions api/src/features/todo/use_cases/get_todo_by_id.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,12 @@
from typing import cast

from pydantic import BaseModel, Field

from common.exceptions import MissingPrivilegeException
from data_providers.repository_interfaces.TodoRepositoryInterface import (
TodoRepositoryInterface,
)
from entities.TodoItem import TodoItem


class GetTodoByIdResponse(BaseModel):
id: str = Field(...)
title: str = Field(...)
is_completed: bool = False

@staticmethod
def from_entity(todo_item: TodoItem) -> "GetTodoByIdResponse":
return GetTodoByIdResponse(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed)
from features.todo.shared_models import TodoItemResponseModel


def get_todo_by_id_use_case(id: str, user_id: str, todo_repository: TodoRepositoryInterface) -> GetTodoByIdResponse:
def get_todo_by_id_use_case(id: str, user_id: str, todo_repository: TodoRepositoryInterface) -> TodoItemResponseModel:
todo_item = todo_repository.get(id)
if todo_item.user_id != user_id:
raise MissingPrivilegeException
return GetTodoByIdResponse.from_entity(cast(TodoItem, todo_item))
return TodoItemResponseModel.parse_obj(todo_item)
30 changes: 11 additions & 19 deletions api/src/features/todo/use_cases/update_todo.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,29 @@
from pydantic import BaseModel, Field

from common.entity_mapper import filter_fields
from common.exceptions import MissingPrivilegeException
from data_providers.repository_interfaces.TodoRepositoryInterface import (
TodoRepositoryInterface,
)
from entities.TodoItem import TodoItem
from features.todo.shared_models import TodoItemResponseModel


class UpdateTodoRequest(BaseModel):
title: str = Field(
"",
title="The title of the item",
max_length=300,
min_length=1,
)
is_completed: bool


class UpdateTodoResponse(BaseModel):
success: bool = Field(...)
# An alternative solution is to use Pydantic BaseModel
# and to duplicate the title and is_completed fields
@filter_fields(name="UpdateTodo")
class UpdateTodoRequest(TodoItem):
class Config:
include = ["title", "is_completed"]


def update_todo_use_case(
id: str,
data: UpdateTodoRequest,
user_id: str,
todo_repository: TodoRepositoryInterface,
) -> UpdateTodoResponse:
) -> TodoItemResponseModel:
todo_item = todo_repository.get(id)
if todo_item.user_id != user_id:
raise MissingPrivilegeException

updated_todo_item = TodoItem(id=todo_item.id, title=data.title, is_completed=data.is_completed, user_id=user_id)
if todo_repository.update(updated_todo_item):
return UpdateTodoResponse(success=True)
return UpdateTodoResponse(success=False)
todo_repository.update(updated_todo_item)
return TodoItemResponseModel.parse_obj(updated_todo_item)
3 changes: 1 addition & 2 deletions api/src/tests/integration/features/todo/test_todo_feature.py
Original file line number Diff line number Diff line change
@@ -59,7 +59,7 @@ def test_update_todo(self, test_app):
response = test_app.put("/todos/1", json={"title": "title 1 updated", "is_completed": False})

assert response.status_code == HTTP_200_OK
assert response.json()["success"]
assert response.json()["title"] == "title 1 updated"

def test_update_todo_should_return_not_found(self, test_app):
response = test_app.put("/todos/unknown", json={"title": "something", "is_completed": False})
@@ -74,7 +74,6 @@ def test_delete_todo(self, test_app: TestClient):
response = test_app.delete("/todos/1")

assert response.status_code == HTTP_200_OK
assert response.json()["success"]

def test_delete_todo_should_return_not_found(self, test_app: TestClient):
response = test_app.delete("/todos/unknown")
Loading