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 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)
@@ -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,9 @@
1
+ """메시지 패키지 공개 API."""
2
+ from .catalog import (
3
+ get_message,
4
+ load_message_files,
5
+ msg,
6
+ register_messages,
7
+ )
8
+
9
+ __all__ = ["msg", "get_message", "register_messages", "load_message_files"]
@@ -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: "아이디 또는 비밀번호가 올바르지 않습니다."
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pxa_common