bella-openapi 1.0.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.
- ait_openapi/__init__.py +22 -0
- ait_openapi/auth_billing.py +91 -0
- ait_openapi/authorize.py +61 -0
- ait_openapi/bella_trace/__init__.py +13 -0
- ait_openapi/bella_trace/_context.py +61 -0
- ait_openapi/bella_trace/fastapi_interceptor.py +28 -0
- ait_openapi/bella_trace/record_log.py +58 -0
- ait_openapi/bella_trace/trace_requests.py +58 -0
- ait_openapi/config.py +15 -0
- ait_openapi/console/__init__.py +3 -0
- ait_openapi/console/models.py +45 -0
- ait_openapi/exception.py +7 -0
- ait_openapi/log.py +222 -0
- ait_openapi/middleware/__init__.py +3 -0
- ait_openapi/middleware/context_middleware.py +108 -0
- ait_openapi/openapi_contexvar.py +6 -0
- ait_openapi/schema.py +63 -0
- bella_openapi-1.0.0.dist-info/METADATA +256 -0
- bella_openapi-1.0.0.dist-info/RECORD +22 -0
- bella_openapi-1.0.0.dist-info/WHEEL +5 -0
- bella_openapi-1.0.0.dist-info/licenses/LICENSE +21 -0
- bella_openapi-1.0.0.dist-info/top_level.txt +1 -0
ait_openapi/__init__.py
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
from .authorize import validate_token, support_model, account_balance_enough, check_configuration
|
2
|
+
from .log import operation_log, submit_log
|
3
|
+
from .openapi_contexvar import trace_id_context, caller_id_context, request_url_context
|
4
|
+
from .auth_billing import ErrorInfo, async_authenticate_decorator_args, authenticate_user, print_context, \
|
5
|
+
get_context, set_context, clean_context, report
|
6
|
+
__all__ = ["validate_token", "operation_log",
|
7
|
+
"support_model",
|
8
|
+
"account_balance_enough",
|
9
|
+
"check_configuration",
|
10
|
+
"trace_id_context",
|
11
|
+
"caller_id_context",
|
12
|
+
"request_url_context",
|
13
|
+
"submit_log",
|
14
|
+
"ErrorInfo",
|
15
|
+
"async_authenticate_decorator_args",
|
16
|
+
"authenticate_user",
|
17
|
+
"print_context",
|
18
|
+
"get_context",
|
19
|
+
"set_context",
|
20
|
+
"clean_context",
|
21
|
+
"report"
|
22
|
+
]
|
@@ -0,0 +1,91 @@
|
|
1
|
+
from .log import operation_log
|
2
|
+
from .authorize import validate_token, check_configuration, account_balance_enough
|
3
|
+
from .openapi_contexvar import trace_id_context, caller_id_context, request_url_context
|
4
|
+
from pydantic import BaseModel
|
5
|
+
import uuid
|
6
|
+
from fastapi import Request
|
7
|
+
import logging
|
8
|
+
# 创建一个日志记录器
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
logger.setLevel(logging.INFO) # 设置日志级别为INFO
|
11
|
+
|
12
|
+
# 创建一个控制台处理器,并设置其级别和格式
|
13
|
+
console_handler = logging.StreamHandler()
|
14
|
+
console_handler.setLevel(logging.INFO)
|
15
|
+
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
16
|
+
console_handler.setFormatter(formatter)
|
17
|
+
|
18
|
+
# 将处理器添加到日志记录器
|
19
|
+
logger.addHandler(console_handler)
|
20
|
+
|
21
|
+
class ErrorInfo(BaseModel):
|
22
|
+
task_id: str = ""
|
23
|
+
result: str = ""
|
24
|
+
status: int = 40000001
|
25
|
+
message: str = ""
|
26
|
+
|
27
|
+
def async_authenticate_decorator_args(end_point):
|
28
|
+
def async_authenticate_decorator(func):
|
29
|
+
async def wrapper(*args, **kwargs):
|
30
|
+
request_arg = None
|
31
|
+
for arg in args:
|
32
|
+
if type(arg) == Request:
|
33
|
+
request_arg = arg
|
34
|
+
break
|
35
|
+
if request_arg is not None:
|
36
|
+
task_id = str(uuid.uuid4())
|
37
|
+
supported, error_json, caller_id = authenticate_user(request_arg.headers.get("Authorization"), task_id)
|
38
|
+
if not supported:
|
39
|
+
return error_json
|
40
|
+
t_token, c_token, r_token = set_context(task_id, caller_id, end_point)
|
41
|
+
result = await func(*args, **kwargs)
|
42
|
+
clean_context(t_token, c_token, r_token)
|
43
|
+
return result
|
44
|
+
else:
|
45
|
+
logger.warn("please check your request param,have not a param's type is Request of fastapi!")
|
46
|
+
return func(*args, **kwargs)
|
47
|
+
return wrapper
|
48
|
+
return async_authenticate_decorator
|
49
|
+
|
50
|
+
|
51
|
+
def authenticate_user(token, task_id):
|
52
|
+
if check_configuration():
|
53
|
+
if token is None:
|
54
|
+
return False, ErrorInfo(task_id=task_id, result="", status=40000001, message="token is missing").dict(), ""
|
55
|
+
try:
|
56
|
+
caller_id = validate_token(token)
|
57
|
+
balance = account_balance_enough(token, cost=1.0)
|
58
|
+
|
59
|
+
except Exception as e:
|
60
|
+
return False, ErrorInfo(task_id=task_id, result="", status=40000001, message=str(e)[:100]).dict(), ""
|
61
|
+
return balance, ErrorInfo(task_id=task_id, result="", status=40000001, message="Insufficient balance").dict(), caller_id
|
62
|
+
else:
|
63
|
+
return True, "", ""
|
64
|
+
|
65
|
+
|
66
|
+
@operation_log(op_type='upload_cost_log', is_cost_log=True, ucid_key="ucid")
|
67
|
+
def upload_cost_log(result_obejct, ucid):
|
68
|
+
response = result_obejct.dict()
|
69
|
+
return response
|
70
|
+
|
71
|
+
def print_context(log):
|
72
|
+
logger.info(f"{log} trace_id:{trace_id_context.get()}, caller_id:{caller_id_context.get()}, end_point:{request_url_context.get()}")
|
73
|
+
|
74
|
+
def get_context():
|
75
|
+
return trace_id_context.get(), caller_id_context.get(), request_url_context.get()
|
76
|
+
|
77
|
+
def set_context(trace_id, caller_id, end_point):
|
78
|
+
t_token = trace_id_context.set(trace_id)
|
79
|
+
c_token = caller_id_context.set(caller_id)
|
80
|
+
r_token = request_url_context.set(end_point)
|
81
|
+
return t_token, c_token, r_token
|
82
|
+
|
83
|
+
def clean_context(t_token, c_token, r_token):
|
84
|
+
trace_id_context.reset(t_token)
|
85
|
+
caller_id_context.reset(c_token)
|
86
|
+
request_url_context.reset(r_token)
|
87
|
+
|
88
|
+
def report(result_obejct, ucid = ""):
|
89
|
+
if not check_configuration():
|
90
|
+
return
|
91
|
+
upload_cost_log(result_obejct, ucid)
|
ait_openapi/authorize.py
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
import httpx
|
2
|
+
import logging
|
3
|
+
from .config import openapi_config
|
4
|
+
from .exception import AuthorizationException
|
5
|
+
|
6
|
+
|
7
|
+
def check_configuration() -> bool:
|
8
|
+
res = openapi_config.OPENAPI_HOST is not None
|
9
|
+
return res
|
10
|
+
|
11
|
+
|
12
|
+
def validate_token(token: str) -> str:
|
13
|
+
"""
|
14
|
+
根据传入token, 解析用户身份
|
15
|
+
:param token:
|
16
|
+
:return: 用户身份,返回账户id
|
17
|
+
:raises: AuthorizationException 如果token无效, 抛出异常
|
18
|
+
"""
|
19
|
+
return _validate_token_request(token)
|
20
|
+
|
21
|
+
|
22
|
+
def support_model(token: str, model: str) -> bool:
|
23
|
+
"""
|
24
|
+
根据传入token,判断用户是否有对应的模型权限
|
25
|
+
:param token: 用户token
|
26
|
+
:param model: 模型名
|
27
|
+
:return: bool
|
28
|
+
"""
|
29
|
+
url = openapi_config.OPENAPI_HOST + "/v1/openapi/support/model"
|
30
|
+
# 使用httpx发送get请求
|
31
|
+
response = httpx.get(url, headers={"Authorization": token}, params={"model": model})
|
32
|
+
if response.status_code == 200:
|
33
|
+
return response.json()['data']
|
34
|
+
else:
|
35
|
+
raise AuthorizationException(response.text, response.status_code)
|
36
|
+
|
37
|
+
|
38
|
+
def account_balance_enough(token: str, cost: float = 0) -> bool:
|
39
|
+
"""
|
40
|
+
账户余额判断,支持传入指定判断阈值,服务方可以在每次用户请求前,根据本次预估花费,对用户余额进行校验,若余额不足,则可拒绝请求
|
41
|
+
:param token: 用户token
|
42
|
+
:param cost: 消耗金额, 单位:元/RMB
|
43
|
+
:return: bool
|
44
|
+
"""
|
45
|
+
url = openapi_config.OPENAPI_HOST + "/v1/openapi/check/account/balance"
|
46
|
+
# 使用httpx发送get请求
|
47
|
+
response = httpx.get(url, headers={"Authorization": token}, params={"cost": cost})
|
48
|
+
if response.status_code == 200:
|
49
|
+
return response.json()['data']
|
50
|
+
else:
|
51
|
+
raise AuthorizationException(response.text, response.status_code)
|
52
|
+
|
53
|
+
|
54
|
+
def _validate_token_request(token: str) -> str:
|
55
|
+
url = openapi_config.OPENAPI_HOST + "/v1/openapi/validate/tokens"
|
56
|
+
# 使用httpx发送get请求
|
57
|
+
response = httpx.get(url, headers={"Authorization": token})
|
58
|
+
if response.status_code == 200:
|
59
|
+
return response.json()['data']
|
60
|
+
else:
|
61
|
+
raise AuthorizationException(response.text, response.status_code)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
# ======================
|
3
|
+
# Date : 2024/12/30
|
4
|
+
# Author : Liu Yuchen
|
5
|
+
# Content :
|
6
|
+
# 协议规范:https://doc.weixin.qq.com/doc/w3_AagAxwZdAD4dsCIEHU3RL26Knh1x8?scode=AJMA1Qc4AAwYUI6MJrAAEASgZXANE
|
7
|
+
# ======================
|
8
|
+
from ._context import TraceContext, TRACE_ID
|
9
|
+
from .fastapi_interceptor import FastapiBellaTraceMiddleware
|
10
|
+
import ait_openapi.bella_trace.trace_requests as requests
|
11
|
+
from .record_log import trace, BellaTraceHandler
|
12
|
+
|
13
|
+
__all__ = ["TraceContext", "TRACE_ID", "FastapiBellaTraceMiddleware", "requests", "trace", "BellaTraceHandler"]
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
# ======================
|
3
|
+
# Date : 2024/12/30
|
4
|
+
# Author : Liu Yuchen
|
5
|
+
# Content :
|
6
|
+
#
|
7
|
+
# ======================
|
8
|
+
import os
|
9
|
+
import uuid
|
10
|
+
from contextvars import ContextVar
|
11
|
+
|
12
|
+
__all__ = ["TraceContext", "TRACE_ID"]
|
13
|
+
|
14
|
+
|
15
|
+
_trace_id = ContextVar("bella_trace_id", default="")
|
16
|
+
_mock_request = ContextVar("mock_request", default="false")
|
17
|
+
|
18
|
+
|
19
|
+
TRACE_ID = "X-BELLA-TRACE-ID"
|
20
|
+
MOCK_REQUEST = "X-BELLA-MOCK-REQUEST"
|
21
|
+
|
22
|
+
|
23
|
+
class _TraceContext(object):
|
24
|
+
@property
|
25
|
+
def trace_id(self) -> str:
|
26
|
+
return _trace_id.get()
|
27
|
+
|
28
|
+
@trace_id.setter
|
29
|
+
def trace_id(self, value):
|
30
|
+
_trace_id.set(value)
|
31
|
+
|
32
|
+
@property
|
33
|
+
def service_id(self) -> str:
|
34
|
+
return _get_service_id()
|
35
|
+
|
36
|
+
@staticmethod
|
37
|
+
def generate_trace_id() -> str:
|
38
|
+
return f"{_get_service_id()}-{uuid.uuid4().hex}"
|
39
|
+
|
40
|
+
@property
|
41
|
+
def mock_request(self):
|
42
|
+
return _mock_request.get()
|
43
|
+
|
44
|
+
@mock_request.setter
|
45
|
+
def mock_request(self, value: str):
|
46
|
+
_mock_request.set(value)
|
47
|
+
|
48
|
+
@property
|
49
|
+
def is_mock_request(self) -> bool:
|
50
|
+
return self.mock_request.lower() == "true"
|
51
|
+
|
52
|
+
@property
|
53
|
+
def headers(self) -> dict:
|
54
|
+
return {TRACE_ID: self.trace_id, MOCK_REQUEST: self.mock_request}
|
55
|
+
|
56
|
+
|
57
|
+
TraceContext = _TraceContext()
|
58
|
+
|
59
|
+
|
60
|
+
def _get_service_id() -> str:
|
61
|
+
return os.environ.get("SERVICE_ID", "")
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
# ======================
|
3
|
+
# Date : 2024/12/30
|
4
|
+
# Author : Liu Yuchen
|
5
|
+
# Content :
|
6
|
+
#
|
7
|
+
# ======================
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
9
|
+
|
10
|
+
from ._context import TraceContext, TRACE_ID, MOCK_REQUEST
|
11
|
+
|
12
|
+
__all__ = ["FastapiBellaTraceMiddleware"]
|
13
|
+
|
14
|
+
|
15
|
+
class FastapiBellaTraceMiddleware(BaseHTTPMiddleware):
|
16
|
+
async def dispatch(self, request, call_next):
|
17
|
+
# 设置 trace_id
|
18
|
+
if trace_id_h := request.headers.get(TRACE_ID):
|
19
|
+
TraceContext.trace_id = trace_id_h
|
20
|
+
else:
|
21
|
+
TraceContext.trace_id = TraceContext.generate_trace_id()
|
22
|
+
|
23
|
+
if mock_request_h := request.headers.get(MOCK_REQUEST):
|
24
|
+
TraceContext.mock_request = mock_request_h
|
25
|
+
|
26
|
+
# 继续处理请求
|
27
|
+
return await call_next(request)
|
28
|
+
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
# ======================
|
3
|
+
# Date : 2024/12/30
|
4
|
+
# Author : Liu Yuchen
|
5
|
+
# Content :
|
6
|
+
#
|
7
|
+
# ======================
|
8
|
+
import functools
|
9
|
+
import json
|
10
|
+
import logging
|
11
|
+
import time
|
12
|
+
import traceback
|
13
|
+
|
14
|
+
from ._context import TraceContext
|
15
|
+
|
16
|
+
__all__ = ["trace", "BellaTraceHandler"]
|
17
|
+
|
18
|
+
|
19
|
+
def trace(logger=logging):
|
20
|
+
def decorator(func):
|
21
|
+
@functools.wraps(func)
|
22
|
+
def wrapper(*args, **kwargs):
|
23
|
+
data = {
|
24
|
+
"trace_info": {
|
25
|
+
"bellaTraceId": TraceContext.trace_id,
|
26
|
+
"serviceId": TraceContext.service_id,
|
27
|
+
"start": time.time()
|
28
|
+
},
|
29
|
+
"function": func.__name__,
|
30
|
+
"args": args,
|
31
|
+
"kwargs": kwargs
|
32
|
+
}
|
33
|
+
try:
|
34
|
+
result = func(*args, **kwargs)
|
35
|
+
data["result"] = result
|
36
|
+
data["trace_info"]["end"] = time.time()
|
37
|
+
logger.info(json.dumps(data, ensure_ascii=False))
|
38
|
+
return result
|
39
|
+
except Exception as e:
|
40
|
+
try:
|
41
|
+
data["error_msg"] = traceback.format_exception(e)
|
42
|
+
logger.error(json.dumps(data, ensure_ascii=False))
|
43
|
+
except Exception as i_e:
|
44
|
+
logger.error(traceback.format_exception(i_e))
|
45
|
+
raise e
|
46
|
+
return wrapper
|
47
|
+
return decorator
|
48
|
+
|
49
|
+
|
50
|
+
class BellaTraceHandler(logging.Handler):
|
51
|
+
|
52
|
+
def __init__(self, fmt: str = "{name}={value}"):
|
53
|
+
super().__init__()
|
54
|
+
self.format = fmt
|
55
|
+
|
56
|
+
def emit(self, record):
|
57
|
+
if trace_id := TraceContext.trace_id:
|
58
|
+
record.msg = f"{self.format.format(name='trace_id', value=trace_id)} {record.msg}"
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
# ======================
|
3
|
+
# Date : 2024/12/30
|
4
|
+
# Author : Liu Yuchen
|
5
|
+
# Content :
|
6
|
+
#
|
7
|
+
# ======================
|
8
|
+
from requests import sessions
|
9
|
+
from requests.adapters import HTTPAdapter
|
10
|
+
|
11
|
+
from ._context import TraceContext
|
12
|
+
|
13
|
+
|
14
|
+
__all__ = ["BellaTraceAdapter"]
|
15
|
+
|
16
|
+
|
17
|
+
class BellaTraceAdapter(HTTPAdapter):
|
18
|
+
|
19
|
+
def send(self, request, **kwargs):
|
20
|
+
request.headers.update(TraceContext.headers)
|
21
|
+
return super().send(request, **kwargs)
|
22
|
+
|
23
|
+
|
24
|
+
def request(method, url, **kwargs):
|
25
|
+
with sessions.Session() as session:
|
26
|
+
session.mount("http://", BellaTraceAdapter())
|
27
|
+
session.mount("https://", BellaTraceAdapter())
|
28
|
+
return session.request(method=method, url=url, **kwargs)
|
29
|
+
|
30
|
+
|
31
|
+
def get(url, params=None, **kwargs):
|
32
|
+
return request("get", url, params=params, **kwargs)
|
33
|
+
|
34
|
+
|
35
|
+
def options(url, **kwargs):
|
36
|
+
return request("options", url, **kwargs)
|
37
|
+
|
38
|
+
|
39
|
+
def head(url, **kwargs):
|
40
|
+
kwargs.setdefault("allow_redirects", False)
|
41
|
+
return request("head", url, **kwargs)
|
42
|
+
|
43
|
+
|
44
|
+
def post(url, data=None, json=None, **kwargs):
|
45
|
+
return request("post", url, data=data, json=json, **kwargs)
|
46
|
+
|
47
|
+
|
48
|
+
def put(url, data=None, **kwargs):
|
49
|
+
return request("put", url, data=data, **kwargs)
|
50
|
+
|
51
|
+
|
52
|
+
def patch(url, data=None, **kwargs):
|
53
|
+
return request("patch", url, data=data, **kwargs)
|
54
|
+
|
55
|
+
|
56
|
+
def delete(url, **kwargs):
|
57
|
+
return request("delete", url, **kwargs)
|
58
|
+
|
ait_openapi/config.py
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
import pydantic
|
3
|
+
|
4
|
+
if pydantic.version.VERSION.startswith('1.'):
|
5
|
+
from pydantic import BaseSettings
|
6
|
+
else:
|
7
|
+
from pydantic.v1 import BaseSettings
|
8
|
+
|
9
|
+
|
10
|
+
class OpenAPIConfig(BaseSettings):
|
11
|
+
OPENAPI_HOST: Optional[str]
|
12
|
+
OPENAPI_CONSOLE_KEY: Optional[str]
|
13
|
+
|
14
|
+
|
15
|
+
openapi_config = OpenAPIConfig()
|
@@ -0,0 +1,45 @@
|
|
1
|
+
from typing import Optional, Dict, Any, Mapping
|
2
|
+
|
3
|
+
import httpx
|
4
|
+
|
5
|
+
from ait_openapi.config import openapi_config
|
6
|
+
|
7
|
+
|
8
|
+
def get_model_list(
|
9
|
+
token: str = openapi_config.OPENAPI_CONSOLE_KEY,
|
10
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
11
|
+
extra_query: Optional[Mapping[str, object]] = None,
|
12
|
+
) -> Dict[str, Any]:
|
13
|
+
"""
|
14
|
+
获取模型列表
|
15
|
+
|
16
|
+
Args:
|
17
|
+
token (str): 访问令牌
|
18
|
+
extra_headers: 额外的请求头
|
19
|
+
extra_query: 额外的查询参数,可以包含status、features等
|
20
|
+
|
21
|
+
Returns:
|
22
|
+
Dict[str, Any]: API响应的JSON数据
|
23
|
+
|
24
|
+
Examples:
|
25
|
+
>>> get_model_list(token='****',extra_query={"status": "active", "features": "vision"})
|
26
|
+
"""
|
27
|
+
url = openapi_config.OPENAPI_HOST + '/console/model/list'
|
28
|
+
headers = {
|
29
|
+
'Authorization': token,
|
30
|
+
'Content-Type': 'application/json',
|
31
|
+
}
|
32
|
+
# 添加额外的请求头
|
33
|
+
if extra_headers:
|
34
|
+
headers.update(extra_headers)
|
35
|
+
# 构建查询参数
|
36
|
+
params = {}
|
37
|
+
# 添加额外的查询参数
|
38
|
+
if extra_query:
|
39
|
+
params.update(extra_query)
|
40
|
+
# 发送请求
|
41
|
+
response = httpx.get(url, headers=headers, params=params)
|
42
|
+
# 检查响应状态
|
43
|
+
response.raise_for_status()
|
44
|
+
# 返回JSON数据
|
45
|
+
return response.json()
|
ait_openapi/exception.py
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
class AuthorizationException(Exception):
|
2
|
+
def __init__(self, message, http_status_code=None):
|
3
|
+
self.message = message
|
4
|
+
self.http_stauts_code = http_status_code
|
5
|
+
|
6
|
+
def __repr__(self):
|
7
|
+
return f"AuthorizationError(message={self.message}, http_status_code={self.http_stauts_code})"
|
ait_openapi/log.py
ADDED
@@ -0,0 +1,222 @@
|
|
1
|
+
import asyncio
|
2
|
+
import functools
|
3
|
+
import inspect
|
4
|
+
import json
|
5
|
+
import logging
|
6
|
+
import traceback
|
7
|
+
from asyncio import AbstractEventLoop, CancelledError
|
8
|
+
from concurrent.futures import Future
|
9
|
+
from threading import Thread
|
10
|
+
|
11
|
+
import httpx
|
12
|
+
|
13
|
+
from .config import openapi_config
|
14
|
+
from .schema import OperationLog
|
15
|
+
|
16
|
+
|
17
|
+
class operation_log:
|
18
|
+
"""
|
19
|
+
当前操作日志装饰器,同时支持同步和异步协程
|
20
|
+
|
21
|
+
用法:
|
22
|
+
@operation_log(op_type='safety_check', is_cost_log=True, ucid_key="ucid")
|
23
|
+
def safety_check(request, *, validate_output: bool):
|
24
|
+
pass
|
25
|
+
|
26
|
+
@operation_log(op_type='safety_check', is_cost_log=True, ucid_key="ucid")
|
27
|
+
async def safety_check(request, *, validate_output: bool):
|
28
|
+
pass
|
29
|
+
"""
|
30
|
+
|
31
|
+
def __init__(self, *, op_type: str = None, is_cost_log=False, ucid_key="ucid"):
|
32
|
+
"""
|
33
|
+
:param op_type: 表示当前操作动作, 如果不传入则默认使用被装饰的函数名
|
34
|
+
:param is_cost_log: 表示当前日志是否是计费相关日志, 默认为False
|
35
|
+
|
36
|
+
"""
|
37
|
+
self.op_type = op_type
|
38
|
+
self.is_cost_log = is_cost_log
|
39
|
+
self.ucid_key = ucid_key
|
40
|
+
|
41
|
+
def __call__(self, func):
|
42
|
+
if inspect.iscoroutinefunction(func):
|
43
|
+
return operation_log.async_log_decorator(func, op_type=self.op_type, is_cost_log=self.is_cost_log, ucid_key=self.ucid_key)
|
44
|
+
return operation_log.log_decorator(func, op_type=self.op_type, is_cost_log=self.is_cost_log, ucid_key=self.ucid_key)
|
45
|
+
|
46
|
+
@staticmethod
|
47
|
+
def log_decorator(func, *, op_type, is_cost_log, ucid_key):
|
48
|
+
"""
|
49
|
+
日志装饰器,在函数调用前后记录日志
|
50
|
+
:param func:
|
51
|
+
:param op_type:
|
52
|
+
:param is_cost_log:
|
53
|
+
:return:
|
54
|
+
"""
|
55
|
+
|
56
|
+
@functools.wraps(func)
|
57
|
+
def wrapper(*args, **kwargs):
|
58
|
+
nonlocal op_type
|
59
|
+
op_type = op_type if op_type else func.__name__
|
60
|
+
ucid_value = operation_log._get_ucid(ucid_key, func, args, kwargs)
|
61
|
+
opt_in_log = OperationLog(opLogType='in', opType=op_type,
|
62
|
+
operationStatus='success',
|
63
|
+
request=[args, kwargs],
|
64
|
+
response=None,
|
65
|
+
isCostLog=is_cost_log,
|
66
|
+
ucid=ucid_value,
|
67
|
+
)
|
68
|
+
|
69
|
+
submit_log(opt_in_log)
|
70
|
+
|
71
|
+
opt_out_log = None
|
72
|
+
try:
|
73
|
+
resp = func(*args, **kwargs)
|
74
|
+
opt_out_log = OperationLog(opLogType='out', opType=op_type,
|
75
|
+
operationStatus='success',
|
76
|
+
request=[args, kwargs],
|
77
|
+
response=resp,
|
78
|
+
isCostLog=is_cost_log,
|
79
|
+
ucid=ucid_value,
|
80
|
+
)
|
81
|
+
return resp
|
82
|
+
except Exception as e:
|
83
|
+
opt_out_log = OperationLog(opLogType='out', opType=op_type,
|
84
|
+
operationStatus='failed',
|
85
|
+
request=[args, kwargs],
|
86
|
+
response=None,
|
87
|
+
errMsg=traceback.format_exc()[:1024],
|
88
|
+
isCostLog=is_cost_log,
|
89
|
+
ucid=ucid_value,
|
90
|
+
)
|
91
|
+
raise e
|
92
|
+
finally:
|
93
|
+
submit_log(opt_out_log)
|
94
|
+
|
95
|
+
return wrapper
|
96
|
+
|
97
|
+
@staticmethod
|
98
|
+
def async_log_decorator(func, *, op_type, is_cost_log, ucid_key):
|
99
|
+
"""
|
100
|
+
日志装饰器,在函数调用前后记录日志
|
101
|
+
:param func:
|
102
|
+
:param op_type:
|
103
|
+
:param is_cost_log:
|
104
|
+
:return:
|
105
|
+
"""
|
106
|
+
|
107
|
+
@functools.wraps(func)
|
108
|
+
async def wrapper(*args, **kwargs):
|
109
|
+
nonlocal op_type
|
110
|
+
op_type = op_type if op_type else func.__name__
|
111
|
+
ucid_value = operation_log._get_ucid(ucid_key, func, args, kwargs)
|
112
|
+
opt_in_log = OperationLog(opLogType='in', opType=op_type,
|
113
|
+
operationStatus='success',
|
114
|
+
request=[args, kwargs],
|
115
|
+
response=None,
|
116
|
+
isCostLog=is_cost_log,
|
117
|
+
ucid=ucid_value,
|
118
|
+
)
|
119
|
+
|
120
|
+
submit_log(opt_in_log)
|
121
|
+
|
122
|
+
opt_out_log = None
|
123
|
+
try:
|
124
|
+
resp = await func(*args, **kwargs)
|
125
|
+
opt_out_log = OperationLog(opLogType='out', opType=op_type,
|
126
|
+
operationStatus='success',
|
127
|
+
request=[args, kwargs],
|
128
|
+
response=resp,
|
129
|
+
isCostLog=is_cost_log,
|
130
|
+
ucid=ucid_value,
|
131
|
+
)
|
132
|
+
return resp
|
133
|
+
except Exception as e:
|
134
|
+
opt_out_log = OperationLog(opLogType='out', opType=op_type,
|
135
|
+
operationStatus='failed',
|
136
|
+
request=[args, kwargs],
|
137
|
+
response=None,
|
138
|
+
errMsg=traceback.format_exc()[:1024],
|
139
|
+
isCostLog=is_cost_log,
|
140
|
+
ucid=ucid_value,
|
141
|
+
)
|
142
|
+
raise e
|
143
|
+
finally:
|
144
|
+
submit_log(opt_out_log)
|
145
|
+
|
146
|
+
return wrapper
|
147
|
+
@staticmethod
|
148
|
+
def _get_ucid(ucid_key, func, args, kwargs):
|
149
|
+
# 尝试从关键字参数中获取 UCID
|
150
|
+
ucid = kwargs.get(ucid_key)
|
151
|
+
if ucid is None:
|
152
|
+
# 如果 UCID 不在关键字参数中,尝试从位置参数中获取
|
153
|
+
func_params = func.__code__.co_varnames
|
154
|
+
if ucid_key in func_params:
|
155
|
+
ucid_index = func_params.index(ucid_key)
|
156
|
+
if ucid_index < len(args):
|
157
|
+
ucid = args[ucid_index]
|
158
|
+
return ucid
|
159
|
+
|
160
|
+
def submit_log(log: OperationLog):
|
161
|
+
try:
|
162
|
+
task = asyncio.create_task(_async_log(log))
|
163
|
+
task.add_done_callback(log_callback)
|
164
|
+
except RuntimeError:
|
165
|
+
_submit_log_in_thread_event_log(log)
|
166
|
+
|
167
|
+
|
168
|
+
# 监控任务日志执行结果
|
169
|
+
def log_callback(future: Future):
|
170
|
+
try:
|
171
|
+
future.result()
|
172
|
+
except CancelledError:
|
173
|
+
logging.exception(f'openapi log report task cancelled')
|
174
|
+
except Exception:
|
175
|
+
logging.exception(f'openapi log report task failed')
|
176
|
+
|
177
|
+
|
178
|
+
class ThreadedEventLoop(Thread):
|
179
|
+
"""
|
180
|
+
如果当前线程没有event loop, 则使用独立线程中的async_log_event_loop
|
181
|
+
"""
|
182
|
+
|
183
|
+
def __init__(self, loop: AbstractEventLoop):
|
184
|
+
super().__init__()
|
185
|
+
self.loop = loop
|
186
|
+
self.daemon = True
|
187
|
+
|
188
|
+
def run(self) -> None:
|
189
|
+
self.loop.run_forever()
|
190
|
+
|
191
|
+
|
192
|
+
# 启动asyncio event loop, 如果用户当前线程没有event loop,则使用独立线程中的async_log_event_loop
|
193
|
+
async_log_event_loop = asyncio.new_event_loop()
|
194
|
+
asyncio_thread = ThreadedEventLoop(async_log_event_loop)
|
195
|
+
asyncio_thread.start()
|
196
|
+
|
197
|
+
|
198
|
+
def _submit_log_in_thread_event_log(log):
|
199
|
+
future = asyncio.run_coroutine_threadsafe(
|
200
|
+
_async_log(log), async_log_event_loop)
|
201
|
+
future.add_done_callback(log_callback)
|
202
|
+
|
203
|
+
|
204
|
+
# 异步上报日志
|
205
|
+
async_httpx_client = None
|
206
|
+
|
207
|
+
|
208
|
+
async def _async_log(log: OperationLog):
|
209
|
+
global async_httpx_client
|
210
|
+
if async_httpx_client is None:
|
211
|
+
async_httpx_client = await httpx.AsyncClient(limits=httpx.Limits(max_connections=50)).__aenter__()
|
212
|
+
# 异步写入日志
|
213
|
+
url = openapi_config.OPENAPI_HOST + "/v1/openapi/report/log"
|
214
|
+
log_data = json.loads(json.dumps(log.dict(by_alias=True), default=lambda x: None))
|
215
|
+
logging.info(f'openapi链路日志上报:{log_data}')
|
216
|
+
response = await async_httpx_client.post(url, json=log_data)
|
217
|
+
if response.status_code != 200:
|
218
|
+
logging.warning(f'log failed, status_code: {response.status_code}, response: {response.text}')
|
219
|
+
return response.status_code
|
220
|
+
|
221
|
+
|
222
|
+
__all__ = ['operation_log', 'OperationLog', 'submit_log']
|
@@ -0,0 +1,108 @@
|
|
1
|
+
import uuid
|
2
|
+
|
3
|
+
import werkzeug
|
4
|
+
from werkzeug.routing import Map, Rule
|
5
|
+
from starlette.datastructures import URL
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
7
|
+
from starlette.responses import JSONResponse
|
8
|
+
|
9
|
+
from ait_openapi import caller_id_context, request_url_context, trace_id_context
|
10
|
+
from ait_openapi import validate_token
|
11
|
+
from ait_openapi.exception import AuthorizationException
|
12
|
+
from urllib.parse import parse_qs
|
13
|
+
|
14
|
+
|
15
|
+
class WebSocketHttpContextMiddleware:
|
16
|
+
def __init__(self, app, *, exclude_url: list[str] = None):
|
17
|
+
self.app = app
|
18
|
+
self.exclude_url = exclude_url or []
|
19
|
+
|
20
|
+
async def __call__(self, scope, receive, send):
|
21
|
+
if scope["type"] != "websocket":
|
22
|
+
return await self.app(scope, receive, send)
|
23
|
+
|
24
|
+
url = URL(scope=scope)
|
25
|
+
if match_url(self.exclude_url, url.path):
|
26
|
+
return await self.app(scope, receive, send)
|
27
|
+
|
28
|
+
query_params = parse_qs(url.query)
|
29
|
+
if not (query_params.get('token')) or query_params.get('token') == '':
|
30
|
+
# send token required error
|
31
|
+
await send({
|
32
|
+
"type": "websocket.close",
|
33
|
+
"code": 1006,
|
34
|
+
"reason": "token required",
|
35
|
+
})
|
36
|
+
return
|
37
|
+
try:
|
38
|
+
token = query_params.get('token')
|
39
|
+
caller = validate_token(token[0])
|
40
|
+
except AuthorizationException as e:
|
41
|
+
await send({
|
42
|
+
"type": "websocket.close",
|
43
|
+
"code": 1006,
|
44
|
+
"reason": "token invalid",
|
45
|
+
})
|
46
|
+
return
|
47
|
+
else:
|
48
|
+
caller_context_token = caller_id_context.set(caller)
|
49
|
+
trace_id_context_token = trace_id_context.set(str(uuid.uuid4()))
|
50
|
+
request_url_context_token = request_url_context.set(url.path)
|
51
|
+
await self.app(scope, receive, send)
|
52
|
+
caller_id_context.reset(caller_context_token)
|
53
|
+
trace_id_context.reset(trace_id_context_token)
|
54
|
+
request_url_context.reset(request_url_context_token)
|
55
|
+
|
56
|
+
|
57
|
+
def match_url(patterns, url):
|
58
|
+
if patterns is None:
|
59
|
+
return False
|
60
|
+
# 创建 URL 规则
|
61
|
+
rules = [Rule(pattern) for pattern in patterns]
|
62
|
+
# 匹配 URL
|
63
|
+
adapter = Map(rules).bind('')
|
64
|
+
try:
|
65
|
+
result = adapter.match(url)
|
66
|
+
return True
|
67
|
+
except werkzeug.exceptions.NotFound:
|
68
|
+
return False
|
69
|
+
|
70
|
+
|
71
|
+
class HttpContextMiddleware(BaseHTTPMiddleware):
|
72
|
+
def __init__(self, app, *, exclude_url: list[str] = None, ):
|
73
|
+
"""
|
74
|
+
:param app
|
75
|
+
:param exclude_url: 不需要验证token的url,
|
76
|
+
根据https://werkzeug.palletsprojects.com/en/2.2.x/routing/ 规则进行配置
|
77
|
+
"""
|
78
|
+
super().__init__(app)
|
79
|
+
self.exclude_url = exclude_url
|
80
|
+
|
81
|
+
async def dispatch(self, request, call_next):
|
82
|
+
if match_url(self.exclude_url, request.url.path):
|
83
|
+
return await call_next(request)
|
84
|
+
|
85
|
+
if request.url.path.startswith("/v1/actuator/health"):
|
86
|
+
return await call_next(request)
|
87
|
+
authorization = request.headers.get("Authorization")
|
88
|
+
if authorization is None:
|
89
|
+
return JSONResponse(status_code=401, content={"message": "empty Authorization header"})
|
90
|
+
|
91
|
+
try:
|
92
|
+
caller = validate_token(authorization)
|
93
|
+
except AuthorizationException as e:
|
94
|
+
return JSONResponse(status_code=401, content={"message": e.message})
|
95
|
+
|
96
|
+
caller_context_token = caller_id_context.set(caller)
|
97
|
+
trace_id_context_token = trace_id_context.set(str(uuid.uuid4()))
|
98
|
+
request_url_context_token = request_url_context.set(request.url.path)
|
99
|
+
|
100
|
+
# 继续处理请求
|
101
|
+
response = await call_next(request)
|
102
|
+
|
103
|
+
# 重置contextvars上下文
|
104
|
+
caller_id_context.reset(caller_context_token)
|
105
|
+
trace_id_context.reset(trace_id_context_token)
|
106
|
+
request_url_context.reset(request_url_context_token)
|
107
|
+
|
108
|
+
return response
|
@@ -0,0 +1,6 @@
|
|
1
|
+
from contextvars import ContextVar
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
trace_id_context: ContextVar[Optional[str]] = ContextVar("trace_id", default=None)
|
5
|
+
caller_id_context: ContextVar[Optional[str]] = ContextVar("caller_id", default=None)
|
6
|
+
request_url_context: ContextVar[Optional[str]] = ContextVar("request_url", default=None)
|
ait_openapi/schema.py
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
import uuid
|
5
|
+
|
6
|
+
import pydantic
|
7
|
+
from pydantic import BaseModel, Field
|
8
|
+
from .openapi_contexvar import trace_id_context, caller_id_context, request_url_context
|
9
|
+
|
10
|
+
|
11
|
+
class BaseOperationLog(BaseModel):
|
12
|
+
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
13
|
+
request_id: Optional[str] = Field(default_factory=lambda: trace_id_context.get(),
|
14
|
+
alias='requestId')
|
15
|
+
caller_id: Optional[str] = Field(default_factory=lambda: caller_id_context.get(),
|
16
|
+
alias='callerId')
|
17
|
+
request_url: Optional[str] = Field(default_factory=lambda: request_url_context.get(),
|
18
|
+
alias='requestUrl')
|
19
|
+
op_log_type: str = Field(alias='opLogType')
|
20
|
+
op_type: str = Field(alias='opType')
|
21
|
+
is_cost_log: bool = Field(default=False, alias='isCostLog')
|
22
|
+
operation_status: str = Field(alias='operationStatus')
|
23
|
+
start_time_millis: int = Field(default_factory=lambda: int(datetime.now().timestamp() * 1000),
|
24
|
+
alias='startTimeMillis')
|
25
|
+
duration_millis: int = Field(default=0, alias='durationMillis')
|
26
|
+
request: object
|
27
|
+
response: object = Field(default=None)
|
28
|
+
err_msg: Optional[str] = Field(default=None, alias='errMsg')
|
29
|
+
extra_info: dict = Field(default={}, alias='extraInfo')
|
30
|
+
ucid: str = Field(default='ucid')
|
31
|
+
|
32
|
+
@staticmethod
|
33
|
+
def validate(values):
|
34
|
+
if 'request_id' not in values or values['request_id'] is None:
|
35
|
+
raise ValueError('request_id is required, please set trace_id_context')
|
36
|
+
if 'caller_id' not in values or values['caller_id'] is None:
|
37
|
+
raise ValueError('caller_id is required, please set caller_id_context')
|
38
|
+
if 'request_url' not in values or values['request_url'] is None:
|
39
|
+
raise ValueError('request_url is required, please set request_url_context')
|
40
|
+
return values
|
41
|
+
|
42
|
+
|
43
|
+
if pydantic.version.VERSION.startswith('1.'):
|
44
|
+
from pydantic import root_validator
|
45
|
+
|
46
|
+
|
47
|
+
class OperationLog(BaseOperationLog):
|
48
|
+
@root_validator
|
49
|
+
def validate_duration_millis(cls, values):
|
50
|
+
super().validate(values)
|
51
|
+
return values
|
52
|
+
|
53
|
+
|
54
|
+
else:
|
55
|
+
from pydantic import model_validator
|
56
|
+
|
57
|
+
|
58
|
+
class OperationLog(BaseOperationLog):
|
59
|
+
|
60
|
+
@model_validator(mode='after')
|
61
|
+
def validate_duration_millis(cls, values):
|
62
|
+
super().validate(values.dict())
|
63
|
+
return values
|
@@ -0,0 +1,256 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: bella-openapi
|
3
|
+
Version: 1.0.0
|
4
|
+
Summary: client for openapi service.
|
5
|
+
Home-page:
|
6
|
+
Author: ['tangxiaolong', 'fanqiangwei', 'zhangxiaojia', 'liumin', 'wangyukun']
|
7
|
+
Author-email:
|
8
|
+
Platform: any
|
9
|
+
Classifier: Programming Language :: Python :: 3.5
|
10
|
+
Description-Content-Type: text/markdown
|
11
|
+
License-File: LICENSE
|
12
|
+
Requires-Dist: httpx<=0.26.0,>=0.10.0
|
13
|
+
Requires-Dist: Werkzeug==3.0.1
|
14
|
+
Dynamic: author
|
15
|
+
Dynamic: classifier
|
16
|
+
Dynamic: description
|
17
|
+
Dynamic: description-content-type
|
18
|
+
Dynamic: license-file
|
19
|
+
Dynamic: platform
|
20
|
+
Dynamic: requires-dist
|
21
|
+
Dynamic: summary
|
22
|
+
|
23
|
+
OpenAPI网关服务客户端
|
24
|
+
=======================
|
25
|
+
|
26
|
+
## 功能列表
|
27
|
+
|
28
|
+
1. 校验token,获取用户信息
|
29
|
+
2. 统一上报操作日志至网关侧
|
30
|
+
|
31
|
+
## 使用指南
|
32
|
+
|
33
|
+
### 安装
|
34
|
+
|
35
|
+
```shell
|
36
|
+
pip install ait_openapi
|
37
|
+
```
|
38
|
+
|
39
|
+
### 配置网关域名
|
40
|
+
|
41
|
+
通过环境变量设置网关域名
|
42
|
+
|
43
|
+
```python
|
44
|
+
OPENAPI_HOST = "https://***.com"
|
45
|
+
# 需要调用console类接口获得元信息时候会默认取全局的此key,使用者也可根据场景选择不使用默认的KEY,更细粒度的传递指定key
|
46
|
+
OPENAPI_CONSOLE_KEY = "************"
|
47
|
+
```
|
48
|
+
|
49
|
+
### 配置校验
|
50
|
+
|
51
|
+
```python
|
52
|
+
from ait_openapi import check_configuration
|
53
|
+
# 该函数判断是否所有配置ok,只有所有配置ok的情况下,才能调用鉴权计费等接口
|
54
|
+
config_is_ok = check_configuration()
|
55
|
+
```
|
56
|
+
### token校验
|
57
|
+
|
58
|
+
```python
|
59
|
+
from ait_openapi import validate_token, support_model, account_balance_enough
|
60
|
+
|
61
|
+
token = "****"
|
62
|
+
# 根据token,解析用户身份
|
63
|
+
user_name = validate_token(token)
|
64
|
+
# 查询当前登陆用户,是否具有指定模型的权限
|
65
|
+
supported = support_model(token, 'model_name')
|
66
|
+
# 账户余额判断,支持传入指定判断阈值,服务方可以在每次用户请求前,根据本次预估花费,对用户余额进行校验,若余额不足,则可拒绝请求
|
67
|
+
|
68
|
+
balance = account_balance_enough(token, cost=1.0)
|
69
|
+
```
|
70
|
+
|
71
|
+
如果token无效,抛出`AuthorizationException`异常
|
72
|
+
|
73
|
+
### 上报操作日志
|
74
|
+
|
75
|
+
为了追踪用户调用中间处理流程,建议在必要处理操作处,进行操作日志记录上报。操作日志记录分别在操作入口处
|
76
|
+
(操作前)和操作出口处(操作后)进行记录上报。
|
77
|
+
|
78
|
+
#### 1. 上报模式
|
79
|
+
|
80
|
+
为降低上报操作日志延迟对业务逻辑的影响,上报操作日志采用异步上报模式
|
81
|
+
|
82
|
+
##### async异步上报
|
83
|
+
|
84
|
+
日志上报,将采用异步io的方式,上报操作日志,这种方式,可以在单线程中,同时处理多个并发非阻塞IO操作
|
85
|
+
|
86
|
+
```python
|
87
|
+
from ait_openapi import operation_log
|
88
|
+
|
89
|
+
|
90
|
+
@operation_log()
|
91
|
+
def safety_check(request, *, validate_output: bool):
|
92
|
+
pass
|
93
|
+
```
|
94
|
+
|
95
|
+
#### 2. 上报参数更新
|
96
|
+
|
97
|
+
操作日志记录,需要传递一些动态参数,需导入预定义的contextvar,在请求起始处进行设置, 请求结束时清空contextvar
|
98
|
+
|
99
|
+
```python
|
100
|
+
from ait_openapi import trace_id_context, caller_id_context, request_url_context
|
101
|
+
|
102
|
+
# 整个链路处理前,设置
|
103
|
+
t_token = trace_id_context.set("*********") # trace_id 当前请求链路唯一标识
|
104
|
+
c_token = caller_id_context.set("*********") # caller_id 调用方标识, 通过user_info.username获取
|
105
|
+
r_token = request_url_context.set("*********") # request_url 当前请求url
|
106
|
+
# #
|
107
|
+
# 请求处理
|
108
|
+
# 清空contextvar
|
109
|
+
trace_id_context.reset(t_token)
|
110
|
+
caller_id_context.reset(c_token)
|
111
|
+
request_url_context.reset(r_token)
|
112
|
+
```
|
113
|
+
|
114
|
+
##### 2.1 使用middleware设置contextvar, 将自动进行token解析,以及上下文设置
|
115
|
+
|
116
|
+
如果当前url不需要进行拦截,可以配置exclude_path进行过滤
|
117
|
+
|
118
|
+
```python
|
119
|
+
from ait_openapi.middleware import HttpContextMiddleware, WebSocketHttpContextMiddleware
|
120
|
+
from fastapi import FastAPI
|
121
|
+
|
122
|
+
app = FastAPI()
|
123
|
+
app.add_middleware(HttpContextMiddleware, exclude_path=["/v1/actuator/health/<pattern>"])
|
124
|
+
```
|
125
|
+
|
126
|
+
##### 2.2 跨线程上下文复制
|
127
|
+
|
128
|
+
由于上下文变量是线程隔离的,因此如果需要在独立于设置上下文变量的其他线程中,进行日志记录操作,需要将上下文变量复制到新的线程中
|
129
|
+
|
130
|
+
```python
|
131
|
+
# 使用contextvars.copy_context(), 复制上下文,并在复制上下文中,执行方法
|
132
|
+
import contextvars
|
133
|
+
from functools import partial
|
134
|
+
from threading import Thread
|
135
|
+
|
136
|
+
|
137
|
+
class ThreadCopyContext(Thread):
|
138
|
+
def __init__(self, group=None, target=None, name=None,
|
139
|
+
args=(), kwargs=None, *, daemon=None):
|
140
|
+
context = contextvars.copy_context()
|
141
|
+
super().__init__(group, context.run, name,
|
142
|
+
[partial(target, *args, **kwargs if kwargs else {})],
|
143
|
+
None, daemon=daemon)
|
144
|
+
|
145
|
+
|
146
|
+
request_url_context = contextvars.ContextVar('request_url', default='empty')
|
147
|
+
r_token = request_url_context.set('http://localhost:8000')
|
148
|
+
|
149
|
+
|
150
|
+
def run():
|
151
|
+
print(request_url_context.get())
|
152
|
+
|
153
|
+
|
154
|
+
# 未复制contextvar,新的线程中,上下文变量为默认值空
|
155
|
+
t = Thread(target=run)
|
156
|
+
t.start()
|
157
|
+
t.join()
|
158
|
+
# > empty
|
159
|
+
# 复制contextvar,可以获取到复制的上下文变量
|
160
|
+
t = ThreadCopyContext(target=run)
|
161
|
+
t.start()
|
162
|
+
t.join()
|
163
|
+
# > http://localhost:8000
|
164
|
+
```
|
165
|
+
|
166
|
+
#### 3. 日志记录配置
|
167
|
+
|
168
|
+
日志记录中,op_type字段默认取值为当前方法名称,可通过在装饰器中传入参数进行覆盖。该装饰器可同时用于同步方法以及异步协程的日志上报
|
169
|
+
为了进行调用计费,如果当前操作日志中的信息,需要用于回调计费,则需要在装饰器中传入is_cost_log=True
|
170
|
+
|
171
|
+
```python
|
172
|
+
from ait_openapi import operation_log
|
173
|
+
|
174
|
+
|
175
|
+
@operation_log(op_type='safety_check', is_cost_log=False, ucid="ucid")
|
176
|
+
def safety_check(request, *, validate_output: bool):
|
177
|
+
pass
|
178
|
+
|
179
|
+
|
180
|
+
@operation_log(is_cost_log=False, ucid="ucid")
|
181
|
+
async def safety_check(request, *, validate_output: bool):
|
182
|
+
pass
|
183
|
+
```
|
184
|
+
|
185
|
+
#### 4. 直接上报链路日志
|
186
|
+
##### 4.1 调用submit_log方法
|
187
|
+
|
188
|
+
```python
|
189
|
+
from ait_openapi import submit_log
|
190
|
+
# construct log
|
191
|
+
log = ...
|
192
|
+
submit_log(log)
|
193
|
+
```
|
194
|
+
##### 4.2 http上报
|
195
|
+
|
196
|
+
如果在特定场景下,通过装饰器进行链路日志上报不够灵活,您可直接通过调用对应的http 接口上传链路日志
|
197
|
+
POST接口url:/v1/openapi/report/log
|
198
|
+
|
199
|
+
接口参数:
|
200
|
+
|
201
|
+
##### Params
|
202
|
+
|
203
|
+
| Name | Type | Required | Title | Description |
|
204
|
+
|-----------------|---------|----------|---------|------------------------------------------|
|
205
|
+
| uuid | string | yes | 日志uuid | 每条日志,使用不同uuid标识 |
|
206
|
+
| requestId | string | yes | 链路id | 一次用户请求中,后台多条处理日志,应该使用同一requestId进行串联 |
|
207
|
+
| callerId | string | yes | 调用房标识 | 调用token解析得到的当前调用方标识 |
|
208
|
+
| requestUrl | string | yes | 能力端点 | 用户请求,接口url地址 |
|
209
|
+
| opLogType | string | yes | 操作日志类型 | 如果日志在操作入口记录,则改 |
|
210
|
+
| opType | string | yes | 操作类型 | 日志记录对应的操作名称 |
|
211
|
+
| isCostLog | boolean | yes | 是否计费日志 | 如果为true,且opLogType为out,该条日志将被用户计算用户调用的费用 |
|
212
|
+
| operationStatus | string | yes | 操作状态 | none |
|
213
|
+
| startTimeMillis | integer | yes | 操作起始时间戳 | none |
|
214
|
+
| durationMillis | integer | yes | 操作结束时间戳 | none |
|
215
|
+
| request | object | yes | 操作入参 | none |
|
216
|
+
| response | object | no | 操作出参 | none |
|
217
|
+
| errMsg | string | no | 异常信息 | 如果操作失败,记录失败信息 |
|
218
|
+
| extraInfo | object | no | | 其他透传信息 |
|
219
|
+
|
220
|
+
##### Enum
|
221
|
+
|
222
|
+
| Name | Value | Description |
|
223
|
+
|-----------------|---------|--------------|
|
224
|
+
| opLogType | in | 日志记录为操作入口处记录 |
|
225
|
+
| opLogType | out | 日志记录为操作出口处记录 |
|
226
|
+
| operationStatus | success | 当前操作成功 |
|
227
|
+
| operationStatus | failed | 当前操作失败 |
|
228
|
+
#### 5. 链路日志查询
|
229
|
+
get接口地址:/v1/openapi/log/{requestId}
|
230
|
+
传入链路id,可查询当前链路下的所有日志信息
|
231
|
+
|
232
|
+
### Trace SDK
|
233
|
+
使用方法见:[Trace README](./src/ait_openapi/bella_trace/README.md)
|
234
|
+
|
235
|
+
## release log
|
236
|
+
|
237
|
+
* 0.0.1.2
|
238
|
+
* 修复pydantic 2.x版本,BaseModel校验问题
|
239
|
+
* 0.0.1.3
|
240
|
+
* 修复account_balance_enough调用异常
|
241
|
+
* 操作日志中,时间戳记录单位改为毫秒
|
242
|
+
* README文档,增加日志记录contextvar跨线程复制说明, 操作日志上报http接口介绍
|
243
|
+
* 0.0.1.4
|
244
|
+
* 修复log日志记录中,对象无法序列化问题
|
245
|
+
* 0.0.1.5
|
246
|
+
* 暴露submit_log,支持直接上报日志
|
247
|
+
* 0.0.1.8
|
248
|
+
* 支持不配置openapi_host的情况下,鉴权计费可以正常调通
|
249
|
+
* 0.0.1.16
|
250
|
+
* 提供配置检测接口
|
251
|
+
* 0.0.1.25
|
252
|
+
* 新增 Bella Trace 链路日志
|
253
|
+
* 0.0.1.26
|
254
|
+
- 新增 console 模块,实现元数据获取
|
255
|
+
* 1.0.0
|
256
|
+
- 项目发到python中央仓库
|
@@ -0,0 +1,22 @@
|
|
1
|
+
ait_openapi/__init__.py,sha256=x4-zOWaFs2J7ca0I6e0kP2jPfajmBoRqAbzs6T9PC54,919
|
2
|
+
ait_openapi/auth_billing.py,sha256=JbFnCU96NHmME19JXSTbHuqDTU6YDIGTpIDSwaY5WCk,3611
|
3
|
+
ait_openapi/authorize.py,sha256=5-vxEUC1LeWd7BKDeCEbOGMluBWbgOJ3bgZhASAW7yY,2211
|
4
|
+
ait_openapi/config.py,sha256=JOlLZIslOx6-gB1CRtDVqFK7Po96kO456Fog0p0J4og,334
|
5
|
+
ait_openapi/exception.py,sha256=A9wc10ygVTaG0pNUiIc4Cg_kcUcsa9HELiI3htdY9yM,312
|
6
|
+
ait_openapi/log.py,sha256=FP3LUNp2mBaAHaNI35_iGdkINNRdB9Q7orSQJgoPLrk,8596
|
7
|
+
ait_openapi/openapi_contexvar.py,sha256=70zichjGa9c-8IZjJJyidOD3aHmKNrWlUWP8gulHLFA,327
|
8
|
+
ait_openapi/schema.py,sha256=hWVaw9jVVTTiUAn5VXJQYc9idYeJx15-oA-CU_fbopQ,2537
|
9
|
+
ait_openapi/bella_trace/__init__.py,sha256=ItVqtDZ79m4ByGSbutQhAOTQ9NjgXneaYx2tigZ_-hU,588
|
10
|
+
ait_openapi/bella_trace/_context.py,sha256=mwaOjqMvMdSkFNWRTKKFvWctbvS412K4DNy29JoyL6w,1400
|
11
|
+
ait_openapi/bella_trace/fastapi_interceptor.py,sha256=IAlU2JOAxu3WMTvvXv94w56_7B2IBWcVKVXqaR8jJCU,850
|
12
|
+
ait_openapi/bella_trace/record_log.py,sha256=33vB3ZotpTltT4YRBP7awdK9-HCAZGaASNVAatKMx3M,1775
|
13
|
+
ait_openapi/bella_trace/trace_requests.py,sha256=8R4QiTan8ruH3jBmipdDeNVd8HQIlYqF908I0huy4W8,1428
|
14
|
+
ait_openapi/console/__init__.py,sha256=yUP83l7Hp-5qWT7uZ4QZoDfeWx7AjRguWBbPPfVd7Pw,66
|
15
|
+
ait_openapi/console/models.py,sha256=HWn7AitLt73Ag2ROyKoUHK34QWtwQ9s_Vz9Wu4IB3cQ,1316
|
16
|
+
ait_openapi/middleware/__init__.py,sha256=ZzvwJ9NjB7bKs3LtyVnTp8msHyxLVQoWcyAVmP0k-XA,160
|
17
|
+
ait_openapi/middleware/context_middleware.py,sha256=RusQmPV0vLpPJnyH_hKSWYFMXG5n2QkbfRoHGmTQQco,3986
|
18
|
+
bella_openapi-1.0.0.dist-info/licenses/LICENSE,sha256=56xiUFcFori4XRJ1nMyyS7DXMSjzaBKWRaZbqCffoY8,1075
|
19
|
+
bella_openapi-1.0.0.dist-info/METADATA,sha256=pwC6bFKHFY4aAcKsBTjq48elDw1ufBMQttEqrnpylRw,9483
|
20
|
+
bella_openapi-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
21
|
+
bella_openapi-1.0.0.dist-info/top_level.txt,sha256=GfjeyDToGVkp5snTGNX7KVPp37VtVWikor-7Yzwr2Hc,12
|
22
|
+
bella_openapi-1.0.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
ait_openapi
|