store-api-py 0.1.0__py3-none-any.whl
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/__init__.py +10 -0
- store_api_py/app.py +200 -0
- store_api_py/errors.py +62 -0
- store_api_py/params.py +50 -0
- store_api_py-0.1.0.dist-info/METADATA +19 -0
- store_api_py-0.1.0.dist-info/RECORD +8 -0
- store_api_py-0.1.0.dist-info/WHEEL +5 -0
- store_api_py-0.1.0.dist-info/top_level.txt +1 -0
store_api_py/__init__.py
ADDED
|
@@ -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"]
|
store_api_py/app.py
ADDED
|
@@ -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
|
store_api_py/errors.py
ADDED
|
@@ -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)
|
store_api_py/params.py
ADDED
|
@@ -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,8 @@
|
|
|
1
|
+
store_api_py/__init__.py,sha256=Cjzla907vQPX8Mf-2m8NJTdE9BIXfBUWIqztABj4eQI,408
|
|
2
|
+
store_api_py/app.py,sha256=prXtQdwoJN3GpKqJXKKGVyFGAHnHGgIVnTU0ZUqfj74,7935
|
|
3
|
+
store_api_py/errors.py,sha256=WkTkoseeNcGd5IWVhKSq9bR6lsQlePnYBrhupEIIPsM,2433
|
|
4
|
+
store_api_py/params.py,sha256=FyHZByR6hoSxPdKGGsWlMsFNSUqpxOYQAnVbvTkXsZM,1563
|
|
5
|
+
store_api_py-0.1.0.dist-info/METADATA,sha256=woZFQmbSFZYifiFpatCzlcyIdGqkPKH4YqGSohDO4LQ,646
|
|
6
|
+
store_api_py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
store_api_py-0.1.0.dist-info/top_level.txt,sha256=E-5s238JcynR2nQSRrep1Urv2EIWu4NCypieB8d704E,13
|
|
8
|
+
store_api_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_api_py
|