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/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