store-graphql-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.
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""store-graphql-py — 为 py-store 已注册 schema 自动生成标准 GraphQL API。
|
|
2
|
+
|
|
3
|
+
语义依据:../../spec/*.md(多端 parity,改动先改 spec)。
|
|
4
|
+
设计哲学:GraphQL 只是 GQL 的又一层 HTTP 皮 —— 适配层零语义发明。
|
|
5
|
+
|
|
6
|
+
分层:
|
|
7
|
+
build_graphql_schema(store, **opts) → 纯 graphql-core GraphQLSchema
|
|
8
|
+
create_app(store, **opts) → FastAPI 应用(可选依赖 fastapi)
|
|
9
|
+
export_sdl(schema) → SDL 字符串(喂客户端 codegen)
|
|
10
|
+
|
|
11
|
+
store 端口契约见 spec/00:list/get/query/insert/update/remove/set_context。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .adapter import (
|
|
15
|
+
build_graphql_schema,
|
|
16
|
+
create_app,
|
|
17
|
+
export_sdl,
|
|
18
|
+
filter_archived,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"build_graphql_schema",
|
|
23
|
+
"create_app",
|
|
24
|
+
"export_sdl",
|
|
25
|
+
"filter_archived",
|
|
26
|
+
]
|
store_graphql/adapter.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""adapter — py 端核心实现(语义依据 ../../spec/*.md,与 node 端镜像)。"""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from graphql import (
|
|
7
|
+
graphql,
|
|
8
|
+
parse,
|
|
9
|
+
GraphQLArgument,
|
|
10
|
+
GraphQLBoolean,
|
|
11
|
+
GraphQLField,
|
|
12
|
+
GraphQLFloat,
|
|
13
|
+
GraphQLID,
|
|
14
|
+
GraphQLInt,
|
|
15
|
+
GraphQLList,
|
|
16
|
+
GraphQLNonNull,
|
|
17
|
+
GraphQLObjectType,
|
|
18
|
+
GraphQLScalarType,
|
|
19
|
+
GraphQLSchema,
|
|
20
|
+
GraphQLString,
|
|
21
|
+
print_schema,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# GraphQL AST kind 字符串(graphql-core 3.x 的 node.kind 即字符串,无 Kind 常量类)
|
|
25
|
+
K_FIELD = "field"
|
|
26
|
+
K_INLINE_FRAGMENT = "inline_fragment"
|
|
27
|
+
K_FRAGMENT_SPREAD = "fragment_spread"
|
|
28
|
+
K_VARIABLE = "variable"
|
|
29
|
+
K_BOOLEAN = "boolean"
|
|
30
|
+
|
|
31
|
+
ARCHIVE_SUFFIX = "Deleted"
|
|
32
|
+
|
|
33
|
+
# spec/02 limit 守卫常量:core 的行数封顶仅 text2query 档生效(standard 档原样返回),
|
|
34
|
+
# 适配层守上界;调整先改 spec 再三端同步。
|
|
35
|
+
LIST_LIMIT_DEFAULT = 50
|
|
36
|
+
LIST_LIMIT_MAX = 1000
|
|
37
|
+
|
|
38
|
+
# spec/04 查询深度守卫:深度 = 从 operation 顶层 selectionSet 起的最大字段嵌套层数,
|
|
39
|
+
# FragmentSpread 按定义递归(visiting 防环);超限 HTTP 400 + ERR_DEPTH: 稳定前缀。
|
|
40
|
+
MAX_QUERY_DEPTH = 10
|
|
41
|
+
|
|
42
|
+
# spec/04 复杂度守卫:AST 字段节点总数(别名不单列——字段计数已覆盖别名堆叠)。
|
|
43
|
+
MAX_QUERY_FIELDS = 300
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def query_field_count(document):
|
|
47
|
+
"""字段计数 + introspection 使用检测(fragment 展开计入,防环;__typename 放行)。"""
|
|
48
|
+
fragments = {
|
|
49
|
+
d.name.value: d
|
|
50
|
+
for d in document.definitions
|
|
51
|
+
if d.kind == "fragment_definition"
|
|
52
|
+
}
|
|
53
|
+
out = {"fields": 0, "introspection_used": False}
|
|
54
|
+
|
|
55
|
+
def walk_sel_set(sel_set, visiting):
|
|
56
|
+
if sel_set is None:
|
|
57
|
+
return
|
|
58
|
+
for sel in sel_set.selections:
|
|
59
|
+
if sel.kind == K_FIELD:
|
|
60
|
+
out["fields"] += 1
|
|
61
|
+
name = sel.name.value
|
|
62
|
+
if not out["introspection_used"] and name in ("__schema", "__type"):
|
|
63
|
+
out["introspection_used"] = True
|
|
64
|
+
walk_sel_set(sel.selection_set, visiting)
|
|
65
|
+
elif sel.kind == K_INLINE_FRAGMENT:
|
|
66
|
+
walk_sel_set(sel.selection_set, visiting)
|
|
67
|
+
elif sel.kind == K_FRAGMENT_SPREAD:
|
|
68
|
+
if sel.name.value in visiting:
|
|
69
|
+
continue
|
|
70
|
+
frag = fragments.get(sel.name.value)
|
|
71
|
+
if frag:
|
|
72
|
+
walk_sel_set(frag.selection_set, visiting | {sel.name.value})
|
|
73
|
+
|
|
74
|
+
for d in document.definitions:
|
|
75
|
+
if d.kind == "operation_definition":
|
|
76
|
+
walk_sel_set(d.selection_set, set())
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def query_depth(document):
|
|
81
|
+
fragments = {
|
|
82
|
+
d.name.value: d
|
|
83
|
+
for d in document.definitions
|
|
84
|
+
if d.kind == "fragment_definition"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def depth_of_sel_set(sel_set, visiting):
|
|
88
|
+
if sel_set is None:
|
|
89
|
+
return 0
|
|
90
|
+
max_d = 0
|
|
91
|
+
for sel in sel_set.selections:
|
|
92
|
+
if sel.kind == K_FIELD:
|
|
93
|
+
d = 1 + depth_of_sel_set(sel.selection_set, visiting)
|
|
94
|
+
elif sel.kind == K_INLINE_FRAGMENT:
|
|
95
|
+
d = depth_of_sel_set(sel.selection_set, visiting)
|
|
96
|
+
elif sel.kind == K_FRAGMENT_SPREAD:
|
|
97
|
+
if sel.name.value in visiting:
|
|
98
|
+
continue
|
|
99
|
+
frag = fragments.get(sel.name.value)
|
|
100
|
+
d = (
|
|
101
|
+
depth_of_sel_set(frag.selection_set, visiting | {sel.name.value})
|
|
102
|
+
if frag
|
|
103
|
+
else 0
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
d = 0
|
|
107
|
+
if d > max_d:
|
|
108
|
+
max_d = d
|
|
109
|
+
return max_d
|
|
110
|
+
|
|
111
|
+
max_d = 0
|
|
112
|
+
for d in document.definitions:
|
|
113
|
+
if d.kind == "operation_definition":
|
|
114
|
+
od = depth_of_sel_set(d.selection_set, set())
|
|
115
|
+
if od > max_d:
|
|
116
|
+
max_d = od
|
|
117
|
+
return max_d
|
|
118
|
+
|
|
119
|
+
# spec/05:GraphiQL 文档页(CDN 版,GET /graphql 返回;POST 才执行查询)
|
|
120
|
+
GRAPHIQL_HTML = """<!doctype html>
|
|
121
|
+
<html lang="en">
|
|
122
|
+
<head>
|
|
123
|
+
<title>store-graphql GraphiQL</title>
|
|
124
|
+
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
|
|
125
|
+
<style>body { margin: 0; } #graphiql { height: 100vh; }</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<div id="graphiql">Loading GraphiQL...</div>
|
|
129
|
+
<script crossorigin src="https://unpkg.com/react/umd/react.production.min.js"></script>
|
|
130
|
+
<script crossorigin src="https://unpkg.com/react-dom/umd/react-dom.production.min.js"></script>
|
|
131
|
+
<script crossorigin src="https://unpkg.com/graphiql/graphiql.min.js"></script>
|
|
132
|
+
<script>
|
|
133
|
+
function graphQLFetcher(graphQLParams) {
|
|
134
|
+
return fetch('/graphql', {
|
|
135
|
+
method: 'post',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(graphQLParams),
|
|
138
|
+
}).then(function (r) { return r.json(); });
|
|
139
|
+
}
|
|
140
|
+
ReactDOM.createRoot(document.getElementById('graphiql')).render(
|
|
141
|
+
React.createElement(GraphiQL, { fetcher: graphQLFetcher })
|
|
142
|
+
);
|
|
143
|
+
</script>
|
|
144
|
+
</body>
|
|
145
|
+
</html>
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
# ── spec/01:归档表过滤(与 store-api 三端逐字一致)──
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def filter_archived(names):
|
|
152
|
+
s = set(names)
|
|
153
|
+
return [
|
|
154
|
+
n
|
|
155
|
+
for n in names
|
|
156
|
+
if not (n.endswith(ARCHIVE_SUFFIX) and n[: -len(ARCHIVE_SUFFIX)] in s)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── spec/01:JSON 标量(只接受 variables 传入)──
|
|
161
|
+
|
|
162
|
+
GraphQLJSON = GraphQLScalarType(
|
|
163
|
+
name="JSON",
|
|
164
|
+
description="任意 JSON 值(条件/排序/写入文档)。只接受 variables 形式(spec/02)。",
|
|
165
|
+
serialize=lambda v: v,
|
|
166
|
+
parse_value=lambda v: v,
|
|
167
|
+
parse_literal=lambda _node, *_a: (_ for _ in ()).throw(
|
|
168
|
+
ValueError("JSON 标量只接受 variables 传入(spec/02)")
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── spec/01:defn 字段类型 → GraphQL 输出类型 ──
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _scalar_for(type_name):
|
|
177
|
+
return {
|
|
178
|
+
"string": GraphQLString,
|
|
179
|
+
"datetime": GraphQLString,
|
|
180
|
+
"int": GraphQLInt,
|
|
181
|
+
"float": GraphQLFloat,
|
|
182
|
+
"bool": GraphQLBoolean,
|
|
183
|
+
}.get(type_name)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _out_type(field_defn, type_name):
|
|
187
|
+
if field_defn.get("type") == "object" and "fields" in field_defn:
|
|
188
|
+
return GraphQLObjectType(
|
|
189
|
+
name=type_name,
|
|
190
|
+
description=field_defn.get("description"), # spec/05:description 透传不改写
|
|
191
|
+
fields=lambda: _map_fields(field_defn["fields"], type_name),
|
|
192
|
+
)
|
|
193
|
+
return _scalar_for(field_defn.get("type")) or GraphQLJSON
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _map_fields(fields, type_name):
|
|
197
|
+
return {
|
|
198
|
+
k: GraphQLField(
|
|
199
|
+
GraphQLNonNull(GraphQLID) if k == "_id" else _out_type(v, f"{type_name}_{k}"),
|
|
200
|
+
description=v.get("description"), # spec/05:含 computes 与嵌套字段
|
|
201
|
+
)
|
|
202
|
+
for k, v in (fields or {}).items()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _model_fields(defn):
|
|
207
|
+
merged = dict(defn.get("fields") or {})
|
|
208
|
+
merged.update(defn.get("computes") or {}) # spec/01:computes 视同字段
|
|
209
|
+
return merged
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── spec/02:selection → GQL 投影串 ──
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _should_include(node, variables):
|
|
216
|
+
for d in node.directives or ():
|
|
217
|
+
if d.name.value not in ("skip", "include"):
|
|
218
|
+
continue
|
|
219
|
+
if_arg = next((a for a in d.arguments or () if a.name.value == "if"), None)
|
|
220
|
+
if if_arg is None:
|
|
221
|
+
continue
|
|
222
|
+
if if_arg.value.kind == K_VARIABLE:
|
|
223
|
+
val = (variables or {}).get(if_arg.value.name.value)
|
|
224
|
+
elif if_arg.value.kind == K_BOOLEAN:
|
|
225
|
+
val = if_arg.value.value
|
|
226
|
+
else:
|
|
227
|
+
continue
|
|
228
|
+
if val is None:
|
|
229
|
+
continue
|
|
230
|
+
if d.name.value == "skip" and val:
|
|
231
|
+
return False
|
|
232
|
+
if d.name.value == "include" and not val:
|
|
233
|
+
return False
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def projection_from_info(info):
|
|
238
|
+
root = next((n for n in info.field_nodes if n.kind == K_FIELD), None)
|
|
239
|
+
if root is None or root.selection_set is None:
|
|
240
|
+
return "_id"
|
|
241
|
+
variables = info.variable_values
|
|
242
|
+
if not isinstance(variables, dict): # graphql-core 3.3 的 VariableValues 对象,强制值在 .coerced
|
|
243
|
+
variables = getattr(variables, "coerced", None) or {}
|
|
244
|
+
|
|
245
|
+
def walk(sel):
|
|
246
|
+
if sel.kind == K_FIELD:
|
|
247
|
+
if not _should_include(sel, variables):
|
|
248
|
+
return None
|
|
249
|
+
head = sel.name.value # alias v0 忽略(spec/02)
|
|
250
|
+
if sel.selection_set:
|
|
251
|
+
inner = ", ".join(
|
|
252
|
+
filter(None, (walk(s) for s in sel.selection_set.selections))
|
|
253
|
+
)
|
|
254
|
+
if inner:
|
|
255
|
+
return f"{head} {{ {inner} }}"
|
|
256
|
+
return head
|
|
257
|
+
if sel.kind == K_INLINE_FRAGMENT:
|
|
258
|
+
return ", ".join(
|
|
259
|
+
filter(None, (walk(s) for s in sel.selection_set.selections))
|
|
260
|
+
)
|
|
261
|
+
if sel.kind == K_FRAGMENT_SPREAD:
|
|
262
|
+
frag = (info.fragments or {}).get(sel.name.value)
|
|
263
|
+
if frag is None:
|
|
264
|
+
return None
|
|
265
|
+
return ", ".join(
|
|
266
|
+
filter(None, (walk(s) for s in frag.selection_set.selections))
|
|
267
|
+
)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
return ", ".join(filter(None, (walk(s) for s in root.selection_set.selections))) or "_id"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── spec/02:根 resolver(投影下推,无 N+1)──
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _make_get(store, name, id_field):
|
|
277
|
+
async def resolve(_src, info, *, id):
|
|
278
|
+
projection = projection_from_info(info)
|
|
279
|
+
rows = await store.query(
|
|
280
|
+
f"{name}($condition:@c0) {{ {projection} }}", {"c0": {id_field: id}}
|
|
281
|
+
)
|
|
282
|
+
return rows[0] if rows else None
|
|
283
|
+
|
|
284
|
+
return resolve
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _make_list(store, name):
|
|
288
|
+
async def resolve(_src, info, *, condition=None, sort=None, limit=None):
|
|
289
|
+
# spec/02 limit 守卫:缺省 50 防全表;超上限抛错(ERR_LIMIT: 稳定前缀),不静默截断
|
|
290
|
+
if limit is None:
|
|
291
|
+
limit = LIST_LIMIT_DEFAULT
|
|
292
|
+
if limit > LIST_LIMIT_MAX:
|
|
293
|
+
raise ValueError(f"ERR_LIMIT:list limit 上限 {LIST_LIMIT_MAX},收到 {limit}")
|
|
294
|
+
projection = projection_from_info(info)
|
|
295
|
+
head = name
|
|
296
|
+
parts, params = [], {}
|
|
297
|
+
if condition is not None:
|
|
298
|
+
parts.append("$condition:@c0")
|
|
299
|
+
params["c0"] = condition
|
|
300
|
+
if sort is not None:
|
|
301
|
+
parts.append("$sort:@s1")
|
|
302
|
+
params["s1"] = sort
|
|
303
|
+
parts.append("$limit:@l")
|
|
304
|
+
params["l"] = limit
|
|
305
|
+
head += f"({','.join(parts)})"
|
|
306
|
+
return await store.query(f"{head} {{ {projection} }}", params)
|
|
307
|
+
|
|
308
|
+
return resolve
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _make_create(store, name):
|
|
312
|
+
async def resolve(_src, _info, *, input):
|
|
313
|
+
return await store.insert(name, input)
|
|
314
|
+
|
|
315
|
+
return resolve
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _make_update(store, name, id_field):
|
|
319
|
+
async def resolve(_src, info, *, id, set):
|
|
320
|
+
await store.update(name, {id_field: id}, set)
|
|
321
|
+
projection = projection_from_info(info)
|
|
322
|
+
rows = await store.query(
|
|
323
|
+
f"{name}($condition:@c0) {{ {projection} }}", {"c0": {id_field: id}}
|
|
324
|
+
)
|
|
325
|
+
return rows[0] if rows else None
|
|
326
|
+
|
|
327
|
+
return resolve
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _make_delete(store, name, id_field):
|
|
331
|
+
async def resolve(_src, _info, *, id):
|
|
332
|
+
await store.remove(name, {id_field: id})
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
return resolve
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── schema 构建 ──
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_graphql_schema(
|
|
342
|
+
store,
|
|
343
|
+
*,
|
|
344
|
+
resources=None,
|
|
345
|
+
overrides=None,
|
|
346
|
+
extensions=None,
|
|
347
|
+
id_field="_id",
|
|
348
|
+
):
|
|
349
|
+
overrides = dict(overrides or {})
|
|
350
|
+
extensions = dict(extensions or {})
|
|
351
|
+
names = resources if resources is not None else filter_archived(store.list())
|
|
352
|
+
|
|
353
|
+
query_fields = {}
|
|
354
|
+
mutation_fields = {}
|
|
355
|
+
used_override_keys = set()
|
|
356
|
+
|
|
357
|
+
def resolve_with_override(key, fallback):
|
|
358
|
+
fn = overrides.get(key)
|
|
359
|
+
if fn is not None:
|
|
360
|
+
used_override_keys.add(key)
|
|
361
|
+
return fn or fallback
|
|
362
|
+
|
|
363
|
+
for name in names:
|
|
364
|
+
defn = store.get(name)
|
|
365
|
+
xg = defn.get("x-graphql") or {}
|
|
366
|
+
if xg.get("hidden"): # spec/03 钩子 1:模型级 hidden
|
|
367
|
+
continue
|
|
368
|
+
model_type = GraphQLObjectType(
|
|
369
|
+
name=name,
|
|
370
|
+
description=defn.get("description"), # spec/05
|
|
371
|
+
# n=name 显式捕获:延迟 thunk 求值时循环变量已到末值,会造成嵌套类型重名
|
|
372
|
+
fields=lambda d=defn, n=name: _map_fields(_model_fields(d), n),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
query_fields[f"get_{name}"] = GraphQLField(
|
|
376
|
+
model_type,
|
|
377
|
+
args={"id": GraphQLArgument(GraphQLNonNull(GraphQLID))},
|
|
378
|
+
resolve=resolve_with_override(f"Query.get_{name}", _make_get(store, name, id_field)),
|
|
379
|
+
)
|
|
380
|
+
query_fields[f"list_{name}"] = GraphQLField(
|
|
381
|
+
GraphQLNonNull(GraphQLList(GraphQLNonNull(model_type))),
|
|
382
|
+
args={
|
|
383
|
+
"condition": GraphQLArgument(GraphQLJSON),
|
|
384
|
+
"sort": GraphQLArgument(GraphQLJSON),
|
|
385
|
+
"limit": GraphQLArgument(GraphQLInt),
|
|
386
|
+
},
|
|
387
|
+
resolve=resolve_with_override(f"Query.list_{name}", _make_list(store, name)),
|
|
388
|
+
)
|
|
389
|
+
if not xg.get("readonly"): # spec/03 钩子 1:模型级 readonly → 只出 Query
|
|
390
|
+
mutation_fields[f"create_{name}"] = GraphQLField(
|
|
391
|
+
model_type,
|
|
392
|
+
args={"input": GraphQLArgument(GraphQLNonNull(GraphQLJSON))},
|
|
393
|
+
resolve=resolve_with_override(
|
|
394
|
+
f"Mutation.create_{name}", _make_create(store, name)
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
mutation_fields[f"update_{name}"] = GraphQLField(
|
|
398
|
+
model_type,
|
|
399
|
+
args={
|
|
400
|
+
"id": GraphQLArgument(GraphQLNonNull(GraphQLID)),
|
|
401
|
+
"set": GraphQLArgument(GraphQLNonNull(GraphQLJSON)),
|
|
402
|
+
},
|
|
403
|
+
resolve=resolve_with_override(
|
|
404
|
+
f"Mutation.update_{name}", _make_update(store, name, id_field)
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
mutation_fields[f"delete_{name}"] = GraphQLField(
|
|
408
|
+
GraphQLNonNull(GraphQLBoolean),
|
|
409
|
+
args={"id": GraphQLArgument(GraphQLNonNull(GraphQLID))},
|
|
410
|
+
resolve=resolve_with_override(
|
|
411
|
+
f"Mutation.delete_{name}", _make_delete(store, name, id_field)
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# spec/03 钩子 3:extend(SDL 同步追加)
|
|
416
|
+
for key, fields in extensions.items():
|
|
417
|
+
if key == "Query":
|
|
418
|
+
query_fields.update(fields)
|
|
419
|
+
elif key == "Mutation":
|
|
420
|
+
mutation_fields.update(fields)
|
|
421
|
+
else:
|
|
422
|
+
raise ValueError(f'extensions 仅支持 Query/Mutation,收到 "{key}"(spec/03)')
|
|
423
|
+
|
|
424
|
+
# spec/03:override 键构建期校验,未知路径报错不静默
|
|
425
|
+
for key in overrides:
|
|
426
|
+
if key not in used_override_keys:
|
|
427
|
+
raise ValueError(f'override 路径 "{key}" 未命中任何生成字段(spec/03)')
|
|
428
|
+
|
|
429
|
+
return GraphQLSchema(
|
|
430
|
+
query=GraphQLObjectType(name="Query", fields=lambda: query_fields),
|
|
431
|
+
mutation=(
|
|
432
|
+
GraphQLObjectType(name="Mutation", fields=lambda: mutation_fields)
|
|
433
|
+
if mutation_fields
|
|
434
|
+
else None
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def export_sdl(schema):
|
|
440
|
+
"""spec/01:SDL 导出(喂客户端 codegen)。"""
|
|
441
|
+
return print_schema(schema)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ── spec/04:HTTP 承载(FastAPI,可选依赖)──
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def create_app(
|
|
448
|
+
store,
|
|
449
|
+
*,
|
|
450
|
+
schema=None,
|
|
451
|
+
path="/graphql",
|
|
452
|
+
context_provider=None,
|
|
453
|
+
permission_error=None,
|
|
454
|
+
resources=None,
|
|
455
|
+
overrides=None,
|
|
456
|
+
extensions=None,
|
|
457
|
+
id_field="_id",
|
|
458
|
+
max_query_depth=None,
|
|
459
|
+
max_query_fields=None,
|
|
460
|
+
introspection=True,
|
|
461
|
+
):
|
|
462
|
+
try:
|
|
463
|
+
from fastapi import FastAPI, Request
|
|
464
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
465
|
+
except ImportError as e: # noqa: F841 — 报错信息自身已含原因
|
|
466
|
+
raise ImportError(
|
|
467
|
+
"create_app 需要安装 fastapi:pip install 'store-graphql-py[fastapi]';"
|
|
468
|
+
"或仅用 build_graphql_schema 自行承载"
|
|
469
|
+
) from e
|
|
470
|
+
|
|
471
|
+
app = FastAPI(title="store-graphql")
|
|
472
|
+
gql_schema = schema or build_graphql_schema(
|
|
473
|
+
store,
|
|
474
|
+
resources=resources,
|
|
475
|
+
overrides=overrides,
|
|
476
|
+
extensions=extensions,
|
|
477
|
+
id_field=id_field,
|
|
478
|
+
)
|
|
479
|
+
if permission_error is None:
|
|
480
|
+
permission_error = getattr(store, "PermissionError", None)
|
|
481
|
+
|
|
482
|
+
@app.get(path)
|
|
483
|
+
async def graphiql_page():
|
|
484
|
+
# spec/05:GET 返回 GraphiQL 文档页(执行走 POST;GET 查询执行列 v1)
|
|
485
|
+
return HTMLResponse(GRAPHIQL_HTML)
|
|
486
|
+
|
|
487
|
+
@app.post(path)
|
|
488
|
+
async def graphql_endpoint(request: Request):
|
|
489
|
+
raw = await request.body()
|
|
490
|
+
# spec/04:请求体上限 1MB(对齐 store-api 三端,防大 body 撑内存)
|
|
491
|
+
if len(raw) > (1 << 20):
|
|
492
|
+
return JSONResponse(
|
|
493
|
+
status_code=413,
|
|
494
|
+
content={"errors": [{"message": "请求体超过 1MB 上限(spec/04)"}]},
|
|
495
|
+
)
|
|
496
|
+
try:
|
|
497
|
+
body = json.loads(raw) if raw else {}
|
|
498
|
+
if not isinstance(body, dict):
|
|
499
|
+
raise ValueError("body 不是 JSON 对象")
|
|
500
|
+
except Exception: # noqa: BLE001 — 非 JSON 请求体按 400 明确反馈,不静默
|
|
501
|
+
return JSONResponse(
|
|
502
|
+
status_code=400, content={"errors": [{"message": "请求体必须是 JSON"}]}
|
|
503
|
+
)
|
|
504
|
+
if context_provider is not None:
|
|
505
|
+
try:
|
|
506
|
+
ctx = context_provider(request)
|
|
507
|
+
if inspect.isawaitable(ctx):
|
|
508
|
+
ctx = await ctx
|
|
509
|
+
except Exception as e: # noqa: BLE001 — spec/04:权限类 ⇒ 403;其余 ⇒ 401
|
|
510
|
+
status = 403 if (permission_error and isinstance(e, permission_error)) else 401
|
|
511
|
+
return JSONResponse(
|
|
512
|
+
status_code=status,
|
|
513
|
+
content={"errors": [{"message": str(e) or "CONTEXT_ERROR"}]},
|
|
514
|
+
)
|
|
515
|
+
# spec/04:None 同样显式注入(清除语义必须落地,防身份跨请求残留)
|
|
516
|
+
store.set_context(ctx)
|
|
517
|
+
# spec/04 深度/复杂度/introspection 守卫:执行前独立 parse 检查
|
|
518
|
+
# (语法错不在此拦,维持执行器原路径;双 parse 成本微秒级,如实标注 spec/04)
|
|
519
|
+
depth_limit = MAX_QUERY_DEPTH if max_query_depth is None else max_query_depth
|
|
520
|
+
fields_limit = MAX_QUERY_FIELDS if max_query_fields is None else max_query_fields
|
|
521
|
+
query_str = body.get("query") or ""
|
|
522
|
+
try:
|
|
523
|
+
doc = parse(query_str)
|
|
524
|
+
except Exception: # noqa: BLE001 — 语法解析失败交给执行器原路径报错
|
|
525
|
+
doc = None
|
|
526
|
+
if doc is not None:
|
|
527
|
+
depth = query_depth(doc)
|
|
528
|
+
if depth > depth_limit:
|
|
529
|
+
return JSONResponse(
|
|
530
|
+
status_code=400,
|
|
531
|
+
content={
|
|
532
|
+
"errors": [
|
|
533
|
+
{
|
|
534
|
+
"message": f"ERR_DEPTH:查询深度 {depth} 超过上限 {depth_limit}",
|
|
535
|
+
"extensions": {"code": "ERR_DEPTH"},
|
|
536
|
+
}
|
|
537
|
+
]
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
usage = query_field_count(doc)
|
|
541
|
+
if usage["introspection_used"] and not introspection:
|
|
542
|
+
return JSONResponse(
|
|
543
|
+
status_code=400,
|
|
544
|
+
content={
|
|
545
|
+
"errors": [
|
|
546
|
+
{
|
|
547
|
+
"message": "ERR_INTROSPECTION:introspection 已禁用",
|
|
548
|
+
"extensions": {"code": "ERR_INTROSPECTION"},
|
|
549
|
+
}
|
|
550
|
+
]
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
if usage["fields"] > fields_limit:
|
|
554
|
+
return JSONResponse(
|
|
555
|
+
status_code=400,
|
|
556
|
+
content={
|
|
557
|
+
"errors": [
|
|
558
|
+
{
|
|
559
|
+
"message": f"ERR_COMPLEXITY:查询字段数 {usage['fields']} 超过上限 {fields_limit}",
|
|
560
|
+
"extensions": {"code": "ERR_COMPLEXITY"},
|
|
561
|
+
}
|
|
562
|
+
]
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
result = await graphql(
|
|
566
|
+
gql_schema,
|
|
567
|
+
query_str,
|
|
568
|
+
variable_values=body.get("variables"),
|
|
569
|
+
operation_name=body.get("operationName"),
|
|
570
|
+
)
|
|
571
|
+
payload = {"data": result.data}
|
|
572
|
+
if result.errors:
|
|
573
|
+
payload["errors"] = [e.formatted for e in result.errors]
|
|
574
|
+
return JSONResponse(payload)
|
|
575
|
+
|
|
576
|
+
return app
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-graphql-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GraphQL adapter that auto-generates standard GraphQL APIs for py-store schemas (schema-driven CRUD over GQL, projection pushdown)
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,graphql,crud,gql
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: graphql-core>=3.2
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi>=0.110; extra == "fastapi"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
15
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# store-graphql-py
|
|
18
|
+
|
|
19
|
+
GraphQL adapter that auto-generates standard GraphQL APIs for **py-store** schemas (schema-driven CRUD over GQL, projection pushdown).
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from store_graphql import build_graphql_schema, create_app, export_sdl
|
|
23
|
+
|
|
24
|
+
schema = build_graphql_schema(store) # 反射 py-store 已注册的 JSON schema
|
|
25
|
+
app = create_app(store, schema=schema) # FastAPI 承载(可选依赖);或 graphql(schema, query) 自行执行
|
|
26
|
+
export_sdl(schema) # SDL 导出,喂客户端 codegen
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
完整文档(双语主文档 / spec / conformance):见仓库根 [README.zh-CN.md](https://github.com/coenddt/store-graphql#readme)。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
store_graphql/__init__.py,sha256=rsfpFCLzMBmiHXMtyF3ovKYVhCekpWCd9p69hwY3P6Q,803
|
|
2
|
+
store_graphql/adapter.py,sha256=gLefSz_zvujpn1iJOTIzqCwQFW5-UiRml1TZxc7Lseg,20766
|
|
3
|
+
store_graphql_py-0.1.0.dist-info/METADATA,sha256=ayruBu9jEKHDqNw_BtAJOvHMNJvFZ4uA-Y8hatLbEnQ,1272
|
|
4
|
+
store_graphql_py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
store_graphql_py-0.1.0.dist-info/top_level.txt,sha256=l_s_vchZrxt89NPpKPe3RRSMD14MFd9XQBX6xeWNvMk,14
|
|
6
|
+
store_graphql_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_graphql
|