store-api-py 0.4.0__tar.gz → 0.5.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.4.0 → store_api_py-0.5.0}/PKG-INFO +1 -1
- {store_api_py-0.4.0 → store_api_py-0.5.0}/pyproject.toml +1 -1
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py/app.py +44 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py.egg-info/PKG-INFO +1 -1
- {store_api_py-0.4.0 → store_api_py-0.5.0}/tests/test_smoke.py +89 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/README.md +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/setup.cfg +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py/__init__.py +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py/errors.py +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py/params.py +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py.egg-info/SOURCES.txt +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py.egg-info/dependency_links.txt +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py.egg-info/requires.txt +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/src/store_api_py.egg-info/top_level.txt +0 -0
- {store_api_py-0.4.0 → store_api_py-0.5.0}/tests/test_error_matrix.py +0 -0
|
@@ -57,12 +57,17 @@ def create_app(
|
|
|
57
57
|
file_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
58
58
|
upload_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
59
59
|
upload_limit: int = 32 * 1024 * 1024,
|
|
60
|
+
policy_provider: Callable[[Any], Any | None] | None = None,
|
|
60
61
|
) -> FastAPI:
|
|
61
62
|
"""为 store(py-store 的 store 实例,需已 init + register)生成 RESTful FastAPI 应用。
|
|
62
63
|
|
|
63
64
|
- prefix: 路由前缀,如 '/api'
|
|
64
65
|
- id_field: 单条路由主键字段名(双端一致,spec/01-routing.md,默认 '_id')
|
|
65
66
|
- context_provider: 每请求上下文钩子(spec/04-context.md);非权限类抛错 ⇒ 401 CONTEXT_ERROR
|
|
67
|
+
- policy_provider: 每请求策略覆盖钩子 (ctx) => overrides | None(R2-04 §4.1);
|
|
68
|
+
overrides 非 None 时经 store.base_view().with_policy(overrides) 派生视图,
|
|
69
|
+
并以 with_scope 作用域包裹全部 handler;None(缺省)= 不启用,行为同现状。
|
|
70
|
+
需宿主提供 base_view;provider 异常同走 map_error 单一出口(禁吞)
|
|
66
71
|
- resources: 显式资源名;缺省取 store.list() 并过滤归档表
|
|
67
72
|
- permission_error: store 权限错误类;缺省取 store.PermissionError
|
|
68
73
|
- file_resolver: 下载字节接缝 (request, rec, rid) => {body, contentType, fileName};缺省时下载 501
|
|
@@ -84,9 +89,46 @@ def create_app(
|
|
|
84
89
|
"无需鉴权的内部服务请显式 store.set_require_context(False) 后再装配"
|
|
85
90
|
)
|
|
86
91
|
|
|
92
|
+
# R2-04 装配守卫:注入 policy_provider 需宿主提供 base_view(py-store 提供)——
|
|
93
|
+
# 皮侧禁自持 Registry 派生视图(R2-04 §7),宿主不支持时装配期显式失败(fail-fast)。
|
|
94
|
+
if policy_provider is not None and not callable(getattr(store, "base_view", None)):
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"ERR_POLICY_PROVIDER_CONFIG: 注入 policy_provider 需要宿主 store.base_view()"
|
|
97
|
+
"(py-store 提供);皮侧禁自持 Registry 派生视图(R2-04 §7)"
|
|
98
|
+
)
|
|
99
|
+
_with_scope = None
|
|
100
|
+
if policy_provider is not None:
|
|
101
|
+
try:
|
|
102
|
+
from py_store import with_scope as _with_scope # optional peer,惰性加载
|
|
103
|
+
except Exception as e: # noqa: BLE001 — 依赖缺失装配期显式失败,禁静默降级
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"ERR_DEPENDENCY_MISSING: 注入 policy_provider 需要安装 py-store(with_scope): {e}"
|
|
106
|
+
) from e
|
|
107
|
+
|
|
87
108
|
def _respond(mapped: StoreApiError) -> JSONResponse:
|
|
88
109
|
return JSONResponse(status_code=mapped.status_code, content=error_payload(mapped.code, mapped.message))
|
|
89
110
|
|
|
111
|
+
# 注册顺序说明:Starlette 中间件后注册者在外层。执行序(外→内)=
|
|
112
|
+
# _annotate_cache → _inject_context → _scoped_dispatch → 路由。
|
|
113
|
+
# _scoped_dispatch 需读 _inject_context 写入的 request.state.store_ctx,故先注册(更内层)。
|
|
114
|
+
if policy_provider is not None:
|
|
115
|
+
|
|
116
|
+
@app.middleware("http")
|
|
117
|
+
async def _scoped_dispatch(request: Request, call_next):
|
|
118
|
+
# R2-04 §4.1:ctx 复用自 context_provider 已解析值(request.state.store_ctx,
|
|
119
|
+
# 禁二次调用 provider);无 context_provider 时为 None。
|
|
120
|
+
# provider 异常同走 map_error 单一出口(禁吞、禁静默降级)。
|
|
121
|
+
try:
|
|
122
|
+
overrides = policy_provider(getattr(request.state, "store_ctx", None))
|
|
123
|
+
except Exception as e: # noqa: BLE001 — 错误映射为带语义的 HTTP 响应,禁止静默
|
|
124
|
+
mapped = map_error(e, permission_error)
|
|
125
|
+
return JSONResponse(
|
|
126
|
+
status_code=mapped.status_code, content=error_payload(mapped.code, mapped.message)
|
|
127
|
+
)
|
|
128
|
+
view = store.base_view().with_policy(overrides) if overrides is not None else None
|
|
129
|
+
with _with_scope(view):
|
|
130
|
+
return await call_next(request)
|
|
131
|
+
|
|
90
132
|
if context_provider is not None:
|
|
91
133
|
|
|
92
134
|
@app.middleware("http")
|
|
@@ -100,6 +142,8 @@ def create_app(
|
|
|
100
142
|
# spec/04:返回 None 时同样显式注入空上下文(store.set_context(None) 语义为清除,
|
|
101
143
|
# 有状态持有的运行时禁止残留上一请求上下文,防身份跨请求泄漏)
|
|
102
144
|
store.set_context(ctx)
|
|
145
|
+
# R2-04:已解析 ctx 存 request.state 供 _scoped_dispatch 复用(禁二次调用 provider)
|
|
146
|
+
request.state.store_ctx = ctx
|
|
103
147
|
return await call_next(request)
|
|
104
148
|
|
|
105
149
|
# B6:`x-cache` 响应头注记位——取值唯一来源为宿主 store.cache_status(未实现时恒 BYPASS);
|
|
@@ -5,12 +5,15 @@ conformance 纪律:两端断言逐项对应,任何一端改动必须同步
|
|
|
5
5
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
10
11
|
from fastapi import FastAPI
|
|
11
12
|
from fastapi.testclient import TestClient
|
|
12
13
|
|
|
13
14
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
15
|
+
# py-store 为 optional peer(跨仓源码直连,node 侧对应 junction link)
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "py-store" / "src"))
|
|
14
17
|
|
|
15
18
|
from store_api_py import ParamError, create_app, filter_archived, parse_query_params # noqa: E402
|
|
16
19
|
from store_api_py.params import convert_value # noqa: E402
|
|
@@ -356,3 +359,89 @@ def test_upload_route_and_field():
|
|
|
356
359
|
headers={"content-type": "application/octet-stream"})
|
|
357
360
|
assert missing.status_code == 404
|
|
358
361
|
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ── R2-04:policy_provider 请求入口作用域化(与 node test/smoke.test.js 同构,双端 parity)──
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_policy_provider_guard_and_require_context():
|
|
368
|
+
"""装配守卫 + requireContext 覆盖:无 ctx 请求 → no_context(403);返回 None → 行为同现状。"""
|
|
369
|
+
from py_store import current_scope
|
|
370
|
+
|
|
371
|
+
# 装配守卫:宿主未导出 base_view() 时注入 policy_provider ⇒ 装配期显式失败(fail-fast,禁静默降级)
|
|
372
|
+
legacy = MockStore()
|
|
373
|
+
with pytest.raises(RuntimeError, match="ERR_POLICY_PROVIDER_CONFIG"):
|
|
374
|
+
build_app(legacy, policy_provider=lambda ctx: None)
|
|
375
|
+
|
|
376
|
+
store = MockStore()
|
|
377
|
+
store.base_view = lambda: SimpleNamespace(with_policy=lambda o: {"overrides": o})
|
|
378
|
+
|
|
379
|
+
# 作用域感知的 query(模拟宿主 fail-secure:视图要求 ctx 且当前 ctx 缺失 ⇒ NoContext)
|
|
380
|
+
async def scoped_query(gql, params=None):
|
|
381
|
+
scope = current_scope()
|
|
382
|
+
view = scope.view if scope is not None else None
|
|
383
|
+
ov = (view or {}).get("overrides") or {}
|
|
384
|
+
if ov.get("requireContext") and store.last_context is None:
|
|
385
|
+
e = RuntimeError("ERR_NO_CONTEXT:上下文缺失")
|
|
386
|
+
e.code = "no_context"
|
|
387
|
+
raise e
|
|
388
|
+
return list(store.rows.values())
|
|
389
|
+
|
|
390
|
+
store.query = scoped_query
|
|
391
|
+
|
|
392
|
+
# ① 注入 requireContext 覆盖:未配 context_provider(policy_provider 收到 None)→ 403 no_context
|
|
393
|
+
seen = []
|
|
394
|
+
|
|
395
|
+
def provider(ctx):
|
|
396
|
+
seen.append(ctx)
|
|
397
|
+
return {"requireContext": True}
|
|
398
|
+
|
|
399
|
+
client = TestClient(build_app(store, policy_provider=provider))
|
|
400
|
+
denied = client.get("/api/user")
|
|
401
|
+
assert seen == [None] # policy_provider 入参 = 已解析 ctx(无 context_provider ⇒ None)
|
|
402
|
+
assert denied.status_code == 403
|
|
403
|
+
assert denied.json()["error"]["code"] == "no_context"
|
|
404
|
+
|
|
405
|
+
# ② 注入返回 None ⇒ 无视图作用域(current_scope().view 回退 None)→ 行为同现状
|
|
406
|
+
lax = TestClient(build_app(store, policy_provider=lambda ctx: None))
|
|
407
|
+
ok = lax.get("/api/user")
|
|
408
|
+
assert ok.status_code == 200
|
|
409
|
+
assert "error" not in ok.json()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_policy_provider_profile_view_and_ctx_reuse():
|
|
413
|
+
"""profile 覆盖经视图生效(查询面可观察);ctx 复用自 context_provider(禁二次调用)。"""
|
|
414
|
+
from py_store import current_scope
|
|
415
|
+
|
|
416
|
+
store = MockStore()
|
|
417
|
+
store.base_view = lambda: SimpleNamespace(with_policy=lambda o: {"overrides": o})
|
|
418
|
+
|
|
419
|
+
# 档位判决可观察面:text2query 档视图下输出收缩(模拟 core 判决差异经宿主查询面可见)
|
|
420
|
+
async def scoped_query(gql, params=None):
|
|
421
|
+
scope = current_scope()
|
|
422
|
+
view = scope.view if scope is not None else None
|
|
423
|
+
ov = (view or {}).get("overrides") or {}
|
|
424
|
+
if ov.get("profile") == "text2query":
|
|
425
|
+
return [{"_id": "r1", "name": "redacted"}]
|
|
426
|
+
return list(store.rows.values())
|
|
427
|
+
|
|
428
|
+
store.query = scoped_query
|
|
429
|
+
|
|
430
|
+
ctx_calls = 0
|
|
431
|
+
|
|
432
|
+
def cp(request):
|
|
433
|
+
nonlocal ctx_calls
|
|
434
|
+
ctx_calls += 1
|
|
435
|
+
return {"uid": "u9"}
|
|
436
|
+
|
|
437
|
+
client = TestClient(build_app(
|
|
438
|
+
store,
|
|
439
|
+
context_provider=cp,
|
|
440
|
+
policy_provider=lambda ctx: {"profile": "text2query"} if ctx and ctx.get("uid") == "u9" else None,
|
|
441
|
+
))
|
|
442
|
+
client.post("/api/user", json={"name": "a", "age": 1})
|
|
443
|
+
res = client.get("/api/user")
|
|
444
|
+
assert res.status_code == 200
|
|
445
|
+
assert res.json()["data"] == [{"_id": "r1", "name": "redacted"}]
|
|
446
|
+
# 两个请求各解析一次 context_provider(已解析值存 request.state 复用,禁二次调用)
|
|
447
|
+
assert ctx_calls == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|