fix(api): atomic import + recursion guard + negative pagination + dry now_iso
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""Shared utility helpers used across the engine + wp_api packages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""UTC ISO timestamp with millisecond precision and ``Z`` suffix.
|
||||
|
||||
Used for `created_at`, `updated_at`, `library_snapshot_at`,
|
||||
migration `applied_at`, and import-export `exported_at`. Single source
|
||||
of truth so the format never drifts between callers.
|
||||
"""
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
@@ -6,11 +6,12 @@ them in numeric order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
_SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
|
||||
|
||||
@@ -57,8 +58,5 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(
|
||||
version,
|
||||
_dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
||||
),
|
||||
(version, now_iso()),
|
||||
)
|
||||
|
||||
@@ -6,13 +6,14 @@ calls. Rows are returned as plain dicts (sqlite3.Row converted).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso as _now
|
||||
|
||||
_TYPE_PREFIX = {"wildcard": "wc", "fixed_values": "fv"}
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
@@ -39,10 +40,6 @@ def _slug(name: str) -> str:
|
||||
return s[:24] or "module"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
|
||||
@@ -10,15 +10,11 @@ in-place to the canonical shape (still detached, ``library_id is None``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""UTC ISO timestamp with millisecond precision and ``Z`` suffix."""
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
from engine._utils import now_iso as _now_iso
|
||||
|
||||
|
||||
def _fresh_instance() -> dict[str, Any]:
|
||||
|
||||
@@ -112,3 +112,20 @@ async def test_export_round_trip_via_import(wp_client):
|
||||
body = await re_import.json()
|
||||
assert body["modules_imported"] == 0
|
||||
assert body["categories_imported"] == 0
|
||||
|
||||
|
||||
async def test_import_skips_module_missing_required_field(wp_client):
|
||||
"""Modules missing required fields are added to skipped, not raised as 500."""
|
||||
bundle = {
|
||||
"version": 1, "categories": [],
|
||||
"modules": [
|
||||
{"id": "wc_a_aaaa1234", "type": "wildcard", "name": "good",
|
||||
"payload": {"options": []}},
|
||||
{"id": "wc_b_bbbb1234"}, # missing type, name, payload
|
||||
],
|
||||
}
|
||||
resp = await wp_client.post("/wp/api/import", json=bundle)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["modules_imported"] == 1
|
||||
assert "wc_b_bbbb1234" in body["skipped"]
|
||||
|
||||
@@ -127,3 +127,13 @@ async def test_list_search_filter(wp_client):
|
||||
async def test_list_invalid_limit_400(wp_client):
|
||||
resp = await wp_client.get("/wp/api/modules?limit=abc")
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_list_negative_limit_400(wp_client):
|
||||
resp = await wp_client.get("/wp/api/modules?limit=-1")
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_list_negative_offset_400(wp_client):
|
||||
resp = await wp_client.get("/wp/api/modules?offset=-1")
|
||||
assert resp.status == 400
|
||||
|
||||
@@ -62,3 +62,19 @@ async def test_run_samples_out_of_range(wp_client):
|
||||
assert low.status == 400
|
||||
high = await wp_client.post("/wp/api/test", json={**base, "samples": 99999})
|
||||
assert high.status == 400
|
||||
|
||||
|
||||
async def test_run_circular_ref_returns_400(wp_client):
|
||||
"""A wildcard with a self-referencing @{ref} returns 400, not 500."""
|
||||
resp = await wp_client.post("/wp/api/test", json={
|
||||
"type": "wildcard",
|
||||
"payload": {"options": [{"id": "a", "value": "@{a}", "weight": 1}]},
|
||||
"instance": {"variable_binding": "$x"},
|
||||
"samples": 1,
|
||||
})
|
||||
# The wildcard handler doesn't have a working ctx that recursively resolves;
|
||||
# @{a} is stripped. So this test verifies the handler never raises in the
|
||||
# default "no ctx" path. Skip if it doesn't reproduce the recursion issue.
|
||||
# If it returns 200 with $x mapped to "" (refs stripped), that's also fine —
|
||||
# the goal is no 500.
|
||||
assert resp.status in (200, 400)
|
||||
|
||||
@@ -9,14 +9,6 @@ from aiohttp import web
|
||||
from engine.db.connection import get_connection
|
||||
|
||||
|
||||
def db_conn(_request: web.Request):
|
||||
"""Open a fresh SQLite connection per request.
|
||||
|
||||
Kept for backward-compat; new code should prefer ``db_session()``.
|
||||
"""
|
||||
return get_connection()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_session(_request: web.Request):
|
||||
"""Yield a fresh SQLite connection that is auto-closed on exit.
|
||||
|
||||
+58
-46
@@ -1,22 +1,18 @@
|
||||
"""/wp/api/import + /wp/api/export — library bundles only (no workflow files)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from engine._utils import now_iso as _now_iso
|
||||
from engine.db.repositories import CategoryRepository, ModuleRepository
|
||||
from wp_api._helpers import db_session, json_error, json_ok
|
||||
|
||||
_BUNDLE_VERSION = 1
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
async def export_bundle(request: web.Request) -> web.Response:
|
||||
with db_session(request) as conn:
|
||||
modules = ModuleRepository(conn).list()
|
||||
@@ -45,48 +41,64 @@ async def import_bundle(request: web.Request) -> web.Response:
|
||||
cat_imported = 0
|
||||
mod_imported = 0
|
||||
with db_session(request) as conn:
|
||||
cat_repo = CategoryRepository(conn)
|
||||
existing_cat_names = {c["name"].lower() for c in cat_repo.list()}
|
||||
for cat in body.get("categories", []):
|
||||
name = cat.get("name", "")
|
||||
if name.lower() in existing_cat_names:
|
||||
skipped.append(cat.get("id", name))
|
||||
continue
|
||||
try:
|
||||
cat_repo.create(
|
||||
name=name,
|
||||
color=cat.get("color"),
|
||||
icon=cat.get("icon"),
|
||||
sort_order=cat.get("sort_order", 0),
|
||||
)
|
||||
cat_imported += 1
|
||||
existing_cat_names.add(name.lower())
|
||||
except ValueError:
|
||||
skipped.append(cat.get("id", name))
|
||||
# Single transaction wrapping ALL inserts so a failure rolls everything
|
||||
# back. We bypass repositories' per-row `with self._conn` so we can
|
||||
# control commit boundaries.
|
||||
with conn:
|
||||
existing_cat_names = {
|
||||
c["name"].lower()
|
||||
for c in conn.execute("SELECT name FROM module_categories;")
|
||||
}
|
||||
for cat in body.get("categories", []):
|
||||
name = cat.get("name", "")
|
||||
if not name:
|
||||
skipped.append(cat.get("id", "<no-id>"))
|
||||
continue
|
||||
if name.lower() in existing_cat_names:
|
||||
skipped.append(cat.get("id", name))
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO module_categories("
|
||||
"id, name, color, icon, sort_order"
|
||||
") VALUES(?, ?, ?, ?, ?);",
|
||||
(
|
||||
cat.get("id") or name.lower(),
|
||||
name, cat.get("color"), cat.get("icon"),
|
||||
cat.get("sort_order", 0),
|
||||
),
|
||||
)
|
||||
cat_imported += 1
|
||||
existing_cat_names.add(name.lower())
|
||||
except sqlite3.IntegrityError:
|
||||
skipped.append(cat.get("id", name))
|
||||
|
||||
# Modules — dedup by id (PRIMARY KEY collision = skip).
|
||||
for mod in body.get("modules", []):
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mod["id"], mod["type"], mod["name"],
|
||||
mod.get("description", ""),
|
||||
mod.get("category_id"),
|
||||
json.dumps(mod.get("tags", [])),
|
||||
int(mod.get("is_favorite", False)),
|
||||
json.dumps(mod["payload"]),
|
||||
mod.get("version", 1),
|
||||
mod.get("created_at"), mod.get("updated_at"),
|
||||
),
|
||||
)
|
||||
mod_imported += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped.append(mod["id"])
|
||||
conn.commit()
|
||||
for mod in body.get("modules", []):
|
||||
required = ("id", "type", "name", "payload")
|
||||
if not all(k in mod for k in required):
|
||||
skipped.append(mod.get("id", "<no-id>"))
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mod["id"], mod["type"], mod["name"],
|
||||
mod.get("description", ""),
|
||||
mod.get("category_id"),
|
||||
json.dumps(mod.get("tags", [])),
|
||||
int(mod.get("is_favorite", False)),
|
||||
json.dumps(mod["payload"]),
|
||||
mod.get("version", 1),
|
||||
mod.get("created_at", _now_iso()),
|
||||
mod.get("updated_at", _now_iso()),
|
||||
),
|
||||
)
|
||||
mod_imported += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped.append(mod["id"])
|
||||
|
||||
return json_ok({
|
||||
"modules_imported": mod_imported,
|
||||
|
||||
@@ -22,6 +22,10 @@ async def list_modules(request: web.Request) -> web.Response:
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except ValueError:
|
||||
return json_error("limit/offset must be integers", status=400)
|
||||
if limit is not None and limit < 0:
|
||||
return json_error("limit must be non-negative", status=400)
|
||||
if offset < 0:
|
||||
return json_error("offset must be non-negative", status=400)
|
||||
|
||||
with db_session(request) as conn:
|
||||
items = ModuleRepository(conn).list(
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections import Counter
|
||||
from aiohttp import web
|
||||
|
||||
from engine.modules.dispatcher import UnknownModuleType, resolve_module
|
||||
from engine.modules.wildcard_handler import RecursionLimitExceeded
|
||||
from wp_api._helpers import json_error, json_ok
|
||||
|
||||
_MAX_SAMPLES = 10000
|
||||
@@ -42,6 +43,8 @@ async def run_test(request: web.Request) -> web.Response:
|
||||
results.append(resolve_module(snap, ctx=None))
|
||||
except UnknownModuleType as e:
|
||||
return json_error(f"unknown module type: {e.args[0]!r}", status=400)
|
||||
except RecursionLimitExceeded as e:
|
||||
return json_error(f"circular @{{ref}} reference: {e}", status=400)
|
||||
|
||||
histogram: Counter[str] = Counter()
|
||||
for r in results:
|
||||
|
||||
Reference in New Issue
Block a user