Skip to content

Commit 8544e5b

Browse files
mpywclaude
andcommitted
feat(examples): add search and filter functionality to stupid_todolist
Add search box with debounced input and filter buttons (All/Active/Completed) to the todo list frontend. Backend already supports ?q= and ?filter= params. Also includes related improvements: - Add global_helpers for LIKE query building - Update internal config schema and body parser - Add e2e empty body tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent db9b22c commit 8544e5b

14 files changed

Lines changed: 319 additions & 74 deletions

File tree

SCHEMA.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,8 +666,12 @@ Control which Content-Types are accepted for request body.
666666
accepts: json # Only application/json
667667
accepts: form # Only application/x-www-form-urlencoded
668668
accepts: [json, form] # Both (default)
669+
accepts: [] # No body accepted (empty body only)
669670
```
670671

672+
> [!NOTE]
673+
> Empty body requests (common for DELETE) are always allowed regardless of `accepts` setting.
674+
671675
> [!WARNING]
672676
> Returns 415 Unsupported Media Type if the request Content-Type doesn't match.
673677

e2e/empty_body_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package e2e
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/mpyw/sql-http-proxy/internal/db"
13+
"github.com/mpyw/sql-http-proxy/internal/server"
14+
)
15+
16+
func TestEmptyBody_AcceptsEmpty(t *testing.T) {
17+
cfg := loadConfig(t, "empty_body_test.yaml")
18+
19+
configDir, _ := filepath.Abs(".")
20+
conn, err := db.Connect(cfg, configDir)
21+
require.NoError(t, err)
22+
defer conn.Close()
23+
24+
mux, err := server.NewServeMux(conn, cfg, configDir)
25+
require.NoError(t, err)
26+
27+
t.Run("DELETE with empty body succeeds", func(t *testing.T) {
28+
req := httptest.NewRequest(http.MethodDelete, "/resources/1", nil)
29+
w := httptest.NewRecorder()
30+
mux.ServeHTTP(w, req)
31+
32+
require.Equal(t, http.StatusNoContent, w.Code)
33+
})
34+
35+
t.Run("DELETE with non-empty body rejected when accepts=[]", func(t *testing.T) {
36+
// Non-empty body without Content-Type should fail with "missing Content-Type"
37+
req := httptest.NewRequest(http.MethodDelete, "/resources/1", strings.NewReader(`{"key":"value"}`))
38+
w := httptest.NewRecorder()
39+
mux.ServeHTTP(w, req)
40+
41+
// Should reject because body is non-empty but no Content-Type accepted
42+
require.Equal(t, http.StatusUnsupportedMediaType, w.Code)
43+
})
44+
45+
t.Run("DELETE with Content-Type rejected when accepts=[]", func(t *testing.T) {
46+
// Any Content-Type should fail when accepts=[]
47+
req := httptest.NewRequest(http.MethodDelete, "/resources/1", strings.NewReader(`{"key":"value"}`))
48+
req.Header.Set("Content-Type", "application/json")
49+
w := httptest.NewRecorder()
50+
mux.ServeHTTP(w, req)
51+
52+
require.Equal(t, http.StatusUnsupportedMediaType, w.Code)
53+
})
54+
55+
t.Run("DELETE with body accepted when using default accepts", func(t *testing.T) {
56+
req := httptest.NewRequest(http.MethodDelete, "/resources-default/1", strings.NewReader(`{"key":"value"}`))
57+
w := httptest.NewRecorder()
58+
mux.ServeHTTP(w, req)
59+
60+
// Should succeed because default accepts allows json
61+
require.Equal(t, http.StatusNoContent, w.Code)
62+
})
63+
}

e2e/empty_body_test.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
database:
2+
dsn: "sqlite::memory:"
3+
init: |
4+
CREATE TABLE IF NOT EXISTS resources (
5+
id INTEGER PRIMARY KEY AUTOINCREMENT,
6+
name TEXT NOT NULL
7+
);
8+
DELETE FROM resources;
9+
INSERT INTO resources (id, name) VALUES (1, 'Test');
10+
11+
mutations:
12+
# DELETE with accepts: [] - no body accepted
13+
- type: none
14+
method: DELETE
15+
path: /resources/{id:[0-9]+}
16+
accepts: []
17+
sql: DELETE FROM resources WHERE id = :id
18+
19+
# DELETE without accepts - uses default (json, form)
20+
- type: none
21+
method: DELETE
22+
path: /resources-default/{id:[0-9]+}
23+
sql: DELETE FROM resources WHERE id = :id

examples/stupid_todolist/README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ A minimal todo list application demonstrating sql-http-proxy with SQLite in-memo
1313

1414
```bash
1515
cd examples/stupid_todolist
16-
sql-http-proxy -c config.yaml -l :8080
16+
sql-http-proxy -c config.yaml -l :8090
1717
```
1818

19+
The database is automatically initialized on startup via `database.init`.
20+
1921
### 2. Open the frontend
2022

2123
Open `index.html` in your browser:
@@ -40,18 +42,14 @@ python -m http.server 3000
4042
# Then open http://localhost:3000
4143
```
4244

43-
### 3. Initialize the database
44-
45-
Click the "Initialize Database" button to create the todos table.
46-
4745
## Architecture
4846

4947
```
5048
Browser (index.html)
5149
|
5250
| fetch() with CORS
5351
v
54-
sql-http-proxy (:8080)
52+
sql-http-proxy (:8090)
5553
|
5654
| SQL
5755
v
@@ -62,15 +60,20 @@ SQLite (in-memory)
6260

6361
| Method | Path | Description |
6462
|--------|------|-------------|
65-
| POST | /api/init | Initialize database (create table) |
66-
| GET | /api/todos | List all todos |
63+
| GET | /api/todos | List todos (supports `?q=search` for filtering) |
6764
| GET | /api/todos/:id | Get single todo |
6865
| POST | /api/todos | Create todo |
6966
| PUT | /api/todos/:id | Update todo |
7067
| DELETE | /api/todos/:id | Delete todo |
7168

69+
## Features Demonstrated
70+
71+
- **Database Auto-Init**: Uses `database.init` to create tables on startup
72+
- **Dynamic LIKE Query**: Search with `?q=term` using pre-transform to build LIKE pattern
73+
- **CORS Support**: Enabled via `http.cors: true`
74+
- **Response Status**: Custom status codes (201 for create, 204 for delete)
75+
7276
## Notes
7377

7478
- Uses SQLite in-memory database (`file::memory:?cache=shared`)
7579
- Data is lost when the server stops
76-
- CORS headers are added via `global_helpers` and `response.headers.set()`

examples/stupid_todolist/app.js

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
const API_BASE = 'http://localhost:8082/api';
1+
const API_BASE = 'http://localhost:8090/api';
22

33
const todoForm = document.getElementById('todo-form');
44
const todoInput = document.getElementById('todo-input');
5+
const searchInput = document.getElementById('search-input');
6+
const filterButtons = document.querySelectorAll('.filter-btn');
57
const todoList = document.getElementById('todo-list');
68
const statusDiv = document.getElementById('status');
7-
const initBtn = document.getElementById('init-btn');
9+
10+
let currentFilter = 'all';
811

912
// Show status message
1013
function showStatus(message, isError = false) {
@@ -30,25 +33,15 @@ async function api(endpoint, options = {}) {
3033
return res.json();
3134
}
3235

33-
// Initialize database
34-
async function initDatabase() {
35-
try {
36-
initBtn.disabled = true;
37-
await api('/init', { method: 'POST' });
38-
showStatus('Database initialized!');
39-
loadTodos();
40-
} catch (err) {
41-
showStatus('Init failed: ' + err.message, true);
42-
} finally {
43-
initBtn.disabled = false;
44-
}
45-
}
46-
47-
// Load all todos
48-
async function loadTodos() {
36+
// Load todos with optional search and filter
37+
async function loadTodos(search = '', filter = 'all') {
4938
try {
5039
todoList.classList.add('loading');
51-
const todos = await api('/todos');
40+
const params = new URLSearchParams();
41+
if (search) params.set('q', search);
42+
if (filter && filter !== 'all') params.set('filter', filter);
43+
const query = params.toString() ? `?${params}` : '';
44+
const todos = await api(`/todos${query}`);
5245
renderTodos(todos);
5346
} catch (err) {
5447
showStatus('Failed to load: ' + err.message, true);
@@ -84,7 +77,7 @@ async function addTodo(title) {
8477
body: JSON.stringify({ title }),
8578
});
8679
todoInput.value = '';
87-
loadTodos();
80+
loadTodos(searchInput.value, currentFilter);
8881
} catch (err) {
8982
showStatus('Failed to add: ' + err.message, true);
9083
} finally {
@@ -101,31 +94,51 @@ async function toggleTodo(id, completed) {
10194
method: 'PUT',
10295
body: JSON.stringify({ ...todo, completed }),
10396
});
104-
loadTodos();
97+
loadTodos(searchInput.value, currentFilter);
10598
} catch (err) {
10699
showStatus('Failed to update: ' + err.message, true);
107-
loadTodos();
100+
loadTodos(searchInput.value, currentFilter);
108101
}
109102
}
110103

111104
// Delete todo
112105
async function deleteTodo(id) {
113106
try {
114107
await api(`/todos/${id}`, { method: 'DELETE' });
115-
loadTodos();
108+
loadTodos(searchInput.value, currentFilter);
116109
} catch (err) {
117110
showStatus('Failed to delete: ' + err.message, true);
118111
}
119112
}
120113

114+
// Debounce helper
115+
function debounce(fn, delay) {
116+
let timeout;
117+
return (...args) => {
118+
clearTimeout(timeout);
119+
timeout = setTimeout(() => fn(...args), delay);
120+
};
121+
}
122+
121123
// Event listeners
122124
todoForm.addEventListener('submit', (e) => {
123125
e.preventDefault();
124126
const title = todoInput.value.trim();
125127
if (title) addTodo(title);
126128
});
127129

128-
initBtn.addEventListener('click', initDatabase);
130+
searchInput.addEventListener('input', debounce((e) => {
131+
loadTodos(e.target.value, currentFilter);
132+
}, 300));
133+
134+
filterButtons.forEach(btn => {
135+
btn.addEventListener('click', () => {
136+
filterButtons.forEach(b => b.classList.remove('active'));
137+
btn.classList.add('active');
138+
currentFilter = btn.dataset.filter;
139+
loadTodos(searchInput.value, currentFilter);
140+
});
141+
});
129142

130143
// Initial load
131144
loadTodos();

examples/stupid_todolist/config.yaml

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
# Stupid Todo List - sql-http-proxy example
22
# Uses SQLite in-memory database with auto-initialization
33

4+
global_helpers: |
5+
// Escape LIKE special characters (_, %, \)
6+
function escapeLike(s) {
7+
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
8+
}
9+
// Build LIKE conditions from search string
10+
// Returns [exprs, params] tuple
11+
function buildLikeConditions(query, column, prefix = 'kw') {
12+
const keywords = (query || '').trim().split(/\s+/).filter(k => k);
13+
const exprs = [];
14+
const params = {};
15+
keywords.forEach((kw, i) => {
16+
const name = prefix + i;
17+
exprs.push(column + " LIKE :" + name + " ESCAPE '\\'");
18+
params[name] = '%' + escapeLike(kw) + '%';
19+
});
20+
return [exprs, params];
21+
}
22+
423
database:
524
dsn: "file::memory:?cache=shared"
625
init: |
@@ -15,16 +34,22 @@ http:
1534
cors: true
1635

1736
queries:
18-
# List all todos
37+
# List all todos with optional search (supports multiple keywords)
1938
- type: many
2039
path: /api/todos
21-
sql: SELECT id, title, completed, created_at FROM todos ORDER BY created_at DESC
40+
sql: SELECT id, title, completed, created_at FROM todos WHERE 1=1
2241
transform:
23-
post: |
24-
return output.map(row => ({
25-
...row,
26-
completed: Boolean(row.completed)
27-
}));
42+
pre: |
43+
const [exprs, params] = buildLikeConditions(input.q, 'title');
44+
if (exprs.length) sql = sql + ' AND ' + exprs.join(' AND ');
45+
// Filter by completion status
46+
if (input.filter === 'incomplete') sql = sql + ' AND completed = 0';
47+
else if (input.filter === 'completed') sql = sql + ' AND completed = 1';
48+
sql = sql + ' ORDER BY created_at DESC';
49+
return params;
50+
post:
51+
each: |
52+
return { ...output, completed: Boolean(output.completed) };
2853
2954
# Get single todo
3055
- type: one

examples/stupid_todolist/index.html

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@ <h1>Stupid Todo List</h1>
1616
<button type="submit">Add</button>
1717
</form>
1818

19+
<div class="search-box">
20+
<input type="text" id="search-input" placeholder="Search todos...">
21+
</div>
22+
23+
<div class="filter-buttons">
24+
<button type="button" class="filter-btn active" data-filter="all">All</button>
25+
<button type="button" class="filter-btn" data-filter="incomplete">Active</button>
26+
<button type="button" class="filter-btn" data-filter="completed">Completed</button>
27+
</div>
28+
1929
<div id="status" class="status"></div>
2030

2131
<ul id="todo-list"></ul>
22-
23-
<div class="footer">
24-
<button id="init-btn" class="init-btn">Initialize Database</button>
25-
</div>
2632
</div>
2733

2834
<script src="app.js"></script>

0 commit comments

Comments
 (0)