store-api-py 0.1.0__tar.gz → 0.2.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.
- {store_api_py-0.1.0 → store_api_py-0.2.0}/PKG-INFO +1 -1
- {store_api_py-0.1.0 → store_api_py-0.2.0}/pyproject.toml +1 -1
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py/app.py +27 -2
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py.egg-info/PKG-INFO +1 -1
- {store_api_py-0.1.0 → store_api_py-0.2.0}/tests/test_smoke.py +20 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/README.md +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/setup.cfg +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py/__init__.py +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py/errors.py +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py/params.py +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py.egg-info/SOURCES.txt +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py.egg-info/dependency_links.txt +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py.egg-info/requires.txt +0 -0
- {store_api_py-0.1.0 → store_api_py-0.2.0}/src/store_api_py.egg-info/top_level.txt +0 -0
|
@@ -11,7 +11,7 @@ import json
|
|
|
11
11
|
from typing import Any, Callable
|
|
12
12
|
|
|
13
13
|
from fastapi import FastAPI, Request
|
|
14
|
-
from fastapi.responses import JSONResponse
|
|
14
|
+
from fastapi.responses import JSONResponse, Response
|
|
15
15
|
|
|
16
16
|
from .errors import (
|
|
17
17
|
StoreApiError,
|
|
@@ -49,6 +49,8 @@ def create_app(
|
|
|
49
49
|
context_provider: ContextProvider | None = None,
|
|
50
50
|
resources: list[str] | None = None,
|
|
51
51
|
permission_error: type[BaseException] | None = None,
|
|
52
|
+
file_field: str = "file",
|
|
53
|
+
file_resolver: Callable[[Request, dict, str], Any] | None = None,
|
|
52
54
|
) -> FastAPI:
|
|
53
55
|
"""为 store(py-store 的 store 实例,需已 init + register)生成 RESTful FastAPI 应用。
|
|
54
56
|
|
|
@@ -97,7 +99,7 @@ def create_app(
|
|
|
97
99
|
# 显式投影(spec/01+02):GQL 省略投影段 = 只返回 _id(三端 core 契约)。
|
|
98
100
|
# 列表路由仅在 q 缺失时用它;q 存在时投影完全由 q 决定,适配层不追加。
|
|
99
101
|
proj = _schema_projection(store, name)
|
|
100
|
-
_register_resource(app, store, name, prefix, id_field, permission_error, proj)
|
|
102
|
+
_register_resource(app, store, name, prefix, id_field, permission_error, proj, file_field, file_resolver)
|
|
101
103
|
|
|
102
104
|
return app
|
|
103
105
|
|
|
@@ -153,6 +155,8 @@ def _register_resource(
|
|
|
153
155
|
id_field: str,
|
|
154
156
|
permission_error: type[BaseException] | None,
|
|
155
157
|
proj: str,
|
|
158
|
+
file_field: str,
|
|
159
|
+
file_resolver: Callable[[Request, dict, str], Any] | None,
|
|
156
160
|
) -> None:
|
|
157
161
|
# 工厂函数隔离闭包:handler 签名只含 Request 与路径参数,
|
|
158
162
|
# 否则 FastAPI 会把捕获变量解析成查询参数(缺参即 422)
|
|
@@ -174,6 +178,27 @@ def _register_resource(
|
|
|
174
178
|
raise not_found(f"记录不存在: {id_field}={rid}")
|
|
175
179
|
return {"data": data}
|
|
176
180
|
|
|
181
|
+
@app.get(base + "/{rid}/file")
|
|
182
|
+
@_guard(permission_error)
|
|
183
|
+
async def get_file(rid: str, request: Request):
|
|
184
|
+
rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
|
|
185
|
+
if rec is None:
|
|
186
|
+
raise not_found(f"记录不存在: {id_field}={rid}")
|
|
187
|
+
raw = rec.get(file_field)
|
|
188
|
+
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}"}
|
|
196
|
+
return Response(
|
|
197
|
+
content=out["body"],
|
|
198
|
+
media_type=out.get("contentType", "application/octet-stream"),
|
|
199
|
+
headers={"content-disposition": f'attachment; filename="{out.get("fileName", "file")}"'},
|
|
200
|
+
)
|
|
201
|
+
|
|
177
202
|
@app.post(base, status_code=201)
|
|
178
203
|
@_guard(permission_error)
|
|
179
204
|
async def create_resource(request: Request):
|
|
@@ -223,3 +223,23 @@ def test_x_cache_header():
|
|
|
223
223
|
store.cache_status = lambda: "HIT"
|
|
224
224
|
hit = client.get("/api/user")
|
|
225
225
|
assert hit.headers["x-cache"] == "HIT"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_file_download_route():
|
|
229
|
+
"""文件下载路由:200 + 头;记录/字段缺失 → 404(spec/01+03+05)"""
|
|
230
|
+
store = MockStore()
|
|
231
|
+
client = TestClient(build_app(store))
|
|
232
|
+
|
|
233
|
+
created = client.post("/api/user", json={"name": "a", "age": 1})
|
|
234
|
+
rid = created.json()["data"]["_id"]
|
|
235
|
+
store.rows[rid]["file"] = "hello"
|
|
236
|
+
|
|
237
|
+
file = client.get(f"/api/user/{rid}/file")
|
|
238
|
+
assert file.status_code == 200
|
|
239
|
+
assert file.headers["content-type"] == "text/plain; charset=utf-8"
|
|
240
|
+
assert file.headers["content-disposition"] == f'attachment; filename="user-{rid}"'
|
|
241
|
+
assert file.content == b"hello"
|
|
242
|
+
|
|
243
|
+
missing = client.get("/api/user/nope/file")
|
|
244
|
+
assert missing.status_code == 404
|
|
245
|
+
assert missing.json()["error"]["code"] == "NOT_FOUND"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|