Skip to content

JSON APIs

uv run stk new <module> writes a complete, working set of these endpoints in myapp/<module>/views.py. Start from that file; this page explains its conventions.

Lists paginate manually and serialize with orjson. The response shape is {items, total, perPage}, which is what the frontend’s server-paginated table expects.

import orjson as json
from quart import Response, g, request
from sqlalchemy import func, or_, select
PER_PAGE = 25
@bp.get("/api/posts")
async def list_posts():
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", PER_PAGE, type=int)
query = select(Post)
if search := request.args.get("search"):
query = query.where(
or_(Post.title.ilike(f"%{search}%"), Post.content.ilike(f"%{search}%"))
)
total = await g.db_session.scalar(
select(func.count()).select_from(query.subquery())
)
result = await g.db_session.execute(
query.offset((page - 1) * per_page).limit(per_page)
)
items = [p.to_dict() for p in result.scalars().all()]
return Response(
json.dumps({"items": items, "total": total, "perPage": per_page}),
content_type="application/json",
)

Count from query.subquery() so filters apply to the total too.

The frontend sends {item: {...}}. Create is POST /api/post/, update is POST /api/post/<id> (POST, not PUT), delete is DELETE /api/post/<id>. Admin mutations are logged with Activity.register.

@bp.post("/api/post/")
async def create_post():
data = (await request.json).get("item", {})
if not data.get("title"):
return {"message": "Title is required"}, 400
post = await Post().from_dict(data)
g.db_session.add(post)
try:
await g.db_session.flush() # assigns post.id for the audit entry
await Activity.register(current_user.id, "Post Create", post.to_dict())
await g.db_session.commit()
return {"message": "Post created"}
except Exception:
await g.db_session.rollback()
log.exception("Error creating post")
return {"message": "Error creating post"}, 412

Responses are {"message": ...}: 400 for invalid input, 404 for a missing row, 412 for a failed write. An unhandled exception reaches stk’s global error handler (stk/app.py), which rolls back the session and returns JSON for API requests.

Put auth on the blueprint so no route can forget it:

@bp.before_request
@auth_required("session")
@roles_required("admin")
async def before_request():
pass

uv run stk inspect routes --json reports which routes are guarded, and stk verify fails if a route reported as guarded is reachable anonymously.

Every mutation requires a CSRF token, which Axios sends from the shared layout token. A third-party webhook without a session opts out with @csrf_exempt from stk.security, placed under the route decorator, and must authenticate the caller itself, for example by checking an HMAC signature. See Authentication.