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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: store-api-py
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)
5
5
  License: MIT
6
6
  Keywords: py-store,storepy,fastapi,rest,crud,gql
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "store-api-py"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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);
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: store-api-py
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)
5
5
  License: MIT
6
6
  Keywords: py-store,storepy,fastapi,rest,crud,gql
@@ -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