Skip to content

Commit a0647dd

Browse files
committed
Add FastAPI todobackend.com app example.
1 parent affcbff commit a0647dd

7 files changed

Lines changed: 303 additions & 0 deletions

File tree

fastapi-todo/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# FastAPI Todo Backend
2+
3+
A Python FastAPI 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 |

fastapi-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+
);

fastapi-todo/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "fastapi-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+
}

fastapi-todo/pyproject.toml

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

fastapi-todo/src/worker.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import uuid
2+
3+
from fastapi import FastAPI, Request
4+
from fastapi.middleware.cors import CORSMiddleware
5+
from workers import WorkerEntrypoint
6+
7+
app = FastAPI()
8+
9+
app.add_middleware(
10+
CORSMiddleware,
11+
allow_origins=["*"],
12+
allow_methods=["*"],
13+
allow_headers=["*"],
14+
expose_headers=["*"],
15+
)
16+
17+
18+
def _base_url(request: Request) -> str:
19+
"""Return the root URL for the todos collection."""
20+
return str(request.base_url).rstrip("/") + "/todos"
21+
22+
23+
def _row_to_todo(row, request: Request) -> dict:
24+
"""Convert a D1 row into a todo dict with the absolute ``url`` field."""
25+
return {
26+
"id": row.id,
27+
"title": row.title,
28+
"completed": bool(row.completed),
29+
"order": row.order,
30+
"url": f"{_base_url(request)}/{row.id}",
31+
}
32+
33+
34+
def _db(request: Request):
35+
"""Get the D1 database binding from the ASGI scope."""
36+
return request.scope["env"].DB
37+
38+
39+
@app.get("/todos")
40+
async def list_todos(request: Request):
41+
results = await _db(request).prepare("SELECT * FROM todos").all()
42+
return [_row_to_todo(r, request) for r in results.results]
43+
44+
45+
@app.post("/todos")
46+
async def create_todo(request: Request):
47+
body = await request.json()
48+
todo_id = str(uuid.uuid4())
49+
title = body.get("title", "")
50+
completed = 1 if body.get("completed", False) else 0
51+
order = body.get("order")
52+
53+
await (
54+
_db(request)
55+
.prepare(
56+
'INSERT INTO todos (id, title, completed, "order") VALUES (?, ?, ?, ?)'
57+
)
58+
.bind(todo_id, title, completed, order)
59+
.run()
60+
)
61+
62+
row = (
63+
await _db(request)
64+
.prepare("SELECT * FROM todos WHERE id = ?")
65+
.bind(todo_id)
66+
.first()
67+
)
68+
69+
return _row_to_todo(row, request)
70+
71+
72+
@app.delete("/todos")
73+
async def delete_all_todos(request: Request):
74+
await _db(request).prepare("DELETE FROM todos").run()
75+
return []
76+
77+
78+
@app.get("/todos/{todo_id}")
79+
async def get_todo(todo_id: str, request: Request):
80+
row = (
81+
await _db(request)
82+
.prepare("SELECT * FROM todos WHERE id = ?")
83+
.bind(todo_id)
84+
.first()
85+
)
86+
if row is None:
87+
return {"error": "not found"}
88+
return _row_to_todo(row, request)
89+
90+
91+
@app.patch("/todos/{todo_id}")
92+
async def update_todo(todo_id: str, request: Request):
93+
body = await request.json()
94+
sets = []
95+
values = []
96+
if "title" in body:
97+
sets.append("title = ?")
98+
values.append(body["title"])
99+
if "completed" in body:
100+
sets.append("completed = ?")
101+
values.append(1 if body["completed"] else 0)
102+
if "order" in body:
103+
sets.append('"order" = ?')
104+
values.append(body["order"])
105+
106+
if sets:
107+
values.append(todo_id)
108+
await (
109+
_db(request)
110+
.prepare(f"UPDATE todos SET {', '.join(sets)} WHERE id = ?")
111+
.bind(*values)
112+
.run()
113+
)
114+
115+
row = (
116+
await _db(request)
117+
.prepare("SELECT * FROM todos WHERE id = ?")
118+
.bind(todo_id)
119+
.first()
120+
)
121+
if row is None:
122+
return {"error": "not found"}
123+
return _row_to_todo(row, request)
124+
125+
126+
@app.delete("/todos/{todo_id}")
127+
async def delete_todo(todo_id: str, request: Request):
128+
await _db(request).prepare("DELETE FROM todos WHERE id = ?").bind(todo_id).run()
129+
return []
130+
131+
import asgi
132+
Default = asgi.entrypoint(app)

fastapi-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": "fastapi-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
@@ -173,6 +173,87 @@ def test_10_workflows(dev_server):
173173
assert isinstance(status, dict)
174174

175175

176+
@pytest.fixture
177+
def init_fastapi_todo_db():
178+
subprocess.run(
179+
[
180+
"uv",
181+
"run",
182+
"pywrangler",
183+
"d1",
184+
"execute",
185+
"todos",
186+
"--local",
187+
"--file",
188+
"db_init.sql",
189+
],
190+
cwd=REPO_ROOT / "fastapi-todo",
191+
check=True,
192+
)
193+
194+
195+
def test_fastapi_todo(init_fastapi_todo_db, dev_server):
196+
port = dev_server
197+
base = f"http://localhost:{port}/todos"
198+
199+
# DELETE all todos
200+
response = requests.delete(base)
201+
assert response.status_code == 200
202+
203+
# GET should return empty list
204+
response = requests.get(base)
205+
assert response.status_code == 200
206+
assert response.json() == []
207+
208+
# POST a new todo
209+
response = requests.post(base, json={"title": "walk the dog"})
210+
assert response.status_code == 200
211+
todo = response.json()
212+
assert todo["title"] == "walk the dog"
213+
assert todo["completed"] is False
214+
assert "url" in todo
215+
todo_url = todo["url"]
216+
217+
# GET the individual todo by its url
218+
response = requests.get(todo_url)
219+
assert response.status_code == 200
220+
assert response.json()["title"] == "walk the dog"
221+
222+
# PATCH the todo
223+
response = requests.patch(
224+
todo_url, json={"title": "bathe the cat", "completed": True}
225+
)
226+
assert response.status_code == 200
227+
patched = response.json()
228+
assert patched["title"] == "bathe the cat"
229+
assert patched["completed"] is True
230+
231+
# POST a todo with an order field
232+
response = requests.post(base, json={"title": "ordered todo", "order": 42})
233+
assert response.status_code == 200
234+
assert response.json()["order"] == 42
235+
236+
# GET all todos should return 2
237+
response = requests.get(base)
238+
assert response.status_code == 200
239+
assert len(response.json()) == 2
240+
241+
# DELETE individual todo
242+
response = requests.delete(todo_url)
243+
assert response.status_code == 200
244+
245+
# GET all todos should return 1
246+
response = requests.get(base)
247+
assert response.status_code == 200
248+
assert len(response.json()) == 1
249+
250+
# DELETE all
251+
response = requests.delete(base)
252+
assert response.status_code == 200
253+
response = requests.get(base)
254+
assert response.json() == []
255+
256+
176257
def test_18_django(dev_server):
177258
port = dev_server
178259
response = requests.get(f"http://localhost:{port}")

0 commit comments

Comments
 (0)