feat(api): /wp/api/modules crud endpoints
This commit is contained in:
@@ -20,6 +20,7 @@ dependencies = []
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-aiohttp>=1.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.5",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared aiohttp test fixtures for /wp/api/* endpoints."""
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from engine.db.connection import get_connection
|
||||
from engine.db.migrations import migrate
|
||||
from wp_api import register_routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wp_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("WP_DB_PATH", str(tmp_path / "api.db"))
|
||||
conn = get_connection()
|
||||
migrate(conn)
|
||||
conn.close()
|
||||
|
||||
app = web.Application()
|
||||
register_routes(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def wp_client(wp_app, aiohttp_client):
|
||||
return await aiohttp_client(wp_app)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for /wp/api/modules CRUD endpoints."""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_list_empty(wp_client):
|
||||
resp = await wp_client.get("/wp/api/modules")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body == {"items": [], "total": 0}
|
||||
|
||||
|
||||
async def test_create_returns_201_and_id(wp_client):
|
||||
resp = await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "colors", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"options": []},
|
||||
})
|
||||
assert resp.status == 201
|
||||
body = await resp.json()
|
||||
assert body["id"].startswith("wc_colors_")
|
||||
assert body["version"] == 1
|
||||
assert body["payload"] == {"options": []}
|
||||
|
||||
|
||||
async def test_create_missing_type_returns_400(wp_client):
|
||||
resp = await wp_client.post("/wp/api/modules", json={
|
||||
"name": "x", "payload": {"values": []},
|
||||
})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_create_invalid_json_returns_400(wp_client):
|
||||
resp = await wp_client.post(
|
||||
"/wp/api/modules",
|
||||
data="not json",
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_get_by_id(wp_client):
|
||||
create = await wp_client.post("/wp/api/modules", json={
|
||||
"type": "fixed_values", "name": "lens", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"values": []},
|
||||
})
|
||||
mid = (await create.json())["id"]
|
||||
resp = await wp_client.get(f"/wp/api/modules/{mid}")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["id"] == mid
|
||||
assert body["type"] == "fixed_values"
|
||||
|
||||
|
||||
async def test_get_missing_404(wp_client):
|
||||
resp = await wp_client.get("/wp/api/modules/ghost")
|
||||
assert resp.status == 404
|
||||
|
||||
|
||||
async def test_put_updates_only_provided_fields(wp_client):
|
||||
create = await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "x", "description": "hello",
|
||||
"category_id": None, "tags": ["a"], "payload": {"options": []},
|
||||
})
|
||||
mid = (await create.json())["id"]
|
||||
resp = await wp_client.put(f"/wp/api/modules/{mid}", json={"name": "y"})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["name"] == "y"
|
||||
assert body["description"] == "hello" # unchanged
|
||||
assert body["tags"] == ["a"] # unchanged
|
||||
assert body["version"] == 2
|
||||
|
||||
|
||||
async def test_put_missing_404(wp_client):
|
||||
resp = await wp_client.put("/wp/api/modules/ghost", json={"name": "x"})
|
||||
assert resp.status == 404
|
||||
|
||||
|
||||
async def test_delete_returns_204(wp_client):
|
||||
create = await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "x", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"options": []},
|
||||
})
|
||||
mid = (await create.json())["id"]
|
||||
resp = await wp_client.delete(f"/wp/api/modules/{mid}")
|
||||
assert resp.status == 204
|
||||
follow = await wp_client.get(f"/wp/api/modules/{mid}")
|
||||
assert follow.status == 404
|
||||
|
||||
|
||||
async def test_delete_missing_404(wp_client):
|
||||
resp = await wp_client.delete("/wp/api/modules/ghost")
|
||||
assert resp.status == 404
|
||||
|
||||
|
||||
async def test_list_filter_by_type(wp_client):
|
||||
await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "a", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"options": []},
|
||||
})
|
||||
await wp_client.post("/wp/api/modules", json={
|
||||
"type": "fixed_values", "name": "b", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"values": []},
|
||||
})
|
||||
resp = await wp_client.get("/wp/api/modules?type=wildcard")
|
||||
body = await resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["type"] == "wildcard"
|
||||
|
||||
|
||||
async def test_list_search_filter(wp_client):
|
||||
await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "ColorPalette", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"options": []},
|
||||
})
|
||||
await wp_client.post("/wp/api/modules", json={
|
||||
"type": "wildcard", "name": "lighting", "description": "",
|
||||
"category_id": None, "tags": [], "payload": {"options": []},
|
||||
})
|
||||
resp = await wp_client.get("/wp/api/modules?q=color")
|
||||
body = await resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["name"] == "ColorPalette"
|
||||
|
||||
|
||||
async def test_list_invalid_limit_400(wp_client):
|
||||
resp = await wp_client.get("/wp/api/modules?limit=abc")
|
||||
assert resp.status == 400
|
||||
@@ -0,0 +1,11 @@
|
||||
"""HTTP routes for the SPA Manager + extension API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from wp_api import modules as _modules
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Mount all /wp + /wp/api/* routes on the given app."""
|
||||
_modules.register(app.router)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared helpers for wp_api route handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from engine.db.connection import get_connection
|
||||
|
||||
|
||||
def db_conn(_request: web.Request):
|
||||
"""Open a fresh SQLite connection per request.
|
||||
|
||||
SQLite WAL mode + per-request connections avoids cross-task locking
|
||||
issues. Connections are short-lived and closed by the caller.
|
||||
"""
|
||||
return get_connection()
|
||||
|
||||
|
||||
def json_ok(data: Any, *, status: int = 200) -> web.Response:
|
||||
return web.json_response(data, status=status)
|
||||
|
||||
|
||||
def json_error(message: str, *, status: int) -> web.Response:
|
||||
return web.json_response({"error": message}, status=status)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""/wp/api/modules CRUD + list endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from engine.db.repositories import ModuleNotFound, ModuleRepository
|
||||
from wp_api._helpers import db_conn, json_error, json_ok
|
||||
|
||||
|
||||
async def list_modules(request: web.Request) -> web.Response:
|
||||
type_ = request.query.get("type")
|
||||
category_id = request.query.get("category")
|
||||
query = request.query.get("q")
|
||||
favorites = request.query.get("favorites") == "1"
|
||||
try:
|
||||
limit = int(request.query["limit"]) if "limit" in request.query else None
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except ValueError:
|
||||
return json_error("limit/offset must be integers", status=400)
|
||||
|
||||
conn = db_conn(request)
|
||||
try:
|
||||
repo = ModuleRepository(conn)
|
||||
items = repo.list(
|
||||
type=type_, category_id=category_id, query=query,
|
||||
favorites_only=favorites, limit=limit, offset=offset,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return json_ok({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
async def create_module(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return json_error("invalid JSON body", status=400)
|
||||
required = {"type", "name", "payload"}
|
||||
missing = required - body.keys()
|
||||
if missing:
|
||||
return json_error(
|
||||
f"missing fields: {sorted(missing)}", status=400,
|
||||
)
|
||||
|
||||
conn = db_conn(request)
|
||||
try:
|
||||
repo = ModuleRepository(conn)
|
||||
row = repo.create(
|
||||
type=body["type"], name=body["name"],
|
||||
description=body.get("description", ""),
|
||||
category_id=body.get("category_id"),
|
||||
tags=body.get("tags", []),
|
||||
payload=body["payload"],
|
||||
is_favorite=bool(body.get("is_favorite", False)),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return json_ok(row, status=201)
|
||||
|
||||
|
||||
async def get_module(request: web.Request) -> web.Response:
|
||||
mid = request.match_info["id"]
|
||||
conn = db_conn(request)
|
||||
try:
|
||||
try:
|
||||
row = ModuleRepository(conn).get(mid)
|
||||
except ModuleNotFound:
|
||||
return json_error(f"module not found: {mid}", status=404)
|
||||
finally:
|
||||
conn.close()
|
||||
return json_ok(row)
|
||||
|
||||
|
||||
async def update_module(request: web.Request) -> web.Response:
|
||||
mid = request.match_info["id"]
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return json_error("invalid JSON body", status=400)
|
||||
kwargs: dict = {}
|
||||
for key in ("name", "description", "tags", "payload", "is_favorite", "category_id"):
|
||||
if key in body:
|
||||
kwargs[key] = body[key]
|
||||
conn = db_conn(request)
|
||||
try:
|
||||
repo = ModuleRepository(conn)
|
||||
try:
|
||||
row = repo.update(mid, **kwargs)
|
||||
except ModuleNotFound:
|
||||
return json_error(f"module not found: {mid}", status=404)
|
||||
finally:
|
||||
conn.close()
|
||||
return json_ok(row)
|
||||
|
||||
|
||||
async def delete_module(request: web.Request) -> web.Response:
|
||||
mid = request.match_info["id"]
|
||||
conn = db_conn(request)
|
||||
try:
|
||||
try:
|
||||
ModuleRepository(conn).delete(mid)
|
||||
except ModuleNotFound:
|
||||
return json_error(f"module not found: {mid}", status=404)
|
||||
finally:
|
||||
conn.close()
|
||||
return web.Response(status=204)
|
||||
|
||||
|
||||
def register(router) -> None:
|
||||
router.add_get("/wp/api/modules", list_modules)
|
||||
router.add_post("/wp/api/modules", create_module)
|
||||
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)
|
||||
Reference in New Issue
Block a user