Skip to content

Commit 0e0d5d0

Browse files
committed
Add flask todo parallel to fastapi-todo
1 parent f286df3 commit 0e0d5d0

8 files changed

Lines changed: 304 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
3434
- [**`dynamic-py-py/`**](dynamic-py-py) — shows how to load and run a Python Worker dynamically at runtime using a [Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) binding.
3535
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
3636
- [**`django-todo-d1/`**](django-todo-d1) — uses Django with D1 for a basic TODO application.
37+
- [**`fastapi-todo/`**](fastapi-todo) — implements the [Todo-Backend](https://todobackend.com) spec with FastAPI (ASGI) and D1.
38+
- [**`flask-todo/`**](flask-todo) — implements the same [Todo-Backend](https://todobackend.com) API with [Flask](https://flask.palletsprojects.com/) (WSGI) and D1.
3739

3840

3941

‎flask-todo/README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Flask Todo Backend
2+
3+
A Python Flask implementation of the [Todo-Backend](https://todobackend.com) spec, running on Cloudflare Workers with D1 for storage.
4+
5+
## Development
6+
7+
Initialize the local D1 database and start the dev server:
8+
9+
```sh
10+
uv run pywrangler d1 execute todos --local --file db_init.sql
11+
uv run pywrangler dev
12+
```
13+
14+
## Testing with the Todo-Backend spec runner
15+
16+
Start the dev server, then open the spec runner pointing at your local instance:
17+
18+
```
19+
https://todobackend.com/specs/index.html?http://localhost:8787/todos
20+
```
21+
22+
You can also use the Todo-Backend client app:
23+
24+
```
25+
https://todobackend.com/client/index.html?http://localhost:8787/todos
26+
```
27+
28+
## API
29+
30+
| Method | Path | Description |
31+
| -------- | ---------------- | ------------------ |
32+
| `GET` | `/todos` | List all todos |
33+
| `POST` | `/todos` | Create a todo |
34+
| `DELETE` | `/todos` | Delete all todos |
35+
| `GET` | `/todos/{id}` | Get a single todo |
36+
| `PATCH` | `/todos/{id}` | Update a todo |
37+
| `DELETE` | `/todos/{id}` | Delete a todo |

‎flask-todo/db_init.sql‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE IF NOT EXISTS todos (
2+
id TEXT PRIMARY KEY,
3+
title TEXT NOT NULL DEFAULT '',
4+
completed INTEGER NOT NULL DEFAULT 0,
5+
"order" INTEGER
6+
);

‎flask-todo/package.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "flask-todo",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "uv run pywrangler deploy",
7+
"dev": "uv run pywrangler dev",
8+
"start": "uv run pywrangler dev"
9+
},
10+
"devDependencies": {
11+
"wrangler": "^4.114.0"
12+
}
13+
}

‎flask-todo/pyproject.toml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[project]
2+
name = "flask-todo"
3+
version = "0.1.0"
4+
description = "Flask todo backend conforming to the todobackend.com spec"
5+
readme = "README.md"
6+
requires-python = ">=3.12"
7+
dependencies = [
8+
"flask",
9+
"flask-cors",
10+
]
11+
12+
[dependency-groups]
13+
dev = [
14+
"workers-py",
15+
"workers-runtime-sdk"
16+
]

‎flask-todo/src/worker.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import uuid
2+
3+
from flask import Flask, jsonify, request
4+
from flask_cors import CORS
5+
from pyodide.ffi import run_sync
6+
from werkzeug.exceptions import HTTPException
7+
from workers import WorkerEntrypoint, wsgi
8+
9+
app = Flask(__name__)
10+
# Preserve the field order used below rather than sorting keys alphabetically.
11+
app.json.sort_keys = False
12+
13+
# The todobackend.com spec runner calls this Worker from another origin.
14+
CORS(app, origins="*", send_wildcard=True, allow_headers="*", expose_headers="*")
15+
16+
17+
@app.errorhandler(HTTPException)
18+
def _json_error(err: HTTPException):
19+
"""Render Werkzeug's HTTP errors as JSON instead of HTML."""
20+
return jsonify({"detail": err.name}), err.code
21+
22+
23+
def _base_url() -> str:
24+
"""Return the root URL for the todos collection."""
25+
return request.url_root.rstrip("/") + "/todos"
26+
27+
28+
def _row_to_todo(row) -> dict:
29+
"""Convert a D1 row into a todo dict with the absolute ``url`` field."""
30+
return {
31+
"id": row.id,
32+
"title": row.title,
33+
"completed": bool(row.completed),
34+
"order": row.order,
35+
"url": f"{_base_url()}/{row.id}",
36+
}
37+
38+
39+
def _db():
40+
"""Get the D1 database binding from the WSGI environ."""
41+
return request.environ["workers.env"].DB
42+
43+
44+
@app.get("/todos")
45+
def list_todos():
46+
results = run_sync(_db().prepare("SELECT * FROM todos").all())
47+
return jsonify([_row_to_todo(r) for r in results.results])
48+
49+
50+
@app.post("/todos")
51+
def create_todo():
52+
body = request.get_json(silent=True) or {}
53+
todo_id = str(uuid.uuid4())
54+
title = body.get("title", "")
55+
completed = 1 if body.get("completed", False) else 0
56+
order = body.get("order")
57+
58+
run_sync(
59+
_db()
60+
.prepare(
61+
'INSERT INTO todos (id, title, completed, "order") VALUES (?, ?, ?, ?)'
62+
)
63+
.bind(todo_id, title, completed, order)
64+
.run()
65+
)
66+
67+
row = run_sync(
68+
_db().prepare("SELECT * FROM todos WHERE id = ?").bind(todo_id).first()
69+
)
70+
71+
return jsonify(_row_to_todo(row))
72+
73+
74+
@app.delete("/todos")
75+
def delete_all_todos():
76+
run_sync(_db().prepare("DELETE FROM todos").run())
77+
return jsonify([])
78+
79+
80+
@app.get("/todos/<todo_id>")
81+
def get_todo(todo_id: str):
82+
row = run_sync(
83+
_db().prepare("SELECT * FROM todos WHERE id = ?").bind(todo_id).first()
84+
)
85+
if row is None:
86+
return jsonify({"error": "not found"})
87+
return jsonify(_row_to_todo(row))
88+
89+
90+
@app.patch("/todos/<todo_id>")
91+
def update_todo(todo_id: str):
92+
body = request.get_json(silent=True) or {}
93+
sets = []
94+
values = []
95+
if "title" in body:
96+
sets.append("title = ?")
97+
values.append(body["title"])
98+
if "completed" in body:
99+
sets.append("completed = ?")
100+
values.append(1 if body["completed"] else 0)
101+
if "order" in body:
102+
sets.append('"order" = ?')
103+
values.append(body["order"])
104+
105+
if sets:
106+
values.append(todo_id)
107+
run_sync(
108+
_db()
109+
.prepare(f"UPDATE todos SET {', '.join(sets)} WHERE id = ?")
110+
.bind(*values)
111+
.run()
112+
)
113+
114+
row = run_sync(
115+
_db().prepare("SELECT * FROM todos WHERE id = ?").bind(todo_id).first()
116+
)
117+
if row is None:
118+
return jsonify({"error": "not found"})
119+
return jsonify(_row_to_todo(row))
120+
121+
122+
@app.delete("/todos/<todo_id>")
123+
def delete_todo(todo_id: str):
124+
run_sync(_db().prepare("DELETE FROM todos WHERE id = ?").bind(todo_id).run())
125+
return jsonify([])
126+
127+
128+
class Default(WorkerEntrypoint):
129+
async def fetch(self, request):
130+
return await wsgi.fetch(app, request, self.env)

‎flask-todo/wrangler.jsonc‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"$schema": "node_modules/wrangler/config-schema.json",
3+
"name": "flask-todo",
4+
"main": "src/worker.py",
5+
"compatibility_date": "2026-08-01",
6+
"compatibility_flags": [
7+
"python_workers",
8+
],
9+
"d1_databases": [
10+
{
11+
"binding": "DB",
12+
"database_name": "todos",
13+
"database_id": "00000000-0000-0000-0000-000000000000"
14+
}
15+
],
16+
"observability": {
17+
"enabled": true
18+
}
19+
}

‎tests/test_examples.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,87 @@ def test_fastapi_todo(init_fastapi_todo_db, dev_server):
254254
assert response.json() == []
255255

256256

257+
@pytest.fixture
258+
def init_flask_todo_db():
259+
subprocess.run(
260+
[
261+
"uv",
262+
"run",
263+
"pywrangler",
264+
"d1",
265+
"execute",
266+
"todos",
267+
"--local",
268+
"--file",
269+
"db_init.sql",
270+
],
271+
cwd=REPO_ROOT / "flask-todo",
272+
check=True,
273+
)
274+
275+
276+
def test_flask_todo(init_flask_todo_db, dev_server):
277+
port = dev_server
278+
base = f"http://localhost:{port}/todos"
279+
280+
# DELETE all todos
281+
response = requests.delete(base)
282+
assert response.status_code == 200
283+
284+
# GET should return empty list
285+
response = requests.get(base)
286+
assert response.status_code == 200
287+
assert response.json() == []
288+
289+
# POST a new todo
290+
response = requests.post(base, json={"title": "walk the dog"})
291+
assert response.status_code == 200
292+
todo = response.json()
293+
assert todo["title"] == "walk the dog"
294+
assert todo["completed"] is False
295+
assert "url" in todo
296+
todo_url = todo["url"]
297+
298+
# GET the individual todo by its url
299+
response = requests.get(todo_url)
300+
assert response.status_code == 200
301+
assert response.json()["title"] == "walk the dog"
302+
303+
# PATCH the todo
304+
response = requests.patch(
305+
todo_url, json={"title": "bathe the cat", "completed": True}
306+
)
307+
assert response.status_code == 200
308+
patched = response.json()
309+
assert patched["title"] == "bathe the cat"
310+
assert patched["completed"] is True
311+
312+
# POST a todo with an order field
313+
response = requests.post(base, json={"title": "ordered todo", "order": 42})
314+
assert response.status_code == 200
315+
assert response.json()["order"] == 42
316+
317+
# GET all todos should return 2
318+
response = requests.get(base)
319+
assert response.status_code == 200
320+
assert len(response.json()) == 2
321+
322+
# DELETE individual todo
323+
response = requests.delete(todo_url)
324+
assert response.status_code == 200
325+
326+
# GET all todos should return 1
327+
response = requests.get(base)
328+
assert response.status_code == 200
329+
assert len(response.json()) == 1
330+
331+
# DELETE all
332+
response = requests.delete(base)
333+
assert response.status_code == 200
334+
response = requests.get(base)
335+
assert response.json() == []
336+
337+
257338
def test_django(dev_server):
258339
port = dev_server
259340
response = requests.get(f"http://localhost:{port}")

0 commit comments

Comments
 (0)