├── .gitignore ├── .pre-commit-config.yaml ├── README.MD ├── app ├── adapters │ ├── orm.py │ └── repository.py ├── config.py ├── domains │ ├── events.py │ └── model.py ├── entrypoints │ ├── di │ │ └── containers.py │ ├── event_source │ │ ├── external │ │ │ ├── external.py │ │ │ └── publisher.py │ │ └── internal │ │ │ └── internal_loop.py │ └── fastapi │ │ ├── dto.py │ │ ├── handlers.py │ │ ├── main.py │ │ └── router.py └── services │ ├── dto.py │ ├── handlers.py │ ├── message_queue.py │ ├── messagebus.py │ ├── service.py │ └── uow.py ├── makefile ├── pyproject.toml ├── requirements.txt └── tests ├── conftest.py ├── e2e └── test_endpoint.py ├── integration ├── test_session.py └── test_uow.py └── unittest ├── test_domain.py └── test_service.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .idea/ 3 | **/db 4 | .coverage 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - repo: https://github.com/psf/black 8 | rev: 20.8b1 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/PyCQA/isort 12 | rev: 5.8.0 13 | hooks: 14 | - id: isort 15 | - repo: https://github.com/hadialqattan/pycln 16 | rev: 0.0.1-beta.3 17 | hooks: 18 | - id: pycln 19 | args: [--config=pyproject.toml] 20 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | ### [TODO] 2 | - [ ] 테스트에 필요한 fake object(repository, uow 등) 구현하기 3 | - [ ] e2e 테스트의 독립 환경(docker-compose) 구축하기 4 | - [ ] user, post aggregate 분리하기 5 | -------------------------------------------------------------------------------- /app/adapters/orm.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import ( 2 | Column, 3 | ForeignKey, 4 | Integer, 5 | MetaData, 6 | String, 7 | Table, 8 | create_engine, 9 | ) 10 | from sqlalchemy.orm import mapper, relationship, sessionmaker 11 | 12 | from app.config import DB_PATH 13 | from app.domains.model import Post, User 14 | 15 | metadata = MetaData() 16 | 17 | users = Table( 18 | "users", 19 | metadata, 20 | Column("id", Integer, primary_key=True, autoincrement=True), 21 | Column("user_id", String(15), unique=True), 22 | Column("name", String(10)), 23 | Column("password", String(15)), 24 | ) 25 | 26 | posts = Table( 27 | "posts", 28 | metadata, 29 | Column("id", Integer, primary_key=True, autoincrement=True), 30 | Column("title", String(30)), 31 | Column("content", String(500)), 32 | Column("user_id", ForeignKey("users.id")), 33 | ) 34 | 35 | 36 | def start_mappers(): 37 | posts_mapper = mapper(Post, posts) 38 | users_mapper = mapper(User, users, properties={"posts": relationship(posts_mapper)}) 39 | 40 | 41 | def get_session_factory(): 42 | engine = create_engine(url=f"sqlite:///{DB_PATH}?check_same_thread=False") 43 | metadata.create_all(engine) 44 | session_factory = sessionmaker(bind=engine, expire_on_commit=False) 45 | return session_factory 46 | -------------------------------------------------------------------------------- /app/adapters/repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import List 3 | 4 | from sqlalchemy.orm import Session 5 | 6 | from app.domains.model import Post, User 7 | 8 | 9 | class AbstractRepository(abc.ABC): 10 | def __init__(self, session: Session): 11 | self.session = session 12 | 13 | @abc.abstractmethod 14 | def create(self, model): 15 | ... 16 | 17 | @abc.abstractmethod 18 | def find_one_by_id(self, id): 19 | ... 20 | 21 | @abc.abstractmethod 22 | def find_all(self): 23 | ... 24 | 25 | @abc.abstractmethod 26 | def delete(self, model): 27 | ... 28 | 29 | 30 | class UserRepository(AbstractRepository): 31 | def create(self, user: User): 32 | self.session.add(user) 33 | 34 | def find_one_by_id(self, id: str) -> User: 35 | return self.session.query(User).filter_by(user_id=id).first() 36 | 37 | def find_all(self) -> List[User]: 38 | return self.session.query(User).all() 39 | 40 | def find_one_by_password(self, user_id: str, password: str) -> User: 41 | return ( 42 | self.session.query(User) 43 | .filter_by(user_id=user_id, password=password) 44 | .first() 45 | ) 46 | 47 | def delete(self, user: User): 48 | self.session.delete(user) 49 | 50 | 51 | class PostRepository(AbstractRepository): 52 | def create(self, post: Post) -> Post: 53 | self.session.add(post) 54 | return post 55 | 56 | def find_one_by_id(self, id: int) -> Post: 57 | return self.session.query(Post).filter_by(id=id).first() 58 | 59 | def find_by_user_id(self, user_id: int) -> List[Post]: 60 | return self.session.query(Post).filter_by(user_id=user_id).all() 61 | 62 | def find_all(self) -> List[Post]: 63 | return self.session.query(Post).all() 64 | 65 | def delete(self, post: Post): 66 | self.session.delete(post) 67 | 68 | def delete_by_user_id(self, user_id: int): 69 | return ( 70 | self.session.query(Post) 71 | .filter_by(user_id=user_id) 72 | .delete(synchronize_session=False) 73 | ) 74 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | DB_PATH = "db" 2 | -------------------------------------------------------------------------------- /app/domains/events.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Event: 6 | ... 7 | 8 | 9 | @dataclass 10 | class SendEmail(Event): 11 | msg: str 12 | 13 | 14 | @dataclass 15 | class SendSlack(Event): 16 | msg: str 17 | 18 | 19 | @dataclass 20 | class DeleteUserPosts(Event): 21 | user_id: int 22 | -------------------------------------------------------------------------------- /app/domains/model.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | 4 | class Post: 5 | def __init__(self, title: str, content: str, user_id: Optional[int] = None): 6 | self.title = title 7 | self.content = content 8 | self.user_id = user_id 9 | 10 | 11 | class User: 12 | def __init__( 13 | self, user_id: str, name: str, password: str, posts: Optional[List[Post]] = [] 14 | ): 15 | self.user_id = user_id 16 | self.name = name 17 | self.password = password 18 | self.posts = posts 19 | 20 | def add_post(self, post: Post): 21 | self.posts.append(post) 22 | return post 23 | -------------------------------------------------------------------------------- /app/entrypoints/di/containers.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from app.adapters.orm import get_session_factory 4 | from app.entrypoints.event_source.external.external import ExternalEventEmitter 5 | from app.entrypoints.event_source.external.publisher import PubSubPublisher 6 | from app.services.service import PostService, UserService 7 | from app.services.uow import PostUnitOfWork, UserUnitOfWork 8 | 9 | session_factory = get_session_factory() 10 | 11 | 12 | class Container(containers.DeclarativeContainer): 13 | session_factory = providers.Object(session_factory) 14 | user_uow = providers.Singleton(UserUnitOfWork, session_factory=session_factory) 15 | post_uow = providers.Singleton(PostUnitOfWork, session_factory=session_factory) 16 | 17 | user_service = providers.Factory(UserService, uow=user_uow) 18 | post_service = providers.Factory( 19 | PostService, uow=post_uow, user_service=user_service 20 | ) 21 | pubsub_client = providers.Singleton(PubSubPublisher) 22 | event_emitter = providers.Singleton(ExternalEventEmitter, publisher=pubsub_client) 23 | -------------------------------------------------------------------------------- /app/entrypoints/event_source/external/external.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from app.domains.events import Event 4 | from app.entrypoints.event_source.external.publisher import Publisher 5 | 6 | 7 | class Upstream(Enum): 8 | GCP_PUBSUB = "GCP_PUBSUB" 9 | KAFKA = "KAFKA" 10 | ... 11 | 12 | 13 | class ExternalEventEmitter: 14 | def __init__(self, publisher: Publisher): 15 | self.publisher = publisher 16 | 17 | async def emit(self, event: Event): 18 | self.publisher.publish(event) 19 | -------------------------------------------------------------------------------- /app/entrypoints/event_source/external/publisher.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from app.domains.events import Event 4 | 5 | 6 | class Publisher(abc.ABC): 7 | def __init__(self, config): 8 | self.config = config 9 | 10 | @abc.abstractmethod 11 | def publish(self, event: Event): 12 | ... 13 | 14 | 15 | class PubSubPublisher(Publisher): 16 | def publish(self, event: Event): 17 | print("publish to pubsub") 18 | ... 19 | 20 | 21 | class KafkaPublisher(Publisher): 22 | def publish(self, event: Event): 23 | print("publish to kafka") 24 | ... 25 | -------------------------------------------------------------------------------- /app/entrypoints/event_source/internal/internal_loop.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from app.services.message_queue import message_queue 4 | from app.services.messagebus import handle_event 5 | 6 | 7 | async def event_loop(): 8 | while True: 9 | event = await message_queue.get() 10 | handle_event(event) 11 | 12 | 13 | def loop_in_thread(loop): 14 | asyncio.set_event_loop(loop) 15 | loop.create_task(event_loop()) 16 | 17 | 18 | def run_event_loop_background(): 19 | loop = asyncio.new_event_loop() 20 | from concurrent.futures import ThreadPoolExecutor 21 | 22 | with ThreadPoolExecutor() as executor: 23 | executor.submit(loop_in_thread, loop) 24 | -------------------------------------------------------------------------------- /app/entrypoints/fastapi/dto.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class UserRequest(BaseModel): 7 | id: str 8 | name: str 9 | password: str 10 | 11 | 12 | class UserResponse(BaseModel): 13 | id: str 14 | name: str 15 | 16 | 17 | class UserListResponseItem(BaseModel): 18 | id: str 19 | name: str 20 | 21 | 22 | class UserListResponse(BaseModel): 23 | items: List[UserListResponseItem] 24 | 25 | 26 | class DeleteUserRequest(BaseModel): 27 | password: str 28 | 29 | 30 | class CreatePostRequest(BaseModel): 31 | user_id: str 32 | user_password: str 33 | title: str 34 | content: str 35 | 36 | 37 | class PostResponse(BaseModel): 38 | user_id: int 39 | user_name: Optional[str] 40 | id: int 41 | title: str 42 | content: str 43 | 44 | 45 | class PostListResponseItem(BaseModel): 46 | user_id: int 47 | id: int 48 | title: str 49 | content: str 50 | 51 | 52 | class PostListResponse(BaseModel): 53 | items: List[PostListResponseItem] 54 | 55 | 56 | class DeletePostRequest(BaseModel): 57 | user_id: str 58 | password: str 59 | -------------------------------------------------------------------------------- /app/entrypoints/fastapi/handlers.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | from starlette.responses import JSONResponse 3 | 4 | 5 | async def global_exception_handler(_: Request, exc: Exception): 6 | return JSONResponse( 7 | status_code=400, 8 | content={"message": str(exc)}, 9 | ) 10 | -------------------------------------------------------------------------------- /app/entrypoints/fastapi/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | 4 | from app.adapters import orm 5 | from app.entrypoints.di.containers import Container 6 | from app.entrypoints.event_source.internal.internal_loop import ( 7 | run_event_loop_background, 8 | ) 9 | from app.entrypoints.fastapi import router 10 | from app.entrypoints.fastapi.handlers import global_exception_handler 11 | 12 | orm.start_mappers() 13 | 14 | 15 | def create_app(): 16 | app = FastAPI() 17 | container = Container() 18 | container.wire(modules=[router]) 19 | 20 | app.include_router(router.router) 21 | app.add_exception_handler(Exception, global_exception_handler) 22 | app.add_event_handler("startup", run_event_loop_background) 23 | 24 | return app 25 | 26 | 27 | app = create_app() 28 | 29 | if __name__ == "__main__": 30 | uvicorn.run( 31 | app, 32 | host="0.0.0.0", 33 | port=8000, 34 | ) 35 | -------------------------------------------------------------------------------- /app/entrypoints/fastapi/router.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import Provide, inject 2 | from fastapi import APIRouter, Depends 3 | 4 | from app.domains.events import DeleteUserPosts 5 | from app.entrypoints.di.containers import Container 6 | from app.entrypoints.event_source.external.external import ExternalEventEmitter 7 | from app.entrypoints.fastapi.dto import ( 8 | CreatePostRequest, 9 | DeleteUserRequest, 10 | PostListResponse, 11 | PostListResponseItem, 12 | PostResponse, 13 | UserListResponse, 14 | UserListResponseItem, 15 | UserRequest, 16 | UserResponse, 17 | ) 18 | from app.services.service import PostService, UserService 19 | 20 | router = APIRouter(prefix="") 21 | 22 | 23 | @router.get("/users", status_code=200) 24 | @inject 25 | async def find_all_users( 26 | service: UserService = Depends(Provide[Container.user_service]), 27 | ): 28 | users = service.find_all_users() 29 | return UserListResponse( 30 | items=[UserListResponseItem(id=user.user_id, name=user.name) for user in users] 31 | ) 32 | 33 | 34 | @router.get("/users/{user_id}", status_code=200) 35 | @inject 36 | def find_user( 37 | user_id: str, service: UserService = Depends(Provide[Container.user_service]) 38 | ): 39 | user = service.find_user_by_id(user_id=user_id) 40 | return UserResponse(id=user.user_id, name=user.name) 41 | 42 | 43 | @router.post("/users", status_code=201) 44 | @inject 45 | def create_user( 46 | user: UserRequest, service: UserService = Depends(Provide[Container.user_service]) 47 | ): 48 | user = service.create_user( 49 | user_id=user.id, 50 | name=user.name, 51 | password=user.password, 52 | ) 53 | return UserResponse(id=user.user_id, name=user.name) 54 | 55 | 56 | @router.delete("/users/{user_id}", status_code=201) 57 | @inject 58 | def delete_user( 59 | user_id: str, 60 | body: DeleteUserRequest, 61 | service: UserService = Depends(Provide[Container.user_service]), 62 | emitter: ExternalEventEmitter = Depends(Provide[Container.event_emitter]), 63 | ): 64 | service.delete_user(user_id=user_id, password=body.password) 65 | emitter.emit(DeleteUserPosts(user_id=int(user_id))) # 외부 비동기 메시지 큐에 이벤트를 보낸다. 66 | return True 67 | 68 | 69 | @router.get("/posts", status_code=200) 70 | @inject 71 | def find_all_posts(service: PostService = Depends(Provide[Container.post_service])): 72 | posts = service.find_all_posts() 73 | return PostListResponse( 74 | items=[ 75 | PostListResponseItem( 76 | user_id=post.user_id, id=post.id, title=post.title, content=post.content 77 | ) 78 | for post in posts 79 | ] 80 | ) 81 | 82 | 83 | @router.get("/posts/{post_id}", status_code=200) 84 | @inject 85 | def find_post( 86 | post_id: int, service: PostService = Depends(Provide[Container.post_service]) 87 | ): 88 | post = service.find_post_by_id(post_id=post_id) 89 | return PostResponse( 90 | user_id=post.user_id, id=post.id, title=post.title, content=post.content 91 | ) 92 | 93 | 94 | @router.post("/posts", status_code=201) 95 | @inject 96 | def create_post( 97 | post: CreatePostRequest, 98 | service: PostService = Depends(Provide[Container.post_service]), 99 | ): 100 | post = service.create_post( 101 | user_id=post.user_id, 102 | user_password=post.user_password, 103 | title=post.title, 104 | content=post.content, 105 | ) 106 | return PostResponse( 107 | user_id=post.user_id, 108 | user_name=post.user_name, 109 | id=post.id, 110 | title=post.title, 111 | content=post.content, 112 | ) 113 | -------------------------------------------------------------------------------- /app/services/dto.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class CreateUserDTO(BaseModel): 5 | user_id: str 6 | name: str 7 | 8 | 9 | class FindPostDTO(BaseModel): 10 | id: str 11 | user_id: str 12 | title: str 13 | content: str 14 | 15 | 16 | class CreatePostDTO(FindPostDTO): 17 | user_name: str 18 | -------------------------------------------------------------------------------- /app/services/handlers.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from app.domains.events import DeleteUserPosts, SendEmail, SendSlack 4 | from app.services.message_queue import message_queue 5 | from app.services.uow import PostUnitOfWork 6 | 7 | 8 | def send_email(event: SendEmail): 9 | sleep(5) 10 | print(f"COMPLETE SEND EMAIL ({event.msg})") 11 | 12 | 13 | def send_slack(event: SendSlack): 14 | sleep(5) 15 | print(f"COMPLETE SEND EMAIL ({event.msg})") 16 | 17 | 18 | def delete_post(event: DeleteUserPosts, uow: PostUnitOfWork = PostUnitOfWork()): 19 | with uow: 20 | try: 21 | uow.repo.delete_by_user_id(event.user_id) 22 | uow.commit() 23 | except Exception as e: 24 | print("erorr 발생!") 25 | message_queue.put_nowait(SendSlack(msg=str(e))) 26 | -------------------------------------------------------------------------------- /app/services/message_queue.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | from app.domains.events import Event 6 | 7 | message_queue: asyncio.Queue[Event] = asyncio.Queue() 8 | -------------------------------------------------------------------------------- /app/services/messagebus.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, List, Type 2 | 3 | from app.domains.events import DeleteUserPosts, Event, SendEmail, SendSlack 4 | from app.services.handlers import delete_post, send_email, send_slack 5 | 6 | 7 | def handle_event(event: Event): 8 | handlers = EVENT_HANDLERS[type(event)] 9 | for handler in handlers: 10 | handler(event) 11 | 12 | 13 | EVENT_HANDLERS: Dict[Type[Event], List[Callable]] = { 14 | SendEmail: [send_email], 15 | SendSlack: [send_slack], 16 | DeleteUserPosts: [delete_post], # 엄밀히는 Command 17 | } 18 | -------------------------------------------------------------------------------- /app/services/service.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.domains.events import DeleteUserPosts 4 | from app.domains.model import Post, User 5 | from app.entrypoints.event_source.internal.internal_loop import message_queue 6 | from app.services.dto import CreatePostDTO, CreateUserDTO, FindPostDTO 7 | from app.services.uow import PostUnitOfWork, UserUnitOfWork 8 | 9 | 10 | class UserService: 11 | def __init__(self, uow: UserUnitOfWork): 12 | self.uow = uow 13 | 14 | def create_user(self, user_id: str, name: str, password: str) -> CreateUserDTO: 15 | with self.uow: 16 | user = User(user_id=user_id, name=name, password=password) 17 | self.uow.repo.create(user) 18 | self.uow.commit() 19 | user.password = None 20 | return CreateUserDTO(user_id=user.user_id, name=user.name) 21 | 22 | def find_user_by_id(self, user_id: str) -> User: 23 | with self.uow: 24 | user = self.uow.repo.find_one_by_id(id=user_id) 25 | if not user: 26 | raise Exception("해당 유저가 존재하지 않습니다") 27 | self.uow.commit() 28 | return user 29 | 30 | def find_one_by_password(self, user_id: str, password: str) -> User: 31 | with self.uow: 32 | user = self.uow.repo.find_one_by_password( 33 | user_id=user_id, password=password 34 | ) 35 | if not user: 36 | raise Exception("해당 유저가 존재하지 않습니다") 37 | self.uow.commit() 38 | return user 39 | 40 | def find_all_users(self) -> List[User]: 41 | with self.uow: 42 | users = self.uow.repo.find_all() 43 | self.uow.commit() 44 | return users 45 | 46 | def delete_user(self, user_id: str, password: str): 47 | with self.uow: 48 | user = self.uow.repo.find_one_by_password( 49 | user_id=user_id, password=password 50 | ) 51 | if not user: 52 | raise Exception("해당 유저가 존재하지 않습니다") 53 | # self.uow.repo.delete(user) 54 | message_queue.put_nowait(DeleteUserPosts(user_id=user.id)) 55 | return True 56 | 57 | 58 | class PostService: 59 | def __init__(self, uow: PostUnitOfWork, user_service: UserService): 60 | self.uow = uow 61 | self.user_service = user_service 62 | 63 | def find_all_posts(self) -> List[FindPostDTO]: 64 | with self.uow: 65 | posts = self.uow.repo.find_all() 66 | self.uow.commit() 67 | return [ 68 | FindPostDTO( 69 | id=post.id, 70 | user_id=post.user_id, 71 | title=post.title, 72 | content=post.content, 73 | ) 74 | for post in posts 75 | ] 76 | 77 | def find_post_by_id(self, post_id: int) -> FindPostDTO: 78 | with self.uow: 79 | post = self.uow.repo.find_one_by_id(id=post_id) 80 | if not post: 81 | raise Exception("해당 포스트가 존재하지 않습니다") 82 | self.uow.commit() 83 | return FindPostDTO( 84 | id=post.id, 85 | user_id=post.user_id, 86 | title=post.title, 87 | content=post.content, 88 | ) 89 | 90 | def create_post( 91 | self, user_id: str, user_password: str, title: str, content: str 92 | ) -> CreatePostDTO: 93 | with self.uow: 94 | user = self.user_service.find_one_by_password( 95 | user_id=user_id, password=user_password 96 | ) 97 | if not user: 98 | raise Exception("해당 유저가 존재하지 않습니다") 99 | post = self.uow.repo.create( 100 | Post(title=title, content=content, user_id=user.id) 101 | ) 102 | self.uow.commit() 103 | 104 | return CreatePostDTO( 105 | id=post.id, 106 | user_id=user.id, 107 | user_name=user.name, 108 | title=post.title, 109 | content=post.content, 110 | ) 111 | 112 | def delete_post(self, post_id: int, user_id: str, password: str): 113 | with self.uow: 114 | user = self.user_service.find_one_by_password( 115 | user_id=user_id, password=password 116 | ) 117 | if not user: 118 | raise Exception("해당 유저가 존재하지 않습니다") 119 | post = self.uow.repo.find_one_by_id(id=post_id) 120 | self.uow.repo.delete(post) 121 | self.uow.commit() 122 | return True 123 | -------------------------------------------------------------------------------- /app/services/uow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | 5 | from app.adapters.orm import get_session_factory 6 | from app.adapters.repository import PostRepository, UserRepository 7 | 8 | 9 | class AbstractUnitOfWork(abc.ABC): 10 | def __enter__(self) -> AbstractUnitOfWork: 11 | return self 12 | 13 | def __exit__(self, *args): 14 | self.rollback() 15 | 16 | @abc.abstractmethod 17 | def commit(self): 18 | raise NotImplementedError 19 | 20 | @abc.abstractmethod 21 | def rollback(self): 22 | raise NotImplementedError 23 | 24 | 25 | class UserUnitOfWork(AbstractUnitOfWork): 26 | def __init__(self, session_factory=None): 27 | if not session_factory: 28 | session_factory = get_session_factory() 29 | self.session_factory = session_factory 30 | 31 | def __enter__(self): 32 | self.session = self.session_factory() 33 | self.repo = UserRepository(self.session) 34 | return super().__enter__() 35 | 36 | def __exit__(self, *args): 37 | super().__exit__(*args) 38 | self.session.close() 39 | 40 | def commit(self): 41 | self.session.commit() 42 | 43 | def rollback(self): 44 | self.session.rollback() 45 | 46 | 47 | class PostUnitOfWork(AbstractUnitOfWork): 48 | def __init__(self, session_factory=None): 49 | if not session_factory: 50 | session_factory = get_session_factory() 51 | self.session_factory = session_factory 52 | 53 | def __enter__(self): 54 | self.session = self.session_factory() 55 | self.repo = PostRepository(self.session) 56 | return super().__enter__() 57 | 58 | def __exit__(self, *args): 59 | super().__exit__(*args) 60 | self.session.close() 61 | 62 | def commit(self): 63 | self.session.commit() 64 | 65 | def rollback(self): 66 | self.session.rollback() 67 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | project := app 2 | 3 | install: 4 | pip install -r requirements.txt 5 | 6 | freeze: 7 | pip list --not-required --format=freeze > requirements.txt 8 | 9 | test: 10 | PYTHONPATH=. PYTHONDONTWRITEBYTECODE=1 py.test --tb short -rxs \ 11 | --cov-config .coveragerc --cov ./app tests 12 | 13 | local: 14 | uvicorn app.entrypoints.main:app --port 8000 --reload 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | multi_line_output = 3 4 | 5 | [tool.pycln] 6 | all = true 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | containers==0.0.4 2 | dependency-injector==4.35.2 3 | fastapi==0.68.0 4 | pip==21.2.3 5 | pytest-cov==2.12.1 6 | requests==2.26.0 7 | setuptools==57.4.0 8 | SQLAlchemy==1.4.22 9 | uvicorn==0.14.0 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import create_engine 3 | from sqlalchemy.orm import clear_mappers, sessionmaker 4 | 5 | from app.adapters.orm import metadata, start_mappers 6 | from app.domains.model import Post, User 7 | 8 | 9 | @pytest.fixture 10 | def in_memory_db(): 11 | engine = create_engine("sqlite:///") 12 | metadata.create_all(engine) 13 | return engine 14 | 15 | 16 | @pytest.fixture 17 | def mappers(): 18 | start_mappers() 19 | yield 20 | clear_mappers() 21 | 22 | 23 | @pytest.fixture 24 | def session_factory(in_memory_db): 25 | yield sessionmaker(bind=in_memory_db, expire_on_commit=False) 26 | 27 | 28 | @pytest.fixture 29 | def session(session_factory): 30 | return session_factory() 31 | 32 | 33 | ### Depends on session 34 | @pytest.fixture(scope="function") 35 | def mock_default_users(session): 36 | mock_users = [ 37 | User(user_id="grab1", password="grab1", name="hoyeon1", posts=[]), 38 | User(user_id="grab2", password="grab2", name="hoyeon2", posts=[]), 39 | ] 40 | for user in mock_users: 41 | session.add(user) 42 | session.commit() 43 | 44 | return mock_users 45 | 46 | 47 | ### Depends on session 48 | @pytest.fixture(scope="function") 49 | def mock_default_posts(session, mock_default_users): 50 | mock_posts = [ 51 | Post(user_id=mock_default_users[0].id, title="제목1", content="내용1"), 52 | Post(user_id=mock_default_users[1].id, title="제목2", content="내용2"), 53 | ] 54 | for post in mock_posts: 55 | session.add(post) 56 | session.commit() 57 | 58 | return mock_posts 59 | -------------------------------------------------------------------------------- /tests/e2e/test_endpoint.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from app.entrypoints.fastapi.main import create_app 4 | 5 | 6 | # TODO: e2e 테스트들은 docker-compose로 빼기 7 | # TODO: 테스트 별 DB 분리시키기 8 | def test_user_create_api(): 9 | client = TestClient(create_app()) 10 | response = client.post( 11 | "/users", json={"id": "grab", "name": "hoyeon", "password": "zzang"} 12 | ) 13 | 14 | assert response.status_code == 201 15 | assert response.json() == {"id": "grab", "name": "hoyeon"} 16 | 17 | 18 | def test_post_create_api(): 19 | client = TestClient(create_app()) 20 | user_id, name, password = "grab1", "hoyeon1", "good1" 21 | title, content = "제목", "내용" 22 | 23 | # 유저 생성 call 24 | result = client.post( 25 | "/users", json={"id": user_id, "name": name, "password": password} 26 | ) 27 | user = result.json() 28 | # 글 생성 call 29 | response = client.post( 30 | "/posts", 31 | json={ 32 | "user_id": user_id, 33 | "user_password": password, 34 | "title": title, 35 | "content": content, 36 | }, 37 | ) 38 | 39 | assert response.status_code == 201 40 | assert response.json() == { 41 | "id": 1, 42 | "user_id": 2, 43 | "user_name": name, 44 | "title": title, 45 | "content": content, 46 | } 47 | -------------------------------------------------------------------------------- /tests/integration/test_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.domains.model import Post, User 4 | 5 | pytest.mark.usefixtures("mappers") 6 | 7 | 8 | def test_create_user(session): 9 | user_id, name, password = "grab", "hoyeon", "zzang" 10 | user = User(user_id=user_id, name=name, password=password) 11 | 12 | session.add(user) 13 | session.commit() 14 | 15 | result = session.query(User).filter_by(user_id=user_id, password=password).first() 16 | assert result == user 17 | 18 | 19 | def test_create_post(session): 20 | user_id, name, password = "grab", "hoyeon", "zzang" 21 | user = User(user_id=user_id, name=name, password=password) 22 | post = Post(title="hello", content="world") 23 | 24 | user.add_post(post) 25 | session.add(user) 26 | session.commit() 27 | 28 | result = session.query(Post).filter_by(user_id=user.id).first() 29 | 30 | assert result.user_id == user.id 31 | assert result == post 32 | -------------------------------------------------------------------------------- /tests/integration/test_uow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.domains.model import User 4 | from app.services.uow import UserUnitOfWork 5 | 6 | pytest.mark.usefixtures("mappers") 7 | 8 | 9 | def test_uow_create_user(session_factory, session): 10 | uow = UserUnitOfWork(session_factory) 11 | user = User(user_id="grab", name="hoyeon", password="1234") 12 | 13 | with uow: 14 | uow.repo.create(user) 15 | uow.commit() 16 | 17 | assert session.query(User).filter_by(user_id="grab").first() 18 | -------------------------------------------------------------------------------- /tests/unittest/test_domain.py: -------------------------------------------------------------------------------- 1 | from app.domains.model import Post, User 2 | 3 | 4 | def test_create_user_domain(): 5 | user_id, name, password = "grab", "hoyeon", "zzang" 6 | user = User(user_id=user_id, name=name, password=password) 7 | 8 | assert len(user.posts) == 0 9 | assert user.name == name 10 | assert user.password == password 11 | 12 | 13 | def test_create_post_domain(): 14 | user_id, name, password = "grab", "hoyeon", "zzang" 15 | user = User(user_id=user_id, name=name, password=password) 16 | post = Post(title="hello", content="world") 17 | 18 | user.add_post(post) 19 | 20 | assert len(user.posts) == 1 21 | assert user.posts[0] == post 22 | -------------------------------------------------------------------------------- /tests/unittest/test_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.services.dto import CreatePostDTO, CreateUserDTO, FindPostDTO 4 | from app.services.service import PostService, UserService 5 | from app.services.uow import PostUnitOfWork, UserUnitOfWork 6 | 7 | pytest.mark.usefixtures("mappers") 8 | 9 | 10 | @pytest.fixture 11 | def user_service(session_factory, mock_default_users): 12 | uow = UserUnitOfWork(session_factory=session_factory) 13 | return UserService(uow=uow) 14 | 15 | 16 | @pytest.fixture 17 | def post_service(session_factory, user_service, mock_default_posts): 18 | uow = PostUnitOfWork(session_factory=session_factory) 19 | return PostService(uow=uow, user_service=user_service) 20 | 21 | 22 | def test_create_user(session_factory, user_service): 23 | user_id, name, password = "grab", "hoyeon", "zzang" 24 | user = user_service.create_user(user_id=user_id, name=name, password=password) 25 | 26 | assert user == CreateUserDTO(user_id=user_id, name=name) 27 | 28 | 29 | def test_find_all_users(session_factory, user_service): 30 | 31 | users = user_service.find_all_users() 32 | 33 | assert [user.user_id for user in users] == ["grab1", "grab2"] 34 | 35 | 36 | def test_delete_user_well(session_factory, user_service): 37 | user_id, name, password = "grab1", "hoyeon", "grab1" 38 | 39 | result = user_service.delete_user(user_id=user_id, password=password) 40 | assert result is True 41 | 42 | 43 | def test_delete_user_not_found(session_factory, user_service): 44 | user_id, name, password = "hardy", "hoyeon", "humphrey" 45 | 46 | with pytest.raises(Exception): 47 | user_service.delete_user(user_id=user_id, password=password) 48 | 49 | 50 | def test_find_all_posts_service(session_factory, post_service): 51 | posts = post_service.find_all_posts() 52 | 53 | # default_mock_posts 참고 54 | assert posts == [ 55 | FindPostDTO(id=1, user_id=1, title="제목1", content="내용1"), 56 | FindPostDTO(id=2, user_id=2, title="제목2", content="내용2"), 57 | ] 58 | 59 | 60 | def test_find_post_by_id_service(session_factory, post_service): 61 | post = post_service.find_post_by_id(post_id=1) 62 | 63 | assert post == FindPostDTO(id=1, user_id=1, title="제목1", content="내용1") 64 | 65 | 66 | def test_create_post_service(session_factory, post_service): 67 | user_id, name, password = "grab1", "hoyeon1", "grab1" 68 | 69 | title, content = "제목", "내용" 70 | 71 | post = post_service.create_post( 72 | user_id=user_id, user_password=password, title=title, content=content 73 | ) 74 | 75 | assert post == CreatePostDTO( 76 | user_id=1, id=post.id, user_name=name, title=title, content=content 77 | ) 78 | 79 | 80 | def test_delete_post_well(session_factory, post_service): 81 | post_id, user_id, password = 1, "grab1", "grab1" 82 | 83 | result = post_service.delete_post( 84 | post_id=post_id, user_id=user_id, password=password 85 | ) 86 | assert result is True 87 | --------------------------------------------------------------------------------