feat(api): /wp/api/modules crud endpoints

This commit is contained in:
DumiFlex
2026-04-26 16:40:46 +03:00
parent 9a98626dd7
commit 89b7827534
7 changed files with 304 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@ dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-aiohttp>=1.0",
"pytest-asyncio>=0.23",
"ruff>=0.5",
]
View File
+24
View File
@@ -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)
+129
View File
@@ -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
+11
View File
@@ -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)
+25
View File
@@ -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)
+114
View File
@@ -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)