pxa-common 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pxa_common/__init__.py +67 -0
- pxa_common/codes.py +41 -0
- pxa_common/config.py +113 -0
- pxa_common/exceptions.py +120 -0
- pxa_common/fastapi.py +67 -0
- pxa_common/logging_conf.py +45 -0
- pxa_common/messages/__init__.py +9 -0
- pxa_common/messages/catalog.py +85 -0
- pxa_common/messages/default.yaml +22 -0
- pxa_common/middleware.py +39 -0
- pxa_common/response.py +64 -0
- pxa_common-0.1.0.dist-info/METADATA +113 -0
- pxa_common-0.1.0.dist-info/RECORD +15 -0
- pxa_common-0.1.0.dist-info/WHEEL +5 -0
- pxa_common-0.1.0.dist-info/top_level.txt +1 -0
pxa_common/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""pxa-common — PXA 공통 기반 패키지.
|
|
2
|
+
|
|
3
|
+
로깅 / 예외·에러코드 / 설정파일 관리 / 표준 request·response 포맷 /
|
|
4
|
+
메시지 카탈로그를 제공한다. PEP 네이밍 점검은 pxa-extend 에 있다.
|
|
5
|
+
|
|
6
|
+
다른 pxa 패키지(pxa-db-connector, pxa-auth)와는 서로 의존하지 않는다.
|
|
7
|
+
함께 쓰려면 각 패키지의 ``integrations.pxa_common`` 어댑터를 호출한다.
|
|
8
|
+
"""
|
|
9
|
+
from .codes import AppCode
|
|
10
|
+
from .config import (
|
|
11
|
+
AppConfig,
|
|
12
|
+
LoggingConfig,
|
|
13
|
+
MessagesConfig,
|
|
14
|
+
get_app_config,
|
|
15
|
+
get_logging_config,
|
|
16
|
+
get_messages_config,
|
|
17
|
+
get_raw,
|
|
18
|
+
load_raw,
|
|
19
|
+
load_section,
|
|
20
|
+
reload_config,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
AppError,
|
|
24
|
+
DbError,
|
|
25
|
+
DuplicatedError,
|
|
26
|
+
ForbiddenError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
RequiredFieldError,
|
|
29
|
+
UnauthorizedError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
register_exception_handlers,
|
|
32
|
+
)
|
|
33
|
+
from .logging_conf import setup_logging
|
|
34
|
+
from .messages import load_message_files, msg, register_messages
|
|
35
|
+
from .middleware import RequestLoggingMiddleware
|
|
36
|
+
from .response import ApiResponse
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ApiResponse",
|
|
42
|
+
"AppCode",
|
|
43
|
+
"AppConfig",
|
|
44
|
+
"LoggingConfig",
|
|
45
|
+
"MessagesConfig",
|
|
46
|
+
"load_raw",
|
|
47
|
+
"get_raw",
|
|
48
|
+
"reload_config",
|
|
49
|
+
"load_section",
|
|
50
|
+
"get_app_config",
|
|
51
|
+
"get_logging_config",
|
|
52
|
+
"get_messages_config",
|
|
53
|
+
"setup_logging",
|
|
54
|
+
"RequestLoggingMiddleware",
|
|
55
|
+
"register_exception_handlers",
|
|
56
|
+
"AppError",
|
|
57
|
+
"NotFoundError",
|
|
58
|
+
"ValidationError",
|
|
59
|
+
"RequiredFieldError",
|
|
60
|
+
"UnauthorizedError",
|
|
61
|
+
"ForbiddenError",
|
|
62
|
+
"DbError",
|
|
63
|
+
"DuplicatedError",
|
|
64
|
+
"msg",
|
|
65
|
+
"register_messages",
|
|
66
|
+
"load_message_files",
|
|
67
|
+
]
|
pxa_common/codes.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""애플리케이션 코드 중앙 관리.
|
|
2
|
+
|
|
3
|
+
모든 HTTP 응답은 200 으로 내려가며, 정상/비정상은 애플리케이션 코드로 구분한다.
|
|
4
|
+
|
|
5
|
+
- pxa-10000 : 정상
|
|
6
|
+
- pxa-2xxxx : 비정상(에러)
|
|
7
|
+
|
|
8
|
+
새로운 에러코드가 필요하면 이 Enum 에만 추가한다.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AppCode(str, Enum):
|
|
16
|
+
"""애플리케이션 코드. (코드, 기본 메시지, HTTP 상태) 를 함께 보유한다."""
|
|
17
|
+
|
|
18
|
+
# ---- 정상 ----
|
|
19
|
+
SUCCESS = ("pxa-10000", "정상 처리되었습니다.", 200)
|
|
20
|
+
|
|
21
|
+
# ---- 비정상 (공통) ----
|
|
22
|
+
UNKNOWN_ERROR = ("pxa-20000", "알 수 없는 오류가 발생했습니다.", 200)
|
|
23
|
+
VALIDATION_ERROR = ("pxa-20001", "요청 데이터 검증에 실패했습니다.", 200)
|
|
24
|
+
REQUIRED_FIELD_MISSING = ("pxa-20002", "필수 데이터가 누락되었습니다.", 200)
|
|
25
|
+
UNAUTHORIZED = ("pxa-20003", "인증이 필요합니다.", 200)
|
|
26
|
+
NOT_FOUND = ("pxa-20004", "데이터를 찾을 수 없습니다.", 200)
|
|
27
|
+
FORBIDDEN = ("pxa-20005", "권한이 없습니다.", 200)
|
|
28
|
+
DB_ERROR = ("pxa-20006", "데이터베이스 처리 중 오류가 발생했습니다.", 200)
|
|
29
|
+
DUPLICATED = ("pxa-20007", "이미 존재하는 데이터입니다.", 200)
|
|
30
|
+
|
|
31
|
+
def __new__(cls, code: str, message: str, http_status: int):
|
|
32
|
+
obj = str.__new__(cls, code)
|
|
33
|
+
obj._value_ = code
|
|
34
|
+
obj.code = code
|
|
35
|
+
obj.message = message
|
|
36
|
+
obj.http_status = http_status
|
|
37
|
+
return obj
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_success(self) -> bool:
|
|
41
|
+
return self.code.startswith("pxa-1")
|
pxa_common/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""설정 파일 관리 (코드와 환경값 분리).
|
|
2
|
+
|
|
3
|
+
- YAML config 파일 + 환경변수에서 설정을 읽는다.
|
|
4
|
+
- 우선순위: 환경변수(PXA_*) > config.yaml > 기본값
|
|
5
|
+
- config 파일 경로는 환경변수 ``PXA_CONFIG`` 로 지정한다.
|
|
6
|
+
|
|
7
|
+
각 패키지(db-connector, auth)는 자신의 설정 모델을 정의하고
|
|
8
|
+
``load_section("db", DbConfig)`` 처럼 자기 섹션만 읽어간다.
|
|
9
|
+
따라서 공통 모듈이 DB/인증 설정을 알 필요가 없다(느슨한 결합).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Type, TypeVar
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from .fastapi import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
DEFAULT_CONFIG_PATH = "config/config.yaml"
|
|
22
|
+
|
|
23
|
+
M = TypeVar("M", bound=BaseModel)
|
|
24
|
+
|
|
25
|
+
_raw_cache: Optional[dict] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AppConfig(BaseModel):
|
|
29
|
+
name: str = "pxa-app"
|
|
30
|
+
debug: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LoggingConfig(BaseModel):
|
|
34
|
+
dir: str = "./logs"
|
|
35
|
+
level: str = "INFO"
|
|
36
|
+
filename: str = "app.log"
|
|
37
|
+
rotate_when: str = "midnight"
|
|
38
|
+
backup_count: int = 14
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MessagesConfig(BaseModel):
|
|
42
|
+
files: list[str] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _apply_env_overrides(data: dict) -> dict:
|
|
46
|
+
"""PXA_SECTION__KEY 형태의 환경변수로 설정을 덮어쓴다.
|
|
47
|
+
|
|
48
|
+
예) PXA_DB__HOST=db.internal -> data['db']['host'] = 'db.internal'
|
|
49
|
+
"""
|
|
50
|
+
prefix = "PXA_"
|
|
51
|
+
for env_key, env_val in os.environ.items():
|
|
52
|
+
if not env_key.startswith(prefix) or env_key == "PXA_CONFIG":
|
|
53
|
+
continue
|
|
54
|
+
path = env_key[len(prefix):].lower().split("__")
|
|
55
|
+
cursor = data
|
|
56
|
+
for part in path[:-1]:
|
|
57
|
+
# 같은 자리에 스칼라가 있으면 dict 로 바꾼다(설정 병합 중 타입 충돌 방지)
|
|
58
|
+
if not isinstance(cursor.get(part), dict):
|
|
59
|
+
cursor[part] = {}
|
|
60
|
+
cursor = cursor[part]
|
|
61
|
+
cursor[path[-1]] = env_val
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_raw(config_path: Optional[str] = None) -> dict:
|
|
66
|
+
"""config 파일 + 환경변수를 병합한 원본 dict 를 만든다."""
|
|
67
|
+
path = config_path or os.environ.get("PXA_CONFIG", DEFAULT_CONFIG_PATH)
|
|
68
|
+
raw: dict = {}
|
|
69
|
+
cfg_file = Path(path)
|
|
70
|
+
if cfg_file.is_file():
|
|
71
|
+
raw = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) or {}
|
|
72
|
+
return _apply_env_overrides(raw)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_raw() -> dict:
|
|
76
|
+
"""프로세스 전역에서 한 번만 읽는 설정 원본(캐시)."""
|
|
77
|
+
global _raw_cache
|
|
78
|
+
if _raw_cache is None:
|
|
79
|
+
_raw_cache = load_raw()
|
|
80
|
+
return _raw_cache
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reload_config(config_path: Optional[str] = None) -> dict:
|
|
84
|
+
"""설정을 다시 읽는다(테스트/설정 변경 시)."""
|
|
85
|
+
global _raw_cache
|
|
86
|
+
_raw_cache = load_raw(config_path)
|
|
87
|
+
return _raw_cache
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_section(name: str, model: Type[M], raw: Optional[dict] = None) -> M:
|
|
91
|
+
"""설정의 한 섹션을 타입이 보장된 모델로 만든다.
|
|
92
|
+
|
|
93
|
+
섹션이 없으면 모델 기본값으로 생성된다.
|
|
94
|
+
"""
|
|
95
|
+
data = (raw if raw is not None else get_raw()).get(name) or {}
|
|
96
|
+
if not isinstance(data, dict):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"설정의 '{name}' 섹션은 key: value 형태여야 합니다 "
|
|
99
|
+
f"(현재 {type(data).__name__})."
|
|
100
|
+
)
|
|
101
|
+
return model(**data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_app_config() -> AppConfig:
|
|
105
|
+
return load_section("app", AppConfig)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_logging_config() -> LoggingConfig:
|
|
109
|
+
return load_section("logging", LoggingConfig)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_messages_config() -> MessagesConfig:
|
|
113
|
+
return load_section("messages", MessagesConfig)
|
pxa_common/exceptions.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""공통 예외 및 예외 핸들러.
|
|
2
|
+
|
|
3
|
+
프레임워크 전역 예외를 하나의 ``AppError`` 계열로 관리하고,
|
|
4
|
+
표준 응답 포맷(ApiResponse)으로 자동 변환한다.
|
|
5
|
+
주니어 개발자는 ``raise NotFoundError("...")`` 처럼 의미만 표현하면 된다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .codes import AppCode
|
|
13
|
+
from .fastapi import (
|
|
14
|
+
FastAPI,
|
|
15
|
+
JSONResponse,
|
|
16
|
+
Request,
|
|
17
|
+
RequestValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .response import ApiResponse
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("pxa")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AppError(Exception):
|
|
25
|
+
"""공통 예외 베이스. 항상 AppCode 를 가진다."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
code: AppCode = AppCode.UNKNOWN_ERROR,
|
|
30
|
+
message: Optional[str] = None,
|
|
31
|
+
result: Any = None,
|
|
32
|
+
):
|
|
33
|
+
self.code = code
|
|
34
|
+
self.message = message or code.message
|
|
35
|
+
self.result = result
|
|
36
|
+
super().__init__(self.message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(AppError):
|
|
40
|
+
"""없는 데이터를 조회/변경할 때 (pxa-20004)."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: Optional[str] = None, result: Any = None):
|
|
43
|
+
super().__init__(AppCode.NOT_FOUND, message, result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ValidationError(AppError):
|
|
47
|
+
"""데이터 정합성 검증 실패 (pxa-20001)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: Optional[str] = None, result: Any = None):
|
|
50
|
+
super().__init__(AppCode.VALIDATION_ERROR, message, result)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RequiredFieldError(AppError):
|
|
54
|
+
"""필수 데이터 누락 (pxa-20002)."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, missing: list[str] | str, result: Any = None):
|
|
57
|
+
fields = missing if isinstance(missing, str) else ", ".join(missing)
|
|
58
|
+
message = f"필수 데이터가 누락되었습니다: {fields}"
|
|
59
|
+
super().__init__(AppCode.REQUIRED_FIELD_MISSING, message, result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UnauthorizedError(AppError):
|
|
63
|
+
"""인증 실패 (pxa-20003)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: Optional[str] = None):
|
|
66
|
+
super().__init__(AppCode.UNAUTHORIZED, message)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ForbiddenError(AppError):
|
|
70
|
+
"""권한 없음 (pxa-20005)."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, message: Optional[str] = None):
|
|
73
|
+
super().__init__(AppCode.FORBIDDEN, message)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DbError(AppError):
|
|
77
|
+
"""DB 처리 오류 (pxa-20006)."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: Optional[str] = None):
|
|
80
|
+
super().__init__(AppCode.DB_ERROR, message)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DuplicatedError(AppError):
|
|
84
|
+
"""중복 데이터 (pxa-20007)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, message: Optional[str] = None):
|
|
87
|
+
super().__init__(AppCode.DUPLICATED, message)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _json(payload: ApiResponse) -> JSONResponse:
|
|
91
|
+
# 모든 응답의 HTTP status 는 200 으로 고정한다.
|
|
92
|
+
return JSONResponse(status_code=200, content=payload.model_dump())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
96
|
+
"""FastAPI 앱에 공통 예외 핸들러를 등록한다."""
|
|
97
|
+
|
|
98
|
+
@app.exception_handler(AppError)
|
|
99
|
+
async def _handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
|
100
|
+
logger.warning(
|
|
101
|
+
"AppError code=%s path=%s msg=%s",
|
|
102
|
+
exc.code.code, request.url.path, exc.message,
|
|
103
|
+
)
|
|
104
|
+
return _json(ApiResponse.fail(exc.code, exc.message, exc.result))
|
|
105
|
+
|
|
106
|
+
@app.exception_handler(RequestValidationError)
|
|
107
|
+
async def _handle_request_validation(
|
|
108
|
+
request: Request, exc: RequestValidationError
|
|
109
|
+
) -> JSONResponse:
|
|
110
|
+
details = [
|
|
111
|
+
{"field": ".".join(str(p) for p in e["loc"]), "msg": e["msg"]}
|
|
112
|
+
for e in exc.errors()
|
|
113
|
+
]
|
|
114
|
+
logger.info("RequestValidationError path=%s", request.url.path)
|
|
115
|
+
return _json(ApiResponse.fail(AppCode.VALIDATION_ERROR, result=details))
|
|
116
|
+
|
|
117
|
+
@app.exception_handler(Exception)
|
|
118
|
+
async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
|
|
119
|
+
logger.exception("Unhandled error path=%s", request.url.path)
|
|
120
|
+
return _json(ApiResponse.fail(AppCode.UNKNOWN_ERROR))
|
pxa_common/fastapi.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""FastAPI 파사드 (FastAPI/pydantic 직접 노출 금지).
|
|
2
|
+
|
|
3
|
+
주니어 개발자는 ``fastapi`` / ``pydantic`` 을 직접 import 하지 않고
|
|
4
|
+
``pxa_common.fastapi`` 만 사용한다. 내부 구현(FastAPI 0.128)이 바뀌어도
|
|
5
|
+
사용자 코드는 그대로 둘 수 있다.
|
|
6
|
+
|
|
7
|
+
from pxa_common.fastapi import APIRouter, Depends, BaseModel
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
# 절대 import 이므로 site-packages 의 실제 fastapi 를 가리킨다.
|
|
14
|
+
from fastapi import (
|
|
15
|
+
APIRouter,
|
|
16
|
+
BackgroundTasks,
|
|
17
|
+
Body,
|
|
18
|
+
Cookie,
|
|
19
|
+
Depends,
|
|
20
|
+
FastAPI,
|
|
21
|
+
File,
|
|
22
|
+
Form,
|
|
23
|
+
Header,
|
|
24
|
+
HTTPException,
|
|
25
|
+
Path,
|
|
26
|
+
Query,
|
|
27
|
+
Request,
|
|
28
|
+
Response,
|
|
29
|
+
UploadFile,
|
|
30
|
+
WebSocket,
|
|
31
|
+
status,
|
|
32
|
+
)
|
|
33
|
+
from fastapi.exceptions import RequestValidationError
|
|
34
|
+
from fastapi.responses import (
|
|
35
|
+
FileResponse,
|
|
36
|
+
HTMLResponse,
|
|
37
|
+
JSONResponse,
|
|
38
|
+
PlainTextResponse,
|
|
39
|
+
RedirectResponse,
|
|
40
|
+
StreamingResponse,
|
|
41
|
+
)
|
|
42
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING: # 런타임에는 import 하지 않는다 (아래 __getattr__ 참고)
|
|
45
|
+
from fastapi.testclient import TestClient
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"APIRouter", "Depends", "FastAPI", "Request", "Response",
|
|
49
|
+
"BackgroundTasks", "WebSocket", "HTTPException",
|
|
50
|
+
"RequestValidationError", "status",
|
|
51
|
+
"Body", "Cookie", "File", "Form", "Header", "Path", "Query", "UploadFile",
|
|
52
|
+
"JSONResponse", "HTMLResponse", "PlainTextResponse", "RedirectResponse",
|
|
53
|
+
"StreamingResponse", "FileResponse",
|
|
54
|
+
"BaseModel", "Field", "field_validator", "model_validator",
|
|
55
|
+
"TestClient",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def __getattr__(name: str):
|
|
60
|
+
"""TestClient 는 테스트에서만 쓰고 httpx 를 요구하므로 지연 import 한다.
|
|
61
|
+
|
|
62
|
+
이렇게 해야 ``import pxa_common`` 이 운영 환경에서 httpx 없이도 된다.
|
|
63
|
+
"""
|
|
64
|
+
if name == "TestClient":
|
|
65
|
+
from fastapi.testclient import TestClient
|
|
66
|
+
return TestClient
|
|
67
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""로깅 설정.
|
|
2
|
+
|
|
3
|
+
로그는 config 에 지정된 디렉토리에 파일로 저장되며 자정마다 회전한다.
|
|
4
|
+
디렉토리 위치/레벨/보관일수는 config 파일에서 변경한다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .config import LoggingConfig, get_logging_config
|
|
14
|
+
|
|
15
|
+
_LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def setup_logging(cfg: Optional[LoggingConfig] = None) -> logging.Logger:
|
|
19
|
+
"""'pxa' 로거에 콘솔 + 파일(자정 회전) 핸들러를 구성한다."""
|
|
20
|
+
cfg = cfg or get_logging_config()
|
|
21
|
+
log_dir = Path(cfg.dir)
|
|
22
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("pxa")
|
|
25
|
+
logger.setLevel(cfg.level.upper())
|
|
26
|
+
logger.handlers.clear()
|
|
27
|
+
logger.propagate = False
|
|
28
|
+
|
|
29
|
+
formatter = logging.Formatter(_LOG_FORMAT)
|
|
30
|
+
|
|
31
|
+
file_handler = TimedRotatingFileHandler(
|
|
32
|
+
filename=str(log_dir / cfg.filename),
|
|
33
|
+
when=cfg.rotate_when,
|
|
34
|
+
backupCount=cfg.backup_count,
|
|
35
|
+
encoding="utf-8",
|
|
36
|
+
)
|
|
37
|
+
file_handler.setFormatter(formatter)
|
|
38
|
+
logger.addHandler(file_handler)
|
|
39
|
+
|
|
40
|
+
console_handler = logging.StreamHandler()
|
|
41
|
+
console_handler.setFormatter(formatter)
|
|
42
|
+
logger.addHandler(console_handler)
|
|
43
|
+
|
|
44
|
+
logger.info("로깅 초기화 완료: dir=%s level=%s", cfg.dir, cfg.level)
|
|
45
|
+
return logger
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""메시지 카탈로그 (메시지 중앙관리).
|
|
2
|
+
|
|
3
|
+
- 사용자 노출 메시지는 코드에 하드코딩하지 않고 key 로 관리한다.
|
|
4
|
+
- 프레임워크 기본 메시지는 ``default.yaml`` 에 동봉된다.
|
|
5
|
+
- 애플리케이션은 config(messages.files)의 YAML 을 추가하거나
|
|
6
|
+
``register_messages()`` 로 직접 추가/재정의할 수 있다.
|
|
7
|
+
- ``{name}`` 형태의 포맷 파라미터를 지원한다.
|
|
8
|
+
|
|
9
|
+
from pxa_common.messages import msg
|
|
10
|
+
msg("item.created", name="사과")
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterable, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("pxa")
|
|
21
|
+
|
|
22
|
+
_DEFAULT_FILE = Path(__file__).parent / "default.yaml"
|
|
23
|
+
|
|
24
|
+
_catalog: dict[str, str] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _flatten(data: Mapping, prefix: str = "") -> dict[str, str]:
|
|
28
|
+
"""중첩 dict 를 'a.b.c' 형태의 평탄한 key 로 변환한다."""
|
|
29
|
+
flat: dict[str, str] = {}
|
|
30
|
+
for key, value in data.items():
|
|
31
|
+
full = f"{prefix}{key}"
|
|
32
|
+
if isinstance(value, Mapping):
|
|
33
|
+
flat.update(_flatten(value, f"{full}."))
|
|
34
|
+
else:
|
|
35
|
+
flat[full] = str(value)
|
|
36
|
+
return flat
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def register_messages(mapping: Mapping[str, object]) -> None:
|
|
40
|
+
"""메시지를 추가/재정의한다. 중첩 dict 는 'a.b' key 로 평탄화된다."""
|
|
41
|
+
_catalog.update(_flatten(mapping))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_message_files(paths: Iterable[str]) -> None:
|
|
45
|
+
"""YAML 메시지 파일들을 로드한다 (config: messages.files)."""
|
|
46
|
+
for p in paths:
|
|
47
|
+
path = Path(p)
|
|
48
|
+
if not path.is_file():
|
|
49
|
+
logger.warning("메시지 파일을 찾을 수 없습니다: %s", p)
|
|
50
|
+
continue
|
|
51
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
52
|
+
register_messages(data)
|
|
53
|
+
logger.info("메시지 파일 로드: %s", p)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_message(
|
|
57
|
+
key: str,
|
|
58
|
+
default: Optional[str] = None,
|
|
59
|
+
**params: object,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""key 로 메시지를 조회한다.
|
|
62
|
+
|
|
63
|
+
카탈로그에 없으면 default, default 도 없으면 key 자체를 반환한다.
|
|
64
|
+
포맷 파라미터가 부족해도 예외 없이 원문 템플릿을 반환한다.
|
|
65
|
+
"""
|
|
66
|
+
template = _catalog.get(key, default if default is not None else key)
|
|
67
|
+
if params:
|
|
68
|
+
try:
|
|
69
|
+
return template.format(**params)
|
|
70
|
+
except (KeyError, IndexError):
|
|
71
|
+
logger.warning("메시지 포맷 파라미터 불일치: key=%s", key)
|
|
72
|
+
return template
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# 짧은 별칭 — 주니어 개발자용
|
|
76
|
+
msg = get_message
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_defaults() -> None:
|
|
80
|
+
if _DEFAULT_FILE.is_file():
|
|
81
|
+
data = yaml.safe_load(_DEFAULT_FILE.read_text(encoding="utf-8")) or {}
|
|
82
|
+
register_messages(data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_load_defaults()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# =====================================================================
|
|
2
|
+
# pxa-common 기본 메시지 카탈로그
|
|
3
|
+
# 애플리케이션은 config(messages.files)의 자체 YAML 로 재정의/추가할 수 있다.
|
|
4
|
+
# key 는 애플리케이션 코드(pxa-xxxxx) 또는 도메인 key(a.b.c) 를 사용한다.
|
|
5
|
+
# =====================================================================
|
|
6
|
+
|
|
7
|
+
# ---- 애플리케이션 코드 메시지 (codes.py 와 1:1) ----
|
|
8
|
+
pxa-10000: "정상 처리되었습니다."
|
|
9
|
+
pxa-20000: "알 수 없는 오류가 발생했습니다."
|
|
10
|
+
pxa-20001: "요청 데이터 검증에 실패했습니다."
|
|
11
|
+
pxa-20002: "필수 데이터가 누락되었습니다."
|
|
12
|
+
pxa-20003: "인증이 필요합니다."
|
|
13
|
+
pxa-20004: "데이터를 찾을 수 없습니다."
|
|
14
|
+
pxa-20005: "권한이 없습니다."
|
|
15
|
+
pxa-20006: "데이터베이스 처리 중 오류가 발생했습니다."
|
|
16
|
+
pxa-20007: "이미 존재하는 데이터입니다."
|
|
17
|
+
|
|
18
|
+
# ---- 공통 도메인 메시지 ----
|
|
19
|
+
auth:
|
|
20
|
+
login_ok: "로그인 되었습니다."
|
|
21
|
+
logout_ok: "로그아웃 되었습니다."
|
|
22
|
+
login_failed: "아이디 또는 비밀번호가 올바르지 않습니다."
|
pxa_common/middleware.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""요청/응답 로깅 미들웨어.
|
|
2
|
+
|
|
3
|
+
모든 요청에 request-id 를 부여하고 처리시간과 함께 로깅한다.
|
|
4
|
+
주니어 개발자는 별도 설정 없이 접근 로그를 얻는다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("pxa")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
19
|
+
async def dispatch(self, request: Request, call_next):
|
|
20
|
+
request_id = uuid.uuid4().hex[:12]
|
|
21
|
+
request.state.request_id = request_id
|
|
22
|
+
start = time.perf_counter()
|
|
23
|
+
try:
|
|
24
|
+
response = await call_next(request)
|
|
25
|
+
except Exception:
|
|
26
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
27
|
+
logger.exception(
|
|
28
|
+
"[%s] %s %s -> EXC (%.1fms)",
|
|
29
|
+
request_id, request.method, request.url.path, elapsed,
|
|
30
|
+
)
|
|
31
|
+
raise
|
|
32
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
33
|
+
logger.info(
|
|
34
|
+
"[%s] %s %s -> %s (%.1fms)",
|
|
35
|
+
request_id, request.method, request.url.path,
|
|
36
|
+
response.status_code, elapsed,
|
|
37
|
+
)
|
|
38
|
+
response.headers["X-Request-Id"] = request_id
|
|
39
|
+
return response
|
pxa_common/response.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""표준 request/response 포맷.
|
|
2
|
+
|
|
3
|
+
모든 API 응답은 동일한 JSON 구조로 내려간다::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"success": true,
|
|
7
|
+
"code": "pxa-10000",
|
|
8
|
+
"message": "정상 처리되었습니다.",
|
|
9
|
+
"result": { ... }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
주니어 개발자는 ``ApiResponse.ok(result, message)`` 처럼 결과값과 메시지만
|
|
13
|
+
넣으면 된다. HTTP 상태코드는 항상 200 이며, 정상/비정상은 code 로 구분한다.
|
|
14
|
+
메시지를 생략하면 메시지 카탈로그에서 코드 메시지를 조회한다.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Generic, Optional, TypeVar
|
|
19
|
+
|
|
20
|
+
from .codes import AppCode
|
|
21
|
+
from .fastapi import BaseModel
|
|
22
|
+
from .messages.catalog import get_message
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApiResponse(BaseModel, Generic[T]):
|
|
28
|
+
"""표준 응답 봉투(envelope). 라우터는 이 객체를 그대로 return 한다."""
|
|
29
|
+
|
|
30
|
+
success: bool
|
|
31
|
+
code: str
|
|
32
|
+
message: str
|
|
33
|
+
result: Optional[T] = None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def ok(
|
|
37
|
+
cls,
|
|
38
|
+
result: Any = None,
|
|
39
|
+
message: Optional[str] = None,
|
|
40
|
+
) -> "ApiResponse":
|
|
41
|
+
"""성공 응답. 개발자는 result 와 (선택적으로) message 만 채운다."""
|
|
42
|
+
return cls(
|
|
43
|
+
success=True,
|
|
44
|
+
code=AppCode.SUCCESS.code,
|
|
45
|
+
message=message or get_message(
|
|
46
|
+
AppCode.SUCCESS.code, default=AppCode.SUCCESS.message
|
|
47
|
+
),
|
|
48
|
+
result=result,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def fail(
|
|
53
|
+
cls,
|
|
54
|
+
code: AppCode = AppCode.UNKNOWN_ERROR,
|
|
55
|
+
message: Optional[str] = None,
|
|
56
|
+
result: Any = None,
|
|
57
|
+
) -> "ApiResponse":
|
|
58
|
+
"""실패 응답. 코드는 AppCode Enum 에서 선택한다."""
|
|
59
|
+
return cls(
|
|
60
|
+
success=False,
|
|
61
|
+
code=code.code,
|
|
62
|
+
message=message or get_message(code.code, default=code.message),
|
|
63
|
+
result=result,
|
|
64
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pxa-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PXA 공통 기반 (로깅/예외·에러코드/설정관리/표준 응답포맷/메시지)
|
|
5
|
+
Author: Platform Team
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi==0.128.*
|
|
10
|
+
Requires-Dist: pydantic>=2.7
|
|
11
|
+
Requires-Dist: PyYAML>=6.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
14
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# pxa-common
|
|
17
|
+
|
|
18
|
+
PXA 공통 기반 패키지. **로깅 · 예외처리/에러코드 · 설정파일 관리 · 표준 request/response 포맷 · 메시지 카탈로그** 를 제공합니다.
|
|
19
|
+
|
|
20
|
+
> **단독 사용 가능** — 네 패키지(`pxa-common`, `pxa-auth`, `pxa-db-connector`, `pxa-extend`)는 서로 의존하지 않습니다. 필요한 것만 골라 설치하세요.
|
|
21
|
+
> 함께 쓰려면 각 패키지의 `integrations.pxa_common` 어댑터를 한 줄 호출하면 됩니다.
|
|
22
|
+
|
|
23
|
+
PEP 네이밍 점검은 **`pxa-extend`** 로 분리되어 있습니다.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 설치
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pxa-common --index-url https://nexus.example.com/repository/pypi-internal/simple/
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 1. 표준 응답 포맷
|
|
34
|
+
|
|
35
|
+
모든 응답의 HTTP status 는 **항상 200**이고, 정상/비정상은 애플리케이션 코드로 구분합니다.
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{ "success": true, "code": "pxa-10000", "message": "정상 처리되었습니다.", "result": {} }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pxa_common import ApiResponse
|
|
43
|
+
|
|
44
|
+
return ApiResponse.ok(result=item, message="조회 성공") # pxa-10000
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `pxa-10000` 정상 / `pxa-2xxxx` 비정상 (`20001` 검증, `20002` 필수값 누락, `20003` 미인증, `20004` 없음, `20005` 권한, `20006` DB, `20007` 중복)
|
|
48
|
+
- 메시지를 생략하면 메시지 카탈로그에서 코드 메시지를 조회합니다.
|
|
49
|
+
- 에러코드 추가는 `pxa_common/codes.py` 의 `AppCode` 에 한 줄 추가하면 됩니다.
|
|
50
|
+
|
|
51
|
+
## 2. 예외 처리
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from pxa_common import NotFoundError, register_exception_handlers
|
|
55
|
+
|
|
56
|
+
register_exception_handlers(app) # 앱 생성 시 1회
|
|
57
|
+
raise NotFoundError("주문 없음") # -> HTTP 200 + pxa-20004 표준 응답
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`AppError` 계열(`NotFoundError`, `ValidationError`, `RequiredFieldError`, `UnauthorizedError`, `ForbiddenError`, `DbError`, `DuplicatedError`)과 요청 검증 실패, 예상치 못한 예외까지 모두 표준 포맷으로 자동 변환됩니다.
|
|
61
|
+
|
|
62
|
+
## 3. 설정파일 관리
|
|
63
|
+
|
|
64
|
+
우선순위: **환경변수(`PXA_*`) > config.yaml > 기본값**. 파일 경로는 `PXA_CONFIG` 로 지정합니다.
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
app: { name: "my-service", debug: false }
|
|
68
|
+
logging: { dir: "./logs", level: "INFO" }
|
|
69
|
+
db: { host: "localhost" } # 각 패키지가 자기 섹션을 읽어간다
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from pxa_common import load_section
|
|
74
|
+
from pydantic import BaseModel
|
|
75
|
+
|
|
76
|
+
class DbConfig(BaseModel):
|
|
77
|
+
host: str = "localhost"
|
|
78
|
+
|
|
79
|
+
db = load_section("db", DbConfig) # 공통 모듈은 db 구조를 몰라도 된다
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
환경변수 override 는 `PXA_DB__HOST=db.internal` 형식입니다.
|
|
83
|
+
|
|
84
|
+
## 4. 로깅
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from pxa_common import setup_logging
|
|
88
|
+
setup_logging() # config 의 logging 섹션을 사용
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
지정 디렉토리에 파일로 저장되며 자정마다 회전합니다(보관일수 설정 가능). 요청 로그는 `RequestLoggingMiddleware` 를 추가하면 request-id 와 처리시간까지 자동 기록됩니다.
|
|
92
|
+
|
|
93
|
+
## 5. 메시지 카탈로그
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from pxa_common import msg, register_messages
|
|
97
|
+
|
|
98
|
+
register_messages({"order": {"created": "주문 '{no}' 생성됨"}})
|
|
99
|
+
msg("order.created", no="A-1") # -> "주문 'A-1' 생성됨"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
기본 메시지는 `messages/default.yaml` 에서 자동 로드되고, `config(messages.files)` 로 애플리케이션 YAML 을 추가할 수 있습니다.
|
|
103
|
+
|
|
104
|
+
## 6. 테스트 & 배포
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e ".[dev]"
|
|
108
|
+
pytest
|
|
109
|
+
|
|
110
|
+
python -m build && twine upload -r nexus dist/*
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
PEP 네이밍 점검은 `pxa-extend` 의 `pxa-lint` / `pxa-naming` 을 사용합니다.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pxa_common/__init__.py,sha256=2u7NLelVK5EIWTW4SAYXsJbPRD11JSuGdVtcoj0qB-U,1645
|
|
2
|
+
pxa_common/codes.py,sha256=XlyGusYeOYTEAJw1G0p-MiB9OqehPzBcdUoDYCHeFNc,1591
|
|
3
|
+
pxa_common/config.py,sha256=-EfTaxk6XedAPEp6NbmOgYoROAA-0EV5eQn0VPfQKNA,3471
|
|
4
|
+
pxa_common/exceptions.py,sha256=Y-rKXx8XRsFDqeFbH_sdsQrqDtlyGI4qRKxA_EY2yfg,3879
|
|
5
|
+
pxa_common/fastapi.py,sha256=MEZt5FMkcws1Sc7DxVHJMLZsnxhzz6Ow7DULaVjXgRc,2021
|
|
6
|
+
pxa_common/logging_conf.py,sha256=FEzjc6DifeeFjtfMZDuMjbGo9nVp5v-oHR_1OySJbCY,1436
|
|
7
|
+
pxa_common/middleware.py,sha256=HoB0oR3v97YM25YS5o4eGApsPpVSajfP1vNmotkZ358,1273
|
|
8
|
+
pxa_common/response.py,sha256=tJ_q01in-BtOMqmBlH0v5CnjpnlDQJ0qhNMF6f6b4Og,1878
|
|
9
|
+
pxa_common/messages/__init__.py,sha256=1zyuSVAAQ2HeWcar9KjR2uuGW86eQVugmHskWsunwDY,213
|
|
10
|
+
pxa_common/messages/catalog.py,sha256=nQh4hAbOecsrF2hHw1ODrV8bux9ndX2977UCjqeYTEg,2697
|
|
11
|
+
pxa_common/messages/default.yaml,sha256=1VZwzeHvcyntKjkCZaoASqwQOMtRSotMN6EQjgr6VsI,1127
|
|
12
|
+
pxa_common-0.1.0.dist-info/METADATA,sha256=Qwu7zEHehzl4LkWshetvjWCQNSn__-US4a41xqyPaOo,4084
|
|
13
|
+
pxa_common-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
pxa_common-0.1.0.dist-info/top_level.txt,sha256=NWDuN6XH5lg33gFD6XMtNsDzZV3piJHhibDuKLw6r_8,11
|
|
15
|
+
pxa_common-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pxa_common
|