Files
tai/examples/todo-rest-api.py
T

502 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""Todo REST API + SQLite + embedded UI. Stdlib only, single file, 3.6+.
Run:
python3 todo-rest-api.py --port 8081
python3 todo-rest-api.py --port 8081 --db ./todos.db
Open http://127.0.0.1:8081/ voor de UI, of praat JSON tegen /api/todos.
Spec (dit bestand is de spec):
Endpoint Method Statusen Beschrijving
/api/todos GET 200 Lijst (filter/paging/sort)
/api/todos POST 201/400/413 Aanmaken, JSON body
/api/todos/{id} GET 200/404 Een todo
/api/todos/{id} PUT 200/400/404 Vervangen (volledig)
/api/todos/{id} PATCH 200/400/404 Deels bijwerken
/api/todos/{id} DELETE 204/404 Verwijderen
/api/todos/{id}/toggle POST 200/404 done-flag omklappen
/ GET 200 Embedded UI (geen deps)
/healthz GET 200 {"ok": true, "todos": N}
Query params (lijst): status=open|done|all (default all), q=zoektekst,
limit=1..100 (default 50), offset>=0 (default 0),
sort=created|updated|priority|title (default updated),
order=asc|desc (default desc).
Todo-object: {"id": int, "title": str, "notes": str, "priority": 1..5,
"done": bool, "due": "YYYY-MM-DD"|null,
"created_at": iso, "updated_at": iso}.
Caps: MAX_TITLE 200, MAX_NOTES 5000, MAX_BODY 64 KiB, MAX_LIMIT 100,
MAX_TODOS 100000. Concurrency: een SQLite-verbinding + RLock, WAL-mode,
busy_timeout 5s.
"""
import argparse
import json
import os
import re
import socketserver
import sqlite3
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
DUE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
class TodoError(Exception):
def __init__(self, status, message):
super(TodoError, self).__init__(message)
self.status = status
self.message = message
class TodoValidator(object):
"""Enige plek die beslist wat een geldige todo is."""
MAX_TITLE = 200
MAX_NOTES = 5000
@classmethod
def clean(cls, data, partial=False):
if not isinstance(data, dict):
raise TodoError(400, "body must be a JSON object")
out = {}
if "title" in data or not partial:
title = data.get("title", "")
if not isinstance(title, str) or not title.strip():
raise TodoError(400, "title is required (1..200 chars)")
title = title.strip()
if len(title) > cls.MAX_TITLE:
raise TodoError(400, "title too long (max 200)")
out["title"] = title
if "notes" in data:
notes = data["notes"]
if notes is None:
notes = ""
if not isinstance(notes, str):
raise TodoError(400, "notes must be a string")
if len(notes) > cls.MAX_NOTES:
raise TodoError(400, "notes too long (max 5000)")
out["notes"] = notes
if "priority" in data:
prio = data["priority"]
if not isinstance(prio, int) or not 1 <= prio <= 5:
raise TodoError(400, "priority must be int 1..5")
out["priority"] = prio
if "done" in data:
done = data["done"]
if not isinstance(done, bool):
raise TodoError(400, "done must be boolean")
out["done"] = done
if "due" in data:
due = data["due"]
if due is not None and (not isinstance(due, str)
or not DUE_RE.match(due)):
raise TodoError(400, "due must be YYYY-MM-DD or null")
out["due"] = due
return out
class TodoStore(object):
"""SQLite-opslag. Thread-safe via een RLock rond een verbinding."""
SCHEMA = ("CREATE TABLE IF NOT EXISTS todos ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"title TEXT NOT NULL,"
"notes TEXT NOT NULL DEFAULT '',"
"priority INTEGER NOT NULL DEFAULT 3,"
"done INTEGER NOT NULL DEFAULT 0,"
"due TEXT,"
"created_at TEXT NOT NULL,"
"updated_at TEXT NOT NULL)")
INDEXES = ("CREATE INDEX IF NOT EXISTS idx_todos_done ON todos(done)",
"CREATE INDEX IF NOT EXISTS idx_todos_updated "
"ON todos(updated_at)",
"CREATE INDEX IF NOT EXISTS idx_todos_priority "
"ON todos(priority)")
SORTS = {"created": "created_at", "updated": "updated_at",
"priority": "priority", "title": "title"}
MAX_TODOS = 100000
def __init__(self, path):
self._lock = threading.RLock()
self.db = sqlite3.connect(path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("PRAGMA busy_timeout=5000")
self.db.execute("PRAGMA synchronous=NORMAL")
with self.db:
self.db.execute(self.SCHEMA)
for index in self.INDEXES:
self.db.execute(index)
@staticmethod
def _now():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@staticmethod
def _row_to_dict(row):
return {"id": row["id"], "title": row["title"],
"notes": row["notes"], "priority": row["priority"],
"done": bool(row["done"]), "due": row["due"],
"created_at": row["created_at"],
"updated_at": row["updated_at"]}
def count(self):
with self._lock:
row = self.db.execute("SELECT COUNT(*) AS n FROM todos").fetchone()
return row["n"]
def create(self, fields):
with self._lock:
if self.count() >= self.MAX_TODOS:
raise TodoError(507, "todo store full")
now = self._now()
cur = self.db.execute(
"INSERT INTO todos (title, notes, priority, done, due,"
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(fields["title"], fields.get("notes", ""),
fields.get("priority", 3), int(fields.get("done", False)),
fields.get("due"), now, now))
self.db.commit()
return self.get(cur.lastrowid)
def get(self, todo_id):
with self._lock:
row = self.db.execute("SELECT * FROM todos WHERE id=?",
(todo_id,)).fetchone()
if row is None:
raise TodoError(404, "no such todo")
return self._row_to_dict(row)
def list(self, status="all", q="", limit=50, offset=0,
sort="updated", order="desc"):
column = self.SORTS.get(sort, "updated_at")
direction = "ASC" if order == "asc" else "DESC"
where = []
params = []
if status == "open":
where.append("done=0")
elif status == "done":
where.append("done=1")
if q:
where.append("(title LIKE ? OR notes LIKE ?)")
params.extend(["%" + q + "%", "%" + q + "%"])
sql = "SELECT * FROM todos"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY %s %s, id %s LIMIT ? OFFSET ?" % (column, direction,
direction)
params.extend([limit, offset])
count_sql = "SELECT COUNT(*) AS n FROM todos"
if where:
count_sql += " WHERE " + " AND ".join(where)
with self._lock:
total = self.db.execute(count_sql, params[:-2]).fetchone()["n"]
rows = self.db.execute(sql, params).fetchall()
return total, [self._row_to_dict(r) for r in rows]
def update(self, todo_id, fields):
with self._lock:
current = self.get(todo_id)
merged = {"title": current["title"], "notes": current["notes"],
"priority": current["priority"], "done": current["done"],
"due": current["due"]}
merged.update(fields)
now = self._now()
self.db.execute(
"UPDATE todos SET title=?, notes=?, priority=?, done=?,"
" due=?, updated_at=? WHERE id=?",
(merged["title"], merged["notes"], merged["priority"],
int(merged["done"]), merged["due"], now, todo_id))
self.db.commit()
return self.get(todo_id)
def delete(self, todo_id):
with self._lock:
cur = self.db.execute("DELETE FROM todos WHERE id=?", (todo_id,))
self.db.commit()
if cur.rowcount == 0:
raise TodoError(404, "no such todo")
def toggle(self, todo_id):
with self._lock:
current = self.get(todo_id)
return self.update(todo_id, {"done": not current["done"]})
class TodoHandler(BaseHTTPRequestHandler):
"""Een instantie per request. Deelt de store via server.store."""
server_version = "TaiTodo/1.0"
protocol_version = "HTTP/1.1"
MAX_BODY = 64 * 1024
def log_message(self, fmt, *args):
pass
# -- generieke hulpjes ------------------------------------------------
def _send(self, code, body=b"", ctype="text/plain; charset=utf-8",
extra=None):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD" and body:
self.wfile.write(body)
def _json(self, code, obj):
self._send(code, json.dumps(obj), "application/json")
def _read_json(self):
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
raise TodoError(400, "bad Content-Length")
if length < 0 or length > self.MAX_BODY:
raise TodoError(413, "body too large (max 64 KiB)")
raw = self.rfile.read(length) if length else b""
if not raw:
raise TodoError(400, "empty body, want JSON object")
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
raise TodoError(400, "body is not valid JSON")
def _route(self):
"""Splits pad in (resource, id, action). Gooit TodoError bij 404."""
parsed = urlparse(self.path)
parts = [p for p in parsed.path.split("/") if p]
if not parts:
return ("ui", None, None, parsed)
if parts == ["healthz"]:
return ("healthz", None, None, parsed)
if parts[0] != "api" or len(parts) < 2 or parts[1] != "todos":
raise TodoError(404, "unknown endpoint")
if len(parts) == 2:
return ("todos", None, None, parsed)
try:
todo_id = int(parts[2])
if todo_id <= 0:
raise ValueError
except ValueError:
raise TodoError(404, "bad todo id")
if len(parts) == 3:
return ("todos", todo_id, None, parsed)
if len(parts) == 4 and parts[3] == "toggle":
return ("todos", todo_id, "toggle", parsed)
raise TodoError(404, "unknown endpoint")
def _handle(self):
store = self.server.store # type: TodoStore
resource, todo_id, action, parsed = self._route()
method = self.command
if resource == "ui":
if method not in ("GET", "HEAD"):
raise TodoError(405, "method not allowed")
self._send(200, UI_HTML, "text/html; charset=utf-8")
return
if resource == "healthz":
if method not in ("GET", "HEAD"):
raise TodoError(405, "method not allowed")
self._json(200, {"ok": True, "todos": store.count()})
return
if todo_id is None:
if method == "GET":
qs = parse_qs(parsed.query)
status = qs.get("status", ["all"])[0]
if status not in ("open", "done", "all"):
raise TodoError(400, "status must be open|done|all")
try:
limit = int(qs.get("limit", ["50"])[0])
offset = int(qs.get("offset", ["0"])[0])
except ValueError:
raise TodoError(400, "limit/offset must be ints")
limit = min(max(limit, 1), 100)
offset = max(offset, 0)
total, items = store.list(
status=status, q=qs.get("q", [""])[0][:200],
limit=limit, offset=offset,
sort=qs.get("sort", ["updated"])[0],
order=qs.get("order", ["desc"])[0])
self._json(200, {"total": total, "limit": limit,
"offset": offset, "items": items})
elif method == "POST":
todo = store.create(TodoValidator.clean(self._read_json()))
self._json(201, todo)
else:
raise TodoError(405, "method not allowed")
return
if action == "toggle":
if method != "POST":
raise TodoError(405, "method not allowed")
self._json(200, store.toggle(todo_id))
return
if method == "GET":
self._json(200, store.get(todo_id))
elif method == "PUT":
self._json(200, store.update(
todo_id, TodoValidator.clean(self._read_json())))
elif method == "PATCH":
self._json(200, store.update(
todo_id, TodoValidator.clean(self._read_json(), partial=True)))
elif method == "DELETE":
store.delete(todo_id)
self._send(204, b"")
else:
raise TodoError(405, "method not allowed")
def _dispatch(self):
try:
self._handle()
except TodoError as exc:
self._json(exc.status, {"error": exc.message})
except BrokenPipeError:
pass
do_GET = _dispatch
do_HEAD = _dispatch
do_POST = _dispatch
do_PUT = _dispatch
do_PATCH = _dispatch
do_DELETE = _dispatch
UI_HTML = """<!DOCTYPE html>
<html lang="nl"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Todo</title>
<style>
:root{--bg:#fff;--fg:#1a1a1a;--muted:#666;--line:#ddd;--accent:#0a5;}
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);
color:var(--fg);margin:0;padding:0 1em 3em}
main{max-width:36em;margin:0 auto}
h1{font-size:1.4em;margin:1em 0 .3em}
.toolbar{display:flex;gap:.5em;flex-wrap:wrap;margin:.8em 0}
.toolbar input[type=search]{flex:1;min-width:8em;padding:.5em;font-size:1em;
border:1px solid var(--line);border-radius:6px}
.btnrow{display:flex;gap:.4em}
button{padding:.5em .9em;font-size:.95em;border:1px solid var(--line);
background:#f4f4f4;border-radius:6px;cursor:pointer}
button[aria-pressed=true]{background:#1a1a1a;color:#fff;border-color:#1a1a1a}
form.add{display:flex;gap:.5em;margin:.8em 0}
form.add input{flex:1;padding:.6em;font-size:1em;border:1px solid var(--line);
border-radius:6px}
ul{list-style:none;margin:0;padding:0}
li{display:flex;gap:.6em;align-items:flex-start;padding:.6em .2em;
border-bottom:1px solid var(--line)}
li input[type=checkbox]{margin-top:.25em;width:1.1em;height:1.1em}
li .body{flex:1;min-width:0}
li .title{font-weight:600;overflow-wrap:break-word}
li.done .title{text-decoration:line-through;color:var(--muted)}
li .meta{font-size:.85em;color:var(--muted);margin-top:.2em}
li .del{background:none;border:none;font-size:1.1em;line-height:1;
padding:.2em .4em;color:#a00}
.pager{display:flex;gap:.5em;align-items:center;justify-content:center;
margin:1.2em 0;color:var(--muted)}
@media(max-width:30em){
.btnrow{width:100%}
.btnrow button{flex:1}
form.add{flex-direction:column}
}
</style></head><body><main>
<h1>Todo</h1>
<form class="add" id="add"><input id="title" maxlength="200"
placeholder="Nieuwe todo..." autocomplete="off"><button type="submit">Toevoegen</button></form>
<div class="toolbar">
<input type="search" id="q" placeholder="Zoeken...">
<div class="btnrow" role="group" aria-label="Filter">
<button data-f="all" aria-pressed="true">Alle</button>
<button data-f="open" aria-pressed="false">Open</button>
<button data-f="done" aria-pressed="false">Klaar</button>
</div></div>
<ul id="list"></ul>
<div class="pager"><button id="prev">Vorige</button><span id="count"></span>
<button id="next">Volgende</button></div>
<script>
"use strict";
var f="all",q="",off=0,total=0,LIM=20;
function esc(s){return s.replace(/[&<>"]/g,function(c){
return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c];});}
function load(){
fetch("api/todos?status="+f+"&q="+encodeURIComponent(q)+"&limit="+LIM+"&offset="+off)
.then(function(r){return r.json();}).then(function(d){
total=d.total;var ul=document.getElementById("list");ul.innerHTML="";
d.items.forEach(function(t){
var li=document.createElement("li");if(t.done)li.className="done";
li.innerHTML='<input type="checkbox"'+(t.done?" checked":"")+' aria-label="klaar">'+
'<div class="body"><div class="title">'+esc(t.title)+"</div>"+
'<div class="meta">p'+t.priority+(t.due?" &middot; "+esc(t.due):"")+"</div></div>"+
'<button class="del" aria-label="verwijderen">&times;</button>';
li.querySelector("input").addEventListener("change",function(){
fetch("api/todos/"+t.id+"/toggle",{method:"POST"}).then(load);});
li.querySelector(".del").addEventListener("click",function(){
if(confirm("Verwijderen?"))fetch("api/todos/"+t.id,{method:"DELETE"}).then(load);});
ul.appendChild(li);});
document.getElementById("count").textContent=
(total?off+1:0)+"-"+Math.min(off+LIM,total)+" van "+total;
document.getElementById("prev").disabled=off<=0;
document.getElementById("next").disabled=off+LIM>=total;});}
document.getElementById("add").addEventListener("submit",function(e){
e.preventDefault();var i=document.getElementById("title");
if(!i.value.trim())return;
fetch("api/todos",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({title:i.value})}).then(function(){i.value="";off=0;load();});});
document.getElementById("q").addEventListener("input",function(e){
q=e.target.value;off=0;load();});
document.querySelectorAll(".btnrow button").forEach(function(b){
b.addEventListener("click",function(){
document.querySelectorAll(".btnrow button").forEach(function(x){
x.setAttribute("aria-pressed","false");});
b.setAttribute("aria-pressed","true");f=b.getAttribute("data-f");off=0;load();});});
document.getElementById("prev").addEventListener("click",function(){
off=Math.max(0,off-LIM);load();});
document.getElementById("next").addEventListener("click",function(){
off+=LIM;load();});
load();
</script></main></body></html>
"""
class TodoServer(socketserver.ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
def main(argv=None):
parser = argparse.ArgumentParser(
description="Todo REST API + SQLite + embedded UI (stdlib only).")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8081)
parser.add_argument("--db", default=":memory:",
help="sqlite file (default :memory:)")
args = parser.parse_args(argv)
if args.db != ":memory:":
parent = os.path.dirname(os.path.abspath(args.db))
if not os.path.isdir(parent):
parser.error("db directory does not exist: %s" % parent)
server = TodoServer((args.host, args.port), TodoHandler)
server.store = TodoStore(args.db)
print("todo: http://%s:%d/ (db=%s)" % (args.host, args.port, args.db))
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()