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 ADDED
@@ -0,0 +1,16 @@
1
+ __version__ = "0.6.0"
2
+
3
+ from api_frame.auth import Auth # noqa: F401
4
+ from api_frame.cache import MemoryBackend, cached, set_default_backend, set_version_hook # noqa: F401
5
+ from api_frame.exception import ( # noqa: F401
6
+ AuthError,
7
+ JsonapiException,
8
+ QureyError,
9
+ ResourceDuplicate,
10
+ ResourceNotFound,
11
+ )
12
+ from api_frame.filebase import DownloadFileResource, UploadFileResource # noqa: F401
13
+ from api_frame.query import ArgParse, Filter, FilterAnd, FilterOr # noqa: F401
14
+ from api_frame.resource import BaseResource, Resource, UploadFileBaseResource # noqa: F401
15
+ from api_frame.schema import Relationship, SchemaBase, field_mapping # noqa: F401
16
+ from api_frame.scope import scope # noqa: F401
api_frame/atomic.py ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """Atomic operations module extracted from resource.py.
3
+
4
+ Provides AtomicOperation dataclass and AtomicExecutor class for
5
+ processing JSON:API atomic operations without mutating the request.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ from fastapi import Request
15
+
16
+ from api_frame.exception import AuthError, JsonapiException, serialize_error
17
+ from api_frame.meta import type_registered_resources
18
+
19
+
20
+ @dataclass
21
+ class AtomicOperation:
22
+ """Represents a single atomic operation.
23
+
24
+ Attributes:
25
+ op: The operation type ('add', 'update', 'remove')
26
+ target_cls: The target resource class
27
+ data: Operation data dict (from 'data' field)
28
+ ref_id: Optional reference id (for 'update' and 'remove')
29
+ """
30
+
31
+ op: Literal["add", "update", "remove"]
32
+ target_cls: type[Any]
33
+ data: dict
34
+ ref_id: str | None = None
35
+
36
+
37
+ class AtomicExecutor:
38
+ """Executes JSON:API atomic operations.
39
+
40
+ Processes a list of atomic operations by creating resource instances
41
+ directly and calling their post/patch/delete methods, without
42
+ mutating request.scope or request._json on the original request.
43
+ """
44
+
45
+ def __init__(self, request: Request):
46
+ self.request = request
47
+
48
+ async def run(self, operations: list[dict]) -> dict:
49
+ """Execute a list of atomic operations.
50
+
51
+ Args:
52
+ operations: List of operation dicts from 'atomic:operations'
53
+
54
+ Returns:
55
+ dict with 'atomic:results' key containing operation results
56
+
57
+ Raises:
58
+ JsonapiException: On validation errors
59
+ AuthError: On permission errors
60
+ """
61
+ if not operations:
62
+ raise JsonapiException(status_code=400, detail="atomic:operations 不能为空")
63
+
64
+ # 阶段1:解析并验证所有操作
65
+ resolved = await self._parse_operations(operations)
66
+
67
+ # 阶段2:在共享事务中执行所有操作
68
+ return await self._execute_operations(resolved)
69
+
70
+ async def _parse_operations(self, operations: list[dict]) -> list[tuple]:
71
+ """Parse and validate all operations.
72
+
73
+ Returns list of (op, target_cls, op_data, ref_id) tuples.
74
+ """
75
+ op_to_method = {"add": "POST", "update": "PATCH", "remove": "DELETE"}
76
+ resolved = []
77
+
78
+ for op_data in operations:
79
+ op = op_data.get("op")
80
+ if op not in ("add", "update", "remove"):
81
+ raise JsonapiException(status_code=400, detail=f"不支持的操作符: {op}")
82
+
83
+ if op == "add":
84
+ target_type = op_data.get("data", {}).get("type")
85
+ ref_id = None
86
+ else:
87
+ ref = op_data.get("ref", {})
88
+ target_type = ref.get("type")
89
+ ref_id = ref.get("id")
90
+ if not ref_id:
91
+ raise JsonapiException(status_code=400, detail="缺少资源 id")
92
+
93
+ if not target_type:
94
+ raise JsonapiException(status_code=400, detail="缺少资源类型 type")
95
+
96
+ target_cls = type_registered_resources.get(target_type)
97
+ if not target_cls:
98
+ raise JsonapiException(status_code=400, detail=f"资源类型不存在: {target_type}")
99
+
100
+ # 检查权限
101
+ if target_cls.Auth:
102
+ scope = target_cls.Auth.get_scopes(api_url=target_cls.Meta.link, method=op_to_method[op])
103
+ if scope is not None:
104
+ has_perm = await target_cls.Auth.api_auth(api_url=target_cls.Meta.link, method=op_to_method[op])
105
+ if not has_perm:
106
+ raise AuthError(detail=f"没有 {op_to_method[op]} {target_type} 的权限")
107
+
108
+ resolved.append((op, target_cls, op_data, ref_id))
109
+
110
+ return resolved
111
+
112
+ async def _execute_operations(self, resolved: list[tuple]) -> dict:
113
+ """Execute validated operations with shared transaction management."""
114
+ shared_db = None
115
+ last_target_cls = None # 用于异常时的错误处理回退
116
+ try:
117
+ # 获取 session 数据源
118
+ session_source = next((tc for _, tc, _, _ in resolved if tc.session is not None), None)
119
+
120
+ if session_source and session_source.session:
121
+ shared_db = session_source.session.get()
122
+ try:
123
+ shared_db.rollback()
124
+ except BaseException:
125
+ pass
126
+
127
+ results = []
128
+ for op, target_cls, op_data, ref_id in resolved:
129
+ last_target_cls = target_cls
130
+ result = await self._execute_single(op, target_cls, op_data, ref_id, shared_db)
131
+ results.append(
132
+ {
133
+ "data": {
134
+ "type": target_cls.Meta.type_,
135
+ "id": result.id if result and hasattr(result, "id") else None,
136
+ }
137
+ }
138
+ )
139
+
140
+ if shared_db:
141
+ shared_db.commit()
142
+
143
+ return {"atomic:results": results}
144
+
145
+ except BaseException as e:
146
+ if shared_db:
147
+ shared_db.rollback()
148
+ if last_target_cls is not None:
149
+ return await last_target_cls.handle_error(request=self.request, exc=e)
150
+ return await self._fallback_error(e)
151
+ finally:
152
+ if shared_db:
153
+ shared_db.close()
154
+
155
+ async def _execute_single(self, op: str, target_cls: type, op_data: dict, ref_id: str | None, shared_db):
156
+ """Execute a single atomic operation.
157
+
158
+ Creates a resource instance and calls the appropriate handler,
159
+ without mutating request.scope or request._json.
160
+ """
161
+ if op == "add":
162
+ body = op_data.get("data", {}).copy()
163
+ resource = target_cls(request=self.request, request_context={"request_body": {"data": body}})
164
+ # 标记为新增操作
165
+ resource._atomic_op = "add"
166
+ resource._atomic_body = body
167
+ if shared_db:
168
+ resource.db = shared_db
169
+ result = await resource.post()
170
+ if shared_db and resource.db is not shared_db:
171
+ logger.warning(
172
+ "原子操作: %s.post() 内部调用了 connect_data,已自动恢复共享事务", target_cls.Meta.type_
173
+ )
174
+ resource.db = shared_db
175
+
176
+ elif op == "update":
177
+ body = op_data.get("data", {}).copy()
178
+ body["id"] = ref_id
179
+ body.setdefault("type", target_cls.Meta.type_)
180
+ resource = target_cls(request=self.request, request_context={"request_body": {"data": body}})
181
+ # 标记为更新操作
182
+ resource._atomic_op = "update"
183
+ resource._atomic_body = body
184
+ resource._atomic_id = ref_id
185
+ if shared_db:
186
+ resource.db = shared_db
187
+ result = await resource.patch()
188
+ if shared_db and resource.db is not shared_db:
189
+ logger.warning(
190
+ "原子操作: %s.patch() 内部调用了 connect_data,已自动恢复共享事务", target_cls.Meta.type_
191
+ )
192
+ resource.db = shared_db
193
+
194
+ else: # remove
195
+ resource = target_cls(request=self.request)
196
+ resource._atomic_op = "remove"
197
+ resource._atomic_id = ref_id
198
+ if shared_db:
199
+ resource.db = shared_db
200
+ result = await resource.delete()
201
+ if shared_db and resource.db is not shared_db:
202
+ logger.warning(
203
+ "原子操作: %s.delete() 内部调用了 connect_data,已自动恢复共享事务", target_cls.Meta.type_
204
+ )
205
+ resource.db = shared_db
206
+
207
+ return result
208
+
209
+ async def _fallback_error(self, exc: BaseException) -> dict:
210
+ """Fallback error handler when target_cls is not available."""
211
+
212
+ return serialize_error(request=self.request, exc=exc)
api_frame/auth.py ADDED
@@ -0,0 +1,243 @@
1
+ import abc
2
+ import logging
3
+ import math
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ from bitarray import bitarray
8
+ from bitarray.util import ba2base, base2ba
9
+ from fastapi import Depends, Request, Security, routing
10
+ from fastapi.dependencies.utils import get_parameterless_sub_dependant
11
+ from fastapi.security import OAuth2PasswordBearer, SecurityScopes
12
+ from pydantic import BaseModel, Field
13
+ from treelib import Node, Tree
14
+
15
+ from api_frame.exception import QureyError
16
+ from api_frame.meta import registered_resources
17
+ from api_frame.scope import _SCOPE_DECLARATIONS_ATTR, ScopeConfig
18
+
19
+
20
+ class ScopeTree:
21
+ def __init__(self, project_scopes: list[dict]) -> None:
22
+ self.project_scopes = project_scopes
23
+ self.scope_dict = {}
24
+ self.scope_tree = Tree()
25
+
26
+ for scope in project_scopes:
27
+ self.scope_dict[scope["scope"]] = scope["id"]
28
+ node = Node(data=scope["scope"], tag=scope["name"], identifier=scope["id"])
29
+ if scope["pid"] is not None:
30
+ self.scope_tree.add_node(node, parent=scope["pid"])
31
+ else:
32
+ self.scope_tree.add_node(node)
33
+
34
+ def scopes(self):
35
+ scopes = {}
36
+ for scope in self.project_scopes:
37
+ scopes.update({scope["scope"]: scope["name"]})
38
+ return scopes
39
+
40
+ def sub(self, tag: str) -> list:
41
+ sub = []
42
+ nodeid = self.scope_dict[tag]
43
+ children = self.scope_tree.children(nodeid)
44
+ for child in children:
45
+ childlist = self.sub(child.data)
46
+ sub.append(child.data)
47
+ sub = sub + childlist
48
+ return sub
49
+
50
+ def children(self, tags: list) -> list:
51
+ family = []
52
+ for node in tags:
53
+ family.append(node)
54
+ family = family + self.sub(node)
55
+ family = list(set(family))
56
+ return family
57
+
58
+ def to_base(self, checked: list) -> str:
59
+ family = []
60
+ for node in checked:
61
+ family.append(node)
62
+ family = family + self.sub(node)
63
+ family = list(set(family))
64
+ ba_len = math.ceil(len(self.scope_dict) / 6) * 6
65
+ ba = bitarray(ba_len)
66
+ ba.setall(0)
67
+ for item in self.scope_dict:
68
+ if item in family:
69
+ ba[self.scope_dict[item]] = 1
70
+ return str(ba2base(64, ba))
71
+
72
+ def to_sope(self, ba_str: str) -> list:
73
+ ba = base2ba(64, ba_str)
74
+ scopes = []
75
+ for item in self.scope_dict:
76
+ index = self.scope_dict[item]
77
+ if index <= len(ba) - 1 and ba[index]:
78
+ scopes.append(item)
79
+ return scopes
80
+
81
+
82
+ class User(BaseModel):
83
+ id: str = Field(..., title="id")
84
+ name: str = Field(None, title="姓名")
85
+ headimage: str = Field(None, title="头像")
86
+ token: str = Field(None, title="token")
87
+ scope: list[str] = Field(None, title="scope")
88
+
89
+
90
+ class Auth(metaclass=abc.ABCMeta):
91
+ """权限验证基类"""
92
+
93
+ def __init__(self, cert, token_url: str = None, scopes: list[dict] = None):
94
+ self.cert = cert
95
+ self.token_url = token_url
96
+ self.scope_tree = ScopeTree(scopes)
97
+ self.user = None
98
+
99
+ def _scopes(self) -> dict:
100
+ # 项目全部的scopes集合
101
+ return self.scope_tree.scopes()
102
+
103
+ def _oauth(self) -> OAuth2PasswordBearer:
104
+ # 权限依赖
105
+ oauth2_scheme = OAuth2PasswordBearer(
106
+ tokenUrl=self.token_url,
107
+ scopes=self._scopes(),
108
+ )
109
+
110
+ return oauth2_scheme
111
+
112
+ def scope(self, ba_str) -> list:
113
+ """
114
+ 64位的scope字符串转成列表
115
+ :param ba_str: 64进制字符串
116
+ :return: token中的scope对应的列表
117
+ """
118
+ return self.scope_tree.to_sope(ba_str)
119
+
120
+ @abc.abstractmethod
121
+ async def auth(self, security_scopes: SecurityScopes, token: str) -> User:
122
+ """子类必须实现,权限验证方法"""
123
+ raise NotImplementedError
124
+
125
+
126
+ class SecurityConfig(Auth):
127
+ """权限安全配置"""
128
+
129
+ def __init__(
130
+ self,
131
+ api_scopes: dict,
132
+ cert: str,
133
+ token_url: str = None,
134
+ scopes: list[dict] = None,
135
+ strict: bool = False,
136
+ ):
137
+
138
+ super().__init__(cert, token_url, scopes)
139
+ self.api_scopes = api_scopes
140
+ self.res = None
141
+ self.strict = strict
142
+
143
+ def get_scopes(self, api_url, method):
144
+ """
145
+ 获取接口方法对应scope
146
+ Args:
147
+ api_url: api链接
148
+ method: 方法
149
+
150
+ Returns: scope
151
+
152
+ """
153
+ api = self.api_scopes.get(api_url)
154
+ if not api:
155
+ return None
156
+ scope = api.get(method, None)
157
+ return scope
158
+
159
+ async def api_auth(self, api_url, method) -> bool:
160
+ api_scope = self.get_scopes(api_url=api_url, method=method)
161
+ if api_scope is None:
162
+ return True
163
+ if not self.user or not self.user.scope:
164
+ return False
165
+ return bool(set(api_scope).intersection(set(self.user.scope)))
166
+
167
+ def _get_auth(self, res):
168
+ """
169
+ 生成权限判断函数
170
+ Args:
171
+ res: 资源
172
+
173
+ Returns:
174
+
175
+ """
176
+
177
+ async def wrapper(
178
+ request: Request, security_scopes: SecurityScopes, token: str = Depends(self._oauth())
179
+ ) -> User:
180
+ include_param = request.query_params.get("include")
181
+ if include_param:
182
+ path_parts = request.scope.get("path").split("/")
183
+ for include in include_param.split(","):
184
+ if include.count(".") == 1:
185
+ rel_name, relrel_name = include.split(".")
186
+ has_id = "id" in request.path_params
187
+ if len(path_parts) >= 2 and has_id and request.path_params.get("id") == path_parts[-2]:
188
+ res_temp = res.rel_resources()
189
+ last_part = path_parts[-1]
190
+ if last_part not in res_temp:
191
+ raise QureyError(detail=f"include 参数 {rel_name} 不存在")
192
+ rel_res_temp = res_temp[last_part]
193
+ rel_resource = registered_resources.get(rel_res_temp.rel_resource)
194
+ rels = rel_resource.rel_resources()
195
+ else:
196
+ rels = res.rel_resources()
197
+ if rel_name not in rels:
198
+ raise QureyError(detail=f"include 参数 {rel_name} 不存在")
199
+ rel_res = rels[rel_name]
200
+ rel_resource = registered_resources.get(rel_res.rel_resource)
201
+ rel_rels = rel_resource.rel_resources()
202
+ if relrel_name not in rel_rels:
203
+ raise QureyError(detail=f"include 参数 {relrel_name} 不存在")
204
+ relrel_res = registered_resources.get(rel_rels[relrel_name].rel_resource)
205
+ scope = self.get_scopes(api_url=relrel_res.Meta.link, method="GET")
206
+ await self.auth(security_scopes=SecurityScopes(scopes=scope), token=token)
207
+
208
+ user = await self.auth(security_scopes, token)
209
+ self.user = user
210
+ return user
211
+
212
+ return wrapper
213
+
214
+ def run(self, res):
215
+ # Check if resource class has @scope declarations
216
+ scope_declarations = getattr(res, _SCOPE_DECLARATIONS_ATTR, None)
217
+ scope_config = None
218
+ if scope_declarations:
219
+ scope_config = ScopeConfig(api_scopes=self.api_scopes)
220
+
221
+ for route in res.route.routes:
222
+ api_url = route.path_format[len(res.prefix) :]
223
+ method = list(route.methods)[0]
224
+ if scope_config:
225
+ scopes = scope_config.get_scopes(res, api_url, method)
226
+ else:
227
+ scopes = self.get_scopes(api_url=api_url, method=method)
228
+ if scopes is not None:
229
+ if isinstance(route, routing.APIRoute):
230
+ route.dependant.dependencies.insert(
231
+ 0,
232
+ get_parameterless_sub_dependant(
233
+ depends=Security(self._get_auth(res), scopes=scopes), path=route.path_format
234
+ ),
235
+ )
236
+ else:
237
+ msg = f"接口没有配置权限,请确认接口权限是否公开,不公开请配置权限。接口:{route}"
238
+ if self.strict:
239
+ raise RuntimeError(msg)
240
+ logger.warning(
241
+ "接口没有配置权限,请确认接口权限是否公开,不公开请配置权限。接口:%s", route
242
+ )
243
+ return res