feat(rulebook): REST routes — rulebook + topic endpoints

This commit is contained in:
2026-05-27 21:34:25 -04:00
parent 45fe198d54
commit 0219c673c1
3 changed files with 172 additions and 0 deletions
+2
View File
@@ -25,6 +25,7 @@ from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
from fabledassistant.routes.rulebooks import rulebooks_bp
from fabledassistant.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
@@ -83,6 +84,7 @@ def create_app() -> Quart:
app.register_blueprint(search_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
@app.before_request
async def before_request():
+121
View File
@@ -0,0 +1,121 @@
"""Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the
authenticated owner; the service enforces ownership scoping.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import login_required
import fabledassistant.services.rulebooks as rulebooks_svc
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
# ── Rulebooks ───────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks")
@login_required
async def list_rulebooks():
rows = await rulebooks_svc.list_rulebooks(_uid())
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
@rulebooks_bp.post("/rulebooks")
@login_required
async def create_rulebook():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
rb = await rulebooks_svc.create_rulebook(
user_id=_uid(),
title=title,
description=data.get("description", ""),
)
return jsonify(rb.to_dict()), 201
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
@login_required
async def get_rulebook(rulebook_id: int):
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid())
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@rulebooks_bp.patch("/rulebooks/<int:rulebook_id>")
@login_required
async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
@login_required
async def delete_rulebook(rulebook_id: int):
await rulebooks_svc.delete_rulebook(rulebook_id, _uid())
return "", 204
# ── Topics ──────────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>/topics")
@login_required
async def list_topics(rulebook_id: int):
try:
rows = await rulebooks_svc.list_topics(rulebook_id, _uid())
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"topics": [t.to_dict() for t in rows]})
@rulebooks_bp.post("/rulebooks/<int:rulebook_id>/topics")
@login_required
async def create_topic(rulebook_id: int):
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
try:
topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id,
user_id=_uid(),
title=title,
description=data.get("description", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(topic.to_dict()), 201
@rulebooks_bp.patch("/rulebook-topics/<int:topic_id>")
@login_required
async def update_topic(topic_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("title", "description", "order_index")
}
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields)
if topic is None:
return jsonify({"error": "topic not found"}), 404
return jsonify(topic.to_dict())
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required
async def delete_topic(topic_id: int):
await rulebooks_svc.delete_topic(topic_id, _uid())
return "", 204
+49
View File
@@ -0,0 +1,49 @@
"""Route-level tests for the rulebooks blueprint.
Mirrors the structural-tests pattern in tests/test_events_routes.py:
covers blueprint registration, callable handlers, and service-signature
contracts. Full HTTP integration requires a live DB and auth machinery
that the unit-test environment doesn't provide.
"""
import inspect
def test_rulebooks_blueprint_registered():
from fabledassistant.routes.rulebooks import rulebooks_bp
assert rulebooks_bp.name == "rulebooks"
assert rulebooks_bp.url_prefix == "/api"
def test_rulebooks_blueprint_registered_in_app():
from fabledassistant.app import create_app
app = create_app()
assert "rulebooks" in app.blueprints
def test_rulebook_handlers_callable():
from fabledassistant.routes import rulebooks as rb_routes
for name in (
"list_rulebooks", "create_rulebook", "get_rulebook",
"update_rulebook", "delete_rulebook",
):
assert callable(getattr(rb_routes, name))
def test_topic_handlers_callable():
from fabledassistant.routes import rulebooks as rb_routes
for name in (
"list_topics", "create_topic", "update_topic", "delete_topic",
):
assert callable(getattr(rb_routes, name))
def test_service_signatures_require_user_id():
"""Routes must call services with user_id — verify the contract."""
from fabledassistant.services import rulebooks as svc
for fn_name in (
"create_rulebook", "list_rulebooks", "get_rulebook",
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
):
sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"