pxa-common 0.1.0__tar.gz

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.
@@ -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,98 @@
1
+ # pxa-common
2
+
3
+ PXA 공통 기반 패키지. **로깅 · 예외처리/에러코드 · 설정파일 관리 · 표준 request/response 포맷 · 메시지 카탈로그** 를 제공합니다.
4
+
5
+ > **단독 사용 가능** — 네 패키지(`pxa-common`, `pxa-auth`, `pxa-db-connector`, `pxa-extend`)는 서로 의존하지 않습니다. 필요한 것만 골라 설치하세요.
6
+ > 함께 쓰려면 각 패키지의 `integrations.pxa_common` 어댑터를 한 줄 호출하면 됩니다.
7
+
8
+ PEP 네이밍 점검은 **`pxa-extend`** 로 분리되어 있습니다.
9
+
10
+ ---
11
+
12
+ ## 설치
13
+
14
+ ```bash
15
+ pip install pxa-common --index-url https://nexus.example.com/repository/pypi-internal/simple/
16
+ ```
17
+
18
+ ## 1. 표준 응답 포맷
19
+
20
+ 모든 응답의 HTTP status 는 **항상 200**이고, 정상/비정상은 애플리케이션 코드로 구분합니다.
21
+
22
+ ```json
23
+ { "success": true, "code": "pxa-10000", "message": "정상 처리되었습니다.", "result": {} }
24
+ ```
25
+
26
+ ```python
27
+ from pxa_common import ApiResponse
28
+
29
+ return ApiResponse.ok(result=item, message="조회 성공") # pxa-10000
30
+ ```
31
+
32
+ - `pxa-10000` 정상 / `pxa-2xxxx` 비정상 (`20001` 검증, `20002` 필수값 누락, `20003` 미인증, `20004` 없음, `20005` 권한, `20006` DB, `20007` 중복)
33
+ - 메시지를 생략하면 메시지 카탈로그에서 코드 메시지를 조회합니다.
34
+ - 에러코드 추가는 `pxa_common/codes.py` 의 `AppCode` 에 한 줄 추가하면 됩니다.
35
+
36
+ ## 2. 예외 처리
37
+
38
+ ```python
39
+ from pxa_common import NotFoundError, register_exception_handlers
40
+
41
+ register_exception_handlers(app) # 앱 생성 시 1회
42
+ raise NotFoundError("주문 없음") # -> HTTP 200 + pxa-20004 표준 응답
43
+ ```
44
+
45
+ `AppError` 계열(`NotFoundError`, `ValidationError`, `RequiredFieldError`, `UnauthorizedError`, `ForbiddenError`, `DbError`, `DuplicatedError`)과 요청 검증 실패, 예상치 못한 예외까지 모두 표준 포맷으로 자동 변환됩니다.
46
+
47
+ ## 3. 설정파일 관리
48
+
49
+ 우선순위: **환경변수(`PXA_*`) > config.yaml > 기본값**. 파일 경로는 `PXA_CONFIG` 로 지정합니다.
50
+
51
+ ```yaml
52
+ app: { name: "my-service", debug: false }
53
+ logging: { dir: "./logs", level: "INFO" }
54
+ db: { host: "localhost" } # 각 패키지가 자기 섹션을 읽어간다
55
+ ```
56
+
57
+ ```python
58
+ from pxa_common import load_section
59
+ from pydantic import BaseModel
60
+
61
+ class DbConfig(BaseModel):
62
+ host: str = "localhost"
63
+
64
+ db = load_section("db", DbConfig) # 공통 모듈은 db 구조를 몰라도 된다
65
+ ```
66
+
67
+ 환경변수 override 는 `PXA_DB__HOST=db.internal` 형식입니다.
68
+
69
+ ## 4. 로깅
70
+
71
+ ```python
72
+ from pxa_common import setup_logging
73
+ setup_logging() # config 의 logging 섹션을 사용
74
+ ```
75
+
76
+ 지정 디렉토리에 파일로 저장되며 자정마다 회전합니다(보관일수 설정 가능). 요청 로그는 `RequestLoggingMiddleware` 를 추가하면 request-id 와 처리시간까지 자동 기록됩니다.
77
+
78
+ ## 5. 메시지 카탈로그
79
+
80
+ ```python
81
+ from pxa_common import msg, register_messages
82
+
83
+ register_messages({"order": {"created": "주문 '{no}' 생성됨"}})
84
+ msg("order.created", no="A-1") # -> "주문 'A-1' 생성됨"
85
+ ```
86
+
87
+ 기본 메시지는 `messages/default.yaml` 에서 자동 로드되고, `config(messages.files)` 로 애플리케이션 YAML 을 추가할 수 있습니다.
88
+
89
+ ## 6. 테스트 & 배포
90
+
91
+ ```bash
92
+ pip install -e ".[dev]"
93
+ pytest
94
+
95
+ python -m build && twine upload -r nexus dist/*
96
+ ```
97
+
98
+ PEP 네이밍 점검은 `pxa-extend` 의 `pxa-lint` / `pxa-naming` 을 사용합니다.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pxa-common"
7
+ version = "0.1.0"
8
+ description = "PXA 공통 기반 (로깅/예외·에러코드/설정관리/표준 응답포맷/메시지)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{ name = "Platform Team" }]
13
+ dependencies = [
14
+ "fastapi==0.128.*",
15
+ "pydantic>=2.7",
16
+ "PyYAML>=6.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ # PEP 네이밍 점검은 pxa-extend 로 분리되어 있다.
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "httpx>=0.27",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ pxa_common = ["messages/*.yaml"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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")
@@ -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))
@@ -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}")