feat(api): add embed-bundle endpoint for lazy walk + transitive deps

This commit is contained in:
DumiFlex
2026-04-28 13:44:11 +03:00
parent 8817e423b5
commit be0b81768e
2 changed files with 150 additions and 1 deletions
+91
View File
@@ -0,0 +1,91 @@
"""Endpoint tests for embed-bundle + hashes + ModuleRow shape extension.
Spec §4.2."""
from __future__ import annotations
import sqlite3
import pytest
from aiohttp import web
from engine.db.migrations import migrate
from engine.db.repositories import ModuleRepository
from wp_api import modules as modules_api
@pytest.fixture
def app_with_db(tmp_path):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
migrate(conn)
app = web.Application()
app["wp_db"] = conn
modules_api.register(app.router)
return app, conn
async def test_embed_bundle_returns_picks_and_transitive_wildcards(
aiohttp_client, app_with_db,
):
"""Picking outfit (which @{}-refs color) returns:
- modules: [outfit_payload] (only the picks)
- snapshots: {outfit_uuid: ..., color_uuid: ...} (picks + transitive wildcards)
- pickOrder: [outfit_uuid]
- walkOverflow: []"""
app, conn = app_with_db
repo = ModuleRepository(conn)
color = repo.create(
type="wildcard", name="color", description="",
category_id=None, tags=[], payload={
"options": [{"value": "red", "weight": 1}],
},
)
outfit_payload = {
"options": [{"value": f"@{{{color['uuid']}}} dress", "weight": 1}],
}
outfit = repo.create(
type="wildcard", name="outfit", description="",
category_id=None, tags=[], payload=outfit_payload,
)
client = await aiohttp_client(app)
resp = await client.post("/wp/api/modules/embed-bundle", json={
"uuids": [outfit["uuid"]],
})
assert resp.status == 200
body = await resp.json()
data = body["data"]
assert data["pickOrder"] == [outfit["uuid"]]
assert len(data["modules"]) == 1
assert data["modules"][0] == outfit_payload
assert set(data["snapshots"].keys()) == {outfit["uuid"], color["uuid"]}
assert data["walkOverflow"] == []
async def test_embed_bundle_records_missing_target_in_walk_overflow(
aiohttp_client, app_with_db,
):
app, conn = app_with_db
repo = ModuleRepository(conn)
a = repo.create(
type="wildcard", name="a", description="",
category_id=None, tags=[], payload={
"options": [{"value": "@{deadbeef}", "weight": 1}],
},
)
client = await aiohttp_client(app)
resp = await client.post("/wp/api/modules/embed-bundle", json={
"uuids": [a["uuid"]],
})
assert resp.status == 200
overflow = (await resp.json())["data"]["walkOverflow"]
assert any(o["uuid"] == "deadbeef" and o["reason"] == "missing_target"
for o in overflow)
async def test_embed_bundle_rejects_non_list_uuids(aiohttp_client, app_with_db):
app, _ = app_with_db
client = await aiohttp_client(app)
resp = await client.post("/wp/api/modules/embed-bundle", json={
"uuids": "not-a-list",
})
assert resp.status == 400
+59 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from aiohttp import web
from engine.db.repositories import ModuleNotFound, ModuleRepository
from engine.modules.snapshot import freeze_snapshot, payload_hash
from engine.modules.snapshot import freeze_snapshot, payload_hash, walk_transitive_refs
from wp_api._helpers import db_session, json_error, json_ok
_UPDATABLE_FIELDS = (
@@ -162,10 +162,68 @@ async def toggle_favorite(request: web.Request) -> web.Response:
return json_ok(updated)
async def embed_bundle(request: web.Request) -> web.Response:
"""Lazy walk for SPA library picker. Spec §4.2.
Body: {"uuids": [str, ...]}. Server fetches each picked uuid + walks
transitive @{} refs through wildcard payloads only. Returns split
response so the client doesn't have to filter:
- modules: list of picked-module payloads in input order (executor's view)
- snapshots: dict[uuid, SnapshotEntry] of wildcards reachable from picks
- pickOrder: list of uuid strings, original input order
- walkOverflow: [{uuid, reason}] for cycle/depth/missing
"""
try:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
uuids = body.get("uuids")
if not isinstance(uuids, list) or not all(isinstance(u, str) for u in uuids):
return json_error("uuids must be a list of strings", status=400)
with db_session(request) as conn:
repo = ModuleRepository(conn)
def _fetch(uuid: str) -> dict | None:
try:
return repo.get_by_uuid(uuid)
except Exception:
return None
walk = walk_transitive_refs(uuids, fetch_module=_fetch)
# Picks (executor's view): keep input order, drop any uuids that
# weren't found in DB.
picks: list[dict] = []
for uuid in uuids:
entry = walk.snapshots.get(uuid)
if entry is not None:
picks.append(entry["payload"])
# Catalog (resolver's view): only wildcards. Spec §2.7.
catalog = {
uuid: entry
for uuid, entry in walk.snapshots.items()
if entry["type"] == "wildcard"
}
return json_ok({
"data": {
"modules": picks,
"snapshots": catalog,
"pickOrder": uuids,
"walkOverflow": walk.walk_overflow,
},
})
def register(router) -> None:
router.add_get("/wp/api/modules", list_modules)
router.add_post("/wp/api/modules", create_module)
router.add_post("/wp/api/modules/match", match_module)
router.add_post("/wp/api/modules/embed-bundle", embed_bundle)
router.add_get("/wp/api/modules/{id}", get_module)
router.add_put("/wp/api/modules/{id}", update_module)
router.add_delete("/wp/api/modules/{id}", delete_module)