Skip to content

Commit 8f0e0bd

Browse files
authored
Merge pull request #16 from Zerohertz/issue#8/feat/database
[Feat] DB (MySQL) 추가 및 `models`, `repositories`, `services` layer 개발
2 parents 2e9e87e + e42b7e5 commit 8f0e0bd

35 files changed

+772
-109
lines changed

.github/workflows/ci.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,35 @@ jobs:
8888
if: always()
8989

9090
test:
91+
env:
92+
DB_ROOT: "password"
93+
DB_TYPE: "mysql"
94+
DB_DRIVER: "mysqldb"
95+
DB_HOST: "127.0.0.1"
96+
DB_PORT: 3306
97+
DB_USER: "test"
98+
DB_PASSWORD: "test"
99+
DB_NAME: "test"
100+
DB_TABLE_CREATE: true
101+
services:
102+
mysql:
103+
image: mysql:8.0
104+
env:
105+
MYSQL_ROOT_PASSWORD: ${{ env.DB_ROOT }}
106+
# WARN: Kubernetes manifest의 livenessProbe와 다르게 MYSQL_PWD를 설정하면 아래 오류가 발생한다.
107+
# ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: YES)
108+
# MYSQL_PWD: ${{ env.DB_ROOT }}
109+
MYSQL_USER: ${{ env.DB_USER }}
110+
MYSQL_PASSWORD: ${{ env.DB_PASSWORD}}
111+
MYSQL_DATABASE: ${{ env.DB_NAME }}
112+
ports:
113+
- 3306:3306
114+
options: >-
115+
--health-cmd="mysqladmin ping"
116+
--health-interval=10s
117+
--health-timeout=5s
118+
--health-retries=3
119+
91120
name: Test
92121
needs: setup
93122
runs-on: ubuntu-latest
@@ -103,6 +132,12 @@ jobs:
103132
restore-keys: |
104133
${{ runner.os }}-python-venv-
105134
135+
# NOTE: 기본적으로 GitHub Actions의 Ubuntu에는 아래 의존성이 설치된 것으로 확인
136+
# - name: Install MySQL dependencies
137+
# run: |
138+
# sudo apt-get update
139+
# sudo apt-get install default-libmysqlclient-dev build-essential pkg-config -y
140+
106141
- name: Run pytest
107142
run: |
108143
source .venv/bin/activate

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,10 @@ cython_debug/
171171
.pypirc
172172

173173
# Credentials
174-
local.env
174+
dev.env
175175
prod.env
176-
local.yaml
176+
dev.yaml
177+
secrets.yaml
177178

178179
# Etc
179180
junit.xml

Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ ARG DEBIAN_FRONTEND=noninteractive
99
WORKDIR /workspace
1010
COPY ./ /workspace
1111

12-
RUN apt-get update && apt-get install make tzdata -y && \
12+
RUN apt-get update && \
13+
apt-get install make tzdata \
14+
# NOTE: mysqlclient depndencies
15+
default-libmysqlclient-dev build-essential pkg-config -y && \
1316
ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
1417
dpkg-reconfigure --frontend noninteractive tzdata
1518

Makefile

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,37 @@ lint:
1313

1414
.PHONY: test
1515
test:
16-
export DESCRIPTION=$$(cat README.md) && uv run pytest --cov=app --cov-branch --cov-report=xml --junitxml=junit.xml -o junit_family=legacy
16+
export DESCRIPTION=$$(cat README.md) && \
17+
uv run pytest \
18+
--cov=app --cov-branch --cov-report=xml \
19+
--junitxml=junit.xml -o junit_family=legacy
1720

18-
.PHONY: local
19-
local:
20-
export DESCRIPTION=$$(cat README.md) && source ./envs/local.env && uv run uvicorn app.main:app --host 0.0.0.0 --port $${PORT} --env-file ./envs/local.env --proxy-headers --forwarded-allow-ips='*' --reload
21+
.PHONY: dev
22+
dev:
23+
export DESCRIPTION=$$(cat README.md) && \
24+
source ./envs/dev.env && \
25+
uv run uvicorn app.main:app --host 0.0.0.0 --port $${PORT} \
26+
--env-file ./envs/dev.env \
27+
--proxy-headers --forwarded-allow-ips='*' \
28+
--reload
2129

2230
.PHONY: prod
2331
prod:
24-
export DESCRIPTION=$$(cat README.md) && uv run uvicorn app.main:app --host 0.0.0.0 --env-file ./envs/prod.env --proxy-headers --forwarded-allow-ips='*'
32+
export DESCRIPTION=$$(cat README.md) && \
33+
uv run uvicorn app.main:app --host 0.0.0.0 \
34+
--env-file ./envs/prod.env \
35+
--proxy-headers --forwarded-allow-ips='*'
36+
37+
.PHONY: k8s
38+
k8s:
39+
kubectl delete -n fastapi -f k8s/dev.yaml 2> /dev/null || echo "Not deployed to the dev environment! 🌎"
40+
kubectl apply -n fastapi -f k8s/secrets.yaml
41+
kubectl apply -n fastapi -f k8s/dev.yaml
42+
43+
.PHONY: exec
44+
exec:
45+
kubectl exec -it -n fastapi deploy/fastapi-dev -- zsh
46+
47+
.PHONY: expose
48+
expose:
49+
kubectl expose -n fastapi po/mysql-0 --port 3306 --type=NodePort

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
<br />
2121
<img src="https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=FastAPI&logoColor=white"/>
2222
<br />
23-
<img src="https://img.shields.io/badge/uv-DE5FE9?style=flat&logo=uv&logoColor=white"/> <img src="https://img.shields.io/badge/Pydantic-E92063?style=flat&logo=Pydantic&logoColor=white"/> <img src="https://img.shields.io/badge/SQLModel-D71F00?style=flat&logo=SQLAlchemy&logoColor=white"/>
23+
<img src="https://img.shields.io/badge/uv-DE5FE9?style=flat&logo=uv&logoColor=white"/> <img src="https://img.shields.io/badge/Pydantic-E92063?style=flat&logo=Pydantic&logoColor=white"/> <img src="https://img.shields.io/badge/SQLAlchemy-D71F00?style=flat&logo=SQLAlchemy&logoColor=white"/>
2424
<br />
2525
<img src="https://img.shields.io/badge/Pytest-0A9EDC?style=flat&logo=pytest&logoColor=white"/> <a href="https://codecov.io/github/Zerohertz/fastapi-boilerplate" ><img src="https://codecov.io/github/Zerohertz/fastapi-boilerplate/graph/badge.svg?token=8318TEPMVO"/></a> <img src="https://img.shields.io/badge/GitHub Actions-2088FF?style=flat&logo=githubactions&logoColor=white"/>
2626
<br />

app/api/v1/endpoints/users.py

Lines changed: 79 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,87 @@
1-
from datetime import datetime
2-
3-
from loguru import logger
1+
from dependency_injector.wiring import Provide, inject
2+
from fastapi import Depends
3+
from starlette import status
44

5+
from app.core.container import Container
56
from app.core.router import CoreAPIRouter
6-
from app.exceptions.users import (
7-
InsufficientFunds,
8-
InvalidInput,
9-
UnauthorizedAccess,
10-
UserNotFound,
11-
)
12-
from app.schemas.users import User
7+
from app.schemas.users import UserCreateRequest, UserCreateResponse
8+
from app.services.users import UserService
139

1410
router = CoreAPIRouter(prefix="/user", tags=["user"])
1511

1612

17-
@router.get(
13+
@router.post(
1814
"",
19-
response_model=User,
20-
status_code=200,
21-
summary="Get User Test",
22-
description="1 ~ 4: Error!",
15+
response_model=UserCreateResponse,
16+
status_code=status.HTTP_201_CREATED,
17+
summary="",
18+
description="",
19+
)
20+
@inject
21+
async def create_user(
22+
user: UserCreateRequest,
23+
service: UserService = Depends(Provide[Container.user_service]),
24+
):
25+
return service.create(user)
26+
27+
28+
@router.get(
29+
"/{id}",
30+
response_model=UserCreateResponse,
31+
status_code=status.HTTP_200_OK,
32+
summary="",
33+
description="",
34+
)
35+
@inject
36+
async def get_user(
37+
id: int,
38+
service: UserService = Depends(Provide[Container.user_service]),
39+
):
40+
return service.get_by_id(id)
41+
42+
43+
@router.put(
44+
"/{id}",
45+
response_model=UserCreateResponse,
46+
status_code=status.HTTP_200_OK,
47+
summary="",
48+
description="",
49+
)
50+
@inject
51+
async def put_user(
52+
id: int,
53+
user: UserCreateRequest,
54+
service: UserService = Depends(Provide[Container.user_service]),
55+
):
56+
return service.put_by_id(id=id, schema=user)
57+
58+
59+
@router.patch(
60+
"/{id}",
61+
response_model=UserCreateResponse,
62+
status_code=status.HTTP_200_OK,
63+
summary="",
64+
description="",
65+
)
66+
@inject
67+
async def patch_user(
68+
id: int,
69+
user: UserCreateRequest,
70+
service: UserService = Depends(Provide[Container.user_service]),
71+
):
72+
return service.patch_by_id(id=id, schema=user)
73+
74+
75+
@router.delete(
76+
"/{id}",
77+
response_model=UserCreateResponse,
78+
status_code=status.HTTP_200_OK,
79+
summary="",
80+
description="",
2381
)
24-
async def get_user(user_id: int):
25-
try:
26-
if user_id == 1:
27-
raise InsufficientFunds
28-
if user_id == 2:
29-
raise InvalidInput
30-
if user_id == 3:
31-
raise UnauthorizedAccess("Unauthorized.")
32-
if user_id == 4:
33-
raise UserNotFound("User not found.")
34-
except Exception as error:
35-
logger.exception(repr(error))
36-
raise error
37-
return User(id=user_id, created_at=datetime.now(), updated_at=datetime.now())
82+
@inject
83+
async def delete_user(
84+
id: int,
85+
service: UserService = Depends(Provide[Container.user_service]),
86+
):
87+
return service.delete_by_id(id)

app/core/configs.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,49 @@
1+
from typing import Optional
2+
3+
from pydantic import computed_field
4+
from pydantic_core import MultiHostUrl
15
from pydantic_settings import BaseSettings
26

37

48
class Configs(BaseSettings):
9+
# --------- APP SETTINGS --------- #
510
PROJECT_NAME: str
611
DESCRIPTION: str
712
VERSION: str
813
PREFIX: str
914

15+
# --------- DATABASE SETTINGS --------- #
16+
DB_TYPE: str
17+
DB_DRIVER: Optional[str]
18+
DB_HOST: Optional[str]
19+
DB_PORT: Optional[int] = 0
20+
DB_USER: Optional[str]
21+
DB_PASSWORD: Optional[str]
22+
DB_NAME: Optional[str]
23+
DB_ECHO: Optional[bool] = True
24+
DB_TABLE_CREATE: Optional[bool] = True
25+
26+
@property
27+
def DB_SCHEME(self) -> str:
28+
if self.DB_DRIVER:
29+
return f"{self.DB_TYPE}+{self.DB_DRIVER}"
30+
return self.DB_TYPE
31+
32+
@computed_field # type: ignore[prop-decorator]
33+
@property
34+
def DATABASE_URI(self) -> str:
35+
if self.DB_TYPE == "sqlite" and self.DB_PORT == 0:
36+
return f"{self.DB_TYPE}:///{self.DB_NAME}"
37+
return str(
38+
MultiHostUrl.build(
39+
scheme=self.DB_SCHEME,
40+
host=self.DB_HOST,
41+
port=self.DB_PORT,
42+
username=self.DB_USER,
43+
password=self.DB_PASSWORD,
44+
path=self.DB_NAME,
45+
)
46+
)
47+
1048

1149
configs = Configs() # type: ignore

app/core/container.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from dependency_injector.containers import DeclarativeContainer, WiringConfiguration
2+
from dependency_injector.providers import Factory, Singleton
3+
4+
from app.core.database import Database
5+
from app.repositories.users import UserRepository
6+
from app.services.users import UserService
7+
8+
9+
class Container(DeclarativeContainer):
10+
wiring_config = WiringConfiguration(modules=["app.api.v1.endpoints.users"])
11+
12+
database = Singleton(Database)
13+
14+
user_repository = Factory(UserRepository, session=database.provided.session)
15+
16+
user_service = Factory(UserService, user_repository=user_repository)

app/core/database.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from contextlib import contextmanager
2+
from typing import Any, Generator
3+
4+
from sqlalchemy import create_engine
5+
from sqlalchemy.orm import Session, scoped_session, sessionmaker
6+
7+
from app.core.configs import configs
8+
from app.models.base import BaseModel
9+
10+
11+
class Database:
12+
def __init__(self) -> None:
13+
self.engine = create_engine(configs.DATABASE_URI, echo=configs.DB_ECHO)
14+
self.scoped_session = scoped_session(
15+
sessionmaker(
16+
autocommit=False,
17+
autoflush=False,
18+
bind=self.engine,
19+
),
20+
)
21+
22+
def create_all(self) -> None:
23+
BaseModel.metadata.create_all(self.engine)
24+
25+
@contextmanager
26+
def session(self) -> Generator[Any, Any, None]:
27+
session: Session = self.scoped_session()
28+
try:
29+
yield session
30+
except Exception:
31+
session.rollback()
32+
raise
33+
finally:
34+
session.close()

app/core/lifespan.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from fastapi import FastAPI
77
from loguru import logger
88

9+
from app.core.configs import configs
10+
from app.core.container import Container
911
from app.utils.logging import remove_handler
1012

1113

@@ -16,11 +18,19 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument
1618
logger.remove()
1719
logger.add(
1820
sys.stderr,
21+
level=0,
1922
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> <bg #800a0a>"
2023
+ time.tzname[0]
2124
+ "</bg #800a0a> | <level>{level: <8}</level> | <fg #800a0a>{name}</fg #800a0a>:<fg #800a0a>{function}</fg #800a0a>:<fg #800a0a>{line}</fg #800a0a> - <level>{message}</level>",
2225
colorize=True,
2326
)
2427
# logging.getLogger("uvicorn.access").addHandler(LoguruHandler())
2528
# logging.getLogger("uvicorn.error").addHandler(LoguruHandler())
29+
30+
container = Container()
31+
if configs.DB_TABLE_CREATE:
32+
logger.warning("Create database")
33+
database = container.database()
34+
database.create_all()
35+
2636
yield

0 commit comments

Comments
 (0)