From 09cc3a1059e3041c79af127517f8aeccef03cce5 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Sat, 25 Jan 2025 17:49:12 +0900 Subject: [PATCH 01/13] :hammer: update: engine dispose in lifecycle --- app/core/lifespan.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/lifespan.py b/app/core/lifespan.py index b1091cf..8ae63a4 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -37,3 +37,5 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument app.container = Container() # type: ignore[attr-defined] yield + + await database.engine.dispose() From 99ec6c56175ec4cbc2ef3084b73952263b46c362 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Sat, 25 Jan 2025 17:57:03 +0900 Subject: [PATCH 02/13] :recycle: refactor: variable names --- app/repositories/base.py | 68 ++++++++++++++++++++-------------------- app/services/base.py | 30 +++++++++--------- 2 files changed, 50 insertions(+), 48 deletions(-) diff --git a/app/repositories/base.py b/app/repositories/base.py index 0f94268..f0fd489 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -1,7 +1,7 @@ from typing import Any, Generic, Type, TypeVar +from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from sqlalchemy.future import select from sqlalchemy.orm import joinedload from app.core.database import database @@ -18,66 +18,66 @@ def __init__( ) -> None: self.model = model - async def create(self, model: T) -> T: + async def create(self, entity: T) -> T: session = database.scoped_session() - session.add(model) + session.add(entity) try: await session.flush() except IntegrityError as error: raise EntityAlreadyExists from error - await session.refresh(model) - return model + await session.refresh(entity) + return entity async def read_by_id(self, id: int, eager: bool = False) -> T: - query = select(self.model) + stmt = select(self.model) if eager: for _eager in getattr(self.model, "eagers"): - query = query.options(joinedload(getattr(self.model, _eager))) - query = query.filter(self.model.id == id) + stmt = stmt.options(joinedload(getattr(self.model, _eager))) + stmt = stmt.filter(self.model.id == id) session = database.scoped_session() - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + if not entity: raise EntityNotFound - return result + return entity - async def update_by_id(self, id: int, model: dict) -> T: - query = select(self.model).filter(self.model.id == id) + async def update_by_id(self, id: int, data: dict) -> T: + stmt = select(self.model).filter(self.model.id == id) session = database.scoped_session() - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + if not entity: raise EntityNotFound - for key, value in model.items(): - setattr(result, key, value) + for key, value in data.items(): + setattr(entity, key, value) try: await session.flush() except IntegrityError as error: raise EntityAlreadyExists from error - await session.refresh(result) - return result + await session.refresh(entity) + return entity async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: - query = select(self.model).filter(self.model.id == id) + stmt = select(self.model).filter(self.model.id == id) session = database.scoped_session() - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + if not entity: raise EntityNotFound - setattr(result, column, value) + setattr(entity, column, value) try: await session.flush() except IntegrityError as error: raise EntityAlreadyExists from error - await session.refresh(result) - return result + await session.refresh(entity) + return entity async def delete_by_id(self, id: int) -> T: - query = select(self.model).filter(self.model.id == id) + stmt = select(self.model).filter(self.model.id == id) session = database.scoped_session() - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + if not entity: raise EntityNotFound - await session.delete(result) - return result + await session.delete(entity) + return entity diff --git a/app/services/base.py b/app/services/base.py index b6132a0..a875904 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -25,37 +25,39 @@ def mapper(self, data: BaseSchemaRequest | T) -> T | BaseSchemaResponse: @database.transactional async def create(self, schema: BaseSchemaRequest) -> BaseSchemaResponse: - model = self.mapper(schema) - model = await self.repository.create(model=model) - return self.mapper(model) + entity = self.mapper(schema) + entity = await self.repository.create(entity=entity) + return self.mapper(entity) @database.transactional async def get_by_id(self, id: int) -> BaseSchemaResponse: - model = await self.repository.read_by_id(id=id) - return self.mapper(model) + entity = await self.repository.read_by_id(id=id) + return self.mapper(entity) @database.transactional async def put_by_id(self, id: int, schema: BaseSchemaRequest) -> BaseSchemaResponse: - model = await self.repository.update_by_id(id=id, model=schema.model_dump()) - return self.mapper(model) + entity = await self.repository.update_by_id(id=id, data=schema.model_dump()) + return self.mapper(entity) @database.transactional async def patch_by_id( self, id: int, schema: BaseSchemaRequest ) -> BaseSchemaResponse: - model = await self.repository.update_by_id( - id=id, model=schema.model_dump(exclude_none=True) + entity = await self.repository.update_by_id( + id=id, data=schema.model_dump(exclude_none=True) ) - return self.mapper(model) + return self.mapper(entity) @database.transactional async def patch_attr_by_id( self, id: int, attr: str, value: Any ) -> BaseSchemaResponse: - model = await self.repository.update_attr_by_id(id=id, column=attr, value=value) - return self.mapper(model) + entity = await self.repository.update_attr_by_id( + id=id, column=attr, value=value + ) + return self.mapper(entity) @database.transactional async def delete_by_id(self, id: int) -> BaseSchemaResponse: - model = await self.repository.delete_by_id(id=id) - return self.mapper(model) + entity = await self.repository.delete_by_id(id=id) + return self.mapper(entity) From 0b7981930dbea5f8fa20a519a131823a6ddb04cc Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 14:47:30 +0900 Subject: [PATCH 03/13] :bug: fix: sqlite db name (resolves: #25) --- envs/test.env | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/envs/test.env b/envs/test.env index 52af4f6..4f55fb4 100644 --- a/envs/test.env +++ b/envs/test.env @@ -13,5 +13,10 @@ DB_HOST="" DB_PORT=0 DB_USER="" DB_PASSWORD="" -DB_NAME=":memory:" +DB_NAME="test.db" DB_TABLE_CREATE=true + +GITHUB_OAUTH_CLIENT_ID="" +GITHUB_OAUTH_CLIENT_SECRET="" +JWT_SECRET_KEY="fastapi" +JWT_ALGORITHM="HS256" From 847d879227637a106d855f9bc4fb52ccb43e7f0a Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 16:30:38 +0900 Subject: [PATCH 04/13] :sparkles: feat: auth with password and github oauth (related: #22) --- app/api/v1/endpoints/auth.py | 115 +++++++++++++++++++++++++++++ app/api/v1/endpoints/users.py | 18 ++--- app/api/v1/routers.py | 4 +- app/core/auth.py | 51 +++++++++++++ app/core/configs.py | 7 ++ app/core/container.py | 8 ++- app/core/router.py | 31 +++++--- app/exceptions/auth.py | 43 +++++++++++ app/exceptions/database.py | 4 +- app/exceptions/users.py | 23 ------ app/main.py | 2 +- app/models/base.py | 12 ++-- app/models/users.py | 10 ++- app/repositories/base.py | 10 ++- app/repositories/users.py | 37 ++++++++++ app/schemas/auth.py | 20 ++++++ app/schemas/responses.py | 30 +------- app/schemas/users.py | 31 +++++++- app/services/auth.py | 91 +++++++++++++++++++++++ app/services/users.py | 103 +++++++++++++++++++++++++- app/tests/api/v1/test_users.py | 11 ++- app/tests/conftest.py | 13 ++++ app/tests/services/test_users.py | 67 ++++++++++------- app/utils/etc.py | 16 +++++ k8s/postgresql/fastapi.yaml | 20 ++++++ pyproject.toml | 4 ++ uv.lock | 119 +++++++++++++++++++++++++++++++ 27 files changed, 783 insertions(+), 117 deletions(-) create mode 100644 app/api/v1/endpoints/auth.py create mode 100644 app/core/auth.py create mode 100644 app/exceptions/auth.py delete mode 100644 app/exceptions/users.py create mode 100644 app/schemas/auth.py create mode 100644 app/services/auth.py create mode 100644 app/utils/etc.py diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..7f834a7 --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,115 @@ +from dependency_injector.wiring import Provide, inject +from fastapi import Depends, Response +from fastapi.responses import RedirectResponse +from starlette import status + +from app.core.auth import AuthDeps +from app.core.configs import configs +from app.core.container import Container +from app.core.router import CoreAPIRouter +from app.schemas.auth import JwtAccessToken, JwtRefreshToken, JwtToken +from app.schemas.users import ( + UserOut, + UserPasswordRequest, + UserRegisterRequest, + UserResponse, +) +from app.services.users import UserService + +router = CoreAPIRouter(prefix="/auth", tags=["auth"]) + + +@router.post( + "/refresh", + response_model=JwtAccessToken, + status_code=status.HTTP_200_OK, + summary="", + description="", +) +@inject +async def post_refresh_token( + token: JwtRefreshToken, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.refresh(token) + + +@router.post( + "/register", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, + summary="Register with password", + description="", +) +@inject +async def register_password( + request: UserRegisterRequest, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.register(request) + + +@router.post( + "/login", + response_model=JwtToken, + status_code=status.HTTP_302_FOUND, + summary="Log in with password", + description="", +) +@inject +async def log_in_password( + request: UserPasswordRequest, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.log_in_password(schema=request) + + +@router.get( + "/oauth/login/github", + response_model=Response, + status_code=status.HTTP_302_FOUND, + summary="Log in with GitHub OAuth", + description="GitHub OAuth를 위해 redirection", +) +async def log_in_github(): + # NOTE: &scope=repo,user + # TODO: APIResponse (related: #24) + return RedirectResponse( + f"https://github.com/login/oauth/authorize?client_id={configs.GITHUB_OAUTH_CLIENT_ID}" + ) + + +@router.get( + "/oauth/callback/github", + response_model=JwtToken, + status_code=status.HTTP_200_OK, + summary="Callback for GitHub OAuth", + description="GitHub OAuth에 의해 redirection될 endpoint", + include_in_schema=False, +) +@inject +async def callback_github( + code: str, + service: UserService = Depends(Provide[Container.user_service]), +): + """ + # NOTE: Cookie 방식으로 JWT token 사용 시 + response.set_cookie( + key="access_token", value=jwt_token.access_token, httponly=True, secure=True + ) + response.set_cookie( + key="refresh_token", value=jwt_token.refresh_token, httponly=True, secure=True + ) + """ + return await service.log_in_github(code=code) + + +@router.get( + "/me", + response_model=UserOut, + status_code=status.HTTP_200_OK, + summary="", + description="", +) +async def get_me(user: AuthDeps): + return user diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py index 87b8381..a2b3de9 100644 --- a/app/api/v1/endpoints/users.py +++ b/app/api/v1/endpoints/users.py @@ -4,7 +4,7 @@ from app.core.container import Container from app.core.router import CoreAPIRouter -from app.schemas.users import UserCreateRequest, UserCreateResponse +from app.schemas.users import UserRequest, UserResponse from app.services.users import UserService router = CoreAPIRouter(prefix="/user", tags=["user"]) @@ -12,14 +12,14 @@ @router.post( "", - response_model=UserCreateResponse, + response_model=UserResponse, status_code=status.HTTP_201_CREATED, summary="", description="", ) @inject async def create_user( - user: UserCreateRequest, + user: UserRequest, service: UserService = Depends(Provide[Container.user_service]), ): return await service.create(user) @@ -27,7 +27,7 @@ async def create_user( @router.get( "/{id}", - response_model=UserCreateResponse, + response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", description="", @@ -42,7 +42,7 @@ async def get_user( @router.put( "/{id}", - response_model=UserCreateResponse, + response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", description="", @@ -50,7 +50,7 @@ async def get_user( @inject async def put_user( id: int, - user: UserCreateRequest, + user: UserRequest, service: UserService = Depends(Provide[Container.user_service]), ): return await service.put_by_id(id=id, schema=user) @@ -58,7 +58,7 @@ async def put_user( @router.patch( "/{id}", - response_model=UserCreateResponse, + response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", description="", @@ -66,7 +66,7 @@ async def put_user( @inject async def patch_user( id: int, - user: UserCreateRequest, + user: UserRequest, service: UserService = Depends(Provide[Container.user_service]), ): return await service.patch_by_id(id=id, schema=user) @@ -74,7 +74,7 @@ async def patch_user( @router.delete( "/{id}", - response_model=UserCreateResponse, + response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", description="", diff --git a/app/api/v1/routers.py b/app/api/v1/routers.py index faab11a..3725a00 100644 --- a/app/api/v1/routers.py +++ b/app/api/v1/routers.py @@ -1,9 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import shields, users +from app.api.v1.endpoints import auth, shields, users routers = APIRouter(prefix="/v1", tags=["v1"]) -_routers = [users.router, shields.router] +_routers = [auth.router, users.router, shields.router] for _router in _routers: routers.include_router(_router) diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..c7b4528 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,51 @@ +from typing import Annotated, Optional + +from dependency_injector.wiring import Provide, inject +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.container import Container +from app.exceptions.auth import NotAuthenticated +from app.schemas.auth import JwtAccessToken +from app.schemas.users import UserOut +from app.services.users import UserService + + +class JwtBearer(HTTPBearer): + def __init__( + self, + *, + bearerFormat: Optional[str] = None, + scheme_name: Optional[str] = None, + description: Optional[str] = None, + ): + super().__init__( + bearerFormat=bearerFormat, + scheme_name=scheme_name, + description=description, + auto_error=True, + ) + + async def __call__( + self, request: Request + ) -> Optional[HTTPAuthorizationCredentials]: + try: + authorization = await super().__call__(request) + return authorization.credentials + except HTTPException: + raise NotAuthenticated + + +jwt_bearer = JwtBearer() + + +@inject +async def get_current_user( + access_token: Annotated[HTTPAuthorizationCredentials, Depends(jwt_bearer)], + service: UserService = Depends(Provide[Container.user_service]), +) -> UserOut: + token = JwtAccessToken(access_token=access_token) + return await service.verify(token=token) + + +AuthDeps = Annotated[UserOut, Depends(get_current_user)] diff --git a/app/core/configs.py b/app/core/configs.py index 30234ac..9747ce3 100644 --- a/app/core/configs.py +++ b/app/core/configs.py @@ -33,6 +33,13 @@ class Configs(BaseSettings): DB_ECHO: Optional[bool] = True DB_TABLE_CREATE: Optional[bool] = True + # --------- AUTH SETTINGS --------- # + GITHUB_OAUTH_CLIENT_ID: str + GITHUB_OAUTH_CLIENT_SECRET: str + # openssl rand -hex 32 + JWT_SECRET_KEY: str + JWT_ALGORITHM: str + @property def DB_SCHEME(self) -> str: if self.DB_DRIVER: diff --git a/app/core/container.py b/app/core/container.py index 698c681..ccfeab7 100644 --- a/app/core/container.py +++ b/app/core/container.py @@ -6,7 +6,13 @@ class Container(DeclarativeContainer): - wiring_config = WiringConfiguration(modules=["app.api.v1.endpoints.users"]) + wiring_config = WiringConfiguration( + modules=[ + "app.core.auth", + "app.api.v1.endpoints.users", + "app.api.v1.endpoints.auth", + ] + ) user_repository = Factory(UserRepository) diff --git a/app/core/router.py b/app/core/router.py index 5cd37b2..05ca3dd 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -1,13 +1,14 @@ from functools import wraps -from typing import Any, Callable, Coroutine, Type, TypeVar +from typing import Any, Callable, Coroutine, Type, TypeVar, cast -from fastapi import APIRouter +from fastapi import APIRouter, Response from fastapi.types import DecoratedCallable +from loguru import logger +from pydantic import BaseModel -from app.schemas.base import BaseSchemaResponse from app.schemas.responses import APIResponse -T = TypeVar("T", bound=BaseSchemaResponse) +T = TypeVar("T", bound=BaseModel) class CoreAPIRouter(APIRouter): @@ -15,19 +16,29 @@ def api_route( # type: ignore self, path: str, *args, - response_model: Type[T], + response_model: Type[T] | Type[Response], status_code: int, **kwargs, ) -> Callable[ - [DecoratedCallable], Callable[..., Coroutine[Any, Any, APIResponse[T]]] + [DecoratedCallable], + Callable[..., Coroutine[Any, Any, APIResponse[T] | Response]], ]: def decorator( func: DecoratedCallable, - ) -> Callable[..., Coroutine[Any, Any, APIResponse[T]]]: + ) -> Callable[..., Coroutine[Any, Any, APIResponse[T] | Response]]: @wraps(func) - async def success(*_args: tuple, **_kwargs: dict) -> APIResponse[T]: - response: T = await func(*_args, **_kwargs) - return APIResponse[T].success(status=status_code, data=response) + async def success( + *_args: tuple, **_kwargs: dict + ) -> APIResponse[T] | Response: + response: Any = await func(*_args, **_kwargs) + if not isinstance(response, response_model): + logger.warning(f"{type(response)}: {response}") + raise TypeError + if isinstance(response, BaseModel): + return APIResponse[T].success( + status=status_code, data=cast(T, response) + ) + return response self.add_api_route( path, diff --git a/app/exceptions/auth.py b/app/exceptions/auth.py new file mode 100644 index 0000000..ff7c206 --- /dev/null +++ b/app/exceptions/auth.py @@ -0,0 +1,43 @@ +from starlette import status + +from app.exceptions.base import CoreException + + +class AuthException(CoreException): + status: int + message: str + + +class UserAlreadyExists(CoreException): + status: int = status.HTTP_409_CONFLICT + message: str = "User already exists. Please use a different email." + + +class NotRegistered(CoreException): + status: int = status.HTTP_404_NOT_FOUND + message: str = "User not registered. Please sign up first." + + +class LoginFailed(CoreException): + status: int = status.HTTP_401_UNAUTHORIZED + message: str = "Login failed. Invalid credentials." + + +class GitHubOAuth(AuthException): + status: int = status.HTTP_400_BAD_REQUEST + message: str = "GitHub OAuth failed." + + +class NotAuthenticated(CoreException): + status: int = status.HTTP_403_FORBIDDEN + message: str = "Not authenticated." + + +class TokenDecode(AuthException): + status: int = status.HTTP_400_BAD_REQUEST + message: str = "Token decode error." + + +class TokenExpired(AuthException): + status: int = status.HTTP_400_BAD_REQUEST + message: str = "Expired token." diff --git a/app/exceptions/database.py b/app/exceptions/database.py index fa7de9e..fe233ff 100644 --- a/app/exceptions/database.py +++ b/app/exceptions/database.py @@ -4,8 +4,8 @@ class DatabaseException(CoreException): - status: int - message: str + status: int = status.HTTP_500_INTERNAL_SERVER_ERROR + message: str = "Database error occurred." class EntityAlreadyExists(DatabaseException): diff --git a/app/exceptions/users.py b/app/exceptions/users.py deleted file mode 100644 index f45e1f1..0000000 --- a/app/exceptions/users.py +++ /dev/null @@ -1,23 +0,0 @@ -from starlette import status - -from app.exceptions.base import CoreException - - -class InvalidInput(CoreException): - status: int = status.HTTP_400_BAD_REQUEST - message: str = "The input provided is invalid." - - -class UnauthorizedAccess(CoreException): - status: int = status.HTTP_401_UNAUTHORIZED - message: str = "You do not have permission to access this resource." - - -class InsufficientFunds(CoreException): - status: int = status.HTTP_402_PAYMENT_REQUIRED - message: str = "You have insufficient funds to complete this transaction." - - -class UserNotFound(CoreException): - status: int = status.HTTP_404_NOT_FOUND - message: str = "User not found in the system." diff --git a/app/main.py b/app/main.py index 89d850c..a219d92 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,7 @@ version=configs.VERSION, openapi_url=f"{configs.PREFIX}/openapi.json", docs_url=f"{configs.PREFIX}/docs", - redoc_url=f"{configs.PREFIX}/redoc", + redoc_url=None, exception_handlers={ Exception: global_exception_handler, CoreException: core_exception_handler, diff --git a/app/models/base.py b/app/models/base.py index 578b888..84a6bb1 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,5 +1,5 @@ -from sqlalchemy import Column, DateTime, Integer, func -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import DateTime, Integer, func +from sqlalchemy.orm import DeclarativeBase, mapped_column class Base(DeclarativeBase): ... @@ -8,9 +8,11 @@ class Base(DeclarativeBase): ... class BaseModel(Base): __abstract__ = True - id = Column(Integer, primary_key=True, nullable=False) - created_at = Column(DateTime(timezone=True), default=func.now(), nullable=False) - updated_at = Column( + id = mapped_column(Integer, primary_key=True, nullable=False) + created_at = mapped_column( + DateTime(timezone=True), default=func.now(), nullable=False + ) + updated_at = mapped_column( DateTime(timezone=True), default=func.now(), onupdate=func.now(), diff --git a/app/models/users.py b/app/models/users.py index cfe8e67..8987071 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, String +from sqlalchemy import String +from sqlalchemy.orm import mapped_column from app.models.base import BaseModel @@ -6,4 +7,9 @@ class User(BaseModel): __tablename__ = "user" - name = Column(String(255), unique=True) + name = mapped_column(String(255), unique=True, nullable=False) + email = mapped_column(String(255), unique=True, nullable=False) + oauth = mapped_column(String(255), unique=False, nullable=False) + password = mapped_column(String(255), unique=False, nullable=True) + refresh_token = mapped_column(String(255), unique=False, nullable=True) + github_token = mapped_column(String(255), unique=False, nullable=True) diff --git a/app/repositories/base.py b/app/repositories/base.py index f0fd489..523cee9 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -5,7 +5,11 @@ from sqlalchemy.orm import joinedload from app.core.database import database -from app.exceptions.database import EntityAlreadyExists, EntityNotFound +from app.exceptions.database import ( + DatabaseException, + EntityAlreadyExists, + EntityNotFound, +) from app.models.base import BaseModel T = TypeVar("T", bound=BaseModel) @@ -24,7 +28,9 @@ async def create(self, entity: T) -> T: try: await session.flush() except IntegrityError as error: - raise EntityAlreadyExists from error + # TODO: DB engine에 따라서 오류가 천차만별 + # message 기반으로 하기는 어려울 것으로 보임 + raise DatabaseException from error await session.refresh(entity) return entity diff --git a/app/repositories/users.py b/app/repositories/users.py index 194bb04..d7a7732 100644 --- a/app/repositories/users.py +++ b/app/repositories/users.py @@ -1,3 +1,10 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import joinedload +from sqlalchemy.sql.expression import or_ + +from app.core.database import database from app.models.users import User from app.repositories.base import BaseRepository @@ -5,3 +12,33 @@ class UserRepository(BaseRepository[User]): def __init__(self): super().__init__(model=User) + + async def read_by_name(self, name: str, eager: bool = False) -> Optional[User]: + stmt = select(self.model) + if eager: + for _eager in getattr(self.model, "eagers"): + stmt = stmt.options(joinedload(getattr(self.model, _eager))) + stmt = stmt.filter(self.model.name == name) + session = database.scoped_session() + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + return entity + + async def read_by_email(self, email: str, eager: bool = False) -> Optional[User]: + stmt = select(self.model) + if eager: + for _eager in getattr(self.model, "eagers"): + stmt = stmt.options(joinedload(getattr(self.model, _eager))) + stmt = stmt.filter(self.model.email == email) + session = database.scoped_session() + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + return entity + + async def read_by_name_or_email(self, name: str, email: str) -> Optional[User]: + stmt = select(self.model) + stmt = stmt.filter(or_(self.model.name == name, self.model.email == email)) + session = database.scoped_session() + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + return entity diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..71733b7 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class JwtPayload(BaseModel): + sub: str + iat: datetime + exp: datetime + + +class JwtAccessToken(BaseModel): + access_token: str + + +class JwtRefreshToken(BaseModel): + refresh_token: str + + +class JwtToken(JwtAccessToken, JwtRefreshToken): ... diff --git a/app/schemas/responses.py b/app/schemas/responses.py index aa90864..3ae2957 100644 --- a/app/schemas/responses.py +++ b/app/schemas/responses.py @@ -5,9 +5,8 @@ from pydantic import BaseModel from app.core.configs import configs -from app.schemas.base import BaseSchemaResponse -T = TypeVar("T", bound=BaseSchemaResponse) +T = TypeVar("T", bound=BaseModel) class APIResponse(BaseModel, Generic[T]): @@ -52,30 +51,3 @@ def error(cls, *, status: int, message: str) -> "APIResponse[T]": data=None, timestamp=datetime.now().astimezone(pytz.timezone(configs.TZ)), ) - - -if __name__ == "__main__": - - class User(BaseSchemaResponse): - name: str - - print( - APIResponse[User] - .success( - status=200, - data=User( - id=1, created_at=datetime.now(), updated_at=datetime.now(), name="123" - ), - ) - .model_dump_json() - ) - print( - APIResponse.success( - status=200, - data=User( - id=1, created_at=datetime.now(), updated_at=datetime.now(), name="123" - ), - ).model_dump_json() - ) - print(APIResponse[User].error(status=404, message="fail").model_dump_json()) - print(APIResponse.error(status=404, message="fail").model_dump_json()) diff --git a/app/schemas/users.py b/app/schemas/users.py index 76aff32..ba6b2c6 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -1,9 +1,36 @@ +from typing import Optional + from app.schemas.base import BaseSchemaRequest, BaseSchemaResponse -class UserCreateRequest(BaseSchemaRequest): +class UserRequest(BaseSchemaRequest): name: str + email: str + + +class UserRegisterRequest(UserRequest): + password: str + + +class UserPasswordRequest(BaseSchemaRequest): + email: str + password: str -class UserCreateResponse(BaseSchemaResponse): +class UserResponse(BaseSchemaResponse): name: str + email: str + oauth: str + + +class UserIn(UserRequest): + oauth: str + password: Optional[str] = None + refresh_token: Optional[str] = None + github_token: Optional[str] = None + + +class UserOut(UserResponse): + password: Optional[str] = None + refresh_token: Optional[str] = None + github_token: Optional[str] = None diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..ad50bf4 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta + +import httpx +import pytz +from jose import jwt +from jose.exceptions import ExpiredSignatureError, JWTError +from passlib.context import CryptContext + +from app.core.configs import configs +from app.exceptions.auth import GitHubOAuth, TokenDecode, TokenExpired +from app.models.users import User +from app.schemas.auth import JwtPayload + + +class CryptService: + def __init__(self) -> None: + self.context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + def hash(self, secret: str) -> str: + return self.context.hash(secret=secret) + + def verify(self, secret: str, hash: str) -> bool: + return self.context.verify(secret=secret, hash=hash) + + +class JwtService: + def __init__(self) -> None: + self.secret = configs.JWT_SECRET_KEY + self.algorithm = configs.JWT_ALGORITHM + self.access_expire = timedelta(hours=2) + self.refresh_expire = timedelta(days=1) + + def _encode(self, *, sub: str, exp: timedelta) -> str: + payload = JwtPayload( + sub=sub, + iat=datetime.now().astimezone(pytz.timezone(configs.TZ)), + exp=datetime.now().astimezone(pytz.timezone(configs.TZ)) + exp, + ) + return jwt.encode( + claims=payload.model_dump(), key=self.secret, algorithm=self.algorithm + ) + + def create_access_token(self, user: User) -> str: + return self._encode(sub=str(user.id), exp=self.access_expire) + + def create_refresh_token(self, user: User) -> str: + return self._encode(sub=f"{user.id}.refresh", exp=self.refresh_expire) + + def decode(self, *, token: str) -> str: + try: + payload = jwt.decode( + token=token, key=self.secret, algorithms=self.algorithm + ) + return payload["sub"] + except JWTError: + raise TokenDecode + except ExpiredSignatureError: + raise TokenExpired + + +class GitHubService: + def __init__(self) -> None: + self.client_id = configs.GITHUB_OAUTH_CLIENT_ID + self.client_secret = configs.GITHUB_OAUTH_CLIENT_SECRET + + async def get_user(self, code: str) -> tuple[str, str, str]: + async with httpx.AsyncClient() as client: + try: + response = await client.post( + "https://github.com/login/oauth/access_token", + json={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + }, + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + github_token = response.json()["access_token"] + response = await client.get( + "https://api.github.com/user", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {github_token}", + }, + ) + response.raise_for_status() + github_user = response.json() + except httpx.HTTPStatusError: + raise GitHubOAuth + return github_token, github_user["login"], github_user["email"] diff --git a/app/services/users.py b/app/services/users.py index 69d9fa6..de878d8 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,15 +1,32 @@ from typing import overload +from loguru import logger +from passlib.exc import UnknownHashError + +from app.core.database import database +from app.exceptions.auth import LoginFailed, NotRegistered, UserAlreadyExists from app.models.users import User from app.repositories.users import UserRepository +from app.schemas.auth import JwtAccessToken, JwtRefreshToken, JwtToken from app.schemas.base import BaseSchemaRequest, BaseSchemaResponse -from app.schemas.users import UserCreateResponse +from app.schemas.users import ( + UserIn, + UserOut, + UserPasswordRequest, + UserRegisterRequest, + UserResponse, +) +from app.services.auth import CryptService, GitHubService, JwtService from app.services.base import BaseService class UserService(BaseService[User]): def __init__(self, user_repository: UserRepository): super().__init__(user_repository) + self.repository: UserRepository + self.crypt_service = CryptService() + self.jwt_service = JwtService() + self.github_service = GitHubService() @overload def mapper(self, data: BaseSchemaRequest) -> User: ... @@ -20,4 +37,86 @@ def mapper(self, data: User) -> BaseSchemaResponse: ... def mapper(self, data: BaseSchemaRequest | User) -> User | BaseSchemaResponse: if isinstance(data, BaseSchemaRequest): return self.repository.model(**data.model_dump()) - return UserCreateResponse.model_validate(data) + return UserResponse.model_validate(data) + + @database.transactional + async def register(self, schema: UserRegisterRequest) -> UserResponse: + entity = await self.repository.read_by_name_or_email( + name=schema.name, email=schema.email + ) + if entity: + raise UserAlreadyExists + schema = UserIn( + name=schema.name, + email=schema.email, + oauth="password", + password=self.crypt_service.hash(schema.password), + refresh_token=None, + github_token=None, + ) + entity = self.mapper(schema) + entity = await self.repository.create(entity=entity) + return self.mapper(entity) + + @database.transactional + async def log_in_password(self, schema: UserPasswordRequest) -> JwtToken: + entity = await self.repository.read_by_email(schema.email) + if not entity: + raise NotRegistered + elif entity.oauth != "password": + # TODO: 다른 OAuth로 로그인 했음을 밝혀야함 + pass + try: + is_verified = self.crypt_service.verify(schema.password, entity.password) + except UnknownHashError as error: + # TODO: 언제 UnknownHashError가 발생하는지 확인 + raise LoginFailed from error + if not is_verified: + raise LoginFailed + access_token, refresh_token = self.jwt_service.create_access_token( + entity + ), self.jwt_service.create_refresh_token(entity) + entity.refresh_token = refresh_token + jwt_token = JwtToken(access_token=access_token, refresh_token=refresh_token) + return jwt_token + + @database.transactional + async def log_in_github(self, code: str) -> JwtToken: + github_token, github_name, github_email = await self.github_service.get_user( + code + ) + entity = await self.repository.read_by_name(name=github_name) + if entity: + access_token, refresh_token = self.jwt_service.create_access_token( + entity + ), self.jwt_service.create_refresh_token(entity) + entity.refresh_token = refresh_token + return JwtToken(access_token=access_token, refresh_token=refresh_token) + schema = UserIn( + name=github_name, + email=github_email, + oauth="github", + password=None, + refresh_token=None, + github_token=github_token, + ) + entity = self.mapper(schema) + entity = await self.repository.create(entity=entity) + access_token, refresh_token = self.jwt_service.create_access_token( + entity + ), self.jwt_service.create_refresh_token(entity) + entity.refresh_token = refresh_token + jwt_token = JwtToken(access_token=access_token, refresh_token=refresh_token) + return jwt_token + + @database.transactional + async def verify(self, token: JwtAccessToken) -> UserOut: + user_id = int(self.jwt_service.decode(token=token.access_token)) + entity = await self.repository.read_by_id(user_id) + entity.refresh_token = self.jwt_service.create_refresh_token(entity) + return UserOut.model_validate(entity) + + async def refresh(self, token: JwtRefreshToken) -> JwtAccessToken: + user_id = int(self.jwt_service.decode(token=token.refresh_token).split(".")[0]) + entity = await self.repository.read_by_id(user_id) + return JwtAccessToken(access_token=self.jwt_service.create_access_token(entity)) diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index 75be143..460c629 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,12 +1,15 @@ import time from typing import Any +from faker import Faker from fastapi.testclient import TestClient from loguru import logger from starlette import status from app.core.configs import configs +fake = Faker() + def test_crud_user(sync_client: TestClient) -> None: ids = create_user(sync_client) @@ -20,7 +23,9 @@ def create_user(sync_client: TestClient) -> list[tuple[Any, int]]: ids = [] for id in range(30): name = f"routes-create-{id}" - response = sync_client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) + response = sync_client.post( + f"{configs.PREFIX}/v1/user", json={"name": name, "email": fake.email()} + ) logger.warning(response) assert response.status_code == status.HTTP_201_CREATED data = response.json()["data"] @@ -44,7 +49,7 @@ def patch_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: name = f"routes-patch-{id}" time.sleep(1) response = sync_client.patch( - f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} + f"{configs.PREFIX}/v1/user/{pk}", json={"name": name, "email": fake.email()} ) logger.warning(response) assert response.status_code == status.HTTP_200_OK @@ -61,7 +66,7 @@ def put_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: name = f"routes-put-{id}" time.sleep(1) response = sync_client.put( - f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} + f"{configs.PREFIX}/v1/user/{pk}", json={"name": name, "email": fake.email()} ) logger.warning(response) assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 3ba7d6a..48f5cf8 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -10,6 +10,19 @@ from app.core.container import Container from app.core.database import database from app.main import app +from app.models.base import BaseModel + +# @pytest_asyncio.fixture(scope="session", autouse=True) +# async def initialize_database() -> AsyncGenerator[None, None]: +# from loguru import logger +# +# logger.info("dab" * 100) +# await database.create_all() +# async with database.engine.begin() as conn: +# await conn.run_sync(BaseModel.metadata.create_all) +# +# yield +# @pytest_asyncio.fixture(scope="function") diff --git a/app/tests/services/test_users.py b/app/tests/services/test_users.py index b7535d5..0b6df36 100644 --- a/app/tests/services/test_users.py +++ b/app/tests/services/test_users.py @@ -1,34 +1,44 @@ from contextvars import Token import pytest +from faker import Faker from loguru import logger from app.core.container import Container from app.exceptions.database import EntityAlreadyExists, EntityNotFound -from app.schemas.users import UserCreateRequest +from app.schemas.users import UserIn, UserRequest + +fake = Faker() @pytest.mark.asyncio(loop_scope="function") async def test_create_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") - name = "service-layer-user" user_service = container.user_service() for id in range(10): - _name = f"{name}-{id}" - user = await user_service.create(schema=UserCreateRequest(name=_name)) + _name = fake.name() + _email = fake.email() + user = await user_service.create( + schema=UserIn(name=_name, email=_email, oauth="test") + ) assert user.name == _name + assert user.email == _email with pytest.raises(EntityAlreadyExists): - user = await user_service.create(schema=UserCreateRequest(name=_name)) + user = await user_service.create( + schema=UserIn(name=_name, email=_email, oauth="test") + ) @pytest.mark.asyncio(loop_scope="function") async def test_get_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") - name = "service-layer-user" user_service = container.user_service() for id in range(10, 20): - _name = f"{name}-{id}" - user = await user_service.create(schema=UserCreateRequest(name=_name)) + _name = fake.name() + _email = fake.email() + user = await user_service.create( + schema=UserIn(name=_name, email=_email, oauth="test") + ) user = await user_service.get_by_id(id=user.id) assert user.name == _name with pytest.raises(EntityNotFound): @@ -38,53 +48,62 @@ async def test_get_user(container: Container, context: Token) -> None: @pytest.mark.asyncio(loop_scope="function") async def test_put_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") - name = "service-layer-user" user_service = container.user_service() for id in range(20, 30): - _name = f"{name}-{id}" - user = await user_service.create(schema=UserCreateRequest(name=_name)) - _name = f"{name}-put-{id}" + _name = fake.name() + _email = fake.email() + user = await user_service.create( + schema=UserIn(name=_name, email=_email, oauth="test") + ) + _name = fake.name() + _email = fake.email() user = await user_service.put_by_id( - id=user.id, schema=UserCreateRequest(name=_name) + id=user.id, schema=UserRequest(name=_name, email=_email) ) assert user.name == _name with pytest.raises(EntityNotFound): user = await user_service.put_by_id( - id=99999, schema=UserCreateRequest(name=name) + id=99999, schema=UserRequest(name=fake.name(), email=fake.email()) ) @pytest.mark.asyncio(loop_scope="function") async def test_patch_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") - name = "service-layer-user" user_service = container.user_service() for id in range(30, 40): - _name = f"{name}-{id}" - user = await user_service.create(schema=UserCreateRequest(name=_name)) - _name = f"{name}-patch-{id}" + _name = fake.name() + _email = fake.email() + user = await user_service.create( + schema=UserIn(name=_name, email=_email, oauth="test") + ) + _name = fake.name() + _email = fake.email() user = await user_service.patch_by_id( - id=user.id, schema=UserCreateRequest(name=_name) + id=user.id, schema=UserRequest(name=_name, email=_email) ) assert user.name == _name - _name = f"{name}-patch-{id}-2" + _name = fake.name() user = await user_service.patch_attr_by_id(id=user.id, attr="name", value=_name) assert user.name == _name with pytest.raises(EntityNotFound): user = await user_service.patch_by_id( - id=99999, schema=UserCreateRequest(name=name) + id=99999, schema=UserRequest(name=fake.name(), email=fake.email()) ) with pytest.raises(EntityNotFound): - user = await user_service.patch_attr_by_id(id=99999, attr="name", value=name) + user = await user_service.patch_attr_by_id( + id=99999, attr="name", value=fake.name() + ) @pytest.mark.asyncio(loop_scope="function") async def test_delete_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") - name = "service-layer-user" user_service = container.user_service() for _ in range(0, 10): - user = await user_service.create(schema=UserCreateRequest(name=name)) + user = await user_service.create( + schema=UserIn(name=fake.name(), email=fake.email(), oauth="test") + ) user = await user_service.delete_by_id(id=user.id) with pytest.raises(EntityNotFound): user = await user_service.delete_by_id(id=99999) diff --git a/app/utils/etc.py b/app/utils/etc.py new file mode 100644 index 0000000..02c1068 --- /dev/null +++ b/app/utils/etc.py @@ -0,0 +1,16 @@ +from dependency_injector.wiring import Provide, inject +from fastapi import Depends, Request + +from app.core.container import Container +from app.models.users import User +from app.services.users import UserService + + +@inject +async def verify_cookie( + request: Request, + service: UserService = Depends(Provide[Container.user_service]), +) -> User: + access_token = request.cookies.get("access_token") + user = await service.verify(access_token) + return user diff --git a/k8s/postgresql/fastapi.yaml b/k8s/postgresql/fastapi.yaml index ae39c26..3b6e3a5 100644 --- a/k8s/postgresql/fastapi.yaml +++ b/k8s/postgresql/fastapi.yaml @@ -51,6 +51,26 @@ spec: configMapKeyRef: name: postgresql key: DATABASE + - name: GITHUB_OAUTH_CLIENT_ID + valueFrom: + secretKeyRef: + name: auth + key: PROD_CLIENT_ID + - name: GITHUB_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: auth + key: PROD_CLIENT_SECRET + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: auth + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + valueFrom: + secretKeyRef: + name: auth + key: JWT_ALGORITHM ports: - name: http containerPort: 8000 diff --git a/pyproject.toml b/pyproject.toml index f919c6b..eee3c8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,9 @@ dependencies = [ "fastapi>=0.115.6", "httpx>=0.28.1", "loguru>=0.7.3", + "passlib>=1.7.4", "pydantic-settings>=2.7.1", + "python-jose>=3.3.0", "pytz>=2024.2", "sqlalchemy>=2.0.37", "uv>=0.5.21", @@ -26,6 +28,7 @@ lint = [ "pylint>=3.3.3", "ruff>=0.9.2", "sqlalchemy[mypy]>=2.0.37", + "types-python-jose>=3.3.4.20240106", "types-pytz>=2024.2.0.20241221", ] test = [ @@ -36,6 +39,7 @@ test = [ "aiosqlite>=0.20.0", "cryptography>=44.0.0", "pytest-asyncio>=0.25.2", + "faker>=35.0.0", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index 2eeb02b..d1dab76 100644 --- a/uv.lock +++ b/uv.lock @@ -286,6 +286,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/d1/e73b6ad76f0b1fb7f23c35c6d95dbc506a9c8804f43dda8cb5b0fa6331fd/dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", size = 119418 }, ] +[[package]] +name = "ecdsa" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/d0/ec8ac1de7accdcf18cfe468653ef00afd2f609faf67c423efbd02491051b/ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8", size = 197791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/e7/ed3243b30d1bec41675b6394a1daae46349dc2b855cb83be846a5a918238/ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a", size = 149266 }, +] + +[[package]] +name = "faker" +version = "35.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/18/86fe668976308d09e0178041c3756e646a1f5ddc676aa7fb0cf3cd52f5b9/faker-35.0.0.tar.gz", hash = "sha256:42f2da8cf561e38c72b25e9891168b1e25fec42b6b0b5b0b6cd6041da54af885", size = 1855098 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/fe/40452fb1730b10afa34dfe016097b28baa070ad74a1c1a3512ebed438c08/Faker-35.0.0-py3-none-any.whl", hash = "sha256:926d2301787220e0554c2e39afc4dc535ce4b0a8d0a089657137999f66334ef4", size = 1894841 }, +] + [[package]] name = "fastapi" version = "0.115.6" @@ -311,7 +336,9 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "loguru" }, + { name = "passlib" }, { name = "pydantic-settings" }, + { name = "python-jose" }, { name = "pytz" }, { name = "sqlalchemy" }, { name = "uv" }, @@ -326,12 +353,14 @@ lint = [ { name = "pylint" }, { name = "ruff" }, { name = "sqlalchemy", extra = ["mypy"] }, + { name = "types-python-jose" }, { name = "types-pytz" }, ] test = [ { name = "aiomysql" }, { name = "aiosqlite" }, { name = "cryptography" }, + { name = "faker" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -346,7 +375,9 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.6" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "passlib", specifier = ">=1.7.4" }, { name = "pydantic-settings", specifier = ">=2.7.1" }, + { name = "python-jose", specifier = ">=3.3.0" }, { name = "pytz", specifier = ">=2024.2" }, { name = "sqlalchemy", specifier = ">=2.0.37" }, { name = "uv", specifier = ">=0.5.21" }, @@ -361,12 +392,14 @@ lint = [ { name = "pylint", specifier = ">=3.3.3" }, { name = "ruff", specifier = ">=0.9.2" }, { name = "sqlalchemy", extras = ["mypy"], specifier = ">=2.0.37" }, + { name = "types-python-jose", specifier = ">=3.3.4.20240106" }, { name = "types-pytz", specifier = ">=2024.2.0.20241221" }, ] test = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "cryptography", specifier = ">=44.0.0" }, + { name = "faker", specifier = ">=35.0.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-asyncio", specifier = ">=0.25.2" }, { name = "pytest-cov", specifier = ">=6.0.0" }, @@ -585,6 +618,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554 }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -612,6 +654,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + [[package]] name = "pycparser" version = "2.22" @@ -767,6 +818,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -776,6 +839,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] +[[package]] +name = "python-jose" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/19/b2c86504116dc5f0635d29f802da858404d77d930a25633d2e86a64a35b3/python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a", size = 129068 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/2d/e94b2f7bab6773c70efc70a61d66e312e1febccd9e0db6b9e0adf58cbad1/python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a", size = 33530 }, +] + [[package]] name = "pytz" version = "2024.2" @@ -785,6 +862,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, ] +[[package]] +name = "rsa" +version = "4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 }, +] + [[package]] name = "ruff" version = "0.9.2" @@ -810,6 +899,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -874,6 +972,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, ] +[[package]] +name = "types-pyasn1" +version = "0.6.0.20240913" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/e2/42410b64ba53584a8f2b681e76b23569a61869a22325cfeef2728e999ffd/types-pyasn1-0.6.0.20240913.tar.gz", hash = "sha256:a1da054db13d3d4ccfa69c515678154014336ad3d9f9ade01845f9edb1a2bc71", size = 12375 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/6a/847b2a137dba37974e17cc2ae5f62bed0d68006ac609a063810038adecff/types_pyasn1-0.6.0.20240913-py3-none-any.whl", hash = "sha256:95f3cb1fbd63ff91cd0410945f8aeae6b0be359533c00f39d8e17124884157af", size = 19338 }, +] + +[[package]] +name = "types-python-jose" +version = "3.3.4.20240106" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/83/5824277f62a0a07ab3eaade216f19b59fadea4efdad9071d70799d97f170/types-python-jose-3.3.4.20240106.tar.gz", hash = "sha256:b18cf8c5080bbfe1ef7c3b707986435d9efca3e90889acb6a06f65e06bc3405a", size = 6937 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/bd/fe23e814f1ca70f8427bf5defb981b8e0636731863c7836a6d6b6e49c715/types_python_jose-3.3.4.20240106-py3-none-any.whl", hash = "sha256:b515a6c0c61f5e2a53bc93e3a2b024cbd42563e2e19cbde9fd1c2cc2cfe77ccc", size = 9712 }, +] + [[package]] name = "types-pytz" version = "2024.2.0.20241221" From e682d9a45004506d1a3a3d62d846cda3343d1374 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 17:24:08 +0900 Subject: [PATCH 05/13] :hammer: fix: http status code --- app/api/v1/endpoints/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py index 7f834a7..d2245ef 100644 --- a/app/api/v1/endpoints/auth.py +++ b/app/api/v1/endpoints/auth.py @@ -52,7 +52,7 @@ async def register_password( @router.post( "/login", response_model=JwtToken, - status_code=status.HTTP_302_FOUND, + status_code=status.HTTP_200_OK, summary="Log in with password", description="", ) From cd229c74768e00cac2b063ad1d32aed29c513030 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 17:25:04 +0900 Subject: [PATCH 06/13] :hammer: add: enum types --- app/models/enums.py | 11 +++++++++++ app/models/users.py | 8 +++++--- app/schemas/users.py | 7 +++++-- app/services/users.py | 8 +++++--- 4 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 app/models/enums.py diff --git a/app/models/enums.py b/app/models/enums.py new file mode 100644 index 0000000..435b21b --- /dev/null +++ b/app/models/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class Role(Enum): + ADMIN = 0 + USER = 1 + + +class OAuthProvider(Enum): + PASSWORD = "password" + GITHUB = "github" diff --git a/app/models/users.py b/app/models/users.py index 8987071..47b20bf 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -1,7 +1,8 @@ -from sqlalchemy import String -from sqlalchemy.orm import mapped_column +from sqlalchemy import Enum, String, null +from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BaseModel +from app.models.enums import OAuthProvider, Role class User(BaseModel): @@ -9,7 +10,8 @@ class User(BaseModel): name = mapped_column(String(255), unique=True, nullable=False) email = mapped_column(String(255), unique=True, nullable=False) - oauth = mapped_column(String(255), unique=False, nullable=False) + role = mapped_column(Enum(Role), unique=False, nullable=False) + oauth = mapped_column(Enum(OAuthProvider), unique=False, nullable=False) password = mapped_column(String(255), unique=False, nullable=True) refresh_token = mapped_column(String(255), unique=False, nullable=True) github_token = mapped_column(String(255), unique=False, nullable=True) diff --git a/app/schemas/users.py b/app/schemas/users.py index ba6b2c6..d5a8dc0 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -1,5 +1,6 @@ from typing import Optional +from app.models.enums import OAuthProvider, Role from app.schemas.base import BaseSchemaRequest, BaseSchemaResponse @@ -20,17 +21,19 @@ class UserPasswordRequest(BaseSchemaRequest): class UserResponse(BaseSchemaResponse): name: str email: str - oauth: str + oauth: OAuthProvider class UserIn(UserRequest): - oauth: str + role: Role + oauth: OAuthProvider password: Optional[str] = None refresh_token: Optional[str] = None github_token: Optional[str] = None class UserOut(UserResponse): + role: Role password: Optional[str] = None refresh_token: Optional[str] = None github_token: Optional[str] = None diff --git a/app/services/users.py b/app/services/users.py index de878d8..a9b2740 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,10 +1,10 @@ from typing import overload -from loguru import logger from passlib.exc import UnknownHashError from app.core.database import database from app.exceptions.auth import LoginFailed, NotRegistered, UserAlreadyExists +from app.models.enums import OAuthProvider, Role from app.models.users import User from app.repositories.users import UserRepository from app.schemas.auth import JwtAccessToken, JwtRefreshToken, JwtToken @@ -49,7 +49,8 @@ async def register(self, schema: UserRegisterRequest) -> UserResponse: schema = UserIn( name=schema.name, email=schema.email, - oauth="password", + role=Role.USER, + oauth=OAuthProvider.PASSWORD, password=self.crypt_service.hash(schema.password), refresh_token=None, github_token=None, @@ -95,7 +96,8 @@ async def log_in_github(self, code: str) -> JwtToken: schema = UserIn( name=github_name, email=github_email, - oauth="github", + role=Role.USER, + oauth=OAuthProvider.GITHUB, password=None, refresh_token=None, github_token=github_token, From 63283d937687d6901d907ae74e42826d0e02796d Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 17:37:29 +0900 Subject: [PATCH 07/13] :art: style: mypy --- app/core/auth.py | 10 +++++----- app/services/users.py | 8 ++++---- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/core/auth.py b/app/core/auth.py index c7b4528..d439222 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -26,14 +26,14 @@ def __init__( auto_error=True, ) - async def __call__( - self, request: Request - ) -> Optional[HTTPAuthorizationCredentials]: + async def __call__(self, request: Request) -> str: # type: ignore try: authorization = await super().__call__(request) - return authorization.credentials except HTTPException: raise NotAuthenticated + if authorization is None: + raise NotAuthenticated + return authorization.credentials jwt_bearer = JwtBearer() @@ -41,7 +41,7 @@ async def __call__( @inject async def get_current_user( - access_token: Annotated[HTTPAuthorizationCredentials, Depends(jwt_bearer)], + access_token: Annotated[str, Depends(jwt_bearer)], service: UserService = Depends(Provide[Container.user_service]), ) -> UserOut: token = JwtAccessToken(access_token=access_token) diff --git a/app/services/users.py b/app/services/users.py index a9b2740..5c2d324 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -32,9 +32,9 @@ def __init__(self, user_repository: UserRepository): def mapper(self, data: BaseSchemaRequest) -> User: ... @overload - def mapper(self, data: User) -> BaseSchemaResponse: ... + def mapper(self, data: User) -> UserResponse: ... - def mapper(self, data: BaseSchemaRequest | User) -> User | BaseSchemaResponse: + def mapper(self, data: BaseSchemaRequest | User) -> User | UserResponse: if isinstance(data, BaseSchemaRequest): return self.repository.model(**data.model_dump()) return UserResponse.model_validate(data) @@ -46,7 +46,7 @@ async def register(self, schema: UserRegisterRequest) -> UserResponse: ) if entity: raise UserAlreadyExists - schema = UserIn( + _schema = UserIn( name=schema.name, email=schema.email, role=Role.USER, @@ -55,7 +55,7 @@ async def register(self, schema: UserRegisterRequest) -> UserResponse: refresh_token=None, github_token=None, ) - entity = self.mapper(schema) + entity = self.mapper(_schema) entity = await self.repository.create(entity=entity) return self.mapper(entity) diff --git a/pyproject.toml b/pyproject.toml index eee3c8c..8cf03ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ lint = [ "pylint>=3.3.3", "ruff>=0.9.2", "sqlalchemy[mypy]>=2.0.37", + "types-passlib>=1.7.7.20241221", "types-python-jose>=3.3.4.20240106", "types-pytz>=2024.2.0.20241221", ] diff --git a/uv.lock b/uv.lock index d1dab76..38e7b73 100644 --- a/uv.lock +++ b/uv.lock @@ -353,6 +353,7 @@ lint = [ { name = "pylint" }, { name = "ruff" }, { name = "sqlalchemy", extra = ["mypy"] }, + { name = "types-passlib" }, { name = "types-python-jose" }, { name = "types-pytz" }, ] @@ -392,6 +393,7 @@ lint = [ { name = "pylint", specifier = ">=3.3.3" }, { name = "ruff", specifier = ">=0.9.2" }, { name = "sqlalchemy", extras = ["mypy"], specifier = ">=2.0.37" }, + { name = "types-passlib", specifier = ">=1.7.7.20241221" }, { name = "types-python-jose", specifier = ">=3.3.4.20240106" }, { name = "types-pytz", specifier = ">=2024.2.0.20241221" }, ] @@ -972,6 +974,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, ] +[[package]] +name = "types-passlib" +version = "1.7.7.20241221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/65/2af3d80436091cf59f06864ca2780499ab14a85ee06a6255bc653ad76913/types_passlib-1.7.7.20241221.tar.gz", hash = "sha256:c7e7d2d836aef2ef26a650110fc89cff896163767aebd8f5d6d5b2675e460173", size = 23128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/71/759a7c7f679ea041c06fca673e29e3ee09fe37fb12176bfa1d29826e6255/types_passlib-1.7.7.20241221-py3-none-any.whl", hash = "sha256:2376ac4e4b6c179205e987c28d77df9cceba1ebb1f79cfa23623a9cc581dde07", size = 37998 }, +] + [[package]] name = "types-pyasn1" version = "0.6.0.20240913" From cec771e21e0d002c9780992780854c8b095e274c Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 19:21:54 +0900 Subject: [PATCH 08/13] :sparkles: feat: admin user --- app/api/v1/endpoints/users.py | 22 ++++++++++++---------- app/core/auth.py | 12 +++++++++++- app/core/configs.py | 3 +++ app/core/database.py | 24 +++++++++++++++++++++++- app/core/router.py | 9 ++++++--- app/repositories/users.py | 12 +++++++++++- app/schemas/responses.py | 6 +++--- app/schemas/users.py | 6 ++++++ app/services/auth.py | 12 ++++++------ app/services/users.py | 19 ++++++++++++++++++- envs/test.env | 3 +++ k8s/postgresql/fastapi.yaml | 15 +++++++++++++++ 12 files changed, 117 insertions(+), 26 deletions(-) diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py index a2b3de9..b5be9ef 100644 --- a/app/api/v1/endpoints/users.py +++ b/app/api/v1/endpoints/users.py @@ -1,28 +1,30 @@ +from typing import Sequence + from dependency_injector.wiring import Provide, inject from fastapi import Depends from starlette import status +from app.core.auth import AdminDeps from app.core.container import Container from app.core.router import CoreAPIRouter -from app.schemas.users import UserRequest, UserResponse +from app.schemas.users import UserPatchRequest, UserRequest, UserResponse from app.services.users import UserService -router = CoreAPIRouter(prefix="/user", tags=["user"]) +router = CoreAPIRouter(prefix="/user", tags=["user"], dependencies=[AdminDeps]) -@router.post( - "", - response_model=UserResponse, - status_code=status.HTTP_201_CREATED, +@router.get( + "/", + response_model=Sequence[UserResponse], + status_code=status.HTTP_200_OK, summary="", description="", ) @inject -async def create_user( - user: UserRequest, +async def get_users( service: UserService = Depends(Provide[Container.user_service]), ): - return await service.create(user) + return await service.get_all() @router.get( @@ -66,7 +68,7 @@ async def put_user( @inject async def patch_user( id: int, - user: UserRequest, + user: UserPatchRequest, service: UserService = Depends(Provide[Container.user_service]), ): return await service.patch_by_id(id=id, schema=user) diff --git a/app/core/auth.py b/app/core/auth.py index d439222..49f9926 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -2,10 +2,11 @@ from dependency_injector.wiring import Provide, inject from fastapi import Depends, HTTPException, Request -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.security import HTTPBearer from app.core.container import Container from app.exceptions.auth import NotAuthenticated +from app.models.enums import Role from app.schemas.auth import JwtAccessToken from app.schemas.users import UserOut from app.services.users import UserService @@ -49,3 +50,12 @@ async def get_current_user( AuthDeps = Annotated[UserOut, Depends(get_current_user)] + + +async def get_admin_user(user: AuthDeps) -> UserOut: + if user.role != Role.ADMIN: + raise NotAuthenticated + return user + + +AdminDeps = Depends(get_admin_user) diff --git a/app/core/configs.py b/app/core/configs.py index 9747ce3..f84343a 100644 --- a/app/core/configs.py +++ b/app/core/configs.py @@ -39,6 +39,9 @@ class Configs(BaseSettings): # openssl rand -hex 32 JWT_SECRET_KEY: str JWT_ALGORITHM: str + ADMIN_NAME: str + ADMIN_EMAIL: str + ADMIN_PASSWORD: str @property def DB_SCHEME(self) -> str: diff --git a/app/core/database.py b/app/core/database.py index 746091d..0522f50 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -3,7 +3,7 @@ from typing import Awaitable, Callable, Optional from loguru import logger -from sqlalchemy import NullPool +from sqlalchemy import NullPool, select from sqlalchemy.ext.asyncio import ( AsyncSession, async_scoped_session, @@ -13,6 +13,9 @@ from app.core.configs import ENVIRONMENT, configs from app.models.base import BaseModel +from app.models.enums import OAuthProvider, Role +from app.models.users import User +from app.services.auth import CryptService class Context: @@ -64,6 +67,25 @@ async def create_all(self) -> None: if configs.ENV == ENVIRONMENT.TEST: await conn.run_sync(BaseModel.metadata.drop_all) await conn.run_sync(BaseModel.metadata.create_all) + async with self.sessionmaker() as session: + stmt = select(User).filter_by(role=Role.ADMIN) + result = await session.execute(stmt) + entity = result.scalar_one_or_none() + if entity: + logger.warning(f"Admin user already exists: {entity}") + return + crypt_service = CryptService() + admin_user = User( + name=configs.ADMIN_NAME, + email=configs.ADMIN_EMAIL, + role=Role.ADMIN, + oauth=OAuthProvider.PASSWORD, + password=crypt_service.hash(configs.ADMIN_PASSWORD), + refresh_token=None, + github_token=None, + ) + session.add(admin_user) + await session.commit() def transactional(self, func: Callable[..., Awaitable]) -> Callable[..., Awaitable]: @wraps(func) diff --git a/app/core/router.py b/app/core/router.py index 05ca3dd..4700f6c 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -1,5 +1,5 @@ from functools import wraps -from typing import Any, Callable, Coroutine, Type, TypeVar, cast +from typing import Any, Callable, Coroutine, Sequence, Type, TypeVar, cast from fastapi import APIRouter, Response from fastapi.types import DecoratedCallable @@ -31,10 +31,13 @@ async def success( *_args: tuple, **_kwargs: dict ) -> APIResponse[T] | Response: response: Any = await func(*_args, **_kwargs) - if not isinstance(response, response_model): + # FIXME: 우선 response_model이 List와 같은 Sequence로 구성된 경우는 차후에 해결 (#24) + if isinstance(response, Sequence): + pass + elif not isinstance(response, response_model): logger.warning(f"{type(response)}: {response}") raise TypeError - if isinstance(response, BaseModel): + if isinstance(response, BaseModel) or isinstance(response, Sequence): return APIResponse[T].success( status=status_code, data=cast(T, response) ) diff --git a/app/repositories/users.py b/app/repositories/users.py index d7a7732..47fbc57 100644 --- a/app/repositories/users.py +++ b/app/repositories/users.py @@ -1,10 +1,11 @@ -from typing import Optional +from typing import Optional, Sequence from sqlalchemy import select from sqlalchemy.orm import joinedload from sqlalchemy.sql.expression import or_ from app.core.database import database +from app.exceptions.database import EntityNotFound from app.models.users import User from app.repositories.base import BaseRepository @@ -13,6 +14,15 @@ class UserRepository(BaseRepository[User]): def __init__(self): super().__init__(model=User) + async def read_all(self) -> Sequence[User]: + stmt = select(self.model) + session = database.scoped_session() + result = await session.execute(stmt) + entity = result.scalars().all() + if not entity: + raise EntityNotFound + return entity + async def read_by_name(self, name: str, eager: bool = False) -> Optional[User]: stmt = select(self.model) if eager: diff --git a/app/schemas/responses.py b/app/schemas/responses.py index 3ae2957..6137fec 100644 --- a/app/schemas/responses.py +++ b/app/schemas/responses.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Generic, Optional, TypeVar +from typing import Generic, Optional, Sequence, TypeVar import pytz from pydantic import BaseModel @@ -31,11 +31,11 @@ class User(BaseModel): status: int message: str - data: Optional[T] = None + data: Optional[T | Sequence[T]] = None timestamp: datetime @classmethod - def success(cls, *, status: int, data: T) -> "APIResponse[T]": + def success(cls, *, status: int, data: T | Sequence[T]) -> "APIResponse[T]": return cls( status=status, message="The request has been successfully processed.", diff --git a/app/schemas/users.py b/app/schemas/users.py index d5a8dc0..1e595b2 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -18,6 +18,12 @@ class UserPasswordRequest(BaseSchemaRequest): password: str +class UserPatchRequest(BaseSchemaRequest): + name: Optional[str] = None + email: Optional[str] = None + password: Optional[str] = None + + class UserResponse(BaseSchemaResponse): name: str email: str diff --git a/app/services/auth.py b/app/services/auth.py index ad50bf4..02714d3 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -12,15 +12,15 @@ from app.schemas.auth import JwtPayload -class CryptService: +class CryptService(CryptContext): def __init__(self) -> None: - self.context = CryptContext(schemes=["bcrypt"], deprecated="auto") + super().__init__(schemes=["bcrypt"], deprecated="auto") - def hash(self, secret: str) -> str: - return self.context.hash(secret=secret) + def hash(self, secret: str) -> str: # type: ignore + return super().hash(secret=secret) - def verify(self, secret: str, hash: str) -> bool: - return self.context.verify(secret=secret, hash=hash) + def verify(self, secret: str, hash: str) -> bool: # type: ignore + return super().verify(secret=secret, hash=hash) class JwtService: diff --git a/app/services/users.py b/app/services/users.py index 5c2d324..4413667 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -8,11 +8,12 @@ from app.models.users import User from app.repositories.users import UserRepository from app.schemas.auth import JwtAccessToken, JwtRefreshToken, JwtToken -from app.schemas.base import BaseSchemaRequest, BaseSchemaResponse +from app.schemas.base import BaseSchemaRequest from app.schemas.users import ( UserIn, UserOut, UserPasswordRequest, + UserPatchRequest, UserRegisterRequest, UserResponse, ) @@ -39,6 +40,22 @@ def mapper(self, data: BaseSchemaRequest | User) -> User | UserResponse: return self.repository.model(**data.model_dump()) return UserResponse.model_validate(data) + async def get_all(self) -> list[UserResponse]: + entities = await self.repository.read_all() + schemas = [] + for entity in entities: + schemas.append(self.mapper(entity)) + return schemas + + @database.transactional + async def patch_by_id(self, id: int, schema: UserPatchRequest) -> UserResponse: + if schema.password: + schema.password = self.crypt_service.hash(schema.password) + entity = await self.repository.update_by_id( + id=id, data=schema.model_dump(exclude_none=True) + ) + return self.mapper(entity) + @database.transactional async def register(self, schema: UserRegisterRequest) -> UserResponse: entity = await self.repository.read_by_name_or_email( diff --git a/envs/test.env b/envs/test.env index 4f55fb4..69aab58 100644 --- a/envs/test.env +++ b/envs/test.env @@ -20,3 +20,6 @@ GITHUB_OAUTH_CLIENT_ID="" GITHUB_OAUTH_CLIENT_SECRET="" JWT_SECRET_KEY="fastapi" JWT_ALGORITHM="HS256" +ADMIN_NAME="PyTest" +ADMIN_EMAIL="PyTest@zerohertz.xyz" +ADMIN_PASSWORD="PyTest98!@" diff --git a/k8s/postgresql/fastapi.yaml b/k8s/postgresql/fastapi.yaml index 3b6e3a5..4c3338d 100644 --- a/k8s/postgresql/fastapi.yaml +++ b/k8s/postgresql/fastapi.yaml @@ -71,6 +71,21 @@ spec: secretKeyRef: name: auth key: JWT_ALGORITHM + - name: ADMIN_NAME + valueFrom: + secretKeyRef: + name: auth + key: ADMIN_NAME + - name: ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: auth + key: ADMIN_EMAIL + - name: ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: auth + key: ADMIN_PASSWORD ports: - name: http containerPort: 8000 From da6e26cc6d41ebadc169d2c4d9213e85f9e2bb1a Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 19:24:27 +0900 Subject: [PATCH 09/13] :memo: docs: email validation --- app/models/users.py | 5 +++-- app/schemas/users.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/users.py b/app/models/users.py index 47b20bf..149ca23 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -1,5 +1,5 @@ -from sqlalchemy import Enum, String, null -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Enum, String +from sqlalchemy.orm import mapped_column from app.models.base import BaseModel from app.models.enums import OAuthProvider, Role @@ -9,6 +9,7 @@ class User(BaseModel): __tablename__ = "user" name = mapped_column(String(255), unique=True, nullable=False) + # TODO: Email 검증 email = mapped_column(String(255), unique=True, nullable=False) role = mapped_column(Enum(Role), unique=False, nullable=False) oauth = mapped_column(Enum(OAuthProvider), unique=False, nullable=False) diff --git a/app/schemas/users.py b/app/schemas/users.py index 1e595b2..e96498d 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -6,6 +6,7 @@ class UserRequest(BaseSchemaRequest): name: str + # TODO: Email 검증 email: str From 520316ce7c56a8aceec7b808a061f8ed3ec8e26e Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 19:47:11 +0900 Subject: [PATCH 10/13] :recycle: refactor: admin endpoints --- app/api/v1/endpoints/admin/__init__.py | 0 app/api/v1/endpoints/admin/users.py | 89 ++++++++++++++++++++++++++ app/api/v1/endpoints/users.py | 57 ++++------------- app/api/v1/routers.py | 3 +- app/services/users.py | 13 +++- 5 files changed, 115 insertions(+), 47 deletions(-) create mode 100644 app/api/v1/endpoints/admin/__init__.py create mode 100644 app/api/v1/endpoints/admin/users.py diff --git a/app/api/v1/endpoints/admin/__init__.py b/app/api/v1/endpoints/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/admin/users.py b/app/api/v1/endpoints/admin/users.py new file mode 100644 index 0000000..3c08a48 --- /dev/null +++ b/app/api/v1/endpoints/admin/users.py @@ -0,0 +1,89 @@ +from typing import Sequence + +from dependency_injector.wiring import Provide, inject +from fastapi import Depends +from starlette import status + +from app.core.auth import AdminDeps +from app.core.container import Container +from app.core.router import CoreAPIRouter +from app.schemas.users import UserPatchRequest, UserRequest, UserResponse +from app.services.users import UserService + +router = CoreAPIRouter(prefix="/user", tags=["admin"], dependencies=[AdminDeps]) + + +@router.get( + "/", + response_model=Sequence[UserResponse], + status_code=status.HTTP_200_OK, + summary="", + description="", +) +@inject +async def get_users( + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.get_all() + + +@router.get( + "/{id}", + response_model=UserResponse, + status_code=status.HTTP_200_OK, + summary="", + description="", +) +@inject +async def get_user( + id: int, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.get_by_id(id) + + +@router.put( + "/{id}", + response_model=UserResponse, + status_code=status.HTTP_200_OK, + summary="", + description="", +) +@inject +async def put_user( + id: int, + user: UserRequest, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.put_by_id(id=id, schema=user) + + +@router.patch( + "/{id}", + response_model=UserResponse, + status_code=status.HTTP_200_OK, + summary="", + description="", +) +@inject +async def patch_user( + id: int, + user: UserPatchRequest, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.patch_by_id(id=id, schema=user) + + +@router.delete( + "/{id}", + response_model=UserResponse, + status_code=status.HTTP_200_OK, + summary="", + description="", +) +@inject +async def delete_user( + id: int, + service: UserService = Depends(Provide[Container.user_service]), +): + return await service.delete_by_id(id) diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py index b5be9ef..75576f9 100644 --- a/app/api/v1/endpoints/users.py +++ b/app/api/v1/endpoints/users.py @@ -1,49 +1,18 @@ -from typing import Sequence - from dependency_injector.wiring import Provide, inject from fastapi import Depends from starlette import status -from app.core.auth import AdminDeps +from app.core.auth import AuthDeps from app.core.container import Container from app.core.router import CoreAPIRouter from app.schemas.users import UserPatchRequest, UserRequest, UserResponse from app.services.users import UserService -router = CoreAPIRouter(prefix="/user", tags=["user"], dependencies=[AdminDeps]) - - -@router.get( - "/", - response_model=Sequence[UserResponse], - status_code=status.HTTP_200_OK, - summary="", - description="", -) -@inject -async def get_users( - service: UserService = Depends(Provide[Container.user_service]), -): - return await service.get_all() - - -@router.get( - "/{id}", - response_model=UserResponse, - status_code=status.HTTP_200_OK, - summary="", - description="", -) -@inject -async def get_user( - id: int, - service: UserService = Depends(Provide[Container.user_service]), -): - return await service.get_by_id(id) +router = CoreAPIRouter(prefix="/user", tags=["user"]) @router.put( - "/{id}", + "/", response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", @@ -51,15 +20,15 @@ async def get_user( ) @inject async def put_user( - id: int, - user: UserRequest, + user: AuthDeps, + schema: UserRequest, service: UserService = Depends(Provide[Container.user_service]), ): - return await service.put_by_id(id=id, schema=user) + return await service.put_by_id(id=user.id, schema=schema) @router.patch( - "/{id}", + "/", response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", @@ -67,15 +36,15 @@ async def put_user( ) @inject async def patch_user( - id: int, - user: UserPatchRequest, + user: AuthDeps, + schema: UserPatchRequest, service: UserService = Depends(Provide[Container.user_service]), ): - return await service.patch_by_id(id=id, schema=user) + return await service.patch_by_id(id=user.id, schema=schema) @router.delete( - "/{id}", + "/", response_model=UserResponse, status_code=status.HTTP_200_OK, summary="", @@ -83,7 +52,7 @@ async def patch_user( ) @inject async def delete_user( - id: int, + user: AuthDeps, service: UserService = Depends(Provide[Container.user_service]), ): - return await service.delete_by_id(id) + return await service.delete_by_id(id=user.id) diff --git a/app/api/v1/routers.py b/app/api/v1/routers.py index 3725a00..51f8e90 100644 --- a/app/api/v1/routers.py +++ b/app/api/v1/routers.py @@ -1,9 +1,10 @@ from fastapi import APIRouter from app.api.v1.endpoints import auth, shields, users +from app.api.v1.endpoints.admin import users as admin_users routers = APIRouter(prefix="/v1", tags=["v1"]) -_routers = [auth.router, users.router, shields.router] +_routers = [auth.router, users.router, shields.router] + [admin_users.router] for _router in _routers: routers.include_router(_router) diff --git a/app/services/users.py b/app/services/users.py index 4413667..728f418 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -3,7 +3,13 @@ from passlib.exc import UnknownHashError from app.core.database import database -from app.exceptions.auth import LoginFailed, NotRegistered, UserAlreadyExists +from app.exceptions.auth import ( + LoginFailed, + NotAuthenticated, + NotRegistered, + UserAlreadyExists, +) +from app.exceptions.database import EntityNotFound from app.models.enums import OAuthProvider, Role from app.models.users import User from app.repositories.users import UserRepository @@ -131,7 +137,10 @@ async def log_in_github(self, code: str) -> JwtToken: @database.transactional async def verify(self, token: JwtAccessToken) -> UserOut: user_id = int(self.jwt_service.decode(token=token.access_token)) - entity = await self.repository.read_by_id(user_id) + try: + entity = await self.repository.read_by_id(user_id) + except EntityNotFound as error: + raise NotAuthenticated from error entity.refresh_token = self.jwt_service.create_refresh_token(entity) return UserOut.model_validate(entity) From 59942e5bd68506b65b96ab1a8c2f39ac47b89d39 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 21:53:11 +0900 Subject: [PATCH 11/13] :hammer: update: test codes --- app/tests/api/v1/test_auth.py | 75 ++++++++++++++++++ app/tests/api/v1/test_users.py | 126 ++++++++++++++----------------- app/tests/conftest.py | 13 ---- app/tests/services/test_users.py | 83 ++++++++++---------- 4 files changed, 174 insertions(+), 123 deletions(-) create mode 100644 app/tests/api/v1/test_auth.py diff --git a/app/tests/api/v1/test_auth.py b/app/tests/api/v1/test_auth.py new file mode 100644 index 0000000..5d3211d --- /dev/null +++ b/app/tests/api/v1/test_auth.py @@ -0,0 +1,75 @@ +from faker import Faker +from fastapi.testclient import TestClient +from loguru import logger +from starlette import status + +from app.core.configs import configs +from app.models.enums import OAuthProvider, Role +from app.schemas.users import UserPasswordRequest, UserRegisterRequest + +fake = Faker() + + +def test_register_and_login(sync_client: TestClient): + for _ in range(5): + request, access_token = register_and_login(sync_client) + get_me(sync_client, request, access_token) + response = sync_client.post( + f"{configs.PREFIX}/v1/auth/register/", + json=request.model_dump(), + ) + assert response.status_code == 409 + + +def get_mock_request() -> UserRegisterRequest: + return UserRegisterRequest( + name=fake.name(), email=fake.email(), password=fake.password() + ) + + +def register_and_login(sync_client: TestClient) -> tuple[UserRegisterRequest, str]: + request = get_mock_request() + register(sync_client, request) + access_token = log_in( + sync_client, UserPasswordRequest.model_validate(request.model_dump()) + ) + return request, access_token + + +def register(sync_client: TestClient, request: UserRegisterRequest) -> None: + response = sync_client.post( + f"{configs.PREFIX}/v1/auth/register/", + json=request.model_dump(), + ) + logger.warning(response) + assert response.status_code == status.HTTP_201_CREATED + data = response.json()["data"] + assert request.name == data["name"] + assert request.email == data["email"] + + +def log_in(sync_client: TestClient, request: UserPasswordRequest) -> str: + response = sync_client.post( + f"{configs.PREFIX}/v1/auth/login/", json=request.model_dump() + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + return data["access_token"] + + +def get_me(sync_client: TestClient, request: UserRegisterRequest, access_token: str): + logger.warning(access_token) + response = sync_client.get( + f"{configs.PREFIX}/v1/auth/me/", + headers={"Authorization": f"Bearer {access_token}"}, + ) + logger.warning(response) + logger.warning(response.json()) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["oauth"] == OAuthProvider.PASSWORD.value + assert data["role"] == Role.USER.value + assert data["name"] == request.name + assert data["email"] == request.email + assert data["password"] != request.password diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index 460c629..3de8a33 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,85 +1,73 @@ -import time -from typing import Any - +import pytest from faker import Faker from fastapi.testclient import TestClient from loguru import logger from starlette import status from app.core.configs import configs +from app.schemas.users import UserPasswordRequest, UserPatchRequest, UserRequest +from app.tests.api.v1.test_auth import log_in, register_and_login fake = Faker() -def test_crud_user(sync_client: TestClient) -> None: - ids = create_user(sync_client) - get_user(sync_client, ids) - patch_user(sync_client, ids) - put_user(sync_client, ids) - delete_user(sync_client, ids) - - -def create_user(sync_client: TestClient) -> list[tuple[Any, int]]: - ids = [] - for id in range(30): - name = f"routes-create-{id}" - response = sync_client.post( - f"{configs.PREFIX}/v1/user", json={"name": name, "email": fake.email()} - ) - logger.warning(response) - assert response.status_code == status.HTTP_201_CREATED - data = response.json()["data"] - ids.append((data["id"], id)) - return ids - - -def get_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: - for pk, id in ids: - name = f"routes-create-{id}" - response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") - logger.warning(response) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - logger.warning(data) - assert data["name"] == name +def test_patch_user_name(sync_client: TestClient) -> None: + schema, access_token = register_and_login(sync_client) + request = UserPatchRequest(name=fake.name()) + response = sync_client.patch( + f"{configs.PREFIX}/v1/user/", + headers={"Authorization": f"Bearer {access_token}"}, + json=request.model_dump(), + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["name"] != schema.name + assert data["name"] == request.name -def patch_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: - for pk, id in ids[:5]: - name = f"routes-patch-{id}" - time.sleep(1) - response = sync_client.patch( - f"{configs.PREFIX}/v1/user/{pk}", json={"name": name, "email": fake.email()} - ) - logger.warning(response) - assert response.status_code == status.HTTP_200_OK - response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - logger.warning(data) - assert data["name"] == name - assert data["created_at"] != data["updated_at"] +def test_patch_user_password(sync_client: TestClient) -> None: + schema, access_token = register_and_login(sync_client) + request = UserPatchRequest(password=fake.password()) + response = sync_client.patch( + f"{configs.PREFIX}/v1/user/", + headers={"Authorization": f"Bearer {access_token}"}, + json=request.model_dump(), + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + with pytest.raises(AssertionError): + log_in(sync_client, UserPasswordRequest.model_validate(schema.model_dump())) + log_in( + sync_client, UserPasswordRequest(email=schema.email, password=request.password) + ) -def put_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: - for pk, id in ids[:5]: - name = f"routes-put-{id}" - time.sleep(1) - response = sync_client.put( - f"{configs.PREFIX}/v1/user/{pk}", json={"name": name, "email": fake.email()} - ) - logger.warning(response) - assert response.status_code == status.HTTP_200_OK - response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - logger.warning(data) - assert data["name"] == name - assert data["created_at"] != data["updated_at"] +def test_put_user(sync_client: TestClient) -> None: + schema, access_token = register_and_login(sync_client) + request = UserRequest(name=fake.name(), email=fake.email()) + response = sync_client.put( + f"{configs.PREFIX}/v1/user/", + headers={"Authorization": f"Bearer {access_token}"}, + json=request.model_dump(), + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["name"] != schema.name + assert data["email"] != schema.email + assert data["name"] == request.name + assert data["email"] == request.email -def delete_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: - for pk, _ in ids: - response = sync_client.delete(f"{configs.PREFIX}/v1/user/{pk}") - logger.warning(response) - assert response.status_code == status.HTTP_200_OK +def test_delete_user(sync_client: TestClient) -> None: + schema, access_token = register_and_login(sync_client) + response = sync_client.delete( + f"{configs.PREFIX}/v1/user/", + headers={"Authorization": f"Bearer {access_token}"}, + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["name"] == schema.name + assert data["email"] == schema.email diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 48f5cf8..3ba7d6a 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -10,19 +10,6 @@ from app.core.container import Container from app.core.database import database from app.main import app -from app.models.base import BaseModel - -# @pytest_asyncio.fixture(scope="session", autouse=True) -# async def initialize_database() -> AsyncGenerator[None, None]: -# from loguru import logger -# -# logger.info("dab" * 100) -# await database.create_all() -# async with database.engine.begin() as conn: -# await conn.run_sync(BaseModel.metadata.create_all) -# -# yield -# @pytest_asyncio.fixture(scope="function") diff --git a/app/tests/services/test_users.py b/app/tests/services/test_users.py index 0b6df36..6b470e8 100644 --- a/app/tests/services/test_users.py +++ b/app/tests/services/test_users.py @@ -5,28 +5,35 @@ from loguru import logger from app.core.container import Container -from app.exceptions.database import EntityAlreadyExists, EntityNotFound -from app.schemas.users import UserIn, UserRequest +from app.exceptions.database import DatabaseException, EntityNotFound +from app.models.enums import OAuthProvider, Role +from app.schemas.users import UserIn, UserPatchRequest, UserRequest fake = Faker() +def get_mock_user() -> UserIn: + return UserIn( + name=fake.name(), + email=fake.email(), + password=fake.password(), + role=Role.USER, + oauth=OAuthProvider.PASSWORD, + ) + + @pytest.mark.asyncio(loop_scope="function") async def test_create_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() for id in range(10): - _name = fake.name() - _email = fake.email() - user = await user_service.create( - schema=UserIn(name=_name, email=_email, oauth="test") - ) - assert user.name == _name - assert user.email == _email - with pytest.raises(EntityAlreadyExists): - user = await user_service.create( - schema=UserIn(name=_name, email=_email, oauth="test") - ) + schema = get_mock_user() + user = await user_service.create(schema=schema) + assert user.name == schema.name + assert user.email == schema.email + # TODO: DatabaseException + with pytest.raises(DatabaseException): + user = await user_service.create(schema=schema) @pytest.mark.asyncio(loop_scope="function") @@ -34,13 +41,11 @@ async def test_get_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() for id in range(10, 20): - _name = fake.name() - _email = fake.email() - user = await user_service.create( - schema=UserIn(name=_name, email=_email, oauth="test") - ) + schema = get_mock_user() + user = await user_service.create(schema=schema) user = await user_service.get_by_id(id=user.id) - assert user.name == _name + assert user.name == schema.name + assert user.email == schema.email with pytest.raises(EntityNotFound): user = await user_service.get_by_id(id=99999) @@ -50,17 +55,15 @@ async def test_put_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() for id in range(20, 30): - _name = fake.name() - _email = fake.email() - user = await user_service.create( - schema=UserIn(name=_name, email=_email, oauth="test") - ) - _name = fake.name() - _email = fake.email() + schema = get_mock_user() + user = await user_service.create(schema=schema) + schema.name = fake.name() + schema.email = fake.email() user = await user_service.put_by_id( - id=user.id, schema=UserRequest(name=_name, email=_email) + id=user.id, schema=UserRequest.model_validate(schema.model_dump()) ) - assert user.name == _name + assert user.name == schema.name + assert user.email == schema.email with pytest.raises(EntityNotFound): user = await user_service.put_by_id( id=99999, schema=UserRequest(name=fake.name(), email=fake.email()) @@ -72,23 +75,22 @@ async def test_patch_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() for id in range(30, 40): - _name = fake.name() - _email = fake.email() - user = await user_service.create( - schema=UserIn(name=_name, email=_email, oauth="test") - ) - _name = fake.name() - _email = fake.email() + schema = get_mock_user() + user = await user_service.create(schema=schema) + schema.email = fake.email() + schema.password = fake.password() user = await user_service.patch_by_id( - id=user.id, schema=UserRequest(name=_name, email=_email) + id=user.id, schema=UserPatchRequest.model_validate(schema.model_dump()) ) - assert user.name == _name + assert user.name == schema.name + assert user.email == schema.email _name = fake.name() user = await user_service.patch_attr_by_id(id=user.id, attr="name", value=_name) assert user.name == _name with pytest.raises(EntityNotFound): user = await user_service.patch_by_id( - id=99999, schema=UserRequest(name=fake.name(), email=fake.email()) + id=99999, + schema=UserPatchRequest(name=fake.name(), email=fake.email()), ) with pytest.raises(EntityNotFound): user = await user_service.patch_attr_by_id( @@ -101,9 +103,8 @@ async def test_delete_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() for _ in range(0, 10): - user = await user_service.create( - schema=UserIn(name=fake.name(), email=fake.email(), oauth="test") - ) + schema = get_mock_user() + user = await user_service.create(schema=schema) user = await user_service.delete_by_id(id=user.id) with pytest.raises(EntityNotFound): user = await user_service.delete_by_id(id=99999) From c637d076bdef766f25ceab0f2917c54ce489df24 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 22:11:06 +0900 Subject: [PATCH 12/13] :art: style: lint --- app/core/auth.py | 4 ++-- app/core/router.py | 2 +- app/repositories/base.py | 10 +++++++--- app/repositories/users.py | 3 +-- app/services/auth.py | 12 ++++++------ app/services/users.py | 2 +- app/tests/api/v1/test_users.py | 2 ++ app/tests/services/test_users.py | 8 ++++---- pyproject.toml | 5 +++++ 9 files changed, 29 insertions(+), 19 deletions(-) diff --git a/app/core/auth.py b/app/core/auth.py index 49f9926..66b4913 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -30,8 +30,8 @@ def __init__( async def __call__(self, request: Request) -> str: # type: ignore try: authorization = await super().__call__(request) - except HTTPException: - raise NotAuthenticated + except HTTPException as error: + raise NotAuthenticated from error if authorization is None: raise NotAuthenticated return authorization.credentials diff --git a/app/core/router.py b/app/core/router.py index 4700f6c..b939172 100644 --- a/app/core/router.py +++ b/app/core/router.py @@ -37,7 +37,7 @@ async def success( elif not isinstance(response, response_model): logger.warning(f"{type(response)}: {response}") raise TypeError - if isinstance(response, BaseModel) or isinstance(response, Sequence): + if isinstance(response, (BaseModel, Sequence)): return APIResponse[T].success( status=status_code, data=cast(T, response) ) diff --git a/app/repositories/base.py b/app/repositories/base.py index 523cee9..3bb9d3c 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -1,6 +1,6 @@ from typing import Any, Generic, Type, TypeVar -from sqlalchemy import select +from sqlalchemy import Select, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload @@ -34,11 +34,15 @@ async def create(self, entity: T) -> T: await session.refresh(entity) return entity + def _eager(self, stmt: Select) -> Select: + for _eager in getattr(self.model, "eagers"): + stmt = stmt.options(joinedload(getattr(self.model, _eager))) + return stmt + async def read_by_id(self, id: int, eager: bool = False) -> T: stmt = select(self.model) if eager: - for _eager in getattr(self.model, "eagers"): - stmt = stmt.options(joinedload(getattr(self.model, _eager))) + stmt = self._eager(stmt) stmt = stmt.filter(self.model.id == id) session = database.scoped_session() result = await session.execute(stmt) diff --git a/app/repositories/users.py b/app/repositories/users.py index 47fbc57..86f0731 100644 --- a/app/repositories/users.py +++ b/app/repositories/users.py @@ -26,8 +26,7 @@ async def read_all(self) -> Sequence[User]: async def read_by_name(self, name: str, eager: bool = False) -> Optional[User]: stmt = select(self.model) if eager: - for _eager in getattr(self.model, "eagers"): - stmt = stmt.options(joinedload(getattr(self.model, _eager))) + stmt = self._eager(stmt) stmt = stmt.filter(self.model.name == name) session = database.scoped_session() result = await session.execute(stmt) diff --git a/app/services/auth.py b/app/services/auth.py index 02714d3..d1f61f1 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -52,10 +52,10 @@ def decode(self, *, token: str) -> str: token=token, key=self.secret, algorithms=self.algorithm ) return payload["sub"] - except JWTError: - raise TokenDecode - except ExpiredSignatureError: - raise TokenExpired + except ExpiredSignatureError as error: + raise TokenExpired from error + except JWTError as error: + raise TokenDecode from error class GitHubService: @@ -86,6 +86,6 @@ async def get_user(self, code: str) -> tuple[str, str, str]: ) response.raise_for_status() github_user = response.json() - except httpx.HTTPStatusError: - raise GitHubOAuth + except httpx.HTTPStatusError as error: + raise GitHubOAuth from error return github_token, github_user["login"], github_user["email"] diff --git a/app/services/users.py b/app/services/users.py index 728f418..82cb914 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -87,7 +87,7 @@ async def log_in_password(self, schema: UserPasswordRequest) -> JwtToken: entity = await self.repository.read_by_email(schema.email) if not entity: raise NotRegistered - elif entity.oauth != "password": + if entity.oauth != "password": # TODO: 다른 OAuth로 로그인 했음을 밝혀야함 pass try: diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index 3de8a33..06ba2e8 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -38,6 +38,8 @@ def test_patch_user_password(sync_client: TestClient) -> None: assert response.status_code == status.HTTP_200_OK with pytest.raises(AssertionError): log_in(sync_client, UserPasswordRequest.model_validate(schema.model_dump())) + if request.password is None: + raise ValueError log_in( sync_client, UserPasswordRequest(email=schema.email, password=request.password) ) diff --git a/app/tests/services/test_users.py b/app/tests/services/test_users.py index 6b470e8..e411c6e 100644 --- a/app/tests/services/test_users.py +++ b/app/tests/services/test_users.py @@ -26,7 +26,7 @@ def get_mock_user() -> UserIn: async def test_create_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() - for id in range(10): + for _ in range(10): schema = get_mock_user() user = await user_service.create(schema=schema) assert user.name == schema.name @@ -40,7 +40,7 @@ async def test_create_user(container: Container, context: Token) -> None: async def test_get_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() - for id in range(10, 20): + for _ in range(10, 20): schema = get_mock_user() user = await user_service.create(schema=schema) user = await user_service.get_by_id(id=user.id) @@ -54,7 +54,7 @@ async def test_get_user(container: Container, context: Token) -> None: async def test_put_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() - for id in range(20, 30): + for _ in range(20, 30): schema = get_mock_user() user = await user_service.create(schema=schema) schema.name = fake.name() @@ -74,7 +74,7 @@ async def test_put_user(container: Container, context: Token) -> None: async def test_patch_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") user_service = container.user_service() - for id in range(30, 40): + for _ in range(30, 40): schema = get_mock_user() user = await user_service.create(schema=schema) schema.email = fake.email() diff --git a/pyproject.toml b/pyproject.toml index 8cf03ff..9e14178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,8 @@ disable = [ "E0611", "E1102", "R0903", + "W0221", + "W0511", "W0622", "W0718", ] @@ -67,6 +69,9 @@ disable = [ # E0611: No name '*' in module '*' (no-name-in-module) # E1102: * is not callable (not-callable) # R0903: Too few public methods (*/*) (too-few-public-methods) +# W0221: Number of parameters was * in '*' and is now * in overriding '*' method (arguments-differ) +# W0221: Variadics removed in overriding '*' method (arguments-differ) +# W0511: TODO, FIXME # W0622: Redefining built-in '*' (redefined-builtin) # W0718: Catching too general exception Exception (broad-exception-caught) From 9db6e67bc198389985f8d87d5df02c355f6acb91 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 30 Jan 2025 22:18:42 +0900 Subject: [PATCH 13/13] :memo: docs: issue & pr template --- .github/ISSUE_TEMPLATE/feature_request.yaml | 14 +++++++------- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 3721c92..ef1c3ff 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -27,13 +27,13 @@ body: value: "The solution you're proposing." validations: required: true - - type: textarea - id: alternatives-considered - attributes: - label: Describe alternatives you've considered - description: A clear and concise description of any alternative solutions or features you've considered. - placeholder: Tell us about other alternatives you’ve thought about. - value: "Alternatives you've considered." + # - type: textarea + # id: alternatives-considered + # attributes: + # label: Describe alternatives you've considered + # description: A clear and concise description of any alternative solutions or features you've considered. + # placeholder: Tell us about other alternatives you’ve thought about. + # value: "Alternatives you've considered." - type: textarea id: additional-context attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f61067d..56ed434 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,6 +11,9 @@ #### What this PR does / why we need it +- Description ({related|resolves|fixes: #}) + +