socar-api 0.6.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.
- api_frame/__init__.py +16 -0
- api_frame/atomic.py +212 -0
- api_frame/auth.py +243 -0
- api_frame/cache.py +258 -0
- api_frame/exception.py +192 -0
- api_frame/field.py +68 -0
- api_frame/filebase.py +377 -0
- api_frame/filter.py +166 -0
- api_frame/handlers.py +309 -0
- api_frame/jsonapi.py +317 -0
- api_frame/meta.py +22 -0
- api_frame/query.py +660 -0
- api_frame/resource.py +1250 -0
- api_frame/responses.py +27 -0
- api_frame/router.py +232 -0
- api_frame/schema.py +686 -0
- api_frame/scope.py +127 -0
- api_frame/serializer.py +336 -0
- api_frame/url_parse.py +128 -0
- api_frame/util.py +253 -0
- socar_api-0.6.0.dist-info/METADATA +177 -0
- socar_api-0.6.0.dist-info/RECORD +24 -0
- socar_api-0.6.0.dist-info/WHEEL +5 -0
- socar_api-0.6.0.dist-info/top_level.txt +1 -0
api_frame/cache.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified cache decorator / middleware for api_frame resources.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
# Decorator form (recommended)
|
|
7
|
+
@cached(prefix='cars', ttl=300)
|
|
8
|
+
class Cars(BaseResource):
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
# Class-attribute form (alternative)
|
|
12
|
+
class Cars(BaseResource):
|
|
13
|
+
cache_prefix = 'cars'
|
|
14
|
+
cache_ttl = 300
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
The cache uses a Cache-Aside pattern: check cache → hit → return;
|
|
18
|
+
miss → call original → store result → return.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from typing import Any, Protocol
|
|
30
|
+
|
|
31
|
+
from fastapi import Request
|
|
32
|
+
|
|
33
|
+
from api_frame.responses import JsonapiResponse
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# ── Cache Backend Protocol ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CacheBackend(Protocol):
|
|
41
|
+
"""Pluggable cache storage protocol.
|
|
42
|
+
|
|
43
|
+
Projects implement this protocol and register it via
|
|
44
|
+
``api_frame.cache.set_default_backend(backend)``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
async def get(self, key: str) -> Any | None:
|
|
48
|
+
"""Return cached value or ``None``."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def set(self, key: str, value: Any, ttl: int) -> None:
|
|
52
|
+
"""Store *value* for *ttl* seconds."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
async def delete(self, key: str) -> None:
|
|
56
|
+
"""Remove *key* from cache."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _NullBackend:
|
|
61
|
+
"""Fallback backend that never caches (safe default)."""
|
|
62
|
+
|
|
63
|
+
async def get(self, key: str) -> None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
async def set(self, key: str, value: Any, ttl: int) -> None:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def delete(self, key: str) -> None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_default_backend: CacheBackend = _NullBackend()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_default_backend(backend: CacheBackend) -> None:
|
|
77
|
+
"""Set the global default cache backend.
|
|
78
|
+
|
|
79
|
+
Called once during application startup::
|
|
80
|
+
|
|
81
|
+
set_default_backend(RedisCacheBackend(host='...', port=6379))
|
|
82
|
+
"""
|
|
83
|
+
global _default_backend
|
|
84
|
+
_default_backend = backend
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_default_backend() -> CacheBackend:
|
|
88
|
+
"""Return the current default cache backend."""
|
|
89
|
+
return _default_backend
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Version / Invalidation Hook ────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
_version_hook: Callable[[str], str] | None = None
|
|
95
|
+
"""Optional hook that returns a cache-busting version string for a prefix.
|
|
96
|
+
|
|
97
|
+
Signature: ``version_hook(prefix: str) -> str``
|
|
98
|
+
Return ``""`` (or the same value across restarts) to keep cache alive.
|
|
99
|
+
Return a new value to invalidate all keys under that prefix.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_version_hook(hook: Callable[[str], str]) -> None:
|
|
104
|
+
"""Register a cache version hook."""
|
|
105
|
+
global _version_hook
|
|
106
|
+
_version_hook = hook
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Key construction ───────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _make_cache_key(prefix: str, request: Request) -> str:
|
|
113
|
+
"""Build a deterministic cache key from URL + query params.
|
|
114
|
+
|
|
115
|
+
Format: ``{prefix}:{sha256_of_url}``
|
|
116
|
+
"""
|
|
117
|
+
url = str(request.url)
|
|
118
|
+
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
119
|
+
return f"{prefix}:{url_hash}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── The @cached decorator ──────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
CACHE_PREFIX_ATTR = "_cache_prefix"
|
|
126
|
+
CACHE_TTL_ATTR = "_cache_ttl"
|
|
127
|
+
CACHE_BACKEND_ATTR = "_cache_backend"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cached(
|
|
131
|
+
prefix: str = "",
|
|
132
|
+
ttl: int | None = None,
|
|
133
|
+
backend: CacheBackend | None = None,
|
|
134
|
+
) -> Callable[[type], type]:
|
|
135
|
+
"""Class decorator: enable Cache-Aside for all GET/GETS routes.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
prefix : str
|
|
140
|
+
Cache key prefix. Falls back to ``cls.cache_prefix`` if set.
|
|
141
|
+
ttl : int
|
|
142
|
+
Time-to-live in seconds (default 300).
|
|
143
|
+
backend : CacheBackend | None
|
|
144
|
+
Backend override. Uses ``get_default_backend()`` when ``None``.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def decorator(cls: type) -> type:
|
|
148
|
+
_prefix = prefix or getattr(cls, "cache_prefix", cls.__name__.lower())
|
|
149
|
+
_ttl = ttl if ttl is not None else getattr(cls, "cache_ttl", 300)
|
|
150
|
+
_backend = backend if backend is not None else getattr(cls, "_cache_backend", None)
|
|
151
|
+
|
|
152
|
+
setattr(cls, CACHE_PREFIX_ATTR, _prefix)
|
|
153
|
+
setattr(cls, CACHE_TTL_ATTR, _ttl)
|
|
154
|
+
if _backend is not None:
|
|
155
|
+
setattr(cls, CACHE_BACKEND_ATTR, _backend)
|
|
156
|
+
|
|
157
|
+
# Only wrap handle_request if it exists (real Resource classes)
|
|
158
|
+
if not hasattr(cls, "handle_request"):
|
|
159
|
+
return cls
|
|
160
|
+
|
|
161
|
+
# Wrap handle_request if not already wrapped
|
|
162
|
+
original_handle_request = cls.handle_request
|
|
163
|
+
|
|
164
|
+
@functools.wraps(original_handle_request)
|
|
165
|
+
async def cached_handle_request(
|
|
166
|
+
handler_response: str,
|
|
167
|
+
handler_data: str,
|
|
168
|
+
*args: Any,
|
|
169
|
+
**kwargs: Any,
|
|
170
|
+
) -> JsonapiResponse:
|
|
171
|
+
# Only cache GET requests
|
|
172
|
+
request: Request | None = kwargs.get("request")
|
|
173
|
+
if request is None:
|
|
174
|
+
return await original_handle_request(handler_response, handler_data, *args, **kwargs)
|
|
175
|
+
|
|
176
|
+
# Cache only GET (not POST/PATCH/DELETE)
|
|
177
|
+
if request.method not in ("GET",):
|
|
178
|
+
# Invalidate on write
|
|
179
|
+
_backend = getattr(cls, CACHE_BACKEND_ATTR, None) or get_default_backend()
|
|
180
|
+
if not isinstance(_backend, _NullBackend):
|
|
181
|
+
# Best-effort invalidate: delete exact-match key
|
|
182
|
+
key = _make_cache_key(getattr(cls, CACHE_PREFIX_ATTR, ""), request)
|
|
183
|
+
try:
|
|
184
|
+
await _backend.delete(key)
|
|
185
|
+
except Exception:
|
|
186
|
+
logger.warning("Cache invalidation failed for key %s", key, exc_info=True)
|
|
187
|
+
return await original_handle_request(handler_response, handler_data, *args, **kwargs)
|
|
188
|
+
|
|
189
|
+
prefix_key = getattr(cls, CACHE_PREFIX_ATTR, "")
|
|
190
|
+
ttl_val = getattr(cls, CACHE_TTL_ATTR, 300)
|
|
191
|
+
active_backend = getattr(cls, CACHE_BACKEND_ATTR, None) or get_default_backend()
|
|
192
|
+
|
|
193
|
+
# Build key with optional version
|
|
194
|
+
key = _make_cache_key(prefix_key, request)
|
|
195
|
+
if _version_hook is not None:
|
|
196
|
+
ver = _version_hook(prefix_key)
|
|
197
|
+
if ver:
|
|
198
|
+
key = f"{key}:v{ver}"
|
|
199
|
+
|
|
200
|
+
# Try cache
|
|
201
|
+
try:
|
|
202
|
+
cached_data = await active_backend.get(key)
|
|
203
|
+
except Exception:
|
|
204
|
+
cached_data = None
|
|
205
|
+
|
|
206
|
+
if cached_data is not None:
|
|
207
|
+
logger.debug("Cache HIT %s", key)
|
|
208
|
+
return JsonapiResponse(content=cached_data)
|
|
209
|
+
|
|
210
|
+
logger.debug("Cache MISS %s", key)
|
|
211
|
+
|
|
212
|
+
# Cache miss → call original
|
|
213
|
+
response = await original_handle_request(handler_response, handler_data, *args, **kwargs)
|
|
214
|
+
|
|
215
|
+
# Store result
|
|
216
|
+
if response is not None:
|
|
217
|
+
try:
|
|
218
|
+
if hasattr(response, "body") and response.body:
|
|
219
|
+
data = json.loads(response.body.decode())
|
|
220
|
+
else:
|
|
221
|
+
data = response
|
|
222
|
+
await active_backend.set(key, data, ttl_val)
|
|
223
|
+
except Exception:
|
|
224
|
+
logger.warning("Cache store failed for key %s", key, exc_info=True)
|
|
225
|
+
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
cls.handle_request = cached_handle_request # type: ignore[assignment]
|
|
229
|
+
return cls
|
|
230
|
+
|
|
231
|
+
return decorator
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── Simple in-memory backend (for testing / single-instance) ───────────────
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class MemoryBackend:
|
|
238
|
+
"""In-memory cache backend. Not distributed; use only for testing or single-worker deployments."""
|
|
239
|
+
|
|
240
|
+
def __init__(self) -> None:
|
|
241
|
+
self._store: dict[str, Any] = {}
|
|
242
|
+
self._ttls: dict[str, float] = {}
|
|
243
|
+
|
|
244
|
+
async def get(self, key: str) -> Any | None:
|
|
245
|
+
expiry = self._ttls.get(key)
|
|
246
|
+
if expiry is not None and time.monotonic() > expiry:
|
|
247
|
+
self._store.pop(key, None)
|
|
248
|
+
self._ttls.pop(key, None)
|
|
249
|
+
return None
|
|
250
|
+
return self._store.get(key)
|
|
251
|
+
|
|
252
|
+
async def set(self, key: str, value: Any, ttl: int) -> None:
|
|
253
|
+
self._store[key] = value
|
|
254
|
+
self._ttls[key] = time.monotonic() + ttl
|
|
255
|
+
|
|
256
|
+
async def delete(self, key: str) -> None:
|
|
257
|
+
self._store.pop(key, None)
|
|
258
|
+
self._ttls.pop(key, None)
|
api_frame/exception.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
http错误处理
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
from fastapi import Request, status
|
|
14
|
+
from fastapi.exceptions import HTTPException, RequestValidationError
|
|
15
|
+
|
|
16
|
+
from api_frame.jsonapi import ErrorModel, ErrorResponse
|
|
17
|
+
from api_frame.responses import JsonapiResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JsonapiException(HTTPException):
|
|
21
|
+
"""
|
|
22
|
+
jsonapi错误类
|
|
23
|
+
Args:
|
|
24
|
+
status_code: 错误http code
|
|
25
|
+
detail: 错误详情
|
|
26
|
+
errors: 错误列表
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self, status_code: int, detail: str = None, title: str = None, errors: list[ErrorModel] = None, body: Any = None
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(status_code, detail=detail)
|
|
34
|
+
self.errors = errors or []
|
|
35
|
+
self.body = body
|
|
36
|
+
self.errors.append(ErrorModel(detail=detail, status=str(status_code), title=title))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthError(JsonapiException):
|
|
40
|
+
"""HTTP 401 error,没有认证权限"""
|
|
41
|
+
|
|
42
|
+
status_code: int = status.HTTP_401_UNAUTHORIZED
|
|
43
|
+
title: str = "没有认证权限"
|
|
44
|
+
|
|
45
|
+
def __init__(self, status_code: int = None, detail: str = None, title: str = None, body: Any = None) -> None:
|
|
46
|
+
super().__init__(
|
|
47
|
+
status_code if status_code is not None else self.status_code,
|
|
48
|
+
title=title if title is not None else self.title,
|
|
49
|
+
detail=detail,
|
|
50
|
+
errors=None,
|
|
51
|
+
body=body,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ResourceDuplicate(JsonapiException):
|
|
56
|
+
"""HTTP 409 error,资源已存在"""
|
|
57
|
+
|
|
58
|
+
status_code: int = status.HTTP_409_CONFLICT
|
|
59
|
+
title: str = "资源重复创建"
|
|
60
|
+
|
|
61
|
+
def __init__(self, status_code: int = None, detail: str = None, title: str = None, body: Any = None) -> None:
|
|
62
|
+
super().__init__(
|
|
63
|
+
status_code if status_code is not None else self.status_code,
|
|
64
|
+
title=title if title is not None else self.title,
|
|
65
|
+
detail=detail,
|
|
66
|
+
errors=None,
|
|
67
|
+
body=body,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ResourceNotFound(JsonapiException):
|
|
72
|
+
"""HTTP 404 error,资源不存在"""
|
|
73
|
+
|
|
74
|
+
status_code: int = status.HTTP_404_NOT_FOUND
|
|
75
|
+
title: str = "资源不存在"
|
|
76
|
+
|
|
77
|
+
def __init__(self, status_code: int = None, detail: str = None, title: str = None, body: Any = None) -> None:
|
|
78
|
+
super().__init__(
|
|
79
|
+
status_code if status_code is not None else self.status_code,
|
|
80
|
+
title=title if title is not None else self.title,
|
|
81
|
+
detail=detail,
|
|
82
|
+
errors=None,
|
|
83
|
+
body=body,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class QureyError(JsonapiException):
|
|
88
|
+
"""HTTP 400 error,查询参数错误"""
|
|
89
|
+
|
|
90
|
+
status_code: int = status.HTTP_400_BAD_REQUEST
|
|
91
|
+
title: str = "查询参数错误"
|
|
92
|
+
|
|
93
|
+
def __init__(self, status_code: int = None, detail: str = None, title: str = None, body: Any = None) -> None:
|
|
94
|
+
super().__init__(
|
|
95
|
+
status_code if status_code is not None else self.status_code,
|
|
96
|
+
title=title if title is not None else self.title,
|
|
97
|
+
detail=detail,
|
|
98
|
+
errors=None,
|
|
99
|
+
body=body,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_request_context(request: Request, body: Any = None) -> dict:
|
|
104
|
+
"""Extract common request context for error logging."""
|
|
105
|
+
if request.headers.get("X-Real-IP"):
|
|
106
|
+
client_ip = request.headers.get("X-Real-IP")
|
|
107
|
+
else:
|
|
108
|
+
client_ip = request.client.host
|
|
109
|
+
if not body:
|
|
110
|
+
if hasattr(request, "_json"):
|
|
111
|
+
body = request._json
|
|
112
|
+
if hasattr(request, "_form") and request._form:
|
|
113
|
+
for item, value in request._form.multi_items():
|
|
114
|
+
if item == "data":
|
|
115
|
+
body = json.loads(value)
|
|
116
|
+
return {
|
|
117
|
+
"RequestMethod": request.method,
|
|
118
|
+
"url": str(request.url),
|
|
119
|
+
"UserAgent": request.headers.get("user-agent"),
|
|
120
|
+
"IP": client_ip,
|
|
121
|
+
"TokenPrefix": (request.headers.get("authorization", "")[:16] + "...")
|
|
122
|
+
if request.headers.get("authorization")
|
|
123
|
+
else None,
|
|
124
|
+
"RequestBody": body,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _loc_to_json_pointer(loc):
|
|
130
|
+
"""将 Pydantic loc 元组转为 JSON Pointer。
|
|
131
|
+
|
|
132
|
+
("body", "data", "attributes", "title") → "/data/attributes/title"
|
|
133
|
+
"""
|
|
134
|
+
if not loc:
|
|
135
|
+
return ""
|
|
136
|
+
parts = [str(p) for p in loc if str(p) != "body"]
|
|
137
|
+
if not parts:
|
|
138
|
+
return ""
|
|
139
|
+
return "/" + "/".join(parts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def serialize_error(exc: BaseException, request: Request, msg: dict = None) -> JsonapiResponse:
|
|
143
|
+
"""JSON:API 格式的错误序列化。
|
|
144
|
+
错误处理,将所有错误类型转成json:api,
|
|
145
|
+
JsonapiException: 预判规范内的错误,返给前端
|
|
146
|
+
HTTPException: http错误,日志记录
|
|
147
|
+
RequestValidationError,ValidationError:pydantic的验证错误,数据不合规范,记录日志
|
|
148
|
+
对pydantic的验证错误做了处理。detail中指明了验证错误的具体字段和错误原因
|
|
149
|
+
Args:
|
|
150
|
+
exc: 处理对象
|
|
151
|
+
request: Request
|
|
152
|
+
msg: 日志信息
|
|
153
|
+
Returns:JsonapiResponse
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
body = None
|
|
157
|
+
if isinstance(exc, JsonapiException):
|
|
158
|
+
status_code = exc.status_code
|
|
159
|
+
errors = exc.errors
|
|
160
|
+
body = exc.body
|
|
161
|
+
elif isinstance(exc, HTTPException):
|
|
162
|
+
status_code = exc.status_code
|
|
163
|
+
errors = [ErrorModel(status=str(status_code), detail=exc.detail, meta={"headers": exc.headers})]
|
|
164
|
+
elif isinstance(exc, RequestValidationError):
|
|
165
|
+
status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
|
|
166
|
+
errors = [
|
|
167
|
+
ErrorModel(
|
|
168
|
+
detail=error.get("msg"),
|
|
169
|
+
title=error.get("type"),
|
|
170
|
+
status=str(status_code),
|
|
171
|
+
source={
|
|
172
|
+
"pointer": _loc_to_json_pointer(error.get("loc")),
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
for error in exc.errors()
|
|
176
|
+
]
|
|
177
|
+
body = exc.body
|
|
178
|
+
else:
|
|
179
|
+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
180
|
+
errors = [ErrorModel(status=str(status_code), detail="Internal server error")]
|
|
181
|
+
error_body = ErrorResponse(errors=errors)
|
|
182
|
+
|
|
183
|
+
if not msg:
|
|
184
|
+
msg = _extract_request_context(request, body)
|
|
185
|
+
msg["ResponseStatusCode"] = status_code
|
|
186
|
+
|
|
187
|
+
if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
|
|
188
|
+
logger.error(msg, exc_info=exc)
|
|
189
|
+
else:
|
|
190
|
+
logger.warning(msg, exc_info=exc)
|
|
191
|
+
|
|
192
|
+
return JsonapiResponse(status_code=status_code, content=error_body.model_dump())
|
api_frame/field.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
字段
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic.fields import FieldInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Field(FieldInfo):
|
|
13
|
+
"""模型默认字段属性, 提供额外信息。
|
|
14
|
+
与pydantic的field相比,增加了:
|
|
15
|
+
onlyread: 标明此字段是否只读
|
|
16
|
+
onlywrite:标明此字段是否只写
|
|
17
|
+
inmany: 在资源列表中是否显示。=true显示
|
|
18
|
+
ishide: 是否不在schema模型中隐藏。例如用户id,在响应模型中不需要,只在接口和数据库交互时使用。可置ishide=true
|
|
19
|
+
isrel: 是否作为关系,默认否
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, default: Any = None, **kwargs: Any):
|
|
23
|
+
# 在调用 super().__init__() 前弹出自定义元数据 — pydantic v2 FieldInfo
|
|
24
|
+
# only accepts known parameters; unknown ones would raise TypeError
|
|
25
|
+
self.onlyread = kwargs.pop("onlyread", False)
|
|
26
|
+
self.inmany = kwargs.pop("inmany", True)
|
|
27
|
+
self.onlywrite = kwargs.pop("onlywrite", False)
|
|
28
|
+
self.ishide = kwargs.pop("ishide", False)
|
|
29
|
+
self.isrel = kwargs.pop("isrel", False)
|
|
30
|
+
self.mapping = kwargs.pop("mapping", None)
|
|
31
|
+
|
|
32
|
+
# pydantic v2 中 'pattern' 存储在 metadata 中,不作为公共属性暴露。
|
|
33
|
+
# 这里显式保存以便 Email/URL 等子类能对外暴露。
|
|
34
|
+
self._saved_pattern = kwargs.get("pattern")
|
|
35
|
+
|
|
36
|
+
super().__init__(default=default, **kwargs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Email(Field):
|
|
40
|
+
"""
|
|
41
|
+
邮箱
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, default: Any = None, **kwargs: Any):
|
|
45
|
+
kwargs["pattern"] = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"
|
|
46
|
+
super().__init__(default=default, **kwargs)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def pattern(self):
|
|
50
|
+
return self._saved_pattern
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class URL(Field):
|
|
54
|
+
"""
|
|
55
|
+
url
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, default: Any = None, **kwargs: Any):
|
|
59
|
+
kwargs["pattern"] = r"^https?:/{2}\w.+$"
|
|
60
|
+
super().__init__(default=default, **kwargs)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def pattern(self):
|
|
64
|
+
return self._saved_pattern
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Phone(Field):
|
|
68
|
+
pass
|