From c8752000e7ba0b6b53d98f4fbdc193b3cf2b8f05 Mon Sep 17 00:00:00 2001 From: farmer0010 Date: Wed, 15 Jul 2026 13:44:28 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20cli=20=EC=84=B8=ED=8C=85=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B9=83=ED=97=88=EB=B8=8C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/setup.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cli/setup.py diff --git a/cli/setup.py b/cli/setup.py new file mode 100644 index 0000000..e69de29 From 556ed54f02940603d1809c44e2aef54f74d38b44 Mon Sep 17 00:00:00 2001 From: farmer0010 Date: Fri, 17 Jul 2026 14:48:49 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=ED=97=A5=EC=82=AC=EA=B3=A0?= =?UTF-8?q?=EB=82=A0=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EA=B2=A9=EB=A6=AC=20(api.py=EC=97=90=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=A4=91=EC=9D=B8=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=82=98=EB=AA=A8=20=EB=94=94=EB=B9=84=20=EC=88=98=EC=A0=95)?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B9=83=ED=97=88=EB=B8=8C=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B3=B4=EC=95=88=20=EC=9D=B8=EA=B0=80=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 3 +- Makefile | 2 +- backend/adapters/inbound/api.py | 62 +++++---- backend/dependencies.py | 14 +++ cli/mylog.py | 214 +++++++++++++++++++++++++++++--- cli/setup.py | 17 +++ handler.py | 3 +- pyproject.toml | 1 + uv.lock | 74 +++++++++++ 9 files changed, 346 insertions(+), 44 deletions(-) create mode 100644 backend/dependencies.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9de1ede..09dc8f9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,4 +9,5 @@ repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.8.0 hooks: - - id: mypy \ No newline at end of file + - id: mypy + additional_dependencies: [types-requests] \ No newline at end of file diff --git a/Makefile b/Makefile index c77dc69..b25687d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ FUNCTION_NAME ?= my-function-name .PHONY: dev build deploy dev: - uvicorn app.main:app --reload + uvicorn backend.main:app --reload build: rm -rf ./_package lambda.zip diff --git a/backend/adapters/inbound/api.py b/backend/adapters/inbound/api.py index 252af14..b70a3fc 100644 --- a/backend/adapters/inbound/api.py +++ b/backend/adapters/inbound/api.py @@ -1,12 +1,10 @@ -import os - +import requests from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel -from backend.adapters.outbound.dynamodb_repository import DynamoDBRepository from backend.application.exceptions import MemoAccessDeniedError from backend.application.ports import MemoUseCase -from backend.application.service import MemoService +from backend.dependencies import get_memo_use_case from backend.domain.memo import MemoDomainError router = APIRouter() @@ -26,24 +24,46 @@ class UpdateMemoRequest(BaseModel): content: str -def get_memo_use_case() -> MemoUseCase: - table_name = os.getenv("DYNAMODB_TABLE") - if not table_name: - raise ValueError("DYNAMODB_TABLE 환경변수가 설정되지 않았습니다") +def get_current_user_id(authorization: str | None = Header(None)) -> str: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="인증되지 않은 요청입니다. 유효한 Bearer 인증 토큰이 필요합니다.", + ) + + token = authorization.split(" ")[1] + github_user_uri = "https://api.github.com/user" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + try: + response = requests.get(github_user_uri, headers=headers, timeout=5) + + if response.status_code != 200: + raise HTTPException( + status_code=401, detail="유효하지 않거나 만료된 깃허브 토큰입니다." + ) - repo = DynamoDBRepository(table_name=table_name) - return MemoService(repo) + user_data = response.json() + return str(user_data.get("id")) + + except requests.exceptions.RequestException: + raise HTTPException( + status_code=503, detail="깃허브 인증 서버와 통신할 수 없습니다." + ) -@router.post("/add", response_model=MemoCreateResponse) +@router.post("/memos", response_model=MemoCreateResponse) def add_memo( request: MemoCreateRequest, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), use_case: MemoUseCase = Depends(get_memo_use_case), ): try: memo = use_case.create_memo( - user_id=x_user_id, + user_id=user_id, content=request.content, category=request.category, ) @@ -56,11 +76,11 @@ def add_memo( def get_memos( category: str | None = None, limit: int = 5, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): memos = service.get_all_memos( - user_id=x_user_id, + user_id=user_id, category=category, limit=limit, ) @@ -70,12 +90,12 @@ def get_memos( @router.get("/memos/{memo_id}") def get_memo( memo_id: str, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): memo = service.get_memo_by_id( memo_id=memo_id, - user_id=x_user_id, + user_id=user_id, ) if not memo: @@ -88,13 +108,13 @@ def get_memo( def update_memo( memo_id: str, request: UpdateMemoRequest, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): try: updated_memo = service.update_memo( memo_id=memo_id, - user_id=x_user_id, + user_id=user_id, content=request.content, ) return updated_memo @@ -109,11 +129,11 @@ def update_memo( @router.delete("/memos/{memo_id}") def delete_memo( memo_id: str, - x_user_id: str = Header(..., alias="X-USER-ID"), + user_id: str = Depends(get_current_user_id), service: MemoUseCase = Depends(get_memo_use_case), ): try: - service.delete_memo(memo_id=memo_id, user_id=x_user_id) + service.delete_memo(memo_id=memo_id, user_id=user_id) return {"message": "메모 삭제 완료"} except MemoAccessDeniedError as e: raise HTTPException(status_code=403, detail=str(e)) diff --git a/backend/dependencies.py b/backend/dependencies.py new file mode 100644 index 0000000..12338a3 --- /dev/null +++ b/backend/dependencies.py @@ -0,0 +1,14 @@ +import os + +from backend.adapters.outbound.dynamodb_repository import DynamoDBRepository +from backend.application.ports import MemoUseCase +from backend.application.service import MemoService + + +def get_memo_use_case() -> MemoUseCase: + table_name = os.getenv("DYNAMODB_TABLE") + if not table_name: + raise ValueError("DYNAMODB_TABLE 환경 변수가 설정되지 않았습니다.") + + repo = DynamoDBRepository(table_name=table_name) + return MemoService(repo) diff --git a/cli/mylog.py b/cli/mylog.py index 965fc37..d41eade 100644 --- a/cli/mylog.py +++ b/cli/mylog.py @@ -1,41 +1,215 @@ import argparse -import json -import urllib.request +import os +import time -API_URL = "https://4roukuec13.execute-api.ap-northeast-2.amazonaws.com/v1/add" +import keyring +import requests +from dotenv import load_dotenv + +load_dotenv() + +API_BASE_URL = os.getenv("API_BASE_URL") +GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") + +KEYRING_SERVICE_NAME = "mylog-app" +KEYRING_TOKEN_KEY = "user_secure_token" + + +def get_saved_token() -> str | None: + return keyring.get_password(KEYRING_SERVICE_NAME, KEYRING_TOKEN_KEY) + + +def save_token(token: str): + keyring.set_password(KEYRING_SERVICE_NAME, KEYRING_TOKEN_KEY, token) + + +def delete_token(): + try: + keyring.delete_password(KEYRING_SERVICE_NAME, KEYRING_TOKEN_KEY) + print("토큰이 안전하게 파기되었습니다. (로그아웃 성공)") + except keyring.errors.PasswordDeleteError: + print("이미 로그아웃된 상태거나 삭제할 토큰이 없습니다.") def send_to_aws(content): - payload = {"category": "cli", "content": content} - json_data = json.dumps(payload).encode("utf-8") + if not API_BASE_URL: + print("에러: 환경변수에 API_BASE_URL이 설정되어 있지 않습니다.") + return - req = urllib.request.Request( - url=API_URL, data=json_data, headers={"content-type": "application/json"} - ) + url = f"{API_BASE_URL}/memos" + payload = { + "category": "basic", + "content": content, + } + + headers = { + "Content-Type": "application/json", + } + + token = get_saved_token() + if token: + headers["Authorization"] = f"Bearer {token}" try: - with urllib.request.urlopen(req) as response: - result = json.loads(response.read().decode("utf-8")) - print("[aws 저장 성공]") - print(f"서버의 응답 메시지: {result['message']}") - print(f"저장된 고유 ID: {result['id']}") + response = requests.post( + url, + json=payload, + headers=headers, + timeout=5, + ) + + if response.status_code == 401: + print("[인증 실패] 유효한 로그인 정보가 없거나 만료되었습니다.") + print("'mylog login' 명령어를 실행해 다시 로그인해 주세요.") + return + + response.raise_for_status() + result = response.json() + + print("[AWS 저장 성공]") + print(f"서버의 응답 메시지: {result.get('message', '성공')}") + print(f"저장된 고유 ID (ULID): {result.get('id')}") + + except requests.exceptions.Timeout: + print("에러: 서버 응답 시간이 초과되었습니다. 인터넷 연결을 확인해주세요.") except Exception as e: - print(f"서버 통신중 에러 발생: {e}") + print(f"서버 통신 중 에러 발생: {e}") + + +def handle_login(): + if not GITHUB_CLIENT_ID: + print("에러: 환경변수에 GITHUB_CLIENT_ID가 설정되어 있지 않습니다.") + return + + print("GitHub 서버에 로그인 요청 코드를 생성하는 중...") + + device_code_url = "https://github.com/login/device/code" + headers = { + "Accept": "application/json", + } + payload = { + "client_id": GITHUB_CLIENT_ID, + "scope": "read:user", + } + + try: + res = requests.post( + device_code_url, + json=payload, + headers=headers, + timeout=5, + ) + res.raise_for_status() + res_data = res.json() + except Exception as e: + print(f"깃허브 로그인 요청에 실패했습니다: {e}") + return + + device_code = res_data.get("device_code") + user_code = res_data.get("user_code") + verification_uri = res_data.get("verification_uri") + interval = res_data.get("interval", 5) + + print("\n" + "=" * 60) + print("[GitHub 로그인 안내]") + print("1. 웹 브라우저를 열고 다음 주소로 접속하세요.") + print(f" {verification_uri}") + print("2. 아래 코드를 입력하고 승인을 완료하세요.") + print(f" [ {user_code} ]") + print("=" * 60 + "\n") + + token_url = "https://github.com/login/oauth/access_token" + token_payload = { + "client_id": GITHUB_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + print("사용자의 브라우저 로그인을 기다리는 중입니다...") + + while True: + try: + res_token = requests.post( + token_url, + json=token_payload, + headers=headers, + timeout=5, + ) + res_token.raise_for_status() + token_data = res_token.json() + + error = token_data.get("error") + if error: + if error == "authorization_pending": + time.sleep(interval) + continue + elif error == "slow_down": + interval += 5 + time.sleep(interval) + continue + elif error == "expired_token": + print("로그인 제한 시간이 만료되었습니다. 다시 시도해 주세요.") + return + else: + print(f"로그인 중 에러 발생: {token_data.get('error_description')}") + return + + github_access_token = token_data.get("access_token") + break + + except Exception as e: + print(f"대기 중 에러 발생: {e}") + return + + if not github_access_token: + print("깃허브 토큰을 획득하지 못했습니다.") + return + + save_token(github_access_token) + print("\n[로그인 성공]") + print("깃허브 토큰이 안전하게 암호화 되어 저장되었습니다.") def main(): - parser = argparse.ArgumentParser(description="나만의 터미널 메모장") + parser = argparse.ArgumentParser(description="터미널 메모장") subparsers = parser.add_subparsers( - dest="command", help="사용할 명령어를 입력하세요" + dest="command", + help="사용할 명령어를 입력하세요", + ) + + subparsers.add_parser( + "login", + help="깃허브 계정으로 안전하게 로그인합니다.", ) - add_parser = subparsers.add_parser("add", help="새로운 메모를 저장합니다.") - add_parser.add_argument("content", type=str, help="저장할 메모 내용을 입력하세요") + subparsers.add_parser( + "logout", + help="키체인에서 로그인 정보를 삭제합니다.", + ) + + add_parser = subparsers.add_parser( + "add", + help="새로운 메모를 저장합니다.", + ) + add_parser.add_argument( + "content", + type=str, + help="저장할 메모 내용을 입력하세요", + ) args = parser.parse_args() - if args.command == "add": - print(f"입력하신 '{args.content} 문장을 클라우드로 전송'") + if args.command == "login": + handle_login() + elif args.command == "logout": + delete_token() + elif args.command == "add": + if not get_saved_token(): + print("\n[인증 필요] 이 명령어를 사용하려면 로그인이 필요합니다.") + print("먼저 'mylog login'을 실행해 주세요.\n") + return + + print(f"입력하신 '{args.content}' 문장을 클라우드로 전송합니다.") send_to_aws(args.content) else: parser.print_help() diff --git a/cli/setup.py b/cli/setup.py index e69de29..da5036f 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup + +setup( + name="mylog", + version="0.1.0", + py_modules=["mylog"], + install_requires=[ + "requests", + "keyring", + "python-dotenv", + ], + entry_points={ + "console_scripts": [ + "mylog=mylog:main", + ], + }, +) diff --git a/handler.py b/handler.py index c59fec3..ba61f50 100644 --- a/handler.py +++ b/handler.py @@ -1,4 +1,5 @@ -from app.main import app from mangum import Mangum +from backend.main import app + handler = Mangum(app) diff --git a/pyproject.toml b/pyproject.toml index 4f0256f..e32fd26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "uvicorn[standard]>=0.34.0", "mangum>=0.19.0", "python-ulid>=3.1.0", + "requests", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index e9ac26e..39b87cb 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,7 @@ dependencies = [ { name = "fastapi" }, { name = "mangum" }, { name = "python-ulid" }, + { name = "requests" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -62,6 +63,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "mangum", specifier = ">=0.19.0" }, { name = "python-ulid", specifier = ">=3.1.0" }, + { name = "requests" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, ] @@ -143,6 +145,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -152,6 +163,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -698,6 +757,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.13" From c8bfa19d329bdf5fa747a2acab772a23fde58941 Mon Sep 17 00:00:00 2001 From: farmer0010 Date: Tue, 21 Jul 2026 11:17:33 +0900 Subject: [PATCH 3/4] =?UTF-8?q?perf:=20TTLCache=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B9=83=ED=97=88=EB=B8=8C=20=ED=86=A0=ED=81=B0=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=9D=B8=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EB=B0=8F=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- backend/adapters/inbound/api.py | 15 ++++++++++++++- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09dc8f9..e6d5d1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,4 +10,4 @@ repos: rev: v1.8.0 hooks: - id: mypy - additional_dependencies: [types-requests] \ No newline at end of file + additional_dependencies: [types-requests, types-cachetools] \ No newline at end of file diff --git a/backend/adapters/inbound/api.py b/backend/adapters/inbound/api.py index b70a3fc..dcb7f44 100644 --- a/backend/adapters/inbound/api.py +++ b/backend/adapters/inbound/api.py @@ -1,4 +1,7 @@ +from typing import cast + import requests +from cachetools import TTLCache from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel @@ -24,6 +27,9 @@ class UpdateMemoRequest(BaseModel): content: str +TOKEN_CACHE = cast(TTLCache[str, str, float], TTLCache(maxsize=100, ttl=300)) + + def get_current_user_id(authorization: str | None = Header(None)) -> str: if not authorization or not authorization.startswith("Bearer "): raise HTTPException( @@ -32,6 +38,10 @@ def get_current_user_id(authorization: str | None = Header(None)) -> str: ) token = authorization.split(" ")[1] + + if token in TOKEN_CACHE: + return TOKEN_CACHE[token] + github_user_uri = "https://api.github.com/user" headers = { "Authorization": f"Bearer {token}", @@ -47,7 +57,10 @@ def get_current_user_id(authorization: str | None = Header(None)) -> str: ) user_data = response.json() - return str(user_data.get("id")) + user_id = str(user_data.get("id")) + + TOKEN_CACHE[token] = user_id + return user_id except requests.exceptions.RequestException: raise HTTPException( diff --git a/pyproject.toml b/pyproject.toml index e32fd26..b4714d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "mangum>=0.19.0", "python-ulid>=3.1.0", "requests", + "cachetools>=5.3.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 39b87cb..51a54d3 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,7 @@ name = "api-server" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "cachetools" }, { name = "fastapi" }, { name = "mangum" }, { name = "python-ulid" }, @@ -60,6 +61,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=5.3.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "mangum", specifier = ">=0.19.0" }, { name = "python-ulid", specifier = ">=3.1.0" }, @@ -145,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" From ec997cb7ce20ea9cacf1903e9ca21cd3326aa63c Mon Sep 17 00:00:00 2001 From: farmer0010 Date: Fri, 24 Jul 2026 13:43:07 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(cli):=20=ED=97=A5=EC=82=AC?= =?UTF-8?q?=EA=B3=A0=EB=82=A0=20=ED=8F=AC=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/adapters/__init__.py | 0 cli/adapters/inbound/__init__.py | 0 cli/adapters/inbound/cli_adapter.py | 149 ++++++++++++ cli/adapters/outbound/__init__.py | 0 cli/adapters/outbound/github_auth_adapter.py | 51 ++++ cli/adapters/outbound/http_memo_adapter.py | 56 +++++ .../outbound/keyring_token_adapter.py | 24 ++ cli/application/__init__.py | 0 cli/application/exceptions.py | 10 + cli/application/ports/__init__.py | 0 cli/application/ports/inbound.py | 33 +++ cli/application/ports/outbound.py | 43 ++++ cli/application/service.py | 74 ++++++ cli/config.py | 26 +++ cli/dependencies.py | 27 +++ cli/domain/__init__.py | 0 cli/mylog.py | 219 ------------------ cli/setup.py | 6 +- pyproject.toml | 3 + 19 files changed, 499 insertions(+), 222 deletions(-) create mode 100644 cli/adapters/__init__.py create mode 100644 cli/adapters/inbound/__init__.py create mode 100644 cli/adapters/inbound/cli_adapter.py create mode 100644 cli/adapters/outbound/__init__.py create mode 100644 cli/adapters/outbound/github_auth_adapter.py create mode 100644 cli/adapters/outbound/http_memo_adapter.py create mode 100644 cli/adapters/outbound/keyring_token_adapter.py create mode 100644 cli/application/__init__.py create mode 100644 cli/application/exceptions.py create mode 100644 cli/application/ports/__init__.py create mode 100644 cli/application/ports/inbound.py create mode 100644 cli/application/ports/outbound.py create mode 100644 cli/application/service.py create mode 100644 cli/config.py create mode 100644 cli/dependencies.py create mode 100644 cli/domain/__init__.py delete mode 100644 cli/mylog.py diff --git a/cli/adapters/__init__.py b/cli/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/adapters/inbound/__init__.py b/cli/adapters/inbound/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/adapters/inbound/cli_adapter.py b/cli/adapters/inbound/cli_adapter.py new file mode 100644 index 0000000..ce77eba --- /dev/null +++ b/cli/adapters/inbound/cli_adapter.py @@ -0,0 +1,149 @@ +import argparse + +from cli.application.exceptions import ( + AuthDeviceFlowError, + AuthenticationError, +) +from cli.dependencies import container + + +def main(): + parser = argparse.ArgumentParser(description="mylog CLI", add_help=False) + parser.add_argument("-h", "--help", action="store_true", help="도움말 출력") + + subparsers = parser.add_subparsers(dest="command") + + subparsers.add_parser("login", help="깃허브 계정으로 로그인합니다.") + subparsers.add_parser("logout", help="로그아웃합니다.") + + add_parser = subparsers.add_parser("add", help="새로운 메모를 기록합니다.") + add_parser.add_argument("content", type=str, help="저장할 메모 내용") + add_parser.add_argument( + "-c", "--category", type=str, default=None, help="카테고리 지정" + ) + + list_parser = subparsers.add_parser("list", help="저장된 메모를 조회합니다.") + list_parser.add_argument( + "category", type=str, nargs="?", default=None, help="조회할 카테고리" + ) + list_parser.add_argument( + "-l", "--limit", type=int, default=5, help="출력 개수 지정" + ) + + update_parser = subparsers.add_parser("update", help="기존 메모를 수정합니다.") + update_parser.add_argument("id", type=str, help="수정할 메모 고유 ID") + update_parser.add_argument("content", type=str, help="새로운 내용") + + rm_parser = subparsers.add_parser("rm", help="메모를 삭제합니다.") + rm_parser.add_argument("id", type=str, help="삭제할 메모 고유 ID") + + subparsers.add_parser("help", help="도움말 매뉴얼을 출력합니다.") + + args, unknown = parser.parse_known_args() + + if args.help or args.command == "help" or args.command is None: + print("""Usage (사용 예시): + +mylog [options] + +Commands: +login 깃허브 계정으로 로그인하여 클라우드 환경을 연동합니다. +add 새로운 메모를 기록합니다. (-c 카테고리 지정) +list 저장된 메모를 조회합니다. (-l 개수) +update 기존 메모의 내용을 수정합니다. (ID 필요) +rm 저장된 메모를 삭제합니다. (ID 필요) +help 현재 화면의 도움말 매뉴얼을 출력합니다.""") + return + + if args.command == "login": + try: + print("GitHub 서버에 로그인 요청 코드를 생성하는 중...") + login_data = container.auth_service.request_login() + + print("\n" + "=" * 60) + print("[GitHub 로그인 안내]") + print("1. 웹 브라우저를 열고 다음 주소로 접속하세요.") + print(f" {login_data['verification_uri']}") + print("2. 아래 코드를 입력하고 승인을 완료하세요.") + print(f" [ {login_data['user_code']} ]") + print("=" * 60 + "\n") + print("사용자의 브라우저 로그인을 기다리는 중입니다...") + + if container.auth_service.complete_login( + login_data["device_code"], login_data["interval"] + ): + print("\n[로그인 성공]") + print("깃허브 토큰이 안전하게 암호화 되어 저장되었습니다.") + else: + print("\n[로그인 실패] 깃허브 토큰을 획득하지 못했습니다.") + except AuthDeviceFlowError as e: + print(f"\n[로그인 실패] {e}") + except Exception as e: + print(f"로그인 예외 발생: {e}") + + elif args.command == "logout": + try: + if container.auth_service.logout(): + print("토큰이 안전하게 파기되었습니다. (로그아웃 성공)") + else: + print("이미 로그아웃된 상태거나 삭제할 토큰이 없습니다.") + except Exception as e: + print(f"로그아웃 실패: {e}") + + elif args.command == "add": + try: + container.memo_service.save_memo( + content=args.content, + category=args.category, + ) + cat_title = args.category if args.category else "basic" + print(f"\n> [{cat_title}] 메모가 저장되었습니다.") + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[저장 실패] {e}") + + elif args.command == "list": + try: + memos = container.memo_service.get_memos( + category=args.category, + limit=args.limit, + ) + + cat_title = args.category if args.category else "전체" + print(f"\n> [{cat_title}] 최근 메모 ({len(memos)}개 출력)") + for m in memos: + print( + f" [ID: {m.get('id', 'N/A')}] " + f"{m.get('created_at', '')} | " + f"{m.get('content', '')}" + ) + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[조회 실패] {e}") + + elif args.command == "update": + try: + container.memo_service.update_memo( + memo_id=args.id, + content=args.content, + ) + print(f"\n> [{args.id}] 메모가 성공적으로 수정되었습니다.") + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[수정 실패] {e}") + + elif args.command == "rm": + try: + container.memo_service.delete_memo(memo_id=args.id) + print(f"\n> {args.id}번 메모가 깔끔하게 삭제되었습니다.") + except AuthenticationError as e: + print(f"\n[인증 실패] {e}") + except Exception as e: + print(f"\n[삭제 실패] {e}") + + +if __name__ == "__main__": + main() diff --git a/cli/adapters/outbound/__init__.py b/cli/adapters/outbound/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/adapters/outbound/github_auth_adapter.py b/cli/adapters/outbound/github_auth_adapter.py new file mode 100644 index 0000000..b10cc59 --- /dev/null +++ b/cli/adapters/outbound/github_auth_adapter.py @@ -0,0 +1,51 @@ +import time + +import requests + +from cli.application.ports.outbound import AuthRepositoryPort + + +class GitHubDeviceAuthAdapter(AuthRepositoryPort): + DEVICE_CODE_URL = "https://github.com/login/device/code" + ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" + + def __init__(self, client_id: str): + self.client_id = client_id + + def request_device_code(self) -> dict: + headers = {"Accept": "application/json"} + payload = { + "client_id": self.client_id, + "scope": "read:user", + } + response = requests.post( + self.DEVICE_CODE_URL, json=payload, headers=headers, timeout=5 + ) + response.raise_for_status() + return response.json() + + def poll_for_token(self, device_code: str, interval: int = 5) -> dict: + headers = {"Accept": "application/json"} + payload = { + "client_id": self.client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + while True: + time.sleep(interval) + response = requests.post( + self.ACCESS_TOKEN_URL, json=payload, headers=headers, timeout=5 + ) + data = response.json() + + if "access_token" in data: + return data + + error = data.get("error") + if error == "authorization_pending": + continue + elif error == "slow_down": + interval += 5 + else: + raise RuntimeError(f"Github 인증 실패: {error}") diff --git a/cli/adapters/outbound/http_memo_adapter.py b/cli/adapters/outbound/http_memo_adapter.py new file mode 100644 index 0000000..5217ae8 --- /dev/null +++ b/cli/adapters/outbound/http_memo_adapter.py @@ -0,0 +1,56 @@ +import requests + +from cli.application.ports.outbound import MemoRepositoryPort + + +class HttpMemoRepositoryAdapter(MemoRepositoryPort): + def __init__(self, api_base_url: str): + self.api_base_url = api_base_url + + def _get_headers(self, token: str) -> dict: + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + + def save(self, token: str, content: str, category: str | None = None) -> dict: + url = f"{self.api_base_url}/memos" + response = requests.post( + url, + json={"content": content, "category": category}, + headers=self._get_headers(token), + timeout=5, + ) + response.raise_for_status() + return response.json() + + def get_all(self, token: str, category: str | None = None, limit: int = 5) -> list: + url = f"{self.api_base_url}/memos" + params: dict[str, str | int] = {"limit": limit} + if category: + params["category"] = category + + response = requests.get( + url, headers=self._get_headers(token), params=params, timeout=5 + ) + + response.raise_for_status() + data = response.json() + + if isinstance(data, dict) and "memos" in data: + return data["memos"] + return data if isinstance(data, list) else [] + + def update(self, memo_id: str, token: str, content: str) -> dict: + url = f"{self.api_base_url}/memos/{memo_id}" + response = requests.put( + url, json={"content": content}, headers=self._get_headers(token), timeout=5 + ) + response.raise_for_status() + return response.json() + + def delete(self, memo_id: str, token: str) -> dict: + url = f"{self.api_base_url}/memos/{memo_id}" + response = requests.delete(url, headers=self._get_headers(token), timeout=5) + response.raise_for_status() + return response.json() diff --git a/cli/adapters/outbound/keyring_token_adapter.py b/cli/adapters/outbound/keyring_token_adapter.py new file mode 100644 index 0000000..1dc53c4 --- /dev/null +++ b/cli/adapters/outbound/keyring_token_adapter.py @@ -0,0 +1,24 @@ +import keyring + +from cli.application.ports.outbound import TokenStoragePort + + +class KeyringTokenAdapter(TokenStoragePort): + def __init__( + self, service_name: str = "mylog-app", username: str = "user_secure_token" + ): + self.service_name = service_name + self.username = username + + def get_token(self) -> str | None: + return keyring.get_password(self.service_name, self.username) + + def save_token(self, token: str) -> None: + keyring.set_password(self.service_name, self.username, token) + + def delete_token(self) -> bool: + try: + keyring.delete_password(self.service_name, self.username) + return True + except keyring.errors.PasswordDeleteError: + return False diff --git a/cli/application/__init__.py b/cli/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/application/exceptions.py b/cli/application/exceptions.py new file mode 100644 index 0000000..5a86164 --- /dev/null +++ b/cli/application/exceptions.py @@ -0,0 +1,10 @@ +class ApplicationError(Exception): + pass + + +class AuthenticationError(ApplicationError): + pass + + +class AuthDeviceFlowError(AuthenticationError): + pass diff --git a/cli/application/ports/__init__.py b/cli/application/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/application/ports/inbound.py b/cli/application/ports/inbound.py new file mode 100644 index 0000000..92e6d29 --- /dev/null +++ b/cli/application/ports/inbound.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod + + +class MemoUseCase(ABC): + @abstractmethod + def save_memo(self, content: str, category: str | None = None) -> dict: + pass + + @abstractmethod + def get_memos(self, category: str | None = None, limit: int = 5) -> list: + pass + + @abstractmethod + def update_memo(self, memo_id: str, content: str) -> dict: + pass + + @abstractmethod + def delete_memo(self, memo_id: str) -> dict: + pass + + +class AuthUseCase(ABC): + @abstractmethod + def request_login(self) -> dict: + pass + + @abstractmethod + def complete_login(self, device_code: str, interval: int) -> bool: + pass + + @abstractmethod + def logout(self) -> bool: + pass diff --git a/cli/application/ports/outbound.py b/cli/application/ports/outbound.py new file mode 100644 index 0000000..f350567 --- /dev/null +++ b/cli/application/ports/outbound.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod + + +class TokenStoragePort(ABC): + @abstractmethod + def get_token(self) -> str | None: + pass + + @abstractmethod + def save_token(self, token: str) -> None: + pass + + @abstractmethod + def delete_token(self) -> bool: + pass + + +class MemoRepositoryPort(ABC): + @abstractmethod + def save(self, token: str, content: str, category: str | None = None) -> dict: + pass + + @abstractmethod + def get_all(self, token: str, category: str | None = None, limit: int = 5) -> list: + pass + + @abstractmethod + def update(self, memo_id: str, token: str, content: str) -> dict: + pass + + @abstractmethod + def delete(self, memo_id: str, token: str) -> dict: + pass + + +class AuthRepositoryPort(ABC): + @abstractmethod + def request_device_code(self) -> dict: + pass + + @abstractmethod + def poll_for_token(self, device_code: str, interval: int = 5) -> dict: + pass diff --git a/cli/application/service.py b/cli/application/service.py new file mode 100644 index 0000000..f7ec622 --- /dev/null +++ b/cli/application/service.py @@ -0,0 +1,74 @@ +from cli.application.exceptions import AuthDeviceFlowError, AuthenticationError +from cli.application.ports.inbound import AuthUseCase, MemoUseCase +from cli.application.ports.outbound import ( + AuthRepositoryPort, + MemoRepositoryPort, + TokenStoragePort, +) + + +class MemoService(MemoUseCase): + def __init__(self, memo_repo: MemoRepositoryPort, token_storage: TokenStoragePort): + self.memo_repo = memo_repo + self.token_storage = token_storage + + def _require_token(self) -> str: + token = self.token_storage.get_token() + if not token: + raise AuthenticationError( + "유효한 로그인 정보가 없습니다.\n" + "'mylog login' 명령어를 실행해 다시 로그인해 주세요." + ) + return token + + def save_memo(self, content: str, category: str | None = None) -> dict: + token = self._require_token() + return self.memo_repo.save(token=token, content=content, category=category) + + def get_memos(self, category: str | None = None, limit: int = 5) -> list: + token = self._require_token() + return self.memo_repo.get_all(token=token, category=category, limit=limit) + + def update_memo(self, memo_id: str, content: str) -> dict: + token = self._require_token() + return self.memo_repo.update(memo_id=memo_id, token=token, content=content) + + def delete_memo(self, memo_id: str) -> dict: + token = self._require_token() + return self.memo_repo.delete(memo_id=memo_id, token=token) + + +class AuthService(AuthUseCase): + def __init__(self, auth_repo: AuthRepositoryPort, token_storage: TokenStoragePort): + self.auth_repo = auth_repo + self.token_storage = token_storage + + def request_login(self) -> dict: + try: + device_data = self.auth_repo.request_device_code() + return { + "user_code": device_data.get("user_code"), + "verification_uri": device_data.get("verification_uri"), + "device_code": device_data.get("device_code"), + "interval": device_data.get("interval", 5), + } + except Exception as e: + raise AuthDeviceFlowError(f"깃허브 로그인 요청 실패: {e}") from e + + def complete_login(self, device_code: str, interval: int) -> bool: + try: + token_data = self.auth_repo.poll_for_token( + device_code=device_code, + interval=interval, + ) + access_token = token_data.get("access_token") + + if access_token: + self.token_storage.save_token(access_token) + return True + return False + except Exception as e: + raise AuthDeviceFlowError(str(e)) from e + + def logout(self) -> bool: + return self.token_storage.delete_token() diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000..51f9dc7 --- /dev/null +++ b/cli/config.py @@ -0,0 +1,26 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + + +class Settings: + def __init__(self): + self.API_BASE_URL = os.getenv("API_BASE_URL") + self.GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") + + self.validate() + + def validate(self): + if not self.API_BASE_URL: + raise EnvironmentError( + "에러: 환경변수 API_BASE_URL이 설정되어 있지 않습니다" + ) + if not self.GITHUB_CLIENT_ID: + raise EnvironmentError( + "에러: 환경변수 GITHUB_CLIENT_ID가 설정되어 있지 않습니다." + ) + + +settings = Settings() diff --git a/cli/dependencies.py b/cli/dependencies.py new file mode 100644 index 0000000..ce308d6 --- /dev/null +++ b/cli/dependencies.py @@ -0,0 +1,27 @@ +from cli.adapters.outbound.github_auth_adapter import GitHubDeviceAuthAdapter +from cli.adapters.outbound.http_memo_adapter import HttpMemoRepositoryAdapter +from cli.adapters.outbound.keyring_token_adapter import KeyringTokenAdapter +from cli.application.ports.inbound import AuthUseCase, MemoUseCase +from cli.application.service import AuthService, MemoService +from cli.config import settings + + +class Container: + def __init__(self): + self.token_storage = KeyringTokenAdapter() + self.memo_repo = HttpMemoRepositoryAdapter(api_base_url=settings.API_BASE_URL) + self.auth_repo = GitHubDeviceAuthAdapter( + client_id=settings.GITHUB_CLIENT_ID, + ) + + self.memo_service: MemoUseCase = MemoService( + memo_repo=self.memo_repo, + token_storage=self.token_storage, + ) + self.auth_service: AuthUseCase = AuthService( + auth_repo=self.auth_repo, + token_storage=self.token_storage, + ) + + +container = Container() diff --git a/cli/domain/__init__.py b/cli/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/mylog.py b/cli/mylog.py deleted file mode 100644 index d41eade..0000000 --- a/cli/mylog.py +++ /dev/null @@ -1,219 +0,0 @@ -import argparse -import os -import time - -import keyring -import requests -from dotenv import load_dotenv - -load_dotenv() - -API_BASE_URL = os.getenv("API_BASE_URL") -GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") - -KEYRING_SERVICE_NAME = "mylog-app" -KEYRING_TOKEN_KEY = "user_secure_token" - - -def get_saved_token() -> str | None: - return keyring.get_password(KEYRING_SERVICE_NAME, KEYRING_TOKEN_KEY) - - -def save_token(token: str): - keyring.set_password(KEYRING_SERVICE_NAME, KEYRING_TOKEN_KEY, token) - - -def delete_token(): - try: - keyring.delete_password(KEYRING_SERVICE_NAME, KEYRING_TOKEN_KEY) - print("토큰이 안전하게 파기되었습니다. (로그아웃 성공)") - except keyring.errors.PasswordDeleteError: - print("이미 로그아웃된 상태거나 삭제할 토큰이 없습니다.") - - -def send_to_aws(content): - if not API_BASE_URL: - print("에러: 환경변수에 API_BASE_URL이 설정되어 있지 않습니다.") - return - - url = f"{API_BASE_URL}/memos" - payload = { - "category": "basic", - "content": content, - } - - headers = { - "Content-Type": "application/json", - } - - token = get_saved_token() - if token: - headers["Authorization"] = f"Bearer {token}" - - try: - response = requests.post( - url, - json=payload, - headers=headers, - timeout=5, - ) - - if response.status_code == 401: - print("[인증 실패] 유효한 로그인 정보가 없거나 만료되었습니다.") - print("'mylog login' 명령어를 실행해 다시 로그인해 주세요.") - return - - response.raise_for_status() - result = response.json() - - print("[AWS 저장 성공]") - print(f"서버의 응답 메시지: {result.get('message', '성공')}") - print(f"저장된 고유 ID (ULID): {result.get('id')}") - - except requests.exceptions.Timeout: - print("에러: 서버 응답 시간이 초과되었습니다. 인터넷 연결을 확인해주세요.") - except Exception as e: - print(f"서버 통신 중 에러 발생: {e}") - - -def handle_login(): - if not GITHUB_CLIENT_ID: - print("에러: 환경변수에 GITHUB_CLIENT_ID가 설정되어 있지 않습니다.") - return - - print("GitHub 서버에 로그인 요청 코드를 생성하는 중...") - - device_code_url = "https://github.com/login/device/code" - headers = { - "Accept": "application/json", - } - payload = { - "client_id": GITHUB_CLIENT_ID, - "scope": "read:user", - } - - try: - res = requests.post( - device_code_url, - json=payload, - headers=headers, - timeout=5, - ) - res.raise_for_status() - res_data = res.json() - except Exception as e: - print(f"깃허브 로그인 요청에 실패했습니다: {e}") - return - - device_code = res_data.get("device_code") - user_code = res_data.get("user_code") - verification_uri = res_data.get("verification_uri") - interval = res_data.get("interval", 5) - - print("\n" + "=" * 60) - print("[GitHub 로그인 안내]") - print("1. 웹 브라우저를 열고 다음 주소로 접속하세요.") - print(f" {verification_uri}") - print("2. 아래 코드를 입력하고 승인을 완료하세요.") - print(f" [ {user_code} ]") - print("=" * 60 + "\n") - - token_url = "https://github.com/login/oauth/access_token" - token_payload = { - "client_id": GITHUB_CLIENT_ID, - "device_code": device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - } - - print("사용자의 브라우저 로그인을 기다리는 중입니다...") - - while True: - try: - res_token = requests.post( - token_url, - json=token_payload, - headers=headers, - timeout=5, - ) - res_token.raise_for_status() - token_data = res_token.json() - - error = token_data.get("error") - if error: - if error == "authorization_pending": - time.sleep(interval) - continue - elif error == "slow_down": - interval += 5 - time.sleep(interval) - continue - elif error == "expired_token": - print("로그인 제한 시간이 만료되었습니다. 다시 시도해 주세요.") - return - else: - print(f"로그인 중 에러 발생: {token_data.get('error_description')}") - return - - github_access_token = token_data.get("access_token") - break - - except Exception as e: - print(f"대기 중 에러 발생: {e}") - return - - if not github_access_token: - print("깃허브 토큰을 획득하지 못했습니다.") - return - - save_token(github_access_token) - print("\n[로그인 성공]") - print("깃허브 토큰이 안전하게 암호화 되어 저장되었습니다.") - - -def main(): - parser = argparse.ArgumentParser(description="터미널 메모장") - subparsers = parser.add_subparsers( - dest="command", - help="사용할 명령어를 입력하세요", - ) - - subparsers.add_parser( - "login", - help="깃허브 계정으로 안전하게 로그인합니다.", - ) - - subparsers.add_parser( - "logout", - help="키체인에서 로그인 정보를 삭제합니다.", - ) - - add_parser = subparsers.add_parser( - "add", - help="새로운 메모를 저장합니다.", - ) - add_parser.add_argument( - "content", - type=str, - help="저장할 메모 내용을 입력하세요", - ) - - args = parser.parse_args() - - if args.command == "login": - handle_login() - elif args.command == "logout": - delete_token() - elif args.command == "add": - if not get_saved_token(): - print("\n[인증 필요] 이 명령어를 사용하려면 로그인이 필요합니다.") - print("먼저 'mylog login'을 실행해 주세요.\n") - return - - print(f"입력하신 '{args.content}' 문장을 클라우드로 전송합니다.") - send_to_aws(args.content) - else: - parser.print_help() - - -if __name__ == "__main__": - main() diff --git a/cli/setup.py b/cli/setup.py index da5036f..0ec14d9 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -1,9 +1,9 @@ -from setuptools import setup +from setuptools import find_packages, setup setup( name="mylog", version="0.1.0", - py_modules=["mylog"], + packages=find_packages(include=["cli", "cli.*"]), install_requires=[ "requests", "keyring", @@ -11,7 +11,7 @@ ], entry_points={ "console_scripts": [ - "mylog=mylog:main", + "mylog=cli.adapters.inbound.cli_adapter:main", ], }, ) diff --git a/pyproject.toml b/pyproject.toml index b4714d0..8aca273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,6 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "backend.*" ignore_missing_imports = false + +[tool.setuptools.packages.find] +include = ["cli*"] \ No newline at end of file