store-api-py 0.2.0__tar.gz → 0.3.1__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.2.0
3
+ Version: 0.3.1
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.2.0"
3
+ version = "0.3.1"
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"
@@ -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,6 +65,9 @@ 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:
@@ -99,7 +108,10 @@ def create_app(
99
108
  # 显式投影(spec/01+02):GQL 省略投影段 = 只返回 _id(三端 core 契约)。
100
109
  # 列表路由仅在 q 缺失时用它;q 存在时投影完全由 q 决定,适配层不追加。
101
110
  proj = _schema_projection(store, name)
102
- _register_resource(app, store, name, prefix, id_field, permission_error, proj, file_field, file_resolver)
111
+ _register_resource(
112
+ app, store, name, prefix, id_field, permission_error, proj,
113
+ file_field, file_resolver, upload_resolver, upload_limit,
114
+ )
103
115
 
104
116
  return app
105
117
 
@@ -147,6 +159,42 @@ def _guard(permission_error: type[BaseException] | None) -> Callable:
147
159
  return deco
148
160
 
149
161
 
162
+ def _pick_field(rec: dict | None, field_path: str) -> Any:
163
+ """点路径取字段(决策 C):'images.full' → rec['images']['full'];任一段缺失 → None。"""
164
+ if rec is None:
165
+ return None
166
+ cur: Any = rec
167
+ for seg in field_path.split("."):
168
+ if not isinstance(cur, dict):
169
+ return None
170
+ cur = cur.get(seg)
171
+ if cur is None:
172
+ return None
173
+ return cur
174
+
175
+
176
+ async def _read_upload_body(request: Request, upload_limit: int) -> bytes:
177
+ """上传字节体(决策 B'):空体 → 400 EMPTY_BODY;超限 → 413 UPLOAD_TOO_LARGE。
178
+
179
+ FastAPI/starlette 无 Fastify 式 parser/bodyLimit,故 upload_limit 是 py 端唯一防线。
180
+ """
181
+ body = await request.body()
182
+ if not body:
183
+ raise empty_body(f"上传请求体为空: {request.method} {request.url.path}")
184
+ if len(body) > upload_limit:
185
+ raise too_large(f"上传字节体 {len(body)} 字节超过上限 {upload_limit}")
186
+ return body
187
+
188
+
189
+ def _set_by_path(field_path: str, value: Any) -> dict:
190
+ """上传回写的 set 体:字面键,交由 store 寻址。
191
+
192
+ store 对非 `$` 前缀键自动包 `$set`(py-store/src/py_store/crud/write.py),
193
+ 点路径键 `$set {'a.b': v}` 即嵌套写入 —— 与 node 端同策略(parity)。
194
+ """
195
+ return {field_path: value}
196
+
197
+
150
198
  def _register_resource(
151
199
  app: FastAPI,
152
200
  store: Any,
@@ -157,11 +205,18 @@ def _register_resource(
157
205
  proj: str,
158
206
  file_field: str,
159
207
  file_resolver: Callable[[Request, dict, str], Any] | None,
208
+ upload_resolver: Callable[[Request, dict, str], Any] | None,
209
+ upload_limit: int,
160
210
  ) -> None:
161
211
  # 工厂函数隔离闭包:handler 签名只含 Request 与路径参数,
162
212
  # 否则 FastAPI 会把捕获变量解析成查询参数(缺参即 422)
163
213
  base = f"{prefix}/{name}"
164
214
 
215
+ def _field_path(request: Request) -> str:
216
+ # 请求级字段路径:?field= 优先,缺省回落插件级 file_field(spec/02-params.md)
217
+ f = request.query_params.get("field")
218
+ return f if f else file_field
219
+
165
220
  @app.get(base)
166
221
  @_guard(permission_error)
167
222
  async def list_resource(request: Request):
@@ -181,24 +236,49 @@ def _register_resource(
181
236
  @app.get(base + "/{rid}/file")
182
237
  @_guard(permission_error)
183
238
  async def get_file(rid: str, request: Request):
239
+ # spec/03 判定顺序第 1 层:未注入 file_resolver → 501(先于 query_one 判定)
240
+ if file_resolver is None:
241
+ raise file_not_configured(f"GET {base}/{{rid}}/file 未注入 file_resolver")
242
+ fp = _field_path(request)
184
243
  rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
185
244
  if rec is None:
186
245
  raise not_found(f"记录不存在: {id_field}={rid}")
187
- raw = rec.get(file_field)
246
+ raw = _pick_field(rec, fp)
188
247
  if raw is None:
189
- raise not_found(f"文件不存在: {file_field}={rid}")
190
- if file_resolver is not None:
191
- out = file_resolver(request, rec, rid)
192
- if inspect.isawaitable(out):
193
- out = await out
194
- else:
195
- out = {"body": str(raw), "contentType": "text/plain; charset=utf-8", "fileName": f"{name}-{rid}"}
248
+ raise not_found(f"文件不存在: {fp}={rid}")
249
+ out = file_resolver(request, rec, rid)
250
+ if inspect.isawaitable(out):
251
+ out = await out
196
252
  return Response(
197
253
  content=out["body"],
198
254
  media_type=out.get("contentType", "application/octet-stream"),
199
255
  headers={"content-disposition": f'attachment; filename="{out.get("fileName", "file")}"'},
200
256
  )
201
257
 
258
+ @app.post(base + "/{rid}/file")
259
+ @_guard(permission_error)
260
+ async def upload_file(rid: str, request: Request):
261
+ # spec/03 判定顺序第 1 层:未注入 upload_resolver → 501
262
+ if upload_resolver is None:
263
+ raise upload_not_configured(f"POST {base}/{{rid}}/file 未注入 upload_resolver")
264
+ await _read_upload_body(request, upload_limit) # 空体 400 / 超限 413
265
+ fp = _field_path(request)
266
+ rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
267
+ if rec is None:
268
+ raise not_found(f"记录不存在: {id_field}={rid}")
269
+ # 接缝:resolver 负责字节落库并返回引用(决策 B:与 file_resolver 对称)
270
+ out = upload_resolver(request, rec, rid)
271
+ if inspect.isawaitable(out):
272
+ out = await out
273
+ ref = out.get("ref") if isinstance(out, dict) else None
274
+ if ref is None:
275
+ raise RuntimeError( # → map_error 末层 500 原样透传(禁静默)
276
+ f"upload_resolver 未返回 ref: {name}({id_field}={rid})"
277
+ )
278
+ # 回写:走 update 语义 → RBAC 写权限自动生效(决策 A)
279
+ data = await store.update(name, {id_field: rid}, _set_by_path(fp, ref))
280
+ return {"data": data}
281
+
202
282
  @app.post(base, status_code=201)
203
283
  @_guard(permission_error)
204
284
  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) → PermissionError(403) → queryOne 空结果(404) → 其余 500 透传
64
+ 未注入 resolver / 适配层守卫(400/413/501,由 StoreApiError 自带 status) → PermissionError(403)
65
+ → GQL 解析失败(400) → 其余 500 透传。
48
66
  """
49
67
  if isinstance(err, StoreApiError):
50
68
  return err
@@ -59,4 +77,9 @@ def map_error(err: BaseException, permission_error: type[BaseException] | None)
59
77
  text = str(err)
60
78
  if text.startswith("ERR_GQL_PARSE:"):
61
79
  return StoreApiError(400, "GQL_PARSE", text[len("ERR_GQL_PARSE:"):])
80
+ # spec/03 判定顺序第 4 层(core 稳定前缀类):资源无任何可读副本
81
+ # (py-store resource open 零副本行 → ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND,
82
+ # message 剥前缀取原文(与 GQL_PARSE 同构;禁按文案匹配)。
83
+ if text.startswith("ERR_RESOURCE_NOT_FOUND:"):
84
+ return StoreApiError(404, "NOT_FOUND", text[len("ERR_RESOURCE_NOT_FOUND:"):] or None)
62
85
  return StoreApiError(500, store_code(err), text or None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: store-api-py
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -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
- row.update(data)
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):
@@ -226,14 +242,22 @@ def test_x_cache_header():
226
242
 
227
243
 
228
244
  def test_file_download_route():
229
- """文件下载路由:200 + 头;记录/字段缺失 → 404(spec/01+03+05)"""
245
+ """文件下载路由:注入 resolver → 200 + 头;未注入 → 501;缺失 → 404(spec/01+03+05)"""
230
246
  store = MockStore()
231
- client = TestClient(build_app(store))
232
247
 
233
- created = client.post("/api/user", json={"name": "a", "age": 1})
234
- rid = created.json()["data"]["_id"]
248
+ # 未注入 file_resolver → 501 FILE_NOT_CONFIGURED(决策 D,原 text/plain 兜底已废止)
249
+ bare = TestClient(build_app(store))
250
+ rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
235
251
  store.rows[rid]["file"] = "hello"
252
+ not_configured = bare.get(f"/api/user/{rid}/file")
253
+ assert not_configured.status_code == 501
254
+ assert not_configured.json()["error"]["code"] == "FILE_NOT_CONFIGURED"
255
+
256
+ # 注入 resolver → 200
257
+ async def _fr(request, rec, i):
258
+ return {"body": b"hello", "contentType": "text/plain; charset=utf-8", "fileName": f"user-{i}"}
236
259
 
260
+ client = TestClient(build_app(store, file_resolver=_fr))
237
261
  file = client.get(f"/api/user/{rid}/file")
238
262
  assert file.status_code == 200
239
263
  assert file.headers["content-type"] == "text/plain; charset=utf-8"
@@ -243,3 +267,76 @@ def test_file_download_route():
243
267
  missing = client.get("/api/user/nope/file")
244
268
  assert missing.status_code == 404
245
269
  assert missing.json()["error"]["code"] == "NOT_FOUND"
270
+
271
+
272
+ def test_file_download_resource_not_found_maps_404():
273
+ """下载路由:资源无任何可读副本(core 前缀 ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND(spec/03)。"""
274
+ store = MockStore()
275
+ rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
276
+ store.rows[rid]["file"] = "sha1x" # 字段非空才会走到 resolver
277
+
278
+ async def _fr(request, rec, i):
279
+ raise FileNotFoundError("ERR_RESOURCE_NOT_FOUND:资源不存在或无可读副本: sha1x")
280
+
281
+ res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
282
+ assert res.status_code == 404
283
+ assert res.json()["error"]["code"] == "NOT_FOUND"
284
+ assert res.json()["error"]["message"] == "资源不存在或无可读副本: sha1x" # 前缀已剥离
285
+
286
+
287
+ def test_file_download_provider_failure_stays_500():
288
+ """下载路由:provider 读取失败(非前缀错误)→ 500 透传,不得伪装成 404。"""
289
+ store = MockStore()
290
+ rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
291
+ store.rows[rid]["file"] = "sha1x"
292
+
293
+ async def _fr(request, rec, i):
294
+ raise RuntimeError("io down")
295
+
296
+ res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
297
+ assert res.status_code == 500
298
+ assert res.json()["error"]["message"] == "io down"
299
+
300
+
301
+ def test_upload_route_and_field():
302
+ """上传路由:未注入 → 501;注入 → 200 回写;空体 → 400;超限 → 413;field 点路径(spec/01+02+03)"""
303
+ store = MockStore()
304
+ bare = TestClient(build_app(store))
305
+ rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
306
+
307
+ nc = bare.post(f"/api/user/{rid}/file", content=b"bytes",
308
+ headers={"content-type": "application/octet-stream"})
309
+ assert nc.status_code == 501
310
+ assert nc.json()["error"]["code"] == "UPLOAD_NOT_CONFIGURED"
311
+
312
+ async def _ur(request, rec, i):
313
+ return {"ref": "sha1abc"}
314
+
315
+ client = TestClient(build_app(store, upload_resolver=_ur))
316
+ ok = client.post(f"/api/user/{rid}/file", content=b"bytes",
317
+ headers={"content-type": "application/octet-stream"})
318
+ assert ok.status_code == 200
319
+ assert ok.json()["data"]["file"] == "sha1abc"
320
+
321
+ empty = client.post(f"/api/user/{rid}/file", content=b"",
322
+ headers={"content-type": "application/octet-stream"})
323
+ assert empty.status_code == 400
324
+ assert empty.json()["error"]["code"] == "EMPTY_BODY"
325
+
326
+ # 超限 → 413 UPLOAD_TOO_LARGE(A4 parity;用小 upload_limit 触发,避免构造 32MB 大包)
327
+ small = TestClient(build_app(store, upload_resolver=_ur, upload_limit=4))
328
+ too_large = small.post(f"/api/user/{rid}/file", content=b"bytes",
329
+ headers={"content-type": "application/octet-stream"})
330
+ assert too_large.status_code == 413
331
+ assert too_large.json()["error"]["code"] == "UPLOAD_TOO_LARGE"
332
+
333
+ store.rows[rid]["images"] = {"full": "old-ref"}
334
+ ok2 = client.post(f"/api/user/{rid}/file?field=images.full", content=b"bytes",
335
+ headers={"content-type": "application/octet-stream"})
336
+ assert ok2.status_code == 200
337
+ assert ok2.json()["data"]["images"]["full"] == "sha1abc"
338
+
339
+ missing = client.post("/api/user/nope/file", content=b"bytes",
340
+ headers={"content-type": "application/octet-stream"})
341
+ assert missing.status_code == 404
342
+ assert missing.json()["error"]["code"] == "NOT_FOUND"
File without changes
File without changes