aury-boot 0.0.2__py3-none-any.whl → 0.0.3__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.
- aury/boot/__init__.py +66 -0
- aury/boot/_version.py +2 -2
- aury/boot/application/__init__.py +120 -0
- aury/boot/application/app/__init__.py +39 -0
- aury/boot/application/app/base.py +511 -0
- aury/boot/application/app/components.py +434 -0
- aury/boot/application/app/middlewares.py +101 -0
- aury/boot/application/config/__init__.py +44 -0
- aury/boot/application/config/settings.py +663 -0
- aury/boot/application/constants/__init__.py +19 -0
- aury/boot/application/constants/components.py +50 -0
- aury/boot/application/constants/scheduler.py +28 -0
- aury/boot/application/constants/service.py +29 -0
- aury/boot/application/errors/__init__.py +55 -0
- aury/boot/application/errors/chain.py +80 -0
- aury/boot/application/errors/codes.py +67 -0
- aury/boot/application/errors/exceptions.py +238 -0
- aury/boot/application/errors/handlers.py +320 -0
- aury/boot/application/errors/response.py +120 -0
- aury/boot/application/interfaces/__init__.py +76 -0
- aury/boot/application/interfaces/egress.py +224 -0
- aury/boot/application/interfaces/ingress.py +98 -0
- aury/boot/application/middleware/__init__.py +22 -0
- aury/boot/application/middleware/logging.py +451 -0
- aury/boot/application/migrations/__init__.py +13 -0
- aury/boot/application/migrations/manager.py +685 -0
- aury/boot/application/migrations/setup.py +237 -0
- aury/boot/application/rpc/__init__.py +63 -0
- aury/boot/application/rpc/base.py +108 -0
- aury/boot/application/rpc/client.py +294 -0
- aury/boot/application/rpc/discovery.py +218 -0
- aury/boot/application/scheduler/__init__.py +13 -0
- aury/boot/application/scheduler/runner.py +123 -0
- aury/boot/application/server/__init__.py +296 -0
- aury/boot/commands/__init__.py +30 -0
- aury/boot/commands/add.py +76 -0
- aury/boot/commands/app.py +105 -0
- aury/boot/commands/config.py +177 -0
- aury/boot/commands/docker.py +367 -0
- aury/boot/commands/docs.py +284 -0
- aury/boot/commands/generate.py +1277 -0
- aury/boot/commands/init.py +890 -0
- aury/boot/commands/migrate/__init__.py +37 -0
- aury/boot/commands/migrate/app.py +54 -0
- aury/boot/commands/migrate/commands.py +303 -0
- aury/boot/commands/scheduler.py +124 -0
- aury/boot/commands/server/__init__.py +21 -0
- aury/boot/commands/server/app.py +541 -0
- aury/boot/commands/templates/generate/api.py.tpl +105 -0
- aury/boot/commands/templates/generate/model.py.tpl +17 -0
- aury/boot/commands/templates/generate/repository.py.tpl +19 -0
- aury/boot/commands/templates/generate/schema.py.tpl +29 -0
- aury/boot/commands/templates/generate/service.py.tpl +48 -0
- aury/boot/commands/templates/project/CLI.md.tpl +92 -0
- aury/boot/commands/templates/project/DEVELOPMENT.md.tpl +1397 -0
- aury/boot/commands/templates/project/README.md.tpl +111 -0
- aury/boot/commands/templates/project/admin_console_init.py.tpl +50 -0
- aury/boot/commands/templates/project/config.py.tpl +30 -0
- aury/boot/commands/templates/project/conftest.py.tpl +26 -0
- aury/boot/commands/templates/project/env.example.tpl +213 -0
- aury/boot/commands/templates/project/gitignore.tpl +128 -0
- aury/boot/commands/templates/project/main.py.tpl +41 -0
- aury/boot/commands/templates/project/modules/api.py.tpl +19 -0
- aury/boot/commands/templates/project/modules/exceptions.py.tpl +84 -0
- aury/boot/commands/templates/project/modules/schedules.py.tpl +18 -0
- aury/boot/commands/templates/project/modules/tasks.py.tpl +20 -0
- aury/boot/commands/worker.py +143 -0
- aury/boot/common/__init__.py +35 -0
- aury/boot/common/exceptions/__init__.py +114 -0
- aury/boot/common/i18n/__init__.py +16 -0
- aury/boot/common/i18n/translator.py +272 -0
- aury/boot/common/logging/__init__.py +716 -0
- aury/boot/contrib/__init__.py +10 -0
- aury/boot/contrib/admin_console/__init__.py +18 -0
- aury/boot/contrib/admin_console/auth.py +137 -0
- aury/boot/contrib/admin_console/discovery.py +69 -0
- aury/boot/contrib/admin_console/install.py +172 -0
- aury/boot/contrib/admin_console/utils.py +44 -0
- aury/boot/domain/__init__.py +79 -0
- aury/boot/domain/exceptions/__init__.py +132 -0
- aury/boot/domain/models/__init__.py +51 -0
- aury/boot/domain/models/base.py +69 -0
- aury/boot/domain/models/mixins.py +135 -0
- aury/boot/domain/models/models.py +96 -0
- aury/boot/domain/pagination/__init__.py +279 -0
- aury/boot/domain/repository/__init__.py +23 -0
- aury/boot/domain/repository/impl.py +423 -0
- aury/boot/domain/repository/interceptors.py +47 -0
- aury/boot/domain/repository/interface.py +106 -0
- aury/boot/domain/repository/query_builder.py +348 -0
- aury/boot/domain/service/__init__.py +11 -0
- aury/boot/domain/service/base.py +73 -0
- aury/boot/domain/transaction/__init__.py +404 -0
- aury/boot/infrastructure/__init__.py +104 -0
- aury/boot/infrastructure/cache/__init__.py +31 -0
- aury/boot/infrastructure/cache/backends.py +348 -0
- aury/boot/infrastructure/cache/base.py +68 -0
- aury/boot/infrastructure/cache/exceptions.py +37 -0
- aury/boot/infrastructure/cache/factory.py +94 -0
- aury/boot/infrastructure/cache/manager.py +274 -0
- aury/boot/infrastructure/database/__init__.py +39 -0
- aury/boot/infrastructure/database/config.py +71 -0
- aury/boot/infrastructure/database/exceptions.py +44 -0
- aury/boot/infrastructure/database/manager.py +317 -0
- aury/boot/infrastructure/database/query_tools/__init__.py +164 -0
- aury/boot/infrastructure/database/strategies/__init__.py +198 -0
- aury/boot/infrastructure/di/__init__.py +15 -0
- aury/boot/infrastructure/di/container.py +393 -0
- aury/boot/infrastructure/events/__init__.py +33 -0
- aury/boot/infrastructure/events/bus.py +362 -0
- aury/boot/infrastructure/events/config.py +52 -0
- aury/boot/infrastructure/events/consumer.py +134 -0
- aury/boot/infrastructure/events/middleware.py +51 -0
- aury/boot/infrastructure/events/models.py +63 -0
- aury/boot/infrastructure/monitoring/__init__.py +529 -0
- aury/boot/infrastructure/scheduler/__init__.py +19 -0
- aury/boot/infrastructure/scheduler/exceptions.py +37 -0
- aury/boot/infrastructure/scheduler/manager.py +478 -0
- aury/boot/infrastructure/storage/__init__.py +38 -0
- aury/boot/infrastructure/storage/base.py +164 -0
- aury/boot/infrastructure/storage/exceptions.py +37 -0
- aury/boot/infrastructure/storage/factory.py +88 -0
- aury/boot/infrastructure/tasks/__init__.py +24 -0
- aury/boot/infrastructure/tasks/config.py +45 -0
- aury/boot/infrastructure/tasks/constants.py +37 -0
- aury/boot/infrastructure/tasks/exceptions.py +37 -0
- aury/boot/infrastructure/tasks/manager.py +490 -0
- aury/boot/testing/__init__.py +24 -0
- aury/boot/testing/base.py +122 -0
- aury/boot/testing/client.py +163 -0
- aury/boot/testing/factory.py +154 -0
- aury/boot/toolkit/__init__.py +21 -0
- aury/boot/toolkit/http/__init__.py +367 -0
- {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/METADATA +3 -2
- aury_boot-0.0.3.dist-info/RECORD +137 -0
- aury_boot-0.0.2.dist-info/RECORD +0 -5
- {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/WHEEL +0 -0
- {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""测试客户端。
|
|
2
|
+
|
|
3
|
+
封装 FastAPI TestClient,提供便捷的测试接口。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from fastapi.testclient import TestClient as FastAPITestClient
|
|
12
|
+
from httpx import Response
|
|
13
|
+
|
|
14
|
+
from aury.boot.common.logging import logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestClient:
|
|
18
|
+
"""测试客户端。
|
|
19
|
+
|
|
20
|
+
封装 FastAPI TestClient,提供便捷的测试接口。
|
|
21
|
+
|
|
22
|
+
使用示例:
|
|
23
|
+
app = FastAPI()
|
|
24
|
+
client = TestClient(app)
|
|
25
|
+
|
|
26
|
+
# GET 请求
|
|
27
|
+
response = await client.get("/users", headers={"Authorization": "Bearer token"})
|
|
28
|
+
assert response.status_code == 200
|
|
29
|
+
|
|
30
|
+
# POST 请求
|
|
31
|
+
response = await client.post("/users", json={"name": "张三"})
|
|
32
|
+
assert response.status_code == 201
|
|
33
|
+
assert response.json()["name"] == "张三"
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
app: FastAPI,
|
|
39
|
+
base_url: str = "http://test",
|
|
40
|
+
headers: dict[str, str] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""初始化测试客户端。
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
app: FastAPI 应用实例
|
|
46
|
+
base_url: 基础URL(默认 "http://test")
|
|
47
|
+
headers: 默认请求头
|
|
48
|
+
"""
|
|
49
|
+
self._client = FastAPITestClient(app, base_url=base_url)
|
|
50
|
+
self._default_headers = headers or {}
|
|
51
|
+
logger.debug("测试客户端已创建")
|
|
52
|
+
|
|
53
|
+
def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
|
|
54
|
+
"""准备请求头。
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
headers: 额外的请求头
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
dict[str, str]: 合并后的请求头
|
|
61
|
+
"""
|
|
62
|
+
merged_headers = self._default_headers.copy()
|
|
63
|
+
if headers:
|
|
64
|
+
merged_headers.update(headers)
|
|
65
|
+
return merged_headers
|
|
66
|
+
|
|
67
|
+
def get(
|
|
68
|
+
self,
|
|
69
|
+
url: str,
|
|
70
|
+
params: dict[str, Any] | None = None,
|
|
71
|
+
headers: dict[str, str] | None = None,
|
|
72
|
+
) -> Response:
|
|
73
|
+
"""发送 GET 请求。
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
url: 请求URL
|
|
77
|
+
params: URL参数
|
|
78
|
+
headers: 请求头
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Response: HTTP响应
|
|
82
|
+
"""
|
|
83
|
+
logger.debug(f"GET {url}")
|
|
84
|
+
return self._client.get(url, params=params, headers=self._prepare_headers(headers))
|
|
85
|
+
|
|
86
|
+
def post(
|
|
87
|
+
self,
|
|
88
|
+
url: str,
|
|
89
|
+
json: dict[str, Any] | None = None,
|
|
90
|
+
data: dict[str, Any] | None = None,
|
|
91
|
+
headers: dict[str, str] | None = None,
|
|
92
|
+
) -> Response:
|
|
93
|
+
"""发送 POST 请求。
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
url: 请求URL
|
|
97
|
+
json: JSON数据
|
|
98
|
+
data: 表单数据
|
|
99
|
+
headers: 请求头
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Response: HTTP响应
|
|
103
|
+
"""
|
|
104
|
+
logger.debug(f"POST {url}")
|
|
105
|
+
if json:
|
|
106
|
+
return self._client.post(url, json=json, headers=self._prepare_headers(headers))
|
|
107
|
+
else:
|
|
108
|
+
return self._client.post(url, data=data, headers=self._prepare_headers(headers))
|
|
109
|
+
|
|
110
|
+
def put(
|
|
111
|
+
self,
|
|
112
|
+
url: str,
|
|
113
|
+
json: dict[str, Any] | None = None,
|
|
114
|
+
headers: dict[str, str] | None = None,
|
|
115
|
+
) -> Response:
|
|
116
|
+
"""发送 PUT 请求。
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
url: 请求URL
|
|
120
|
+
json: JSON数据
|
|
121
|
+
headers: 请求头
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Response: HTTP响应
|
|
125
|
+
"""
|
|
126
|
+
logger.debug(f"PUT {url}")
|
|
127
|
+
return self._client.put(url, json=json, headers=self._prepare_headers(headers))
|
|
128
|
+
|
|
129
|
+
def delete(
|
|
130
|
+
self,
|
|
131
|
+
url: str,
|
|
132
|
+
headers: dict[str, str] | None = None,
|
|
133
|
+
) -> Response:
|
|
134
|
+
"""发送 DELETE 请求。
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
url: 请求URL
|
|
138
|
+
headers: 请求头
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Response: HTTP响应
|
|
142
|
+
"""
|
|
143
|
+
logger.debug(f"DELETE {url}")
|
|
144
|
+
return self._client.delete(url, headers=self._prepare_headers(headers))
|
|
145
|
+
|
|
146
|
+
def patch(
|
|
147
|
+
self,
|
|
148
|
+
url: str,
|
|
149
|
+
json: dict[str, Any] | None = None,
|
|
150
|
+
headers: dict[str, str] | None = None,
|
|
151
|
+
) -> Response:
|
|
152
|
+
"""发送 PATCH 请求。
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
url: 请求URL
|
|
156
|
+
json: JSON数据
|
|
157
|
+
headers: 请求头
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Response: HTTP响应
|
|
161
|
+
"""
|
|
162
|
+
logger.debug(f"PATCH {url}")
|
|
163
|
+
return self._client.patch(url, json=json, headers=self._prepare_headers(headers))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""测试数据工厂。
|
|
2
|
+
|
|
3
|
+
提供便捷的测试数据生成工具,类似 Django 的 Factory。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from faker import Faker
|
|
11
|
+
|
|
12
|
+
from aury.boot.common.logging import logger
|
|
13
|
+
from aury.boot.domain.models import Base
|
|
14
|
+
|
|
15
|
+
ModelType = TypeVar("ModelType", bound=Base)
|
|
16
|
+
|
|
17
|
+
# 全局 Faker 实例
|
|
18
|
+
_faker = Faker(["zh_CN", "en_US"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Factory:
|
|
22
|
+
"""测试数据工厂。
|
|
23
|
+
|
|
24
|
+
提供便捷的测试数据生成工具。
|
|
25
|
+
|
|
26
|
+
使用示例:
|
|
27
|
+
# 创建工厂
|
|
28
|
+
user_factory = Factory(User)
|
|
29
|
+
|
|
30
|
+
# 创建单个实例
|
|
31
|
+
user = await user_factory.create(name="张三", email="zhangsan@example.com")
|
|
32
|
+
|
|
33
|
+
# 创建多个实例
|
|
34
|
+
users = await user_factory.create_batch(5, name="批量用户")
|
|
35
|
+
|
|
36
|
+
# 使用 Faker 生成随机数据
|
|
37
|
+
user = await user_factory.create() # 使用默认值或随机生成
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, model_class: type[ModelType], **defaults: Any) -> None:
|
|
41
|
+
"""初始化工厂。
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
model_class: 模型类
|
|
45
|
+
**defaults: 默认属性值
|
|
46
|
+
"""
|
|
47
|
+
self._model_class = model_class
|
|
48
|
+
self._defaults = defaults
|
|
49
|
+
logger.debug(f"创建工厂: {model_class.__name__}")
|
|
50
|
+
|
|
51
|
+
def _generate_field_value(self, field_name: str, field_type: Any) -> Any:
|
|
52
|
+
"""生成字段值。
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
field_name: 字段名
|
|
56
|
+
field_type: 字段类型
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Any: 生成的字段值
|
|
60
|
+
"""
|
|
61
|
+
# 根据字段名和类型生成合适的值
|
|
62
|
+
if "email" in field_name.lower():
|
|
63
|
+
return _faker.email()
|
|
64
|
+
elif "name" in field_name.lower() or "username" in field_name.lower():
|
|
65
|
+
return _faker.name()
|
|
66
|
+
elif "phone" in field_name.lower() or "mobile" in field_name.lower():
|
|
67
|
+
return _faker.phone_number()
|
|
68
|
+
elif "address" in field_name.lower():
|
|
69
|
+
return _faker.address()
|
|
70
|
+
elif "url" in field_name.lower():
|
|
71
|
+
return _faker.url()
|
|
72
|
+
elif "text" in field_name.lower() or "content" in field_name.lower():
|
|
73
|
+
return _faker.text()
|
|
74
|
+
elif "date" in field_name.lower() or "time" in field_name.lower():
|
|
75
|
+
return _faker.date_time()
|
|
76
|
+
elif "int" in str(field_type).lower() or "integer" in str(field_type).lower():
|
|
77
|
+
return _faker.random_int()
|
|
78
|
+
elif "float" in str(field_type).lower():
|
|
79
|
+
return _faker.pyfloat()
|
|
80
|
+
elif "bool" in str(field_type).lower() or "boolean" in str(field_type).lower():
|
|
81
|
+
return _faker.boolean()
|
|
82
|
+
else:
|
|
83
|
+
return _faker.word()
|
|
84
|
+
|
|
85
|
+
def _build_attributes(self, **overrides: Any) -> dict[str, Any]:
|
|
86
|
+
"""构建属性字典。
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
**overrides: 覆盖的属性值
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
dict[str, Any]: 属性字典
|
|
93
|
+
"""
|
|
94
|
+
attrs = self._defaults.copy()
|
|
95
|
+
|
|
96
|
+
# 如果模型有字段定义,尝试生成缺失的字段
|
|
97
|
+
if hasattr(self._model_class, "__table__"):
|
|
98
|
+
table = self._model_class.__table__
|
|
99
|
+
for column in table.columns:
|
|
100
|
+
if column.name not in attrs and column.name not in overrides:
|
|
101
|
+
# 跳过主键和自动生成的字段
|
|
102
|
+
if column.primary_key or column.server_default:
|
|
103
|
+
continue
|
|
104
|
+
# 尝试生成值
|
|
105
|
+
attrs[column.name] = self._generate_field_value(column.name, column.type)
|
|
106
|
+
|
|
107
|
+
# 应用覆盖值
|
|
108
|
+
attrs.update(overrides)
|
|
109
|
+
return attrs
|
|
110
|
+
|
|
111
|
+
async def create(self, **kwargs: Any) -> ModelType:
|
|
112
|
+
"""创建单个实例。
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
**kwargs: 属性值(覆盖默认值)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
ModelType: 创建的模型实例
|
|
119
|
+
"""
|
|
120
|
+
attrs = self._build_attributes(**kwargs)
|
|
121
|
+
instance = self._model_class(**attrs)
|
|
122
|
+
logger.debug(f"创建测试数据: {self._model_class.__name__}")
|
|
123
|
+
return instance
|
|
124
|
+
|
|
125
|
+
async def create_batch(self, size: int, **kwargs: Any) -> list[ModelType]:
|
|
126
|
+
"""批量创建实例。
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
size: 创建数量
|
|
130
|
+
**kwargs: 属性值(覆盖默认值)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
list[ModelType]: 创建的模型实例列表
|
|
134
|
+
"""
|
|
135
|
+
instances = []
|
|
136
|
+
for _ in range(size):
|
|
137
|
+
instance = await self.create(**kwargs)
|
|
138
|
+
instances.append(instance)
|
|
139
|
+
logger.debug(f"批量创建 {size} 个测试数据: {self._model_class.__name__}")
|
|
140
|
+
return instances
|
|
141
|
+
|
|
142
|
+
def build(self, **kwargs: Any) -> ModelType:
|
|
143
|
+
"""构建实例(不保存到数据库)。
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
**kwargs: 属性值(覆盖默认值)
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
ModelType: 构建的模型实例
|
|
150
|
+
"""
|
|
151
|
+
attrs = self._build_attributes(**kwargs)
|
|
152
|
+
instance = self._model_class(**attrs)
|
|
153
|
+
logger.debug(f"构建测试数据(未保存): {self._model_class.__name__}")
|
|
154
|
+
return instance
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""工具包模块。
|
|
2
|
+
|
|
3
|
+
提供各种实用工具,如 HTTP 客户端等。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .http import (
|
|
7
|
+
HttpClient,
|
|
8
|
+
HttpClientConfig,
|
|
9
|
+
LoggingInterceptor,
|
|
10
|
+
RequestInterceptor,
|
|
11
|
+
RetryConfig,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"HttpClient",
|
|
16
|
+
"HttpClientConfig",
|
|
17
|
+
"LoggingInterceptor",
|
|
18
|
+
"RequestInterceptor",
|
|
19
|
+
"RetryConfig",
|
|
20
|
+
]
|
|
21
|
+
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""HTTP客户端 - 企业级HTTP请求工具。
|
|
2
|
+
|
|
3
|
+
特性:
|
|
4
|
+
- 连接池管理
|
|
5
|
+
- 自动重试机制(使用tenacity)
|
|
6
|
+
- 请求/响应拦截器
|
|
7
|
+
- 超时控制
|
|
8
|
+
- 错误处理
|
|
9
|
+
- 请求日志
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from typing import Any, Optional, TypeVar
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
from tenacity import (
|
|
20
|
+
retry,
|
|
21
|
+
retry_if_exception_type,
|
|
22
|
+
stop_after_attempt,
|
|
23
|
+
wait_exponential,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from aury.boot.common.logging import logger
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RetryConfig(BaseModel):
|
|
32
|
+
"""重试配置(Pydantic)。"""
|
|
33
|
+
|
|
34
|
+
max_retries: int = 3
|
|
35
|
+
retry_delay: float = 1.0
|
|
36
|
+
backoff_factor: float = 2.0
|
|
37
|
+
retry_on_status: list[int] = [500, 502, 503, 504]
|
|
38
|
+
retry_on_exceptions: tuple = (httpx.TimeoutException, httpx.NetworkError)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HttpClientConfig(BaseModel):
|
|
42
|
+
"""HTTP客户端配置(Pydantic)。"""
|
|
43
|
+
|
|
44
|
+
base_url: str = ""
|
|
45
|
+
timeout: float = 30.0
|
|
46
|
+
follow_redirects: bool = True
|
|
47
|
+
max_connections: int = 100
|
|
48
|
+
max_keepalive_connections: int = 20
|
|
49
|
+
keepalive_expiry: float = 5.0
|
|
50
|
+
retry: RetryConfig | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RequestInterceptor(ABC):
|
|
54
|
+
"""请求拦截器接口。"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def before_request(self, request: httpx.Request) -> httpx.Request:
|
|
58
|
+
"""请求前处理。"""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
async def after_response(self, response: httpx.Response) -> httpx.Response:
|
|
63
|
+
"""响应后处理。"""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LoggingInterceptor(RequestInterceptor):
|
|
68
|
+
"""日志拦截器。"""
|
|
69
|
+
|
|
70
|
+
async def before_request(self, request: httpx.Request) -> httpx.Request:
|
|
71
|
+
"""记录请求日志。"""
|
|
72
|
+
logger.debug(
|
|
73
|
+
f"HTTP请求: {request.method} {request.url} | "
|
|
74
|
+
f"Headers: {dict(request.headers)}"
|
|
75
|
+
)
|
|
76
|
+
return request
|
|
77
|
+
|
|
78
|
+
async def after_response(self, response: httpx.Response) -> httpx.Response:
|
|
79
|
+
"""记录响应日志。"""
|
|
80
|
+
logger.debug(
|
|
81
|
+
f"HTTP响应: {response.status_code} {response.url} | "
|
|
82
|
+
f"耗时: {response.elapsed.total_seconds():.3f}s"
|
|
83
|
+
)
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HttpClient:
|
|
88
|
+
"""企业级HTTP客户端。
|
|
89
|
+
|
|
90
|
+
特性:
|
|
91
|
+
- 连接池管理
|
|
92
|
+
- 自动重试(使用tenacity)
|
|
93
|
+
- 拦截器支持
|
|
94
|
+
- 超时控制
|
|
95
|
+
- 错误处理
|
|
96
|
+
|
|
97
|
+
使用示例:
|
|
98
|
+
# 基础使用
|
|
99
|
+
client = HttpClient(base_url="https://api.example.com")
|
|
100
|
+
response = await client.get("/users")
|
|
101
|
+
|
|
102
|
+
# 带重试
|
|
103
|
+
config = HttpClientConfig(
|
|
104
|
+
base_url="https://api.example.com",
|
|
105
|
+
retry=RetryConfig(max_retries=3)
|
|
106
|
+
)
|
|
107
|
+
client = HttpClient.from_config(config)
|
|
108
|
+
response = await client.get("/users")
|
|
109
|
+
|
|
110
|
+
# 添加拦截器
|
|
111
|
+
client.add_interceptor(LoggingInterceptor())
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
base_url: str = "",
|
|
117
|
+
*,
|
|
118
|
+
timeout: float = 30.0,
|
|
119
|
+
follow_redirects: bool = True,
|
|
120
|
+
max_connections: int = 100,
|
|
121
|
+
retry_config: RetryConfig | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""初始化HTTP客户端。
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
base_url: 基础URL
|
|
127
|
+
timeout: 超时时间(秒)
|
|
128
|
+
follow_redirects: 是否跟随重定向
|
|
129
|
+
max_connections: 最大连接数
|
|
130
|
+
retry_config: 重试配置
|
|
131
|
+
"""
|
|
132
|
+
self._base_url = base_url
|
|
133
|
+
self._timeout = timeout
|
|
134
|
+
self._follow_redirects = follow_redirects
|
|
135
|
+
self._max_connections = max_connections
|
|
136
|
+
self._retry_config = retry_config or RetryConfig()
|
|
137
|
+
self._interceptors: list[RequestInterceptor] = []
|
|
138
|
+
|
|
139
|
+
# 创建HTTP客户端(使用连接池)
|
|
140
|
+
limits = httpx.Limits(
|
|
141
|
+
max_connections=max_connections,
|
|
142
|
+
max_keepalive_connections=20,
|
|
143
|
+
keepalive_expiry=5.0,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self._client = httpx.AsyncClient(
|
|
147
|
+
base_url=base_url,
|
|
148
|
+
timeout=timeout,
|
|
149
|
+
follow_redirects=follow_redirects,
|
|
150
|
+
limits=limits,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
logger.debug(f"HTTP客户端初始化: base_url={base_url}, timeout={timeout}")
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_config(cls, config: HttpClientConfig) -> HttpClient:
|
|
157
|
+
"""从配置创建客户端。
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
config: 客户端配置
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
HttpClient: HTTP客户端实例
|
|
164
|
+
"""
|
|
165
|
+
return cls(
|
|
166
|
+
base_url=config.base_url,
|
|
167
|
+
timeout=config.timeout,
|
|
168
|
+
follow_redirects=config.follow_redirects,
|
|
169
|
+
max_connections=config.max_connections,
|
|
170
|
+
retry_config=config.retry,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def add_interceptor(self, interceptor: RequestInterceptor) -> None:
|
|
174
|
+
"""添加拦截器。
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
interceptor: 拦截器实例
|
|
178
|
+
"""
|
|
179
|
+
self._interceptors.append(interceptor)
|
|
180
|
+
logger.debug(f"添加拦截器: {interceptor.__class__.__name__}")
|
|
181
|
+
|
|
182
|
+
async def _apply_interceptors(
|
|
183
|
+
self,
|
|
184
|
+
request: httpx.Request,
|
|
185
|
+
response: httpx.Response | None = None,
|
|
186
|
+
) -> tuple[httpx.Request, httpx.Response | None]:
|
|
187
|
+
"""应用拦截器。"""
|
|
188
|
+
# 请求前拦截
|
|
189
|
+
for interceptor in self._interceptors:
|
|
190
|
+
request = await interceptor.before_request(request)
|
|
191
|
+
|
|
192
|
+
# 响应后拦截
|
|
193
|
+
if response:
|
|
194
|
+
for interceptor in self._interceptors:
|
|
195
|
+
response = await interceptor.after_response(response)
|
|
196
|
+
|
|
197
|
+
return request, response
|
|
198
|
+
|
|
199
|
+
async def _make_request(
|
|
200
|
+
self,
|
|
201
|
+
method: str,
|
|
202
|
+
url: str,
|
|
203
|
+
**kwargs: Any,
|
|
204
|
+
) -> httpx.Response:
|
|
205
|
+
"""执行HTTP请求(内部方法,使用tenacity重试)。"""
|
|
206
|
+
response = await self._client.request(method, url, **kwargs)
|
|
207
|
+
|
|
208
|
+
# 检查状态码是否需要重试
|
|
209
|
+
if response.status_code in self._retry_config.retry_on_status:
|
|
210
|
+
logger.warning(
|
|
211
|
+
f"请求失败,状态码: {response.status_code}, 将重试"
|
|
212
|
+
)
|
|
213
|
+
raise httpx.HTTPStatusError(
|
|
214
|
+
f"HTTP {response.status_code}",
|
|
215
|
+
request=response.request,
|
|
216
|
+
response=response,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
def _get_retry_decorator(self):
|
|
222
|
+
"""动态构建重试装饰器。"""
|
|
223
|
+
return retry(
|
|
224
|
+
stop=stop_after_attempt(self._retry_config.max_retries + 1),
|
|
225
|
+
wait=wait_exponential(
|
|
226
|
+
multiplier=self._retry_config.retry_delay,
|
|
227
|
+
min=self._retry_config.retry_delay,
|
|
228
|
+
max=self._retry_config.retry_delay * (self._retry_config.backoff_factor ** self._retry_config.max_retries),
|
|
229
|
+
),
|
|
230
|
+
retry=retry_if_exception_type(*self._retry_config.retry_on_exceptions),
|
|
231
|
+
reraise=True,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def request(
|
|
235
|
+
self,
|
|
236
|
+
method: str,
|
|
237
|
+
url: str,
|
|
238
|
+
*,
|
|
239
|
+
headers: dict[str, str] | None = None,
|
|
240
|
+
params: dict[str, Any] | None = None,
|
|
241
|
+
json: Any = None,
|
|
242
|
+
data: Any = None,
|
|
243
|
+
files: Any = None,
|
|
244
|
+
**kwargs: Any,
|
|
245
|
+
) -> httpx.Response:
|
|
246
|
+
"""发送HTTP请求。
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
method: HTTP方法
|
|
250
|
+
url: 请求URL
|
|
251
|
+
headers: 请求头
|
|
252
|
+
params: 查询参数
|
|
253
|
+
json: JSON数据
|
|
254
|
+
data: 表单数据
|
|
255
|
+
files: 文件
|
|
256
|
+
**kwargs: 其他参数
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
httpx.Response: 响应对象
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
httpx.HTTPError: 请求失败
|
|
263
|
+
"""
|
|
264
|
+
# 构建完整URL
|
|
265
|
+
full_url = f"{self._base_url}{url}" if self._base_url else url
|
|
266
|
+
|
|
267
|
+
# 创建请求对象
|
|
268
|
+
request = self._client.build_request(
|
|
269
|
+
method=method,
|
|
270
|
+
url=full_url,
|
|
271
|
+
headers=headers,
|
|
272
|
+
params=params,
|
|
273
|
+
json=json,
|
|
274
|
+
data=data,
|
|
275
|
+
files=files,
|
|
276
|
+
**kwargs,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# 应用拦截器(请求前)
|
|
280
|
+
request, _ = await self._apply_interceptors(request)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
# 使用tenacity重试装饰器
|
|
284
|
+
retry_decorator = self._get_retry_decorator()
|
|
285
|
+
|
|
286
|
+
@retry_decorator
|
|
287
|
+
async def _execute_request():
|
|
288
|
+
return await self._make_request(
|
|
289
|
+
method=request.method,
|
|
290
|
+
url=str(request.url),
|
|
291
|
+
headers=request.headers,
|
|
292
|
+
content=request.content,
|
|
293
|
+
**kwargs,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
response = await _execute_request()
|
|
297
|
+
|
|
298
|
+
# 应用拦截器(响应后)
|
|
299
|
+
_, response = await self._apply_interceptors(request, response)
|
|
300
|
+
|
|
301
|
+
# 检查状态码
|
|
302
|
+
response.raise_for_status()
|
|
303
|
+
|
|
304
|
+
return response
|
|
305
|
+
|
|
306
|
+
except httpx.HTTPError as exc:
|
|
307
|
+
logger.error(
|
|
308
|
+
f"HTTP请求失败: {method} {full_url} | "
|
|
309
|
+
f"错误: {type(exc).__name__}: {exc}"
|
|
310
|
+
)
|
|
311
|
+
raise
|
|
312
|
+
|
|
313
|
+
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
314
|
+
"""GET请求。"""
|
|
315
|
+
return await self.request("GET", url, **kwargs)
|
|
316
|
+
|
|
317
|
+
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
318
|
+
"""POST请求。"""
|
|
319
|
+
return await self.request("POST", url, **kwargs)
|
|
320
|
+
|
|
321
|
+
async def put(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
322
|
+
"""PUT请求。"""
|
|
323
|
+
return await self.request("PUT", url, **kwargs)
|
|
324
|
+
|
|
325
|
+
async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
326
|
+
"""PATCH请求。"""
|
|
327
|
+
return await self.request("PATCH", url, **kwargs)
|
|
328
|
+
|
|
329
|
+
async def delete(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
330
|
+
"""DELETE请求。"""
|
|
331
|
+
return await self.request("DELETE", url, **kwargs)
|
|
332
|
+
|
|
333
|
+
async def head(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
334
|
+
"""HEAD请求。"""
|
|
335
|
+
return await self.request("HEAD", url, **kwargs)
|
|
336
|
+
|
|
337
|
+
async def options(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
338
|
+
"""OPTIONS请求。"""
|
|
339
|
+
return await self.request("OPTIONS", url, **kwargs)
|
|
340
|
+
|
|
341
|
+
async def close(self) -> None:
|
|
342
|
+
"""关闭客户端。"""
|
|
343
|
+
await self._client.aclose()
|
|
344
|
+
logger.debug("HTTP客户端已关闭")
|
|
345
|
+
|
|
346
|
+
async def __aenter__(self) -> HttpClient:
|
|
347
|
+
"""异步上下文管理器入口。"""
|
|
348
|
+
return self
|
|
349
|
+
|
|
350
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
351
|
+
"""异步上下文管理器出口。"""
|
|
352
|
+
await self.close()
|
|
353
|
+
|
|
354
|
+
def __repr__(self) -> str:
|
|
355
|
+
"""字符串表示。"""
|
|
356
|
+
return f"<HttpClient base_url={self._base_url} timeout={self._timeout}>"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
__all__ = [
|
|
360
|
+
"HttpClient",
|
|
361
|
+
"HttpClientConfig",
|
|
362
|
+
"LoggingInterceptor",
|
|
363
|
+
"RequestInterceptor",
|
|
364
|
+
"RetryConfig",
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
|