"""/wp/api/import/* + /wp/api/export/* — library bundle commit pipeline.""" from __future__ import annotations from aiohttp import web from engine.exporter import build_export_payload from engine.importer import commit_import, get_undo_entry, undo_import from wp_api._helpers import db_session, json_error, json_ok from wp_api._validators import ( validate_body_size, validate_wildcard_name, validate_wildcard_subcats, ) # 8-bucket export request keys (Task 10). Each key, when present, must be # a list of UUIDs; missing keys default to []. Mis-typed UUIDs (e.g. a # wildcard id under combine_uuids) are silently dropped by the exporter. _EXPORT_BUILD_KEYS = ( "bundle_uuids", "wildcard_uuids", "fixed_values_uuids", "combine_uuids", "derivation_uuids", "constraint_uuids", "category_uuids", "template_uuids", ) async def export_build(request: web.Request) -> web.Response: """POST /wp/api/export/build — assemble an 8-bucket export payload. Body shape: { "bundle_uuids": [...], "wildcard_uuids": [...], "fixed_values_uuids":[...], "combine_uuids": [...], "derivation_uuids": [...], "constraint_uuids": [...], "category_uuids": [...], "template_uuids": [...], } Every key is optional and defaults to ``[]``. UUID format is NOT validated here: the exporter silently drops missing or cross-bucket UUIDs, which is the intended behavior for the picker UI's loose type tracking. Response is the exporter payload returned directly (no ``{"ok": true, ...}`` wrapper). """ err = validate_body_size(request.content_length) if err is not None: return json_error(err, status=400) 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) for key in _EXPORT_BUILD_KEYS: if key in body and not isinstance(body[key], list): return json_error("uuid lists must be arrays", status=400) with db_session(request) as conn: payload = build_export_payload( conn, bundle_uuids=body.get("bundle_uuids", []), wildcard_uuids=body.get("wildcard_uuids", []), fixed_values_uuids=body.get("fixed_values_uuids", []), combine_uuids=body.get("combine_uuids", []), derivation_uuids=body.get("derivation_uuids", []), constraint_uuids=body.get("constraint_uuids", []), category_uuids=body.get("category_uuids", []), template_uuids=body.get("template_uuids", []), ) return json_ok(payload) def _validate_wildcard_grammar(body: dict) -> str | None: """Walk the 3 commit buckets and reject any wildcard whose name or sub_categories[] would break the `@{uuid#name:subcat}` ref grammar. Bucket entity keys differ — `adds` uses ``entity``, `replaces` uses ``new_content``, `renames` uses ``content``. Each is treated the same for our purposes (we read ``name`` and ``payload.sub_categories``). Returns an error string on first violation, ``None`` on clean pass. """ bucket_entity_key = {"adds": "entity", "replaces": "new_content", "renames": "content"} for bucket, key in bucket_entity_key.items(): items = body.get(bucket) or [] if not isinstance(items, list): continue for i, item in enumerate(items): if not isinstance(item, dict): continue if item.get("kind") != "wildcard": continue entity = item.get(key) if not isinstance(entity, dict): continue name = entity.get("name") if isinstance(name, str): err = validate_wildcard_name(name) if err is not None: return f"{bucket}[{i}]: {err}" payload = entity.get("payload") err = validate_wildcard_subcats(payload) if err is not None: return f"{bucket}[{i}].payload: {err}" return None async def import_commit(request: web.Request) -> web.Response: """POST /wp/api/import/commit — apply a classified commit payload. Body shape (produced by the SPA's client-side classifier, Task 15):: { "adds": [{"kind": str, "entity": {...}}, ...], "replaces": [{"kind": str, "id": str, "new_content": {...}}, ...], "renames": [{"kind": str, "old_id": str, "new_id": str, "content": {...}}, ...] } Every bucket is optional and defaults to ``[]``. ``kind`` must be one of the 8 buckets (``wildcard``, ``fixed_values``, ``combine``, ``derivation``, ``constraint``, ``bundle``, ``category``, ``template``) — the engine rejects anything else as a contract violation. Response on success: ``{"ok": True, "undo_entry_id": str, "summary": {...}}`` (200). Contract violations and DB-layer errors return ``{"error": str}`` with status 400; the engine already wraps raw sqlite errors as ``"database integrity violation"`` so no internals leak. The 5 MB body cap is the same as every other write endpoint. """ err = validate_body_size(request.content_length) if err is not None: return json_error(err, status=400) 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) # Each bucket must be a list when present. Mirrors the export_build # check at line 209 — the engine would crash with TypeError otherwise. for key in ("adds", "replaces", "renames"): if key in body and not isinstance(body[key], list): return json_error(f"{key} must be a list", status=400) # Wildcard name + sub_categories grammar guard. The engine importer # commits whatever it's handed, so the ref-grammar invariant is # enforced at the HTTP boundary here. Older exports (authored # before the `@{uuid#name:subcat}` form locked the forbidden char # set) could otherwise sneak in names with `{}:#@,` and break ref # parsing on the next save. Walks all 3 buckets — adds carry # `entity`, replaces carry `new_content`, renames carry `content`. err = _validate_wildcard_grammar(body) if err is not None: return json_error(err, status=400) with db_session(request) as conn: result = commit_import(conn, body) if not result.get("ok"): # commit_import returns {"ok": False, "error": str} for both # contract violations and wrapped DB errors. 400 covers both. return json_error(result.get("error", "import commit failed"), status=400) # Engine returns ``undo_id``; the locked HTTP contract uses # ``undo_entry_id``. Rename at the boundary so the engine field name # stays decoupled from the client-facing API. return json_ok({ "ok": True, "undo_entry_id": result["undo_id"], "summary": result.get("summary", {}), }) async def import_undo(request: web.Request) -> web.Response: """POST /wp/api/import/undo — reverse a previous import/commit call. Body shape:: {"undo_entry_id": str} Response on success: ``{"ok": True}`` (200). Missing or non-string ``undo_entry_id`` → 400. Unknown ``undo_entry_id`` → **404** (clean miss; the engine returns ``"undo entry ... not found"``). Other failures (corrupt undo blob, DB error) → 400. """ err = validate_body_size(request.content_length) if err is not None: return json_error(err, status=400) 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) if "undo_entry_id" not in body: return json_error("undo_entry_id required", status=400) undo_entry_id = body["undo_entry_id"] if not isinstance(undo_entry_id, str): return json_error("undo_entry_id must be a string", status=400) with db_session(request) as conn: # Structural 404: existence check before attempting the undo. # Mirrors the typed-exception pattern used by ModuleNotFound / # BundleNotFound / CategoryNotFound elsewhere in wp_api — the # HTTP status is decided by row presence, not by parsing engine # error wording. Same connection drives both reads so we keep a # single transaction context. entry = get_undo_entry(conn, undo_entry_id) if entry is None: return json_error( f"undo entry {undo_entry_id!r} not found", status=404, ) result = undo_import(conn, undo_entry_id) if not result.get("ok"): # Remaining failures (corrupt undo blob, IntegrityError during # restore) are all proper 400s — the 404 path is handled above. return json_error(result.get("error", "undo failed"), status=400) return json_ok({"ok": True}) def register(router) -> None: router.add_post("/wp/api/export/build", export_build) router.add_post("/wp/api/import/commit", import_commit) router.add_post("/wp/api/import/undo", import_undo)