Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ lint:
test:
uv sync --group test
export DESCRIPTION=$$(cat README.md) && \
uv run pytest \
uv run pytest -vv \
--cov=app --cov-branch --cov-report=xml \
--junitxml=junit.xml -o junit_family=legacy

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@
</h4>

- [fastapi/full-stack-fastapi-template/backend](https://github.com/fastapi/full-stack-fastapi-template/tree/master/backend)
- [teamhide/fastapi-boilerplate](https://github.com/teamhide/fastapi-boilerplate)
- [jujumilk3/fastapi-clean-architecture](https://github.com/jujumilk3/fastapi-clean-architecture)
- [UponTheSky/How to implement a transactional decorator in FastAPI + SQLAlchemy - with reviewing other approaches](https://dev.to/uponthesky/python-post-reviewhow-to-implement-a-transactional-decorator-in-fastapi-sqlalchemy-ein)
10 changes: 10 additions & 0 deletions app/core/configs.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
from enum import Enum
from typing import Optional

from pydantic import computed_field
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings


class ENVIRONMENT(Enum):
TEST = "TEST"
DEV = "DEV"
PROD = "PROD"


class Configs(BaseSettings):
ENV: ENVIRONMENT

# --------- APP SETTINGS --------- #
PROJECT_NAME: str
DESCRIPTION: str
VERSION: str
PREFIX: str
TZ: str = "Asia/Seoul"

# --------- DATABASE SETTINGS --------- #
DB_TYPE: str
Expand Down
7 changes: 2 additions & 5 deletions app/core/container.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
from dependency_injector.containers import DeclarativeContainer, WiringConfiguration
from dependency_injector.providers import Factory, Singleton
from dependency_injector.providers import Factory

from app.core.database import Database
from app.repositories.users import UserRepository
from app.services.users import UserService


class Container(DeclarativeContainer):
wiring_config = WiringConfiguration(modules=["app.api.v1.endpoints.users"])

database = Singleton(Database)

user_repository = Factory(UserRepository, session=database.provided.session)
user_repository = Factory(UserRepository)

user_service = Factory(UserService, user_repository=user_repository)
87 changes: 72 additions & 15 deletions app/core/database.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,87 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
from contextvars import ContextVar, Token
from functools import wraps
from typing import Awaitable, Callable, Optional

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from loguru import logger
from sqlalchemy import NullPool
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_scoped_session,
async_sessionmaker,
create_async_engine,
)

from app.core.configs import configs
from app.core.configs import ENVIRONMENT, configs
from app.models.base import BaseModel


class Context:
def __init__(self) -> None:
self.context: ContextVar[Optional[int]] = ContextVar(
"session_context", default=None
)

def get(self) -> int:
session_id = self.context.get()
if not session_id:
raise ValueError("Currently no session is available.")

Check warning on line 27 in app/core/database.py

View check run for this annotation

Codecov / codecov/patch

app/core/database.py#L27

Added line #L27 was not covered by tests
return session_id

def set(self, session_id: int) -> Token:
return self.context.set(session_id)

def reset(self, context: Token) -> None:
self.context.reset(context)


class Database:
def __init__(self) -> None:
self.engine = create_async_engine(configs.DATABASE_URI, echo=configs.DB_ECHO)
self.context = Context()
async_engine_kwargs = {
"url": configs.DATABASE_URI,
"echo": configs.DB_ECHO,
}
if configs.ENV == ENVIRONMENT.TEST and configs.DB_DRIVER != "aiosqlite":
# NOTE: PyTest 시 event loop 충돌 발생 (related: #19)
logger.warning("Using NullPool for async engine")
async_engine_kwargs["poolclass"] = NullPool # type: ignore[assignment]
self.engine = create_async_engine(**async_engine_kwargs) # type: ignore[arg-type]
self.sessionmaker = async_sessionmaker(
bind=self.engine, class_=AsyncSession, expire_on_commit=False
bind=self.engine,
class_=AsyncSession,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
self.scoped_session = async_scoped_session(
session_factory=self.sessionmaker,
scopefunc=self.context.get,
)

async def create_all(self) -> None:
logger.warning("Create database")
async with self.engine.begin() as conn:
if configs.ENV == ENVIRONMENT.TEST:
await conn.run_sync(BaseModel.metadata.drop_all)
await conn.run_sync(BaseModel.metadata.create_all)

@asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
async with self.sessionmaker() as session:
def transactional(self, func: Callable[..., Awaitable]) -> Callable[..., Awaitable]:
@wraps(func)
async def wrapper(*args, **kwargs):
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
session = self.scoped_session()
if session.in_transaction():
logger.trace(

Check warning on line 74 in app/core/database.py

View check run for this annotation

Codecov / codecov/patch

app/core/database.py#L74

Added line #L74 was not covered by tests
f"[Session in transaction]\tID: {database.context.get()}, {self.context=}"
)
return await func(*args, **kwargs)

Check warning on line 77 in app/core/database.py

View check run for this annotation

Codecov / codecov/patch

app/core/database.py#L77

Added line #L77 was not covered by tests
async with session.begin():
response = await func(*args, **kwargs)
return response
except Exception as error:
raise error

return wrapper


database = Database()
13 changes: 8 additions & 5 deletions app/core/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
from fastapi import FastAPI
from loguru import logger

from app.core.configs import configs
from app.core.configs import ENVIRONMENT, configs
from app.core.container import Container
from app.core.database import database
from app.utils.logging import remove_handler


Expand All @@ -16,9 +17,12 @@
remove_handler(logging.getLogger("uvicorn.access"))
remove_handler(logging.getLogger("uvicorn.error"))
logger.remove()
level = 0
if configs.ENV == ENVIRONMENT.PROD:
level = 20

Check warning on line 22 in app/core/lifespan.py

View check run for this annotation

Codecov / codecov/patch

app/core/lifespan.py#L22

Added line #L22 was not covered by tests
logger.add(
sys.stderr,
level=0,
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> <bg #800a0a>"
+ time.tzname[0]
+ "</bg #800a0a> | <level>{level: <8}</level> | <fg #800a0a>{name}</fg #800a0a>:<fg #800a0a>{function}</fg #800a0a>:<fg #800a0a>{line}</fg #800a0a> - <level>{message}</level>",
Expand All @@ -27,10 +31,9 @@
# logging.getLogger("uvicorn.access").addHandler(LoguruHandler())
# logging.getLogger("uvicorn.error").addHandler(LoguruHandler())

container = Container()
logger.info(f"{configs.ENV=}")
if configs.DB_TABLE_CREATE:
logger.warning("Create database")
database = container.database()
await database.create_all()
app.container = Container() # type: ignore[attr-defined]

yield
16 changes: 16 additions & 0 deletions app/core/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint

from app.core.database import database
from app.utils.logging import ANSI_BG_COLOR, ANSI_STYLE, ansi_format


Expand Down Expand Up @@ -57,3 +58,18 @@ async def dispatch(
f"[IP: {ip}] [URL: {url}] [Method: {method}] [Status: {status} (Elapsed Time: {elapsed_time})]"
)
return response


class SessionMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
try:
context = database.context.set(session_id=hash(request))
logger.trace(f"[Session Start]\tID: {database.context.get()}, {context=}")
response = await call_next(request)
finally:
await database.scoped_session.remove()
logger.trace(f"[Session End]\tID: {database.context.get()}, {context=}")
database.context.reset(context=context)
return response
8 changes: 8 additions & 0 deletions app/exceptions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,11 @@
class CoreException(abc.ABC, Exception):
status: int
message: str

def __str__(self) -> str:
return (

Check warning on line 9 in app/exceptions/base.py

View check run for this annotation

Codecov / codecov/patch

app/exceptions/base.py#L9

Added line #L9 was not covered by tests
f"[{self.__class__.__name__}] status={self.status}, message={self.message}"
)

def __repr__(self) -> str:
return f"[{self.__class__.__name__}] {self.message}"

Check warning on line 14 in app/exceptions/base.py

View check run for this annotation

Codecov / codecov/patch

app/exceptions/base.py#L14

Added line #L14 was not covered by tests
12 changes: 11 additions & 1 deletion app/exceptions/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
from app.exceptions.base import CoreException


class EntityNotFound(CoreException):
class DatabaseException(CoreException):
status: int
message: str


class EntityAlreadyExists(DatabaseException):
status: int = status.HTTP_409_CONFLICT
message: str = "Entity already exists in the database."


class EntityNotFound(DatabaseException):
status: int = status.HTTP_404_NOT_FOUND
message: str = "Entity not found in the database."
13 changes: 6 additions & 7 deletions app/exceptions/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,13 @@
)


async def business_exception_handler(
request: Request, exc: CoreException
async def core_exception_handler(
request: Request, exc: CoreException # pylint: disable=unused-argument
) -> JSONResponse:
logger.error(f"{request=}, {exc=}")
name = exc.__class__.__name__
logger.error(exc)

Check warning on line 25 in app/exceptions/handlers.py

View check run for this annotation

Codecov / codecov/patch

app/exceptions/handlers.py#L25

Added line #L25 was not covered by tests
return JSONResponse(
content=APIResponse.error(
status=exc.status, message=f"[{name}] {exc.message}"
).model_dump(mode="json"),
content=APIResponse.error(status=exc.status, message=repr(exc)).model_dump(
mode="json"
),
status_code=exc.status,
)
8 changes: 4 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
from app.api.v1.routers import routers as v1_routers
from app.core.configs import configs
from app.core.lifespan import lifespan
from app.core.middlewares import LoggingMiddleware
from app.core.middlewares import LoggingMiddleware, SessionMiddleware
from app.exceptions.base import CoreException
from app.exceptions.handlers import business_exception_handler, global_exception_handler
from app.exceptions.handlers import core_exception_handler, global_exception_handler

app = FastAPI(
title=configs.PROJECT_NAME,
Expand All @@ -17,12 +17,12 @@
redoc_url=f"{configs.PREFIX}/redoc",
exception_handlers={
Exception: global_exception_handler,
CoreException: business_exception_handler,
CoreException: core_exception_handler,
},
lifespan=lifespan,
)

for routers in [v1_routers]:
app.include_router(routers, prefix=configs.PREFIX)
for middleware in [LoggingMiddleware]:
for middleware in [SessionMiddleware, LoggingMiddleware]:
app.add_middleware(middleware)
Loading
Loading