store-gateway-py 0.1.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.
- store_gateway_py-0.1.1/PKG-INFO +29 -0
- store_gateway_py-0.1.1/README.md +9 -0
- store_gateway_py-0.1.1/pyproject.toml +27 -0
- store_gateway_py-0.1.1/setup.cfg +4 -0
- store_gateway_py-0.1.1/src/store_gateway/__init__.py +201 -0
- store_gateway_py-0.1.1/src/store_gateway_py.egg-info/PKG-INFO +29 -0
- store_gateway_py-0.1.1/src/store_gateway_py.egg-info/SOURCES.txt +9 -0
- store_gateway_py-0.1.1/src/store_gateway_py.egg-info/dependency_links.txt +1 -0
- store_gateway_py-0.1.1/src/store_gateway_py.egg-info/requires.txt +15 -0
- store_gateway_py-0.1.1/src/store_gateway_py.egg-info/top_level.txt +1 -0
- store_gateway_py-0.1.1/tests/test_gateway.py +230 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-gateway-py
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Optional combiner for the three py-store API skins (REST store-api-py / GraphQL store-graphql-py / gRPC store-grpc-py): config-driven per-skin enable switches
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,gateway,rest,graphql,grpc,pluggable
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi>=0.110
|
|
10
|
+
Requires-Dist: uvicorn>=0.29
|
|
11
|
+
Provides-Extra: rest
|
|
12
|
+
Requires-Dist: store-api-py; extra == "rest"
|
|
13
|
+
Provides-Extra: graphql
|
|
14
|
+
Requires-Dist: store-graphql-py; extra == "graphql"
|
|
15
|
+
Provides-Extra: grpc
|
|
16
|
+
Requires-Dist: store-grpc-py; extra == "grpc"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# store-gateway-py
|
|
22
|
+
|
|
23
|
+
py-store 三层 API 皮肤(REST / GraphQL / gRPC)的可选组合器(`store-gateway-py`)。规范与用法见仓库根 README 与 `../spec/`。
|
|
24
|
+
|
|
25
|
+
本地开发时三皮肤以各自仓库 editable 安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ../store-api/py ../store-graphql/py ../store-grpc/py
|
|
29
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "store-gateway-py"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Optional combiner for the three py-store API skins (REST store-api-py / GraphQL store-graphql-py / gRPC store-grpc-py): config-driven per-skin enable switches"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
keywords = ["py-store", "storepy", "gateway", "rest", "graphql", "grpc", "pluggable"]
|
|
9
|
+
dependencies = ["fastapi>=0.110", "uvicorn>=0.29"]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
rest = ["store-api-py"]
|
|
13
|
+
graphql = ["store-graphql-py"]
|
|
14
|
+
grpc = ["store-grpc-py"]
|
|
15
|
+
# 皮肤包(store-api-py / store-graphql-py / store-grpc-py)本地开发时以
|
|
16
|
+
# `pip install -e` 各自仓库路径安装(发布 PyPI 前不进 dev extras)
|
|
17
|
+
dev = ["pytest>=8", "httpx>=0.27"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=68"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""store_gateway_py — 三层皮肤(REST / GraphQL / gRPC)的可选组合器。
|
|
2
|
+
|
|
3
|
+
语义依据:../../spec/00-protocol.md(插拔协议唯一事实源)。
|
|
4
|
+
本层零业务语义:读配置 → 按需实例化各皮肤 → 挂到各自 server。
|
|
5
|
+
三皮肤一律 optional:enabled=True 才导入,缺失时抛带安装指引的 ImportError(禁静默降级)。
|
|
6
|
+
|
|
7
|
+
运行期重装配(对齐 node store-gateway):HTTP 侧(REST + GraphQL)的装配抽为 `_assemble()`,
|
|
8
|
+
`POST /-/reload` 经 `Gateway.reload()` 重建后**原子替换**在途 ASGI 应用(构建成功才替换;
|
|
9
|
+
失败保留旧 app 并返回 500)。gRPC 为独立 server,不在 reload 范围(spec/00 规则 5)。
|
|
10
|
+
D1 闭环桥:配置 `opts['reload'] = {'tenant','env'}` 时,reload 在重装配**之前**先按 ns 调
|
|
11
|
+
`store.restore_defs({tenant, env})`(宿主提供),使控制面 publish 落库的新定义经一次 reload 可见。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import importlib
|
|
18
|
+
import inspect
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
__all__ = ["build"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _optional_import(module_name: str, pip_hint: str):
|
|
26
|
+
try:
|
|
27
|
+
return importlib.import_module(module_name)
|
|
28
|
+
except ImportError as e:
|
|
29
|
+
raise ImportError(f"启用该皮肤需要安装 {module_name}(pip install '{pip_hint}')") from e
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_skins(opts: dict) -> dict:
|
|
33
|
+
rest = {"enabled": False, **(opts.get("rest") or {})}
|
|
34
|
+
graphql = {"enabled": False, **(opts.get("graphql") or {})}
|
|
35
|
+
grpc = {"enabled": False, **(opts.get("grpc") or {})}
|
|
36
|
+
if not (rest["enabled"] or graphql["enabled"] or grpc["enabled"]):
|
|
37
|
+
raise ValueError("ERR_NO_SKIN:rest / graphql / grpc 至少启用一项(spec/00-protocol.md 插拔规则 2)")
|
|
38
|
+
return {"rest": rest, "graphql": graphql, "grpc": grpc}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_reload(opts: dict) -> dict | None:
|
|
42
|
+
"""解析 reload 重建配置(进程级单 ns):`opts['reload'] = {'tenant','env'}`。
|
|
43
|
+
|
|
44
|
+
未配置 → None(reload 只重跑既有装配,保持既有语义);配置但缺 tenant/env → 显式报错(fail-fast)。
|
|
45
|
+
"""
|
|
46
|
+
cfg = opts.get("reload")
|
|
47
|
+
if not cfg:
|
|
48
|
+
return None
|
|
49
|
+
tenant, env = cfg.get("tenant"), cfg.get("env")
|
|
50
|
+
if not tenant or not env:
|
|
51
|
+
raise ValueError("ERR_RELOAD_CONFIG:opts.reload 须提供 tenant 与 env(进程级单 ns)")
|
|
52
|
+
return {"tenant": tenant, "env": env}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _active_resources(names: list) -> list:
|
|
56
|
+
"""归档表过滤(与 store-api spec/01-routing.md 同规则):`XxxDeleted` 且 `Xxx` 在列表中 ⇒ 归档表。"""
|
|
57
|
+
s = set(names)
|
|
58
|
+
return [n for n in names if not (n.endswith("Deleted") and n[: -len("Deleted")] in s)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _assemble(store: Any, skins: dict) -> tuple[Any, int]:
|
|
62
|
+
"""装配 HTTP 侧(REST + GraphQL)→ `(FastAPI app, 暴露资源数)`。
|
|
63
|
+
|
|
64
|
+
每次调用都遍历 `store.list()` 生成路由 → 反映当下注册表(reload 的语义来源)。
|
|
65
|
+
"""
|
|
66
|
+
fastapi = _optional_import("fastapi", "fastapi")
|
|
67
|
+
app = fastapi.FastAPI(title="store-gateway")
|
|
68
|
+
|
|
69
|
+
if skins["rest"].get("enabled"):
|
|
70
|
+
rest_cfg = dict(skins["rest"])
|
|
71
|
+
rest_prefix = rest_cfg.pop("prefix", "/api") or "" # prefix 由 gateway 按 mount 应用(spec/00 规则 4)
|
|
72
|
+
mod = _optional_import("store_api_py", "store-api-py")
|
|
73
|
+
rest_cfg.pop("enabled", None)
|
|
74
|
+
# 皮肤路由建在根,gateway 按前缀 mount(保持既有 py 行为,前缀可配)
|
|
75
|
+
app.mount(rest_prefix, mod.create_app(store, prefix="", **rest_cfg))
|
|
76
|
+
|
|
77
|
+
if skins["graphql"].get("enabled"):
|
|
78
|
+
gql_cfg = dict(skins["graphql"])
|
|
79
|
+
gql_path = gql_cfg.pop("path", "/graphql")
|
|
80
|
+
gql_cfg.pop("enabled", None)
|
|
81
|
+
mod = _optional_import("store_graphql", "store-graphql-py")
|
|
82
|
+
if inspect.iscoroutinefunction(mod.create_app):
|
|
83
|
+
raise TypeError("store_graphql.create_app 不应为 async")
|
|
84
|
+
app.mount("/", mod.create_app(store, path=gql_path, **gql_cfg))
|
|
85
|
+
|
|
86
|
+
names = skins["rest"].get("resources") or store.list()
|
|
87
|
+
return app, len(_active_resources(names))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ReloadUnsupportedError(RuntimeError):
|
|
91
|
+
"""reload 已配置却无宿主重建能力(显式失败,禁静默失守)"""
|
|
92
|
+
|
|
93
|
+
code = "ERR_RELOAD_UNSUPPORTED"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _hydrate(store: Any, reload_cfg: dict | None) -> None:
|
|
97
|
+
"""reload 前的注册表重建(D1 闭环桥):按 ns 从持久化定义 restore 到本进程注册表。
|
|
98
|
+
|
|
99
|
+
未配置 reload → 空操作;配置了但 store 无 `restore_defs`/`restoreDefs` 能力 → 显式报错
|
|
100
|
+
(禁静默:配置了要重建却重建不了,必须让 reload 失败而非假装成功)。
|
|
101
|
+
"""
|
|
102
|
+
if not reload_cfg:
|
|
103
|
+
return
|
|
104
|
+
fn = getattr(store, "restore_defs", None) or getattr(store, "restoreDefs", None)
|
|
105
|
+
if not callable(fn):
|
|
106
|
+
raise ReloadUnsupportedError(
|
|
107
|
+
"ERR_RELOAD_UNSUPPORTED:opts.reload 已配置,但 store 未提供 restore_defs/restoreDefs"
|
|
108
|
+
"(无法从持久化定义重建注册表)"
|
|
109
|
+
)
|
|
110
|
+
res = fn({"tenant": reload_cfg["tenant"], "env": reload_cfg["env"]})
|
|
111
|
+
if inspect.isawaitable(res):
|
|
112
|
+
await res
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Dispatch:
|
|
116
|
+
"""稳定 ASGI 入口:http 委托 `state['app']`;`POST /-/reload` 自身承接(不进在途快照)。"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, state: dict, reload_fn) -> None:
|
|
119
|
+
self._state = state
|
|
120
|
+
self._reload = reload_fn # async () -> int(Gateway.reload)
|
|
121
|
+
|
|
122
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
123
|
+
if scope["type"] == "http" and scope["method"] == "POST" and scope["path"] == "/-/reload":
|
|
124
|
+
await self._handle_reload(send)
|
|
125
|
+
return
|
|
126
|
+
await self._state["app"](scope, receive, send)
|
|
127
|
+
|
|
128
|
+
async def _handle_reload(self, send) -> None:
|
|
129
|
+
try:
|
|
130
|
+
resources = await self._reload()
|
|
131
|
+
status = 200
|
|
132
|
+
body = {"data": {"reloaded": True, "resources": resources}}
|
|
133
|
+
except Exception as e: # noqa: BLE001 — reload 失败显式 500,禁静默
|
|
134
|
+
status = 500
|
|
135
|
+
body = {"error": {"code": getattr(e, "code", None) or "ERR_RELOAD_FAILED",
|
|
136
|
+
"message": str(e) or None}}
|
|
137
|
+
payload = json.dumps(body).encode("utf-8")
|
|
138
|
+
await send({"type": "http.response.start", "status": status,
|
|
139
|
+
"headers": [(b"content-type", b"application/json; charset=utf-8")]})
|
|
140
|
+
await send({"type": "http.response.body", "body": payload})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Gateway:
|
|
144
|
+
"""承载句柄:`.app`(稳定 ASGI dispatch,REST+GraphQL 共享)/ `.grpc` / `.run` / `.reload` / `.stop`。"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, state: dict, grpc: Any, rest_prefix: str,
|
|
147
|
+
skins: dict, store: Any, reload_cfg: dict | None) -> None:
|
|
148
|
+
self._state = state
|
|
149
|
+
self._skins = skins
|
|
150
|
+
self._store = store
|
|
151
|
+
self._reload_cfg = reload_cfg
|
|
152
|
+
self._reload_lock = asyncio.Lock() # 重装配串行化(互斥)
|
|
153
|
+
self.grpc = grpc
|
|
154
|
+
self._rest_prefix = rest_prefix
|
|
155
|
+
self.app: Any = None # 由 build() 赋为 _Dispatch
|
|
156
|
+
|
|
157
|
+
def run(self, host: str = "127.0.0.1", port: int = 3000) -> None:
|
|
158
|
+
"""uvicorn 阻塞承载 HTTP(gRPC server 已在 build 时 start)。"""
|
|
159
|
+
import uvicorn
|
|
160
|
+
|
|
161
|
+
uvicorn.run(self.app, host=host, port=port, log_level="warning")
|
|
162
|
+
|
|
163
|
+
async def reload(self) -> int:
|
|
164
|
+
"""原子重装配:先在锁内 hydrate(失败同样不半替换),构建成功才替换在途 app。"""
|
|
165
|
+
async with self._reload_lock:
|
|
166
|
+
await _hydrate(self._store, self._reload_cfg)
|
|
167
|
+
app, resources = _assemble(self._store, self._skins) # 失败向上抛(调用方映射 500)
|
|
168
|
+
self._state["app"] = app
|
|
169
|
+
return resources
|
|
170
|
+
|
|
171
|
+
def stop(self) -> None:
|
|
172
|
+
if self.grpc is not None:
|
|
173
|
+
self.grpc.stop(None)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build(store: Any, **opts: Any) -> Gateway:
|
|
177
|
+
"""spec/00:读配置 → 组合三皮肤 → Gateway。
|
|
178
|
+
|
|
179
|
+
- rest: { enabled, prefix='/api', **store-api-py 选项 }
|
|
180
|
+
- graphql: { enabled, path='/graphql', **store-graphql-py 选项 }
|
|
181
|
+
- grpc: { enabled, host='127.0.0.1', port=50051, **store-grpc-py 选项 }
|
|
182
|
+
- reload: { tenant, env }(可选;reload 前按 ns 调 store.restore_defs 重建注册表)
|
|
183
|
+
"""
|
|
184
|
+
skins = _read_skins(opts)
|
|
185
|
+
reload_cfg = _read_reload(opts)
|
|
186
|
+
|
|
187
|
+
app, _ = _assemble(store, skins)
|
|
188
|
+
state = {"app": app}
|
|
189
|
+
|
|
190
|
+
grpc_handle = None
|
|
191
|
+
if skins["grpc"].get("enabled"):
|
|
192
|
+
grpc_cfg = dict(skins["grpc"])
|
|
193
|
+
grpc_cfg.pop("enabled", None)
|
|
194
|
+
host = grpc_cfg.pop("host", "127.0.0.1")
|
|
195
|
+
port = grpc_cfg.pop("port", 50051)
|
|
196
|
+
mod = _optional_import("store_grpc", "store-grpc-py")
|
|
197
|
+
grpc_handle = mod.create_server(store, host=host, port=port, **grpc_cfg)
|
|
198
|
+
|
|
199
|
+
gw = Gateway(state, grpc_handle, skins["rest"].get("prefix") or "/api", skins, store, reload_cfg)
|
|
200
|
+
gw.app = _Dispatch(state, gw.reload)
|
|
201
|
+
return gw
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-gateway-py
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Optional combiner for the three py-store API skins (REST store-api-py / GraphQL store-graphql-py / gRPC store-grpc-py): config-driven per-skin enable switches
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,gateway,rest,graphql,grpc,pluggable
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi>=0.110
|
|
10
|
+
Requires-Dist: uvicorn>=0.29
|
|
11
|
+
Provides-Extra: rest
|
|
12
|
+
Requires-Dist: store-api-py; extra == "rest"
|
|
13
|
+
Provides-Extra: graphql
|
|
14
|
+
Requires-Dist: store-graphql-py; extra == "graphql"
|
|
15
|
+
Provides-Extra: grpc
|
|
16
|
+
Requires-Dist: store-grpc-py; extra == "grpc"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# store-gateway-py
|
|
22
|
+
|
|
23
|
+
py-store 三层 API 皮肤(REST / GraphQL / gRPC)的可选组合器(`store-gateway-py`)。规范与用法见仓库根 README 与 `../spec/`。
|
|
24
|
+
|
|
25
|
+
本地开发时三皮肤以各自仓库 editable 安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ../store-api/py ../store-graphql/py ../store-grpc/py
|
|
29
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/store_gateway/__init__.py
|
|
4
|
+
src/store_gateway_py.egg-info/PKG-INFO
|
|
5
|
+
src/store_gateway_py.egg-info/SOURCES.txt
|
|
6
|
+
src/store_gateway_py.egg-info/dependency_links.txt
|
|
7
|
+
src/store_gateway_py.egg-info/requires.txt
|
|
8
|
+
src/store_gateway_py.egg-info/top_level.txt
|
|
9
|
+
tests/test_gateway.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_gateway
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""gateway 冒烟测试 — 三层皮肤插拔组合(mock store;HTTP 走 TestClient,gRPC 走真实端口)。
|
|
2
|
+
|
|
3
|
+
语义依据:../../spec/00-protocol.md。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from store_gateway import build
|
|
12
|
+
|
|
13
|
+
pytest.importorskip("store_api_py")
|
|
14
|
+
pytest.importorskip("store_graphql")
|
|
15
|
+
pytest.importorskip("store_grpc")
|
|
16
|
+
pytest.importorskip("httpx")
|
|
17
|
+
grpc = pytest.importorskip("grpc")
|
|
18
|
+
|
|
19
|
+
from fastapi.testclient import TestClient # noqa: E402
|
|
20
|
+
|
|
21
|
+
DEFN = {
|
|
22
|
+
"name": "User",
|
|
23
|
+
"description": "用户表",
|
|
24
|
+
"fields": {
|
|
25
|
+
"_id": {"type": "string"},
|
|
26
|
+
"name": {"type": "string"},
|
|
27
|
+
"age": {"type": "int"},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PermissionErrorMock(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_mock_store():
|
|
37
|
+
rows = []
|
|
38
|
+
|
|
39
|
+
class Store:
|
|
40
|
+
PermissionError = PermissionErrorMock
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.names = ["User"] # 可变列表:reload 用例据此调整暴露资源
|
|
44
|
+
self.restore_calls = [] # 记录 reload 前 hydrate 的调用参数
|
|
45
|
+
|
|
46
|
+
def list(self):
|
|
47
|
+
return self.names
|
|
48
|
+
|
|
49
|
+
get = staticmethod(lambda name: DEFN)
|
|
50
|
+
|
|
51
|
+
async def restore_defs(self, opts):
|
|
52
|
+
self.restore_calls.append(opts)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
async def query(gql, params=None):
|
|
56
|
+
cond = (params or {}).get("c0")
|
|
57
|
+
return [r for r in rows if not cond or all(r.get(k) == v for k, v in cond.items())]
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
async def query_one(gql, params=None):
|
|
61
|
+
cond = (params or {}).get("c0")
|
|
62
|
+
return next((r for r in rows if all(r.get(k) == v for k, v in cond.items())), None)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
async def insert(name, data):
|
|
66
|
+
doc = {"_id": f"u{len(rows) + 1}", **data}
|
|
67
|
+
rows.append(doc)
|
|
68
|
+
return doc
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
async def update(name, cond, data):
|
|
72
|
+
row = next(r for r in rows if all(r.get(k) == v for k, v in cond.items()))
|
|
73
|
+
row.update(data)
|
|
74
|
+
return row
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
async def remove(name, cond):
|
|
78
|
+
for i, r in enumerate(rows):
|
|
79
|
+
if all(r.get(k) == v for k, v in cond.items()):
|
|
80
|
+
rows.pop(i)
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
async def set_context(ctx):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
return Store()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_no_skin_raises():
|
|
92
|
+
store = make_mock_store()
|
|
93
|
+
with pytest.raises(ValueError, match="ERR_NO_SKIN"):
|
|
94
|
+
build(store)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_rest_only():
|
|
98
|
+
store = make_mock_store()
|
|
99
|
+
gw = build(store, rest={"enabled": True, "prefix": "/api"})
|
|
100
|
+
try:
|
|
101
|
+
client = TestClient(gw.app)
|
|
102
|
+
r = client.get("/api/User")
|
|
103
|
+
assert r.status_code == 200
|
|
104
|
+
assert r.json() == {"data": []}
|
|
105
|
+
finally:
|
|
106
|
+
if gw.grpc is not None:
|
|
107
|
+
gw.stop()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_graphql_only():
|
|
111
|
+
store = make_mock_store()
|
|
112
|
+
gw = build(store, graphql={"enabled": True})
|
|
113
|
+
try:
|
|
114
|
+
client = TestClient(gw.app)
|
|
115
|
+
r = client.post("/graphql", json={"query": "{ __typename }"})
|
|
116
|
+
assert r.status_code == 200
|
|
117
|
+
assert r.json()["data"]["__typename"] == "Query"
|
|
118
|
+
finally:
|
|
119
|
+
if gw.grpc is not None:
|
|
120
|
+
gw.stop()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_all_three_skins():
|
|
124
|
+
store = make_mock_store()
|
|
125
|
+
gw = build(
|
|
126
|
+
store,
|
|
127
|
+
rest={"enabled": True, "prefix": "/api"},
|
|
128
|
+
graphql={"enabled": True, "path": "/gql"},
|
|
129
|
+
grpc={"enabled": True, "port": 0},
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
client = TestClient(gw.app)
|
|
133
|
+
# REST:写入
|
|
134
|
+
r = client.post("/api/User", json={"name": "alice", "age": 30})
|
|
135
|
+
assert r.status_code == 201, r.text
|
|
136
|
+
# GraphQL:读回(自定义 path)
|
|
137
|
+
r = client.post("/gql", json={"query": "{ list_User { name age } }"})
|
|
138
|
+
assert r.status_code == 200, r.text
|
|
139
|
+
assert r.json()["data"]["list_User"] == [{"name": "alice", "age": 30}]
|
|
140
|
+
|
|
141
|
+
# gRPC:ListUser 读回同一份数据(同一 store 实例)
|
|
142
|
+
pb = gw.grpc.pb
|
|
143
|
+
channel = grpc.insecure_channel(f"127.0.0.1:{gw.grpc.port}")
|
|
144
|
+
try:
|
|
145
|
+
unary = channel.unary_unary(
|
|
146
|
+
"/store.v0.User/ListUser",
|
|
147
|
+
request_serializer=pb.ListRequest.SerializeToString,
|
|
148
|
+
response_deserializer=pb.StoreReply.FromString,
|
|
149
|
+
)
|
|
150
|
+
reply = unary(pb.ListRequest(q=" { name, age }"), timeout=10)
|
|
151
|
+
assert json.loads(reply.data_json) == [{"_id": "u1", "name": "alice", "age": 30}]
|
|
152
|
+
finally:
|
|
153
|
+
channel.close()
|
|
154
|
+
finally:
|
|
155
|
+
gw.stop()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_missing_skin_package_raises(monkeypatch):
|
|
159
|
+
store = make_mock_store()
|
|
160
|
+
import importlib
|
|
161
|
+
|
|
162
|
+
real_import_module = importlib.import_module
|
|
163
|
+
|
|
164
|
+
def fake_import_module(name, *args, **kwargs):
|
|
165
|
+
if name == "store_api_py":
|
|
166
|
+
raise ImportError("No module named 'store_api_py'")
|
|
167
|
+
return real_import_module(name, *args, **kwargs)
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(importlib, "import_module", fake_import_module)
|
|
170
|
+
with pytest.raises(ImportError, match="store_api_py"):
|
|
171
|
+
build(store, rest={"enabled": True})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ─── 运行期重装配(reload;对齐 node store-gateway) ───────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_reload_reassembles():
|
|
178
|
+
"""POST /-/reload 重装配 HTTP 侧:资源数反映当下注册表,新资源路由可访问。"""
|
|
179
|
+
store = make_mock_store()
|
|
180
|
+
gw = build(store, rest={"enabled": True, "prefix": "/api"})
|
|
181
|
+
try:
|
|
182
|
+
client = TestClient(gw.app)
|
|
183
|
+
r = client.post("/-/reload")
|
|
184
|
+
assert r.status_code == 200
|
|
185
|
+
assert r.json() == {"data": {"reloaded": True, "resources": 1}}
|
|
186
|
+
|
|
187
|
+
store.names.append("Order") # 运行期新注册(快照外)→ 仅经 reload 进入路由
|
|
188
|
+
r2 = client.post("/-/reload")
|
|
189
|
+
assert r2.status_code == 200
|
|
190
|
+
assert r2.json()["data"]["resources"] == 2 # 递增
|
|
191
|
+
assert client.get("/api/Order").status_code == 200
|
|
192
|
+
finally:
|
|
193
|
+
if gw.grpc is not None:
|
|
194
|
+
gw.stop()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_reload_hydrates_registry():
|
|
198
|
+
"""配 reload={tenant,env} → 重装配前按 ns 调 store.restore_defs(D1 闭环桥)。"""
|
|
199
|
+
store = make_mock_store()
|
|
200
|
+
gw = build(store, rest={"enabled": True}, reload={"tenant": "t1", "env": "dev"})
|
|
201
|
+
try:
|
|
202
|
+
client = TestClient(gw.app)
|
|
203
|
+
r = client.post("/-/reload")
|
|
204
|
+
assert r.status_code == 200
|
|
205
|
+
assert store.restore_calls == [{"tenant": "t1", "env": "dev"}]
|
|
206
|
+
finally:
|
|
207
|
+
if gw.grpc is not None:
|
|
208
|
+
gw.stop()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_reload_unsupported_store():
|
|
212
|
+
"""配 reload 但 store 无重建能力 → 500 ERR_RELOAD_UNSUPPORTED(禁静默)。"""
|
|
213
|
+
store = make_mock_store()
|
|
214
|
+
store.restore_defs = None # 显式移除重建能力
|
|
215
|
+
gw = build(store, rest={"enabled": True}, reload={"tenant": "t1", "env": "dev"})
|
|
216
|
+
try:
|
|
217
|
+
client = TestClient(gw.app)
|
|
218
|
+
r = client.post("/-/reload")
|
|
219
|
+
assert r.status_code == 500
|
|
220
|
+
assert r.json()["error"]["code"] == "ERR_RELOAD_UNSUPPORTED"
|
|
221
|
+
finally:
|
|
222
|
+
if gw.grpc is not None:
|
|
223
|
+
gw.stop()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_reload_missing_ns_raises():
|
|
227
|
+
"""reload 缺 tenant/env → 构造期 fail-fast(ERR_RELOAD_CONFIG)。"""
|
|
228
|
+
store = make_mock_store()
|
|
229
|
+
with pytest.raises(ValueError, match="ERR_RELOAD_CONFIG"):
|
|
230
|
+
build(store, rest={"enabled": True}, reload={"tenant": "t1"})
|