store-api-py 0.1.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.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: store-api-py
3
+ Version: 0.1.0
4
+ Summary: FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)
5
+ License: MIT
6
+ Keywords: py-store,storepy,fastapi,rest,crud,gql
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastapi>=0.110
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: httpx>=0.27; extra == "dev"
13
+ Requires-Dist: uvicorn>=0.29; extra == "dev"
14
+
15
+ # store-api-py
16
+
17
+ FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL).
18
+
19
+ Full documentation: https://github.com/coenddt/store-api
@@ -0,0 +1,5 @@
1
+ # store-api-py
2
+
3
+ FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL).
4
+
5
+ Full documentation: https://github.com/coenddt/store-api
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "store-api-py"
3
+ version = "0.1.0"
4
+ description = "FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ keywords = ["py-store", "storepy", "fastapi", "rest", "crud", "gql"]
9
+ dependencies = ["fastapi>=0.110"]
10
+
11
+ [project.optional-dependencies]
12
+ dev = ["pytest>=8", "httpx>=0.27", "uvicorn>=0.29"]
13
+
14
+ [build-system]
15
+ requires = ["setuptools>=68"]
16
+ build-backend = "setuptools.build_meta"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ """store-api-py — 为 py-store schema 自动生成 RESTful API 的 FastAPI 适配器。
2
+
3
+ from store_api_py import create_app
4
+ app = create_app(store, prefix='/api') # uvicorn main:app
5
+ """
6
+
7
+ from .app import create_app, filter_archived
8
+ from .params import ParamError, convert_value, parse_query_params
9
+
10
+ __all__ = ["create_app", "filter_archived", "parse_query_params", "convert_value", "ParamError"]
@@ -0,0 +1,200 @@
1
+ """store-api-py — FastAPI 适配器:为已注册 schema 自动生成 RESTful 路由。
2
+
3
+ 路由/参数/错误/上下文语义全部以 spec/*.md 为唯一依据(双端 parity,改动先改 spec)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ import inspect
10
+ import json
11
+ from typing import Any, Callable
12
+
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from .errors import (
17
+ StoreApiError,
18
+ context_error,
19
+ error_payload,
20
+ invalid_body,
21
+ map_error,
22
+ not_found,
23
+ )
24
+ from .params import parse_query_params
25
+
26
+ ARCHIVE_SUFFIX = "Deleted"
27
+
28
+ ContextProvider = Callable[[Request], Any]
29
+
30
+
31
+ def filter_archived(names: list[str]) -> list[str]:
32
+ """归档表过滤(spec/01-routing.md):`XxxDeleted` 且 `Xxx` 也在列表中 ⇒ 视为归档表。"""
33
+ s = set(names)
34
+ return [n for n in names if not (n.endswith(ARCHIVE_SUFFIX) and n[: -len(ARCHIVE_SUFFIX)] in s)]
35
+
36
+
37
+ async def _resolve_ctx(provider: ContextProvider, request: Request) -> Any:
38
+ ctx = provider(request)
39
+ if inspect.isawaitable(ctx):
40
+ ctx = await ctx
41
+ return ctx
42
+
43
+
44
+ def create_app(
45
+ store: Any,
46
+ *,
47
+ prefix: str = "",
48
+ id_field: str = "_id",
49
+ context_provider: ContextProvider | None = None,
50
+ resources: list[str] | None = None,
51
+ permission_error: type[BaseException] | None = None,
52
+ ) -> FastAPI:
53
+ """为 store(py-store 的 store 实例,需已 init + register)生成 RESTful FastAPI 应用。
54
+
55
+ - prefix: 路由前缀,如 '/api'
56
+ - id_field: 单条路由主键字段名(双端一致,spec/01-routing.md,默认 '_id')
57
+ - context_provider: 每请求上下文钩子(spec/04-context.md);非权限类抛错 ⇒ 401 CONTEXT_ERROR
58
+ - resources: 显式资源名;缺省取 store.list() 并过滤归档表
59
+ - permission_error: store 权限错误类;缺省取 store.PermissionError
60
+ """
61
+ app = FastAPI(title="store-api")
62
+ if permission_error is None:
63
+ permission_error = getattr(store, "PermissionError", None)
64
+
65
+ def _respond(mapped: StoreApiError) -> JSONResponse:
66
+ return JSONResponse(status_code=mapped.status_code, content=error_payload(mapped.code, mapped.message))
67
+
68
+ if context_provider is not None:
69
+
70
+ @app.middleware("http")
71
+ async def _inject_context(request: Request, call_next):
72
+ try:
73
+ ctx = await _resolve_ctx(context_provider, request)
74
+ except Exception as e: # noqa: BLE001 — spec/04-context.md:PermissionError ⇒ 403(RBAC 拒绝);其余 ⇒ 401,message 原样透传
75
+ if permission_error is not None and isinstance(e, permission_error):
76
+ return _respond(map_error(e, permission_error))
77
+ return _respond(context_error(str(e) or None))
78
+ # spec/04:返回 None 时同样显式注入空上下文(store.set_context(None) 语义为清除,
79
+ # 有状态持有的运行时禁止残留上一请求上下文,防身份跨请求泄漏)
80
+ store.set_context(ctx)
81
+ return await call_next(request)
82
+
83
+ # B6:`x-cache` 响应头注记位——取值唯一来源为宿主 store.cache_status(未实现时恒 BYPASS);
84
+ # 覆盖所有响应(含错误响应)。非法值回落 BYPASS(宿主侧已对非法/异常走反馈通道留痕)。
85
+ _CACHE_VALUES = ("HIT", "MISS", "BYPASS")
86
+
87
+ @app.middleware("http")
88
+ async def _annotate_cache(request: Request, call_next):
89
+ response = await call_next(request)
90
+ fn = getattr(store, "cache_status", None)
91
+ v = fn() if callable(fn) else "BYPASS"
92
+ response.headers["x-cache"] = v if v in _CACHE_VALUES else "BYPASS"
93
+ return response
94
+
95
+ names = resources if resources is not None else filter_archived(store.list())
96
+ for name in names:
97
+ # 显式投影(spec/01+02):GQL 省略投影段 = 只返回 _id(三端 core 契约)。
98
+ # 列表路由仅在 q 缺失时用它;q 存在时投影完全由 q 决定,适配层不追加。
99
+ proj = _schema_projection(store, name)
100
+ _register_resource(app, store, name, prefix, id_field, permission_error, proj)
101
+
102
+ return app
103
+
104
+
105
+ def _schema_projection(store: Any, name: str) -> str:
106
+ """从 store 元数据生成显式投影串(' { f1, f2 }')。
107
+ 取值顺序:store.get(name)(nodejs 形态)→ py_store.schema 模块(py-store 未在 Store 类暴露 get)。
108
+ 两者皆不可得 / fields 为空 → 空串(无投影,data 仅 _id——上游 schema 定义不完整的显式后果)。"""
109
+ fields: dict | None = None
110
+ get = getattr(store, "get", None)
111
+ if callable(get):
112
+ try:
113
+ meta = get(name)
114
+ except KeyError:
115
+ meta = None
116
+ if isinstance(meta, dict):
117
+ fields = meta.get("fields")
118
+ else:
119
+ fields = getattr(meta, "fields", None)
120
+ if fields is None:
121
+ try:
122
+ from py_store import schema as py_schema
123
+
124
+ fields = (py_schema.get(name) or {}).get("fields")
125
+ except Exception:
126
+ fields = None
127
+ keys = list((fields or {}).keys())
128
+ return f" {{ {', '.join(keys)} }}" if keys else ""
129
+
130
+
131
+ def _guard(permission_error: type[BaseException] | None) -> Callable:
132
+ """单一错误出口:handler 抛出的所有错误统一走 map_error 判定(禁 catch 后洗成成功)。"""
133
+
134
+ def deco(fn: Callable) -> Callable:
135
+ @functools.wraps(fn) # 保留原签名,FastAPI 靠 inspect.signature 注入 Request/路径参数
136
+ async def wrapper(*args, **kwargs):
137
+ try:
138
+ return await fn(*args, **kwargs)
139
+ except Exception as e: # noqa: BLE001 — 错误必须映射为带语义的 HTTP 响应,禁止静默
140
+ mapped = map_error(e, permission_error)
141
+ return JSONResponse(status_code=mapped.status_code, content=error_payload(mapped.code, mapped.message))
142
+
143
+ return wrapper
144
+
145
+ return deco
146
+
147
+
148
+ def _register_resource(
149
+ app: FastAPI,
150
+ store: Any,
151
+ name: str,
152
+ prefix: str,
153
+ id_field: str,
154
+ permission_error: type[BaseException] | None,
155
+ proj: str,
156
+ ) -> None:
157
+ # 工厂函数隔离闭包:handler 签名只含 Request 与路径参数,
158
+ # 否则 FastAPI 会把捕获变量解析成查询参数(缺参即 422)
159
+ base = f"{prefix}/{name}"
160
+
161
+ @app.get(base)
162
+ @_guard(permission_error)
163
+ async def list_resource(request: Request):
164
+ params = parse_query_params(request.query_params)
165
+ # q 缺失 → 全字段投影(spec/02);q 存在 → 投影完全由 q 决定
166
+ gql = name + (request.query_params.get("q") or proj)
167
+ return {"data": await store.query(gql, params)}
168
+
169
+ @app.get(base + "/{rid}")
170
+ @_guard(permission_error)
171
+ async def get_one(rid: str):
172
+ data = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
173
+ if data is None:
174
+ raise not_found(f"记录不存在: {id_field}={rid}")
175
+ return {"data": data}
176
+
177
+ @app.post(base, status_code=201)
178
+ @_guard(permission_error)
179
+ async def create_resource(request: Request):
180
+ return {"data": await store.insert(name, await _read_json_body(request))}
181
+
182
+ @app.patch(base + "/{rid}")
183
+ @_guard(permission_error)
184
+ async def update_one(rid: str, request: Request):
185
+ return {"data": await store.update(name, {id_field: rid}, await _read_json_body(request))}
186
+
187
+ @app.delete(base + "/{rid}")
188
+ @_guard(permission_error)
189
+ async def delete_one(rid: str):
190
+ return {"data": await store.remove(name, {id_field: rid})}
191
+
192
+
193
+ async def _read_json_body(request: Request) -> dict:
194
+ try:
195
+ body = await request.json()
196
+ except json.JSONDecodeError as e:
197
+ raise invalid_body(f"请求体不是合法 JSON: {e}") from e
198
+ if not isinstance(body, dict):
199
+ raise invalid_body("请求体必须是 JSON 对象")
200
+ return body
@@ -0,0 +1,62 @@
1
+ """错误 → HTTP 状态码映射。
2
+
3
+ 规范依据:spec/03-errors.md(判定顺序双端一致,改动必须先改 spec)。
4
+ 遵循 no-error-masking:message 原样透传、取不到置 None(禁伪造),成功响应不得带 error 字段。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from .params import ParamError
12
+
13
+
14
+ class StoreApiError(Exception):
15
+ """适配层自身守卫错误(400/401/404),message 为确定性描述。"""
16
+
17
+ def __init__(self, status_code: int, code: str, message: str | None):
18
+ super().__init__(message)
19
+ self.status_code = status_code
20
+ self.code = code
21
+ self.message = message
22
+
23
+
24
+ def error_payload(code: str, message: str | None) -> dict[str, Any]:
25
+ return {"error": {"code": code, "message": message}}
26
+
27
+
28
+ def store_code(err: BaseException) -> str:
29
+ """store 错误的分类码:优先 err.code,缺省用错误类名,再缺省用通用码。"""
30
+ return getattr(err, "code", None) or type(err).__name__ or "STORE_ERROR"
31
+
32
+
33
+ def invalid_body(message: str) -> StoreApiError:
34
+ return StoreApiError(400, "INVALID_BODY", message)
35
+
36
+
37
+ def not_found(message: str) -> StoreApiError:
38
+ return StoreApiError(404, "NOT_FOUND", message)
39
+
40
+
41
+ def context_error(message: str | None) -> StoreApiError:
42
+ return StoreApiError(401, "CONTEXT_ERROR", message)
43
+
44
+
45
+ def map_error(err: BaseException, permission_error: type[BaseException] | None) -> StoreApiError:
46
+ """判定顺序(spec/03-errors.md):
47
+ 适配层守卫(400) → PermissionError(403) → queryOne 空结果(404) → 其余 500 透传
48
+ """
49
+ if isinstance(err, StoreApiError):
50
+ return err
51
+ if isinstance(err, ParamError):
52
+ return StoreApiError(400, "INVALID_PARAM", str(err) or None)
53
+ if permission_error is not None and isinstance(err, permission_error):
54
+ # 按类型判定,禁按 message 匹配
55
+ return StoreApiError(403, store_code(err), str(err) or None)
56
+ # spec/03 判定顺序第 3 层:GQL 解析失败(core 稳定前缀 ERR_GQL_PARSE:,与
57
+ # ERR_PERM_PREFIX 同构的类型级契约——前缀判定非文案脆弱匹配)→ 400 GQL_PARSE,
58
+ # message 剥前缀取原文(与 go/rust/node 同语义)。
59
+ text = str(err)
60
+ if text.startswith("ERR_GQL_PARSE:"):
61
+ return StoreApiError(400, "GQL_PARSE", text[len("ERR_GQL_PARSE:"):])
62
+ return StoreApiError(500, store_code(err), text or None)
@@ -0,0 +1,50 @@
1
+ """查询参数 → GQL params 绑定。
2
+
3
+ 规范依据:spec/02-params.md(双端逐字一致,改动必须先改 spec)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from typing import Any
11
+
12
+ _INT_RE = re.compile(r"^-?\d+$")
13
+ _FLOAT_RE = re.compile(r"^-?\d+\.\d+$")
14
+
15
+
16
+ class ParamError(Exception):
17
+ code = "INVALID_PARAM"
18
+
19
+
20
+ def convert_value(raw: str) -> Any:
21
+ """p.<name> 值的类型转换:true/false/null → 字面量;整数/浮点正则全匹配 → 数值;
22
+ ``{``/``[`` 开头 → JSON 解析(失败抛 ParamError,禁静默当字符串);其余 → 字符串原样。
23
+ """
24
+ if raw == "true":
25
+ return True
26
+ if raw == "false":
27
+ return False
28
+ if raw == "null":
29
+ return None
30
+ if _INT_RE.match(raw):
31
+ return int(raw)
32
+ if _FLOAT_RE.match(raw):
33
+ return float(raw)
34
+ if raw.startswith("{") or raw.startswith("["):
35
+ try:
36
+ return json.loads(raw)
37
+ except json.JSONDecodeError as e:
38
+ raise ParamError(f"参数 JSON 解析失败: {e}") from e
39
+ return raw
40
+
41
+
42
+ def parse_query_params(query_params) -> dict[str, Any]:
43
+ """从 QueryParams 收集 p.* 参数。同名重复出现 → 列表(每元素各自转换);单次 → 标量。"""
44
+ params: dict[str, Any] = {}
45
+ for key in query_params.keys():
46
+ if not key.startswith("p."):
47
+ continue
48
+ converted = [convert_value(v) for v in query_params.getlist(key)]
49
+ params[key[2:]] = converted if len(converted) > 1 else converted[0]
50
+ return params
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: store-api-py
3
+ Version: 0.1.0
4
+ Summary: FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)
5
+ License: MIT
6
+ Keywords: py-store,storepy,fastapi,rest,crud,gql
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastapi>=0.110
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: httpx>=0.27; extra == "dev"
13
+ Requires-Dist: uvicorn>=0.29; extra == "dev"
14
+
15
+ # store-api-py
16
+
17
+ FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL).
18
+
19
+ Full documentation: https://github.com/coenddt/store-api
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/store_api_py/__init__.py
4
+ src/store_api_py/app.py
5
+ src/store_api_py/errors.py
6
+ src/store_api_py/params.py
7
+ src/store_api_py.egg-info/PKG-INFO
8
+ src/store_api_py.egg-info/SOURCES.txt
9
+ src/store_api_py.egg-info/dependency_links.txt
10
+ src/store_api_py.egg-info/requires.txt
11
+ src/store_api_py.egg-info/top_level.txt
12
+ tests/test_smoke.py
@@ -0,0 +1,6 @@
1
+ fastapi>=0.110
2
+
3
+ [dev]
4
+ pytest>=8
5
+ httpx>=0.27
6
+ uvicorn>=0.29
@@ -0,0 +1 @@
1
+ store_api_py
@@ -0,0 +1,225 @@
1
+ """smoke 测试:mock store(不连真实库),镜像 node/test/smoke.test.js 的用例矩阵。
2
+
3
+ conformance 纪律:两端断言逐项对应,任何一端改动必须同步另一端。
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from fastapi import FastAPI
11
+ from fastapi.testclient import TestClient
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14
+
15
+ from store_api_py import ParamError, create_app, filter_archived, parse_query_params # noqa: E402
16
+ from store_api_py.params import convert_value # noqa: E402
17
+
18
+
19
+ class MockPermissionError(Exception):
20
+ pass
21
+
22
+
23
+ class MockStore:
24
+ """mock store:只实现适配器调用面(query/query_one/insert/update/remove/list/set_context)。"""
25
+
26
+ PermissionError = MockPermissionError
27
+
28
+ def __init__(self):
29
+ self.rows: dict[str, dict] = {}
30
+ self.last_query: tuple | None = None
31
+ self.last_context = None
32
+
33
+ def list(self):
34
+ return ["user", "userDeleted"]
35
+
36
+ def get(self, name):
37
+ if name == "user":
38
+ return {"name": "user", "fields": {"name": {"type": "string"}, "age": {"type": "number"}}}
39
+ raise KeyError(name)
40
+
41
+ def set_context(self, ctx):
42
+ self.last_context = ctx
43
+
44
+ async def query(self, gql, params=None):
45
+ self.last_query = (gql, params)
46
+ return list(self.rows.values())
47
+
48
+ async def query_one(self, gql, params=None):
49
+ self.last_query = (gql, params)
50
+ rid = (params or {}).get("c0", {}).get("_id")
51
+ return self.rows.get(rid)
52
+
53
+ async def insert(self, name, data):
54
+ rid = data.get("_id") or f"u{len(self.rows) + 1}"
55
+ row = {"_id": rid, **data}
56
+ self.rows[rid] = row
57
+ return row
58
+
59
+ async def update(self, name, cond, data):
60
+ row = self.rows.get(cond.get("_id"))
61
+ if row:
62
+ row.update(data)
63
+ return row
64
+
65
+ async def remove(self, name, cond):
66
+ return self.rows.pop(cond.get("_id"), None)
67
+
68
+
69
+ def build_app(store, **extra):
70
+ return create_app(store, prefix="/api", **extra)
71
+
72
+
73
+ def test_filter_archived():
74
+ assert filter_archived(["user", "userDeleted", "post", "logDeleted"]) == ["user", "post", "logDeleted"]
75
+
76
+
77
+ def test_params_convert_matrix():
78
+ qp = parse_query_params(
79
+ _QP({"p.a": "true", "p.b": "false", "p.c": "null", "p.d": "42", "p.e": "-1.5", "p.f": "abc"})
80
+ )
81
+ assert qp == {"a": True, "b": False, "c": None, "d": 42, "e": -1.5, "f": "abc"}
82
+ assert parse_query_params(_QP({"p.o": '{"age":{"$gte":18}}'})) == {"o": {"age": {"$gte": 18}}}
83
+ # 同名重复出现 → 列表(每元素各自转换)
84
+ assert parse_query_params(_QP([("p.tag", "a"), ("p.tag", "b")])) == {"tag": ["a", "b"]}
85
+
86
+
87
+ def test_params_convert_value_scalars():
88
+ assert convert_value("true") is True
89
+ assert convert_value("null") is None
90
+ assert convert_value("42") == 42
91
+ assert convert_value("abc") == "abc"
92
+
93
+
94
+ def test_params_invalid_json():
95
+ with pytest.raises(ParamError):
96
+ parse_query_params(_QP({"p.o": '{"age":'}))
97
+
98
+
99
+ class _QP:
100
+ """starlette QueryParams 的最小替身"""
101
+
102
+ def __init__(self, items):
103
+ self._items = [(k, v) for k, v in (items.items() if isinstance(items, dict) else items)]
104
+
105
+ def keys(self):
106
+ return list(dict.fromkeys(k for k, _ in self._items))
107
+
108
+ def getlist(self, key):
109
+ return [v for k, v in self._items if k == key]
110
+
111
+ def get(self, key, default=None):
112
+ vs = self.getlist(key)
113
+ return vs[0] if vs else default
114
+
115
+
116
+ def test_crud_full_chain():
117
+ store = MockStore()
118
+ client = TestClient(build_app(store))
119
+
120
+ created = client.post("/api/user", json={"name": "a", "age": 1})
121
+ assert created.status_code == 201
122
+ assert created.json()["data"]["name"] == "a"
123
+ rid = created.json()["data"]["_id"]
124
+
125
+ listed = client.get("/api/user", params={"q": "($condition: @c0)", "p.c0": '{"age":{"$gte":18}}'})
126
+ assert listed.status_code == 200
127
+ assert isinstance(listed.json()["data"], list)
128
+ # 拼接结果 = 资源名 + q 原样(spec/02-params.md)
129
+ assert store.last_query == ("user($condition: @c0)", {"c0": {"age": {"$gte": 18}}})
130
+
131
+ # 无 q → 适配器生成全字段投影(spec/02)
132
+ listed_no_q = client.get("/api/user")
133
+ assert listed_no_q.status_code == 200
134
+ assert store.last_query == ("user { name, age }", {})
135
+
136
+ got = client.get(f"/api/user/{rid}")
137
+ assert got.status_code == 200
138
+ assert store.last_query == ("user($condition: @c0) { name, age }", {"c0": {"_id": rid}})
139
+
140
+ patched = client.patch(f"/api/user/{rid}", json={"age": 2})
141
+ assert patched.status_code == 200
142
+ assert patched.json()["data"]["age"] == 2
143
+
144
+ removed = client.delete(f"/api/user/{rid}")
145
+ assert removed.status_code == 200
146
+
147
+ gone = client.get(f"/api/user/{rid}")
148
+ assert gone.status_code == 404
149
+ assert gone.json()["error"]["code"] == "NOT_FOUND"
150
+
151
+
152
+ def test_error_mapping_400_403_401():
153
+ store = MockStore()
154
+
155
+ def provider(request):
156
+ who = request.headers.get("x-user")
157
+ if who == "denied":
158
+ raise MockPermissionError("权限不足")
159
+ if who == "broken":
160
+ raise ValueError("token 解析失败")
161
+ return {"uid": who}
162
+
163
+ app = build_app(store, context_provider=provider)
164
+ client = TestClient(app)
165
+
166
+ bad_body = client.post("/api/user", json=[1, 2])
167
+ assert bad_body.status_code == 400
168
+ assert bad_body.json()["error"]["code"] == "INVALID_BODY"
169
+
170
+ bad_param = client.get("/api/user", params={"p.c0": '{"age":'})
171
+ assert bad_param.status_code == 400
172
+ assert bad_param.json()["error"]["code"] == "INVALID_PARAM"
173
+ assert "JSON" in bad_param.json()["error"]["message"]
174
+
175
+ denied = client.get("/api/user", headers={"x-user": "denied"})
176
+ assert denied.status_code == 403
177
+ assert denied.json()["error"]["code"] == "MockPermissionError"
178
+ assert denied.json()["error"]["message"] == "权限不足"
179
+
180
+ broken = client.get("/api/user", headers={"x-user": "broken"})
181
+ assert broken.status_code == 401
182
+ assert broken.json()["error"]["code"] == "CONTEXT_ERROR"
183
+ assert broken.json()["error"]["message"] == "token 解析失败"
184
+
185
+ ok = client.get("/api/user", headers={"x-user": "u1"})
186
+ assert ok.status_code == 200
187
+ assert "error" not in ok.json() # 成功响应不得带 error 字段
188
+ assert store.last_context == {"uid": "u1"}
189
+
190
+
191
+ def test_store_error_passthrough_500():
192
+ store = MockStore()
193
+
194
+ async def boom(gql, params=None):
195
+ e = RuntimeError("连接超时")
196
+ e.code = "CONN_TIMEOUT"
197
+ raise e
198
+
199
+ store.query = boom
200
+ client = TestClient(build_app(store))
201
+ res = client.get("/api/user")
202
+ assert res.status_code == 500
203
+ assert res.json()["error"]["code"] == "CONN_TIMEOUT"
204
+ assert res.json()["error"]["message"] == "连接超时"
205
+
206
+
207
+ def test_fastapi_instance():
208
+ assert isinstance(build_app(MockStore()), FastAPI)
209
+
210
+
211
+ def test_x_cache_header():
212
+ """x-cache 注记位(B6):无 provider 恒 BYPASS(含错误响应);provider=HIT 透传"""
213
+ store = MockStore()
214
+ client = TestClient(build_app(store))
215
+
216
+ ok = client.get("/api/user")
217
+ assert ok.headers["x-cache"] == "BYPASS"
218
+
219
+ missing = client.get("/api/user/nope")
220
+ assert missing.status_code == 404
221
+ assert missing.headers["x-cache"] == "BYPASS" # 错误响应同样带注记
222
+
223
+ store.cache_status = lambda: "HIT"
224
+ hit = client.get("/api/user")
225
+ assert hit.headers["x-cache"] == "HIT"