store-api-py 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {store_api_py-0.2.0 → store_api_py-0.4.0}/PKG-INFO +1 -1
- {store_api_py-0.2.0 → store_api_py-0.4.0}/pyproject.toml +1 -1
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py/app.py +100 -9
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py/errors.py +29 -1
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py.egg-info/PKG-INFO +1 -1
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py.egg-info/SOURCES.txt +1 -0
- store_api_py-0.4.0/tests/test_error_matrix.py +52 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/tests/test_smoke.py +118 -5
- {store_api_py-0.2.0 → store_api_py-0.4.0}/README.md +0 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/setup.cfg +0 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py/__init__.py +0 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py/params.py +0 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py.egg-info/dependency_links.txt +0 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py.egg-info/requires.txt +0 -0
- {store_api_py-0.2.0 → store_api_py-0.4.0}/src/store_api_py.egg-info/top_level.txt +0 -0
|
@@ -16,10 +16,14 @@ from fastapi.responses import JSONResponse, Response
|
|
|
16
16
|
from .errors import (
|
|
17
17
|
StoreApiError,
|
|
18
18
|
context_error,
|
|
19
|
+
empty_body,
|
|
19
20
|
error_payload,
|
|
21
|
+
file_not_configured,
|
|
20
22
|
invalid_body,
|
|
21
23
|
map_error,
|
|
22
24
|
not_found,
|
|
25
|
+
too_large,
|
|
26
|
+
upload_not_configured,
|
|
23
27
|
)
|
|
24
28
|
from .params import parse_query_params
|
|
25
29
|
|
|
@@ -51,6 +55,8 @@ def create_app(
|
|
|
51
55
|
permission_error: type[BaseException] | None = None,
|
|
52
56
|
file_field: str = "file",
|
|
53
57
|
file_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
58
|
+
upload_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
59
|
+
upload_limit: int = 32 * 1024 * 1024,
|
|
54
60
|
) -> FastAPI:
|
|
55
61
|
"""为 store(py-store 的 store 实例,需已 init + register)生成 RESTful FastAPI 应用。
|
|
56
62
|
|
|
@@ -59,11 +65,25 @@ def create_app(
|
|
|
59
65
|
- context_provider: 每请求上下文钩子(spec/04-context.md);非权限类抛错 ⇒ 401 CONTEXT_ERROR
|
|
60
66
|
- resources: 显式资源名;缺省取 store.list() 并过滤归档表
|
|
61
67
|
- permission_error: store 权限错误类;缺省取 store.PermissionError
|
|
68
|
+
- file_resolver: 下载字节接缝 (request, rec, rid) => {body, contentType, fileName};缺省时下载 501
|
|
69
|
+
- upload_resolver: 上传字节接缝 (request, rec, rid) => {ref};缺省时上传 501
|
|
70
|
+
- upload_limit: 上传字节体上限(超限 413),默认 32MB
|
|
62
71
|
"""
|
|
63
72
|
app = FastAPI(title="store-api")
|
|
64
73
|
if permission_error is None:
|
|
65
74
|
permission_error = getattr(store, "PermissionError", None)
|
|
66
75
|
|
|
76
|
+
# fail-secure 装配守卫:宿主已开启上下文强制却未配 context_provider 时,装配期即
|
|
77
|
+
# 拒绝(fail-fast)。否则服务能启动、每个请求却在运行期以 ERR_NO_CONTEXT 失败——
|
|
78
|
+
# 配置错误被推迟成运行时事故(no-error-masking:不允许静默带错运行)。
|
|
79
|
+
require_context = getattr(store, "require_context", None)
|
|
80
|
+
if callable(require_context) and require_context() and context_provider is None:
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"ERR_SECURE_CONFIG: 宿主已开启上下文强制(fail-secure)但未配置 context_provider;"
|
|
83
|
+
"请注入从请求解析身份的 context_provider(spec/04-context.md),"
|
|
84
|
+
"无需鉴权的内部服务请显式 store.set_require_context(False) 后再装配"
|
|
85
|
+
)
|
|
86
|
+
|
|
67
87
|
def _respond(mapped: StoreApiError) -> JSONResponse:
|
|
68
88
|
return JSONResponse(status_code=mapped.status_code, content=error_payload(mapped.code, mapped.message))
|
|
69
89
|
|
|
@@ -99,7 +119,10 @@ def create_app(
|
|
|
99
119
|
# 显式投影(spec/01+02):GQL 省略投影段 = 只返回 _id(三端 core 契约)。
|
|
100
120
|
# 列表路由仅在 q 缺失时用它;q 存在时投影完全由 q 决定,适配层不追加。
|
|
101
121
|
proj = _schema_projection(store, name)
|
|
102
|
-
_register_resource(
|
|
122
|
+
_register_resource(
|
|
123
|
+
app, store, name, prefix, id_field, permission_error, proj,
|
|
124
|
+
file_field, file_resolver, upload_resolver, upload_limit,
|
|
125
|
+
)
|
|
103
126
|
|
|
104
127
|
return app
|
|
105
128
|
|
|
@@ -147,6 +170,42 @@ def _guard(permission_error: type[BaseException] | None) -> Callable:
|
|
|
147
170
|
return deco
|
|
148
171
|
|
|
149
172
|
|
|
173
|
+
def _pick_field(rec: dict | None, field_path: str) -> Any:
|
|
174
|
+
"""点路径取字段(决策 C):'images.full' → rec['images']['full'];任一段缺失 → None。"""
|
|
175
|
+
if rec is None:
|
|
176
|
+
return None
|
|
177
|
+
cur: Any = rec
|
|
178
|
+
for seg in field_path.split("."):
|
|
179
|
+
if not isinstance(cur, dict):
|
|
180
|
+
return None
|
|
181
|
+
cur = cur.get(seg)
|
|
182
|
+
if cur is None:
|
|
183
|
+
return None
|
|
184
|
+
return cur
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _read_upload_body(request: Request, upload_limit: int) -> bytes:
|
|
188
|
+
"""上传字节体(决策 B'):空体 → 400 EMPTY_BODY;超限 → 413 UPLOAD_TOO_LARGE。
|
|
189
|
+
|
|
190
|
+
FastAPI/starlette 无 Fastify 式 parser/bodyLimit,故 upload_limit 是 py 端唯一防线。
|
|
191
|
+
"""
|
|
192
|
+
body = await request.body()
|
|
193
|
+
if not body:
|
|
194
|
+
raise empty_body(f"上传请求体为空: {request.method} {request.url.path}")
|
|
195
|
+
if len(body) > upload_limit:
|
|
196
|
+
raise too_large(f"上传字节体 {len(body)} 字节超过上限 {upload_limit}")
|
|
197
|
+
return body
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _set_by_path(field_path: str, value: Any) -> dict:
|
|
201
|
+
"""上传回写的 set 体:字面键,交由 store 寻址。
|
|
202
|
+
|
|
203
|
+
store 对非 `$` 前缀键自动包 `$set`(py-store/src/py_store/crud/write.py),
|
|
204
|
+
点路径键 `$set {'a.b': v}` 即嵌套写入 —— 与 node 端同策略(parity)。
|
|
205
|
+
"""
|
|
206
|
+
return {field_path: value}
|
|
207
|
+
|
|
208
|
+
|
|
150
209
|
def _register_resource(
|
|
151
210
|
app: FastAPI,
|
|
152
211
|
store: Any,
|
|
@@ -157,11 +216,18 @@ def _register_resource(
|
|
|
157
216
|
proj: str,
|
|
158
217
|
file_field: str,
|
|
159
218
|
file_resolver: Callable[[Request, dict, str], Any] | None,
|
|
219
|
+
upload_resolver: Callable[[Request, dict, str], Any] | None,
|
|
220
|
+
upload_limit: int,
|
|
160
221
|
) -> None:
|
|
161
222
|
# 工厂函数隔离闭包:handler 签名只含 Request 与路径参数,
|
|
162
223
|
# 否则 FastAPI 会把捕获变量解析成查询参数(缺参即 422)
|
|
163
224
|
base = f"{prefix}/{name}"
|
|
164
225
|
|
|
226
|
+
def _field_path(request: Request) -> str:
|
|
227
|
+
# 请求级字段路径:?field= 优先,缺省回落插件级 file_field(spec/02-params.md)
|
|
228
|
+
f = request.query_params.get("field")
|
|
229
|
+
return f if f else file_field
|
|
230
|
+
|
|
165
231
|
@app.get(base)
|
|
166
232
|
@_guard(permission_error)
|
|
167
233
|
async def list_resource(request: Request):
|
|
@@ -181,24 +247,49 @@ def _register_resource(
|
|
|
181
247
|
@app.get(base + "/{rid}/file")
|
|
182
248
|
@_guard(permission_error)
|
|
183
249
|
async def get_file(rid: str, request: Request):
|
|
250
|
+
# spec/03 判定顺序第 1 层:未注入 file_resolver → 501(先于 query_one 判定)
|
|
251
|
+
if file_resolver is None:
|
|
252
|
+
raise file_not_configured(f"GET {base}/{{rid}}/file 未注入 file_resolver")
|
|
253
|
+
fp = _field_path(request)
|
|
184
254
|
rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
|
|
185
255
|
if rec is None:
|
|
186
256
|
raise not_found(f"记录不存在: {id_field}={rid}")
|
|
187
|
-
raw = rec
|
|
257
|
+
raw = _pick_field(rec, fp)
|
|
188
258
|
if raw is None:
|
|
189
|
-
raise not_found(f"文件不存在: {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
out = await out
|
|
194
|
-
else:
|
|
195
|
-
out = {"body": str(raw), "contentType": "text/plain; charset=utf-8", "fileName": f"{name}-{rid}"}
|
|
259
|
+
raise not_found(f"文件不存在: {fp}={rid}")
|
|
260
|
+
out = file_resolver(request, rec, rid)
|
|
261
|
+
if inspect.isawaitable(out):
|
|
262
|
+
out = await out
|
|
196
263
|
return Response(
|
|
197
264
|
content=out["body"],
|
|
198
265
|
media_type=out.get("contentType", "application/octet-stream"),
|
|
199
266
|
headers={"content-disposition": f'attachment; filename="{out.get("fileName", "file")}"'},
|
|
200
267
|
)
|
|
201
268
|
|
|
269
|
+
@app.post(base + "/{rid}/file")
|
|
270
|
+
@_guard(permission_error)
|
|
271
|
+
async def upload_file(rid: str, request: Request):
|
|
272
|
+
# spec/03 判定顺序第 1 层:未注入 upload_resolver → 501
|
|
273
|
+
if upload_resolver is None:
|
|
274
|
+
raise upload_not_configured(f"POST {base}/{{rid}}/file 未注入 upload_resolver")
|
|
275
|
+
await _read_upload_body(request, upload_limit) # 空体 400 / 超限 413
|
|
276
|
+
fp = _field_path(request)
|
|
277
|
+
rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
|
|
278
|
+
if rec is None:
|
|
279
|
+
raise not_found(f"记录不存在: {id_field}={rid}")
|
|
280
|
+
# 接缝:resolver 负责字节落库并返回引用(决策 B:与 file_resolver 对称)
|
|
281
|
+
out = upload_resolver(request, rec, rid)
|
|
282
|
+
if inspect.isawaitable(out):
|
|
283
|
+
out = await out
|
|
284
|
+
ref = out.get("ref") if isinstance(out, dict) else None
|
|
285
|
+
if ref is None:
|
|
286
|
+
raise RuntimeError( # → map_error 末层 500 原样透传(禁静默)
|
|
287
|
+
f"upload_resolver 未返回 ref: {name}({id_field}={rid})"
|
|
288
|
+
)
|
|
289
|
+
# 回写:走 update 语义 → RBAC 写权限自动生效(决策 A)
|
|
290
|
+
data = await store.update(name, {id_field: rid}, _set_by_path(fp, ref))
|
|
291
|
+
return {"data": data}
|
|
292
|
+
|
|
202
293
|
@app.post(base, status_code=201)
|
|
203
294
|
@_guard(permission_error)
|
|
204
295
|
async def create_resource(request: Request):
|
|
@@ -38,13 +38,31 @@ def not_found(message: str) -> StoreApiError:
|
|
|
38
38
|
return StoreApiError(404, "NOT_FOUND", message)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
# ── 资源字节上传/下载守卫工厂(spec/03-errors.md) ──
|
|
42
|
+
def empty_body(message: str | None = None) -> StoreApiError:
|
|
43
|
+
return StoreApiError(400, "EMPTY_BODY", message or "上传请求体为空")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def too_large(message: str | None = None) -> StoreApiError:
|
|
47
|
+
return StoreApiError(413, "UPLOAD_TOO_LARGE", message or "上传字节体超过上限")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def upload_not_configured(message: str | None = None) -> StoreApiError:
|
|
51
|
+
return StoreApiError(501, "UPLOAD_NOT_CONFIGURED", message or "未注入 upload_resolver")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def file_not_configured(message: str | None = None) -> StoreApiError:
|
|
55
|
+
return StoreApiError(501, "FILE_NOT_CONFIGURED", message or "未注入 file_resolver")
|
|
56
|
+
|
|
57
|
+
|
|
41
58
|
def context_error(message: str | None) -> StoreApiError:
|
|
42
59
|
return StoreApiError(401, "CONTEXT_ERROR", message)
|
|
43
60
|
|
|
44
61
|
|
|
45
62
|
def map_error(err: BaseException, permission_error: type[BaseException] | None) -> StoreApiError:
|
|
46
63
|
"""判定顺序(spec/03-errors.md):
|
|
47
|
-
适配层守卫(400
|
|
64
|
+
未注入 resolver / 适配层守卫(400/413/501,由 StoreApiError 自带 status) → 权限类(403)
|
|
65
|
+
→ GQL 解析失败(400) → 其余 500 透传。
|
|
48
66
|
"""
|
|
49
67
|
if isinstance(err, StoreApiError):
|
|
50
68
|
return err
|
|
@@ -53,10 +71,20 @@ def map_error(err: BaseException, permission_error: type[BaseException] | None)
|
|
|
53
71
|
if permission_error is not None and isinstance(err, permission_error):
|
|
54
72
|
# 按类型判定,禁按 message 匹配
|
|
55
73
|
return StoreApiError(403, store_code(err), str(err) or None)
|
|
74
|
+
if getattr(err, "code", None) == "no_context":
|
|
75
|
+
# spec/03 判定顺序第 3 层:权限类同档 —— py-store NoContextError(machine code
|
|
76
|
+
# `no_context`,requireContext 开启且 ctx 缺失);按 code 判定(宿主已剥前缀),
|
|
77
|
+
# 禁按 message 匹配 → 403。
|
|
78
|
+
return StoreApiError(403, store_code(err), str(err) or None)
|
|
56
79
|
# spec/03 判定顺序第 3 层:GQL 解析失败(core 稳定前缀 ERR_GQL_PARSE:,与
|
|
57
80
|
# ERR_PERM_PREFIX 同构的类型级契约——前缀判定非文案脆弱匹配)→ 400 GQL_PARSE,
|
|
58
81
|
# message 剥前缀取原文(与 go/rust/node 同语义)。
|
|
59
82
|
text = str(err)
|
|
60
83
|
if text.startswith("ERR_GQL_PARSE:"):
|
|
61
84
|
return StoreApiError(400, "GQL_PARSE", text[len("ERR_GQL_PARSE:"):])
|
|
85
|
+
# spec/03 判定顺序第 4 层(core 稳定前缀类):资源无任何可读副本
|
|
86
|
+
# (py-store resource open 零副本行 → ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND,
|
|
87
|
+
# message 剥前缀取原文(与 GQL_PARSE 同构;禁按文案匹配)。
|
|
88
|
+
if text.startswith("ERR_RESOURCE_NOT_FOUND:"):
|
|
89
|
+
return StoreApiError(404, "NOT_FOUND", text[len("ERR_RESOURCE_NOT_FOUND:"):] or None)
|
|
62
90
|
return StoreApiError(500, store_code(err), text or None)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""映射矩阵:每档错误语义 → REST 状态码(A3「可程序化区分」验收)。
|
|
2
|
+
|
|
3
|
+
直接调纯函数 map_error(无需真实 store/库),用例与 node/test/error-matrix.test.js 同构。
|
|
4
|
+
规范依据:spec/03-errors.md 判定顺序 + core machine code(`no_context` / `permission_denied`)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
from store_api_py.errors import map_error # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PermissionError(Exception):
|
|
16
|
+
"""store 权限错误类(按类型判定,禁按 message 匹配)。"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _err(message: str, code: str) -> Exception:
|
|
20
|
+
e = Exception(message)
|
|
21
|
+
e.code = code
|
|
22
|
+
return e
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_matrix_permission_to_403():
|
|
26
|
+
err = PermissionError("无访问权限")
|
|
27
|
+
err.code = "permission_denied"
|
|
28
|
+
r = map_error(err, PermissionError)
|
|
29
|
+
assert r.status_code == 403
|
|
30
|
+
assert r.code == "permission_denied"
|
|
31
|
+
assert r.message == "无访问权限"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_matrix_no_context_to_403():
|
|
35
|
+
r = map_error(_err("上下文缺失", "no_context"), PermissionError)
|
|
36
|
+
assert r.status_code == 403
|
|
37
|
+
assert r.code == "no_context"
|
|
38
|
+
assert r.message == "上下文缺失"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_matrix_gql_parse_to_400():
|
|
42
|
+
r = map_error(Exception("ERR_GQL_PARSE:意外的 token"), PermissionError)
|
|
43
|
+
assert r.status_code == 400
|
|
44
|
+
assert r.code == "GQL_PARSE"
|
|
45
|
+
assert r.message == "意外的 token"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_matrix_other_to_500():
|
|
49
|
+
r = map_error(_err("连接超时", "CONN_TIMEOUT"), PermissionError)
|
|
50
|
+
assert r.status_code == 500
|
|
51
|
+
assert r.code == "CONN_TIMEOUT"
|
|
52
|
+
assert r.message == "连接超时"
|
|
@@ -20,6 +20,19 @@ class MockPermissionError(Exception):
|
|
|
20
20
|
pass
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _apply_set(row: dict, path: str, value):
|
|
24
|
+
"""模拟 store $set 的点路径写入语义:'images.full' → row['images']['full'](Mongo $set {'a.b': v} 即嵌套写)。"""
|
|
25
|
+
if path.startswith("$"):
|
|
26
|
+
return # 原生操作符($inc/$unset 等)本 mock 不模拟
|
|
27
|
+
segs = path.split(".")
|
|
28
|
+
cur = row
|
|
29
|
+
for s in segs[:-1]:
|
|
30
|
+
if not isinstance(cur.get(s), dict):
|
|
31
|
+
cur[s] = {}
|
|
32
|
+
cur = cur[s]
|
|
33
|
+
cur[segs[-1]] = value
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
class MockStore:
|
|
24
37
|
"""mock store:只实现适配器调用面(query/query_one/insert/update/remove/list/set_context)。"""
|
|
25
38
|
|
|
@@ -59,7 +72,10 @@ class MockStore:
|
|
|
59
72
|
async def update(self, name, cond, data):
|
|
60
73
|
row = self.rows.get(cond.get("_id"))
|
|
61
74
|
if row:
|
|
62
|
-
|
|
75
|
+
# 模拟 store 的 $set 语义:非 `$` 前缀键自动包 $set(py-store/src/py_store/crud/write.py),
|
|
76
|
+
# 键内点路径由后端寻址(Mongo $set {'a.b': v} 即嵌套写入)。
|
|
77
|
+
for k, v in data.items():
|
|
78
|
+
_apply_set(row, k, v)
|
|
63
79
|
return row
|
|
64
80
|
|
|
65
81
|
async def remove(self, name, cond):
|
|
@@ -70,6 +86,22 @@ def build_app(store, **extra):
|
|
|
70
86
|
return create_app(store, prefix="/api", **extra)
|
|
71
87
|
|
|
72
88
|
|
|
89
|
+
def test_secure_config_guard():
|
|
90
|
+
"""fail-secure 装配守卫:require_context=True 且无 context_provider ⇒ 装配期 ERR_SECURE_CONFIG"""
|
|
91
|
+
store = MockStore()
|
|
92
|
+
store.require_context = lambda: True
|
|
93
|
+
# 无 context_provider:装配期拒绝(fail-fast,错误不推迟到运行期)
|
|
94
|
+
with pytest.raises(RuntimeError, match="ERR_SECURE_CONFIG"):
|
|
95
|
+
build_app(store)
|
|
96
|
+
# 补配 context_provider:正常装配
|
|
97
|
+
app = build_app(store, context_provider=lambda request: {"uid": "u1"})
|
|
98
|
+
assert app is not None
|
|
99
|
+
# require_context=False(默认姿态)时无守卫,照常装配
|
|
100
|
+
opened = MockStore()
|
|
101
|
+
opened.require_context = lambda: False
|
|
102
|
+
assert build_app(opened) is not None
|
|
103
|
+
|
|
104
|
+
|
|
73
105
|
def test_filter_archived():
|
|
74
106
|
assert filter_archived(["user", "userDeleted", "post", "logDeleted"]) == ["user", "post", "logDeleted"]
|
|
75
107
|
|
|
@@ -226,14 +258,22 @@ def test_x_cache_header():
|
|
|
226
258
|
|
|
227
259
|
|
|
228
260
|
def test_file_download_route():
|
|
229
|
-
"""
|
|
261
|
+
"""文件下载路由:注入 resolver → 200 + 头;未注入 → 501;缺失 → 404(spec/01+03+05)"""
|
|
230
262
|
store = MockStore()
|
|
231
|
-
client = TestClient(build_app(store))
|
|
232
263
|
|
|
233
|
-
|
|
234
|
-
|
|
264
|
+
# 未注入 file_resolver → 501 FILE_NOT_CONFIGURED(决策 D,原 text/plain 兜底已废止)
|
|
265
|
+
bare = TestClient(build_app(store))
|
|
266
|
+
rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
235
267
|
store.rows[rid]["file"] = "hello"
|
|
268
|
+
not_configured = bare.get(f"/api/user/{rid}/file")
|
|
269
|
+
assert not_configured.status_code == 501
|
|
270
|
+
assert not_configured.json()["error"]["code"] == "FILE_NOT_CONFIGURED"
|
|
236
271
|
|
|
272
|
+
# 注入 resolver → 200
|
|
273
|
+
async def _fr(request, rec, i):
|
|
274
|
+
return {"body": b"hello", "contentType": "text/plain; charset=utf-8", "fileName": f"user-{i}"}
|
|
275
|
+
|
|
276
|
+
client = TestClient(build_app(store, file_resolver=_fr))
|
|
237
277
|
file = client.get(f"/api/user/{rid}/file")
|
|
238
278
|
assert file.status_code == 200
|
|
239
279
|
assert file.headers["content-type"] == "text/plain; charset=utf-8"
|
|
@@ -243,3 +283,76 @@ def test_file_download_route():
|
|
|
243
283
|
missing = client.get("/api/user/nope/file")
|
|
244
284
|
assert missing.status_code == 404
|
|
245
285
|
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_file_download_resource_not_found_maps_404():
|
|
289
|
+
"""下载路由:资源无任何可读副本(core 前缀 ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND(spec/03)。"""
|
|
290
|
+
store = MockStore()
|
|
291
|
+
rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
292
|
+
store.rows[rid]["file"] = "sha1x" # 字段非空才会走到 resolver
|
|
293
|
+
|
|
294
|
+
async def _fr(request, rec, i):
|
|
295
|
+
raise FileNotFoundError("ERR_RESOURCE_NOT_FOUND:资源不存在或无可读副本: sha1x")
|
|
296
|
+
|
|
297
|
+
res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
|
|
298
|
+
assert res.status_code == 404
|
|
299
|
+
assert res.json()["error"]["code"] == "NOT_FOUND"
|
|
300
|
+
assert res.json()["error"]["message"] == "资源不存在或无可读副本: sha1x" # 前缀已剥离
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_file_download_provider_failure_stays_500():
|
|
304
|
+
"""下载路由:provider 读取失败(非前缀错误)→ 500 透传,不得伪装成 404。"""
|
|
305
|
+
store = MockStore()
|
|
306
|
+
rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
307
|
+
store.rows[rid]["file"] = "sha1x"
|
|
308
|
+
|
|
309
|
+
async def _fr(request, rec, i):
|
|
310
|
+
raise RuntimeError("io down")
|
|
311
|
+
|
|
312
|
+
res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
|
|
313
|
+
assert res.status_code == 500
|
|
314
|
+
assert res.json()["error"]["message"] == "io down"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_upload_route_and_field():
|
|
318
|
+
"""上传路由:未注入 → 501;注入 → 200 回写;空体 → 400;超限 → 413;field 点路径(spec/01+02+03)"""
|
|
319
|
+
store = MockStore()
|
|
320
|
+
bare = TestClient(build_app(store))
|
|
321
|
+
rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
|
|
322
|
+
|
|
323
|
+
nc = bare.post(f"/api/user/{rid}/file", content=b"bytes",
|
|
324
|
+
headers={"content-type": "application/octet-stream"})
|
|
325
|
+
assert nc.status_code == 501
|
|
326
|
+
assert nc.json()["error"]["code"] == "UPLOAD_NOT_CONFIGURED"
|
|
327
|
+
|
|
328
|
+
async def _ur(request, rec, i):
|
|
329
|
+
return {"ref": "sha1abc"}
|
|
330
|
+
|
|
331
|
+
client = TestClient(build_app(store, upload_resolver=_ur))
|
|
332
|
+
ok = client.post(f"/api/user/{rid}/file", content=b"bytes",
|
|
333
|
+
headers={"content-type": "application/octet-stream"})
|
|
334
|
+
assert ok.status_code == 200
|
|
335
|
+
assert ok.json()["data"]["file"] == "sha1abc"
|
|
336
|
+
|
|
337
|
+
empty = client.post(f"/api/user/{rid}/file", content=b"",
|
|
338
|
+
headers={"content-type": "application/octet-stream"})
|
|
339
|
+
assert empty.status_code == 400
|
|
340
|
+
assert empty.json()["error"]["code"] == "EMPTY_BODY"
|
|
341
|
+
|
|
342
|
+
# 超限 → 413 UPLOAD_TOO_LARGE(A4 parity;用小 upload_limit 触发,避免构造 32MB 大包)
|
|
343
|
+
small = TestClient(build_app(store, upload_resolver=_ur, upload_limit=4))
|
|
344
|
+
too_large = small.post(f"/api/user/{rid}/file", content=b"bytes",
|
|
345
|
+
headers={"content-type": "application/octet-stream"})
|
|
346
|
+
assert too_large.status_code == 413
|
|
347
|
+
assert too_large.json()["error"]["code"] == "UPLOAD_TOO_LARGE"
|
|
348
|
+
|
|
349
|
+
store.rows[rid]["images"] = {"full": "old-ref"}
|
|
350
|
+
ok2 = client.post(f"/api/user/{rid}/file?field=images.full", content=b"bytes",
|
|
351
|
+
headers={"content-type": "application/octet-stream"})
|
|
352
|
+
assert ok2.status_code == 200
|
|
353
|
+
assert ok2.json()["data"]["images"]["full"] == "sha1abc"
|
|
354
|
+
|
|
355
|
+
missing = client.post("/api/user/nope/file", content=b"bytes",
|
|
356
|
+
headers={"content-type": "application/octet-stream"})
|
|
357
|
+
assert missing.status_code == 404
|
|
358
|
+
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|