#!/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 = """