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.
- pxa_common-0.1.0/PKG-INFO +113 -0
- pxa_common-0.1.0/README.md +98 -0
- pxa_common-0.1.0/pyproject.toml +34 -0
- pxa_common-0.1.0/setup.cfg +4 -0
- pxa_common-0.1.0/src/pxa_common/__init__.py +67 -0
- pxa_common-0.1.0/src/pxa_common/codes.py +41 -0
- pxa_common-0.1.0/src/pxa_common/config.py +113 -0
- pxa_common-0.1.0/src/pxa_common/exceptions.py +120 -0
- pxa_common-0.1.0/src/pxa_common/fastapi.py +67 -0
- pxa_common-0.1.0/src/pxa_common/logging_conf.py +45 -0
- pxa_common-0.1.0/src/pxa_common/messages/__init__.py +9 -0
- pxa_common-0.1.0/src/pxa_common/messages/catalog.py +85 -0
- pxa_common-0.1.0/src/pxa_common/messages/default.yaml +22 -0
- pxa_common-0.1.0/src/pxa_common/middleware.py +39 -0
- pxa_common-0.1.0/src/pxa_common/response.py +64 -0
- pxa_common-0.1.0/src/pxa_common.egg-info/PKG-INFO +113 -0
- pxa_common-0.1.0/src/pxa_common.egg-info/SOURCES.txt +24 -0
- pxa_common-0.1.0/src/pxa_common.egg-info/dependency_links.txt +1 -0
- pxa_common-0.1.0/src/pxa_common.egg-info/requires.txt +7 -0
- pxa_common-0.1.0/src/pxa_common.egg-info/top_level.txt +1 -0
- pxa_common-0.1.0/tests/test_config.py +82 -0
- pxa_common-0.1.0/tests/test_exception_handlers.py +52 -0
- pxa_common-0.1.0/tests/test_facade_imports.py +31 -0
- pxa_common-0.1.0/tests/test_logging.py +62 -0
- pxa_common-0.1.0/tests/test_middleware.py +53 -0
- pxa_common-0.1.0/tests/test_response_format.py +30 -0
|
@@ -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,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}")
|