feat(rulebook): REST routes — rulebook + topic endpoints
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user