502 lines
20 KiB
Python
Executable File
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 {"&":"&","<":"<",">":">",'"':"""}[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?" · "+esc(t.due):"")+"</div></div>"+
|
|
'<button class="del" aria-label="verwijderen">×</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()
|
|
|