webpipe-js 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +609 -0
- package/dist/{parser.d.ts → index.d.cts} +25 -23
- package/dist/index.d.ts +131 -0
- package/{parser.ts → dist/index.mjs} +187 -350
- package/package.json +16 -6
- package/comprehensive_test.wp +0 -1139
- package/dist/parser.js +0 -594
- package/dist/tests/parser.test.d.ts +0 -1
- package/dist/tests/parser.test.js +0 -106
- package/tests/parser.test.ts +0 -119
- package/tsconfig.json +0 -18
package/comprehensive_test.wp
DELETED
|
@@ -1,1139 +0,0 @@
|
|
|
1
|
-
# Test App
|
|
2
|
-
|
|
3
|
-
GET /hello/:world
|
|
4
|
-
|> jq: `{ world: .params.world }`
|
|
5
|
-
|> handlebars: `<p>hello, {{world}}</p>`
|
|
6
|
-
|
|
7
|
-
describe "hello, world"
|
|
8
|
-
it "calls the route"
|
|
9
|
-
when calling GET /hello/world
|
|
10
|
-
then status is 200
|
|
11
|
-
and output equals `<p>hello, world</p>`
|
|
12
|
-
|
|
13
|
-
GET /lua/:id/example
|
|
14
|
-
|> lua: `
|
|
15
|
-
local id = request.params.id
|
|
16
|
-
local name = request.query.name
|
|
17
|
-
return {
|
|
18
|
-
message = "Hello from Lua!",
|
|
19
|
-
id = id,
|
|
20
|
-
name = name
|
|
21
|
-
}
|
|
22
|
-
`
|
|
23
|
-
describe "lua"
|
|
24
|
-
it "calls the route"
|
|
25
|
-
when calling GET /lua/123/example?name=example
|
|
26
|
-
then status is 200
|
|
27
|
-
and output equals `{
|
|
28
|
-
"message": "Hello from Lua!",
|
|
29
|
-
"id": 123,
|
|
30
|
-
"name": "example"
|
|
31
|
-
}`
|
|
32
|
-
|
|
33
|
-
## Config
|
|
34
|
-
config pg {
|
|
35
|
-
host: $WP_PG_HOST || "localhost"
|
|
36
|
-
port: $WP_PG_PORT || "5432"
|
|
37
|
-
database: $WP_PG_DATABASE || "express-test"
|
|
38
|
-
user: $WP_PG_USER || "postgres"
|
|
39
|
-
password: $WP_PG_PASSWORD || "postgres"
|
|
40
|
-
ssl: false
|
|
41
|
-
initialPoolSize: 10
|
|
42
|
-
maxPoolSize: 20
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
config auth {
|
|
46
|
-
sessionTtl: 604800
|
|
47
|
-
cookieName: "wp_session"
|
|
48
|
-
cookieSecure: false
|
|
49
|
-
cookieHttpOnly: true
|
|
50
|
-
cookieSameSite: "Lax"
|
|
51
|
-
cookiePath: "/"
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
## Tests
|
|
55
|
-
|
|
56
|
-
GET /test-lua-env
|
|
57
|
-
|> lua: `
|
|
58
|
-
local env = getEnv("WP_PG_USER")
|
|
59
|
-
return {
|
|
60
|
-
env = env
|
|
61
|
-
}
|
|
62
|
-
`
|
|
63
|
-
|
|
64
|
-
pipeline getTeams =
|
|
65
|
-
|> jq: `{ sqlParams: [.params.id] }`
|
|
66
|
-
|> pg: `SELECT * FROM teams WHERE id = $1`
|
|
67
|
-
|
|
68
|
-
GET /hello
|
|
69
|
-
|> jq: `{ hello: "world" }`
|
|
70
|
-
|
|
71
|
-
GET /page/:id
|
|
72
|
-
|> pipeline: getTeams
|
|
73
|
-
|> jq: `{ team: .data.rows[0] }`
|
|
74
|
-
|
|
75
|
-
pg teamsQuery = `SELECT * FROM teams`
|
|
76
|
-
|
|
77
|
-
GET /teams
|
|
78
|
-
|> jq: `{ sqlParams: [] }`
|
|
79
|
-
|> pg: teamsQuery
|
|
80
|
-
|
|
81
|
-
# Testing standalone variables
|
|
82
|
-
describe "teamsQuery variable"
|
|
83
|
-
with mock pg.teamsQuery returning `{
|
|
84
|
-
"rows": [
|
|
85
|
-
{ "id": "1", "name": "Platform" },
|
|
86
|
-
{ "id": "2", "name": "Growth" },
|
|
87
|
-
{ "id": "3", "name": "Security" }
|
|
88
|
-
]
|
|
89
|
-
}`
|
|
90
|
-
|
|
91
|
-
it "returns all teams"
|
|
92
|
-
when executing variable pg teamsQuery
|
|
93
|
-
with input `{ "sqlParams": [] }`
|
|
94
|
-
then output equals `{
|
|
95
|
-
"rows": [
|
|
96
|
-
{ "id": "1", "name": "Platform" },
|
|
97
|
-
{ "id": "2", "name": "Growth" },
|
|
98
|
-
{ "id": "3", "name": "Security" }
|
|
99
|
-
]
|
|
100
|
-
}`
|
|
101
|
-
|
|
102
|
-
# Testing a pipeline directly
|
|
103
|
-
describe "getTeams pipeline"
|
|
104
|
-
with mock pg returning `{
|
|
105
|
-
"rows": [{ "id": "2", "name": "Growth", "created_at": "2024-01-20" }]
|
|
106
|
-
}`
|
|
107
|
-
|
|
108
|
-
it "transforms params and queries database"
|
|
109
|
-
when executing pipeline getTeams
|
|
110
|
-
with input `{ "params": { "id": "2" } }`
|
|
111
|
-
then output equals `{
|
|
112
|
-
"rows": [{ "id": "2", "name": "Growth", "created_at": "2024-01-20" }]
|
|
113
|
-
}`
|
|
114
|
-
|
|
115
|
-
it "handles string id parameter"
|
|
116
|
-
when executing pipeline getTeams
|
|
117
|
-
with input `{ "params": { "id": 42 } }`
|
|
118
|
-
and mock pg returning `{ "rows": [{ "id": 42, "name": "Marketing" }] }`
|
|
119
|
-
then output equals `{
|
|
120
|
-
"rows": [{ "id": 42, "name": "Marketing" }]
|
|
121
|
-
}`
|
|
122
|
-
|
|
123
|
-
it "supports jq equals on output subpath"
|
|
124
|
-
when executing pipeline getTeams
|
|
125
|
-
with input `{ "params": { "id": 42 } }`
|
|
126
|
-
and mock pg returning `{ "rows": [{ "id": 42, "name": "Marketing" }] }`
|
|
127
|
-
then output `.rows[0].id` equals 42
|
|
128
|
-
|
|
129
|
-
describe "test calling route"
|
|
130
|
-
it "calls the route"
|
|
131
|
-
when calling GET /hello
|
|
132
|
-
then status is 200
|
|
133
|
-
and output equals `{
|
|
134
|
-
"hello": "world"
|
|
135
|
-
}`
|
|
136
|
-
|
|
137
|
-
describe "jq assertions"
|
|
138
|
-
it "supports output contains for partial JSON"
|
|
139
|
-
when calling GET /hello
|
|
140
|
-
then output contains `{ "hello": "world" }`
|
|
141
|
-
|
|
142
|
-
it "supports output matches for HTML"
|
|
143
|
-
when calling GET /hello/world
|
|
144
|
-
then output matches `^<p>hello, .*</p>$`
|
|
145
|
-
|
|
146
|
-
it "supports status ranges"
|
|
147
|
-
when calling GET /hello
|
|
148
|
-
then status in 200..299
|
|
149
|
-
|
|
150
|
-
it "supports jq filter with map"
|
|
151
|
-
when executing pipeline getTeams
|
|
152
|
-
with input `{ "params": { "id": 2 } }`
|
|
153
|
-
and mock pg returning `{ "rows": [{ "id": 2, "name": "Growth" }, { "id": 3, "name": "Security" }] }`
|
|
154
|
-
then output `.rows | map(.id)` equals `[2, 3]`
|
|
155
|
-
|
|
156
|
-
GET /test
|
|
157
|
-
|> jq: `.`
|
|
158
|
-
|
|
159
|
-
GET /test2
|
|
160
|
-
|> lua: `return request`
|
|
161
|
-
|
|
162
|
-
GET /lua-cpu
|
|
163
|
-
|> lua: `
|
|
164
|
-
-- CPU-heavy Lua route for benchmarking (small response)
|
|
165
|
-
local total = 0
|
|
166
|
-
local sumsq = 0
|
|
167
|
-
for i = 1, 100000 do
|
|
168
|
-
total = total + i
|
|
169
|
-
sumsq = sumsq + (i * i)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
local function is_prime(n)
|
|
173
|
-
if n < 2 then return false end
|
|
174
|
-
local limit = math.floor(math.sqrt(n))
|
|
175
|
-
for d = 2, limit do
|
|
176
|
-
if (n % d) == 0 then return false end
|
|
177
|
-
end
|
|
178
|
-
return true
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
local primes = 0
|
|
182
|
-
for i = 2, 2000 do
|
|
183
|
-
if is_prime(i) then primes = primes + 1 end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
note = "CPU-bound Lua result (sum/sumsq/primes)",
|
|
188
|
-
sum = total,
|
|
189
|
-
sumsq = sumsq,
|
|
190
|
-
primesUpTo2000 = primes
|
|
191
|
-
}
|
|
192
|
-
`
|
|
193
|
-
|
|
194
|
-
GET /test3
|
|
195
|
-
|> jq: `{message: "Hello World", status: "success"}`
|
|
196
|
-
|> result
|
|
197
|
-
ok(200):
|
|
198
|
-
|> jq: `{
|
|
199
|
-
success: true,
|
|
200
|
-
data: .message,
|
|
201
|
-
timestamp: now
|
|
202
|
-
}`
|
|
203
|
-
default(500):
|
|
204
|
-
|> jq: `{
|
|
205
|
-
error: "Something went wrong",
|
|
206
|
-
timestamp: now
|
|
207
|
-
}`
|
|
208
|
-
|
|
209
|
-
GET /test4
|
|
210
|
-
|> jq: `{
|
|
211
|
-
errors: [
|
|
212
|
-
{
|
|
213
|
-
type: "validationError",
|
|
214
|
-
field: "email",
|
|
215
|
-
message: "Email is required",
|
|
216
|
-
code: "FIELD_REQUIRED"
|
|
217
|
-
}
|
|
218
|
-
]
|
|
219
|
-
}`
|
|
220
|
-
|> result
|
|
221
|
-
ok(200):
|
|
222
|
-
|> jq: `{success: true}`
|
|
223
|
-
validationError(400):
|
|
224
|
-
|> jq: `{
|
|
225
|
-
error: "Validation failed",
|
|
226
|
-
field: .errors[0].field,
|
|
227
|
-
message: .errors[0].message,
|
|
228
|
-
code: .errors[0].code
|
|
229
|
-
}`
|
|
230
|
-
default(500):
|
|
231
|
-
|> jq: `{error: "Internal server error"}`
|
|
232
|
-
|
|
233
|
-
GET /test5
|
|
234
|
-
|> jq: `{
|
|
235
|
-
errors: [
|
|
236
|
-
{
|
|
237
|
-
type: "authRequired",
|
|
238
|
-
header: "Authorization",
|
|
239
|
-
expected: "Bearer <token>",
|
|
240
|
-
provided: null
|
|
241
|
-
}
|
|
242
|
-
]
|
|
243
|
-
}`
|
|
244
|
-
|> result
|
|
245
|
-
ok(200):
|
|
246
|
-
|> jq: `{success: true, data: .result}`
|
|
247
|
-
authRequired(401):
|
|
248
|
-
|> jq: `{
|
|
249
|
-
error: "Authentication required",
|
|
250
|
-
header: .errors[0].header,
|
|
251
|
-
expected: .errors[0].expected,
|
|
252
|
-
provided: .errors[0].provided
|
|
253
|
-
}`
|
|
254
|
-
default(500):
|
|
255
|
-
|> jq: `{error: "Internal server error"}`
|
|
256
|
-
|
|
257
|
-
GET /test6
|
|
258
|
-
|> jq: `{
|
|
259
|
-
errors: [
|
|
260
|
-
{
|
|
261
|
-
type: "unknownError",
|
|
262
|
-
message: "This is a custom error type not handled explicitly"
|
|
263
|
-
}
|
|
264
|
-
]
|
|
265
|
-
}`
|
|
266
|
-
|> result
|
|
267
|
-
ok(200):
|
|
268
|
-
|> jq: `{success: true}`
|
|
269
|
-
validationError(400):
|
|
270
|
-
|> jq: `{error: "Validation error"}`
|
|
271
|
-
default(500):
|
|
272
|
-
|> jq: `{
|
|
273
|
-
error: "Unhandled error occurred",
|
|
274
|
-
type: .errors[0].type,
|
|
275
|
-
message: .errors[0].message
|
|
276
|
-
}`
|
|
277
|
-
|
|
278
|
-
GET /test7
|
|
279
|
-
|> lua: `
|
|
280
|
-
-- Test executeSql function in Lua
|
|
281
|
-
local result, err = executeSql("SELECT * FROM teams LIMIT 5")
|
|
282
|
-
|
|
283
|
-
if err then
|
|
284
|
-
return {
|
|
285
|
-
error = "Database error: " .. err,
|
|
286
|
-
sql = "SELECT * FROM teams LIMIT 5"
|
|
287
|
-
}
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
return {
|
|
291
|
-
message = "Lua executeSql test successful",
|
|
292
|
-
sql = "SELECT * FROM teams LIMIT 5",
|
|
293
|
-
data = result,
|
|
294
|
-
luaVersion = _VERSION
|
|
295
|
-
}
|
|
296
|
-
`
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
GET /test-sql-error
|
|
300
|
-
|> jq: `{ sqlParams: [] }`
|
|
301
|
-
|> pg: `SELECT * FROM nonexistent_table`
|
|
302
|
-
|> result
|
|
303
|
-
ok(200):
|
|
304
|
-
|> jq: `{success: true, data: .data}`
|
|
305
|
-
sqlError(500):
|
|
306
|
-
|> jq: `{
|
|
307
|
-
error: "Database error",
|
|
308
|
-
sqlstate: .errors[0].sqlstate,
|
|
309
|
-
message: .errors[0].message,
|
|
310
|
-
query: .errors[0].query
|
|
311
|
-
}`
|
|
312
|
-
default(500):
|
|
313
|
-
|> jq: `{error: "Internal server error"}`
|
|
314
|
-
|
|
315
|
-
POST /users
|
|
316
|
-
|> jq: `{
|
|
317
|
-
method: .method,
|
|
318
|
-
name: .body.name,
|
|
319
|
-
email: .body.email,
|
|
320
|
-
action: "create"
|
|
321
|
-
}`
|
|
322
|
-
|
|
323
|
-
PUT /users/:id
|
|
324
|
-
|> jq: `{
|
|
325
|
-
method: .method,
|
|
326
|
-
id: (.params.id | tonumber),
|
|
327
|
-
name: .body.name,
|
|
328
|
-
email: .body.email,
|
|
329
|
-
action: "update"
|
|
330
|
-
}`
|
|
331
|
-
|
|
332
|
-
PATCH /users/:id
|
|
333
|
-
|> jq: `{
|
|
334
|
-
method: .method,
|
|
335
|
-
id: (.params.id | tonumber),
|
|
336
|
-
body: .body,
|
|
337
|
-
action: "partial_update"
|
|
338
|
-
}`
|
|
339
|
-
|
|
340
|
-
POST /test-body
|
|
341
|
-
|> jq: `{
|
|
342
|
-
method: .method,
|
|
343
|
-
body: .body,
|
|
344
|
-
hasBody: (.body != null)
|
|
345
|
-
}`
|
|
346
|
-
|
|
347
|
-
PUT /test-body
|
|
348
|
-
|> jq: `{
|
|
349
|
-
method: .method,
|
|
350
|
-
body: .body,
|
|
351
|
-
hasBody: (.body != null)
|
|
352
|
-
}`
|
|
353
|
-
|
|
354
|
-
PATCH /test-body
|
|
355
|
-
|> jq: `{
|
|
356
|
-
method: .method,
|
|
357
|
-
body: .body,
|
|
358
|
-
hasBody: (.body != null)
|
|
359
|
-
}`
|
|
360
|
-
|
|
361
|
-
GET /hello-handlebars
|
|
362
|
-
|> jq: `{ name: "World", message: "Hello from handlebars!" }`
|
|
363
|
-
|> handlebars: `
|
|
364
|
-
<html>
|
|
365
|
-
<head>
|
|
366
|
-
<title>{{message}}</title>
|
|
367
|
-
</head>
|
|
368
|
-
<body>
|
|
369
|
-
<h1>{{message}}</h1>
|
|
370
|
-
<p>Hello, {{name}}!</p>
|
|
371
|
-
</body>
|
|
372
|
-
</html>
|
|
373
|
-
`
|
|
374
|
-
|
|
375
|
-
GET /handlebars-error-test
|
|
376
|
-
|> jq: `{ invalid: "data" }`
|
|
377
|
-
|> handlebars: `{{syntax_error`
|
|
378
|
-
|
|
379
|
-
handlebars cardPartial = `
|
|
380
|
-
<div class="card">
|
|
381
|
-
<h3>{{title}}</h3>
|
|
382
|
-
<p>{{description}}</p>
|
|
383
|
-
</div>
|
|
384
|
-
`
|
|
385
|
-
|
|
386
|
-
handlebars headerPartial = `
|
|
387
|
-
<header>
|
|
388
|
-
<h1>{{siteName}}</h1>
|
|
389
|
-
<nav>{{>navPartial}}</nav>
|
|
390
|
-
</header>
|
|
391
|
-
`
|
|
392
|
-
|
|
393
|
-
handlebars navPartial = `
|
|
394
|
-
<ul>
|
|
395
|
-
<li><a href="/">Home</a></li>
|
|
396
|
-
<li><a href="/about">About</a></li>
|
|
397
|
-
</ul>
|
|
398
|
-
`
|
|
399
|
-
|
|
400
|
-
GET /test-partials
|
|
401
|
-
|> jq: `{
|
|
402
|
-
title: "Welcome",
|
|
403
|
-
description: "This is a test card",
|
|
404
|
-
siteName: "My Website"
|
|
405
|
-
}`
|
|
406
|
-
|> handlebars: `
|
|
407
|
-
<html>
|
|
408
|
-
<head>
|
|
409
|
-
<title>{{siteName}}</title>
|
|
410
|
-
</head>
|
|
411
|
-
<body>
|
|
412
|
-
{{>headerPartial}}
|
|
413
|
-
<main>
|
|
414
|
-
{{>cardPartial}}
|
|
415
|
-
</main>
|
|
416
|
-
</body>
|
|
417
|
-
</html>
|
|
418
|
-
`
|
|
419
|
-
|
|
420
|
-
GET /test-missing-partial
|
|
421
|
-
|> jq: `{ title: "Test" }`
|
|
422
|
-
|> handlebars: `
|
|
423
|
-
<html>
|
|
424
|
-
<body>
|
|
425
|
-
{{>nonexistentPartial}}
|
|
426
|
-
</body>
|
|
427
|
-
</html>
|
|
428
|
-
`
|
|
429
|
-
|
|
430
|
-
GET /test-sql-error-handlebars
|
|
431
|
-
|> jq: `{ sqlParams: [] }`
|
|
432
|
-
|> pg: `SELECT * FROM nonexistent_table`
|
|
433
|
-
|> result
|
|
434
|
-
ok(200):
|
|
435
|
-
|> jq: `{success: true, data: .data}`
|
|
436
|
-
sqlError(500):
|
|
437
|
-
|> jq: `{
|
|
438
|
-
error: "Database error",
|
|
439
|
-
sqlstate: .errors[0].sqlstate,
|
|
440
|
-
message: .errors[0].message,
|
|
441
|
-
query: .errors[0].query
|
|
442
|
-
}`
|
|
443
|
-
|> handlebars: `{{error}}`
|
|
444
|
-
default(500):
|
|
445
|
-
|> jq: `{error: "Internal server error"}`
|
|
446
|
-
|> handlebars: `{{error}}`
|
|
447
|
-
|
|
448
|
-
GET /cookies
|
|
449
|
-
|> jq: `{
|
|
450
|
-
message: "Cookie test response",
|
|
451
|
-
cookies: .cookies,
|
|
452
|
-
setCookies: [
|
|
453
|
-
"sessionId=abc123; HttpOnly; Secure; Max-Age=3600",
|
|
454
|
-
"userId=john; Max-Age=86400",
|
|
455
|
-
"theme=dark; Path=/"
|
|
456
|
-
]
|
|
457
|
-
}`
|
|
458
|
-
|
|
459
|
-
GET /auth/status
|
|
460
|
-
|> auth: "optional"
|
|
461
|
-
|> jq: `{
|
|
462
|
-
authenticated: (.user != null),
|
|
463
|
-
user: .user,
|
|
464
|
-
message: if .user then "User is authenticated" else "User is not authenticated" end
|
|
465
|
-
}`
|
|
466
|
-
|
|
467
|
-
GET /debug/users
|
|
468
|
-
|> jq: `{ sqlParams: [] }`
|
|
469
|
-
|> pg: `SELECT id, login, email, type, status, created_at FROM users LIMIT 5`
|
|
470
|
-
|> jq: `{
|
|
471
|
-
message: "Users in database",
|
|
472
|
-
users: .data.rows,
|
|
473
|
-
count: (.data.rows | length)
|
|
474
|
-
}`
|
|
475
|
-
|
|
476
|
-
GET /debug/test-user
|
|
477
|
-
|> jq: `{ sqlParams: ["admin"] }`
|
|
478
|
-
|> pg: `SELECT id, login, password_hash, email, type, status FROM users WHERE login = $1 AND status = 'active'`
|
|
479
|
-
|> jq: `{
|
|
480
|
-
message: "Test user lookup for 'admin'",
|
|
481
|
-
found: (.data.rows | length > 0),
|
|
482
|
-
user: if (.data.rows | length > 0) then .data.rows[0] else null end
|
|
483
|
-
}`
|
|
484
|
-
|
|
485
|
-
GET /debug/users-schema
|
|
486
|
-
|> jq: `{ sqlParams: [] }`
|
|
487
|
-
|> pg: `SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'users' ORDER BY ordinal_position`
|
|
488
|
-
|> jq: `{
|
|
489
|
-
message: "Users table schema",
|
|
490
|
-
columns: .data.rows
|
|
491
|
-
}`
|
|
492
|
-
|
|
493
|
-
POST /debug/test-hash
|
|
494
|
-
|> auth: "register"
|
|
495
|
-
|> jq: `{
|
|
496
|
-
message: "Test hash generation (this will fail but show us what's happening)",
|
|
497
|
-
body: .body,
|
|
498
|
-
errors: .errors
|
|
499
|
-
}`
|
|
500
|
-
|
|
501
|
-
config cache {
|
|
502
|
-
enabled: true
|
|
503
|
-
defaultTtl: 60
|
|
504
|
-
maxCacheSize: 10485760
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
GET /cache-test
|
|
508
|
-
|> cache: `
|
|
509
|
-
ttl: 10
|
|
510
|
-
enabled: true
|
|
511
|
-
`
|
|
512
|
-
|> jq: `{
|
|
513
|
-
message: "Hello from cache test!",
|
|
514
|
-
timestamp: now,
|
|
515
|
-
random: (now % 1000)
|
|
516
|
-
}`
|
|
517
|
-
|
|
518
|
-
GET /no-cache-test
|
|
519
|
-
|> jq: `{
|
|
520
|
-
message: "No cache here",
|
|
521
|
-
timestamp: now,
|
|
522
|
-
random: (now % 1000)
|
|
523
|
-
}`
|
|
524
|
-
|
|
525
|
-
GET /slow-cached-test
|
|
526
|
-
|> cache: `
|
|
527
|
-
ttl: 30
|
|
528
|
-
enabled: true
|
|
529
|
-
`
|
|
530
|
-
|> jq: `{ sqlParams: [] }`
|
|
531
|
-
|> pg: `SELECT pg_sleep(0.25), 'Slow database operation completed!' as message, now() as timestamp`
|
|
532
|
-
|> jq: `{
|
|
533
|
-
message: .data.rows[0].message,
|
|
534
|
-
timestamp: .data.rows[0].timestamp,
|
|
535
|
-
cached: true,
|
|
536
|
-
note: "This query sleeps for 250ms - should be fast on cache hit!"
|
|
537
|
-
}`
|
|
538
|
-
|
|
539
|
-
GET /slow-uncached-test
|
|
540
|
-
|> jq: `{ sqlParams: [] }`
|
|
541
|
-
|> pg: `SELECT pg_sleep(0.25), 'Slow database operation completed!' as message, now() as timestamp`
|
|
542
|
-
|> jq: `{
|
|
543
|
-
message: .data.rows[0].message,
|
|
544
|
-
timestamp: .data.rows[0].timestamp,
|
|
545
|
-
cached: false,
|
|
546
|
-
note: "This query sleeps for 250ms - always slow!"
|
|
547
|
-
}`
|
|
548
|
-
|
|
549
|
-
GET /very-slow-cached-test
|
|
550
|
-
|> cache: `
|
|
551
|
-
ttl: 60
|
|
552
|
-
enabled: true
|
|
553
|
-
`
|
|
554
|
-
|> jq: `{ sqlParams: [] }`
|
|
555
|
-
|> pg: `SELECT pg_sleep(1.0), 'Very slow database operation completed!' as message, now() as timestamp, random() as random_value`
|
|
556
|
-
|> jq: `{
|
|
557
|
-
message: .data.rows[0].message,
|
|
558
|
-
timestamp: .data.rows[0].timestamp,
|
|
559
|
-
random_value: .data.rows[0].random_value,
|
|
560
|
-
cached: true,
|
|
561
|
-
note: "This query sleeps for 1 second - cache makes a huge difference!"
|
|
562
|
-
}`
|
|
563
|
-
|
|
564
|
-
GET /user/:id/profile
|
|
565
|
-
|> cache: `
|
|
566
|
-
keyTemplate: user-profile-{params.id}
|
|
567
|
-
ttl: 30
|
|
568
|
-
enabled: true
|
|
569
|
-
`
|
|
570
|
-
|> jq: `{ sqlParams: [.params.id] }`
|
|
571
|
-
|> pg: `SELECT pg_sleep(0.5), $1 as user_id, 'User profile data' as profile_type, now() as fetched_at`
|
|
572
|
-
|> jq: `{
|
|
573
|
-
user_id: .data.rows[0].user_id,
|
|
574
|
-
profile_type: .data.rows[0].profile_type,
|
|
575
|
-
fetched_at: .data.rows[0].fetched_at,
|
|
576
|
-
cache_key_used: "user-profile-" + (.originalRequest.params.id | tostring),
|
|
577
|
-
note: "Cache key includes user ID from URL parameter"
|
|
578
|
-
}`
|
|
579
|
-
|
|
580
|
-
GET /api/search
|
|
581
|
-
|> log: `level: debug, includeBody: true, includeHeaders: true`
|
|
582
|
-
|> cache: `
|
|
583
|
-
keyTemplate: search-{query.q}-{query.category}
|
|
584
|
-
ttl: 15
|
|
585
|
-
enabled: true
|
|
586
|
-
`
|
|
587
|
-
|> jq: `{
|
|
588
|
-
sqlParams: [.query.q // "default", .query.category // "all"],
|
|
589
|
-
search_term: .query.q,
|
|
590
|
-
search_category: .query.category
|
|
591
|
-
}`
|
|
592
|
-
|> pg: `SELECT pg_sleep(0.3), $1 as term, $2 as category, 'Search results' as result_type, now() as searched_at`
|
|
593
|
-
|> jq: `{
|
|
594
|
-
search_term: .data.rows[0].term,
|
|
595
|
-
category: .data.rows[0].category,
|
|
596
|
-
result_type: .data.rows[0].result_type,
|
|
597
|
-
searched_at: .data.rows[0].searched_at,
|
|
598
|
-
cache_key_used: "search-" + (.originalRequest.query.q | tostring) + "-" + (.originalRequest.query.category | tostring),
|
|
599
|
-
note: "Cache key varies by search term and category query parameters"
|
|
600
|
-
}`
|
|
601
|
-
|
|
602
|
-
config log {
|
|
603
|
-
enabled: true
|
|
604
|
-
format: "json"
|
|
605
|
-
level: "debug"
|
|
606
|
-
includeBody: false
|
|
607
|
-
includeHeaders: true
|
|
608
|
-
maxBodySize: 1024
|
|
609
|
-
timestamp: true
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
GET /api/users/:id
|
|
614
|
-
|> log: `level: debug, includeBody: true, includeHeaders: true`
|
|
615
|
-
|> pg: `SELECT * FROM users WHERE id = $1`
|
|
616
|
-
|
|
617
|
-
## Todos App
|
|
618
|
-
|
|
619
|
-
# Default partials to avoid missing-partial errors and enable overrides
|
|
620
|
-
handlebars title = `Default Title`
|
|
621
|
-
handlebars headExtras = ``
|
|
622
|
-
handlebars content = `Default content`
|
|
623
|
-
handlebars footerScripts = ``
|
|
624
|
-
handlebars pageTitle = `Page Title`
|
|
625
|
-
handlebars pageContent = ``
|
|
626
|
-
handlebars authHeader = ``
|
|
627
|
-
|
|
628
|
-
handlebars baseLayout = `
|
|
629
|
-
<!DOCTYPE html>
|
|
630
|
-
<html lang="en">
|
|
631
|
-
<head>
|
|
632
|
-
<meta charset="UTF-8">
|
|
633
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
634
|
-
<title>{{> title}}</title>
|
|
635
|
-
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
636
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
637
|
-
{{> headExtras}}
|
|
638
|
-
</head>
|
|
639
|
-
<body class="bg-gray-100 font-sans">
|
|
640
|
-
{{> content}}
|
|
641
|
-
{{> footerScripts}}
|
|
642
|
-
</body>
|
|
643
|
-
</html>
|
|
644
|
-
`
|
|
645
|
-
|
|
646
|
-
handlebars authLayout = `
|
|
647
|
-
{{#*inline "authHeader"}}
|
|
648
|
-
<div class="flex justify-between items-center border-b-2 border-blue-500 pb-3 mb-6">
|
|
649
|
-
<h1 class="text-3xl font-bold text-gray-800">{{> pageTitle}}</h1>
|
|
650
|
-
<div class="flex items-center space-x-4">
|
|
651
|
-
<span class="text-gray-600">Welcome, {{user.login}}!</span>
|
|
652
|
-
<button hx-post="/logout" hx-swap="none" hx-on::after-request="window.location.href='/login-page'" class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Logout</button>
|
|
653
|
-
</div>
|
|
654
|
-
</div>
|
|
655
|
-
{{/inline}}
|
|
656
|
-
{{#*inline "content"}}
|
|
657
|
-
<div class="max-w-4xl mx-auto p-6">
|
|
658
|
-
<div class="bg-white p-6 rounded-lg shadow-md">
|
|
659
|
-
{{> authHeader}}
|
|
660
|
-
{{> pageContent}}
|
|
661
|
-
</div>
|
|
662
|
-
</div>
|
|
663
|
-
{{/inline}}
|
|
664
|
-
{{> baseLayout}}
|
|
665
|
-
`
|
|
666
|
-
POST /login
|
|
667
|
-
|> validate: `{
|
|
668
|
-
login: string(3..50),
|
|
669
|
-
password: string(6..100)
|
|
670
|
-
}`
|
|
671
|
-
|> auth: "login"
|
|
672
|
-
|> result
|
|
673
|
-
ok(200):
|
|
674
|
-
|> jq: `{
|
|
675
|
-
success: true,
|
|
676
|
-
message: "Login successful",
|
|
677
|
-
user: {
|
|
678
|
-
id: .user.id | tostring,
|
|
679
|
-
login: .user.login,
|
|
680
|
-
email: .user.email,
|
|
681
|
-
type: .user.type
|
|
682
|
-
}
|
|
683
|
-
}`
|
|
684
|
-
validationError(400):
|
|
685
|
-
|> jq: `{
|
|
686
|
-
success: false,
|
|
687
|
-
error: "Validation failed",
|
|
688
|
-
field: .errors[0].context,
|
|
689
|
-
message: .errors[0].message
|
|
690
|
-
}`
|
|
691
|
-
|> handlebars: `
|
|
692
|
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
|
693
|
-
<strong class="font-bold">Validation Error:</strong>
|
|
694
|
-
<span class="block sm:inline">{{message}}</span>
|
|
695
|
-
</div>
|
|
696
|
-
`
|
|
697
|
-
authError(401):
|
|
698
|
-
|> jq: `{
|
|
699
|
-
success: false,
|
|
700
|
-
error: "Login failed",
|
|
701
|
-
message: .errors[0].message,
|
|
702
|
-
context: .errors[0].context,
|
|
703
|
-
fullError: .errors[0]
|
|
704
|
-
}`
|
|
705
|
-
|> handlebars: `
|
|
706
|
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
|
707
|
-
<strong class="font-bold">Login Failed:</strong>
|
|
708
|
-
<span class="block sm:inline">{{message}}</span>
|
|
709
|
-
{{#context}}<br><small>Context: {{context}}</small>{{/context}}
|
|
710
|
-
</div>
|
|
711
|
-
`
|
|
712
|
-
|
|
713
|
-
POST /logout
|
|
714
|
-
|> auth: "logout"
|
|
715
|
-
|> result
|
|
716
|
-
ok(200):
|
|
717
|
-
|> jq: `{
|
|
718
|
-
success: true,
|
|
719
|
-
message: "Logged out successfully"
|
|
720
|
-
}`
|
|
721
|
-
authError(401):
|
|
722
|
-
|> jq: `{
|
|
723
|
-
success: false,
|
|
724
|
-
error: "Logout failed",
|
|
725
|
-
message: .errors[0].message
|
|
726
|
-
}`
|
|
727
|
-
|
|
728
|
-
POST /register
|
|
729
|
-
|> validate: `{
|
|
730
|
-
login: string(3..50),
|
|
731
|
-
email: email,
|
|
732
|
-
password: string(8..100)
|
|
733
|
-
}`
|
|
734
|
-
|> auth: "register"
|
|
735
|
-
|> result
|
|
736
|
-
ok(201):
|
|
737
|
-
|> jq: `{
|
|
738
|
-
success: true,
|
|
739
|
-
message: "Registration successful"
|
|
740
|
-
}`
|
|
741
|
-
|> handlebars: `
|
|
742
|
-
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
|
743
|
-
<strong class="font-bold">Success!</strong>
|
|
744
|
-
<span class="block sm:inline">Account created successfully. Redirecting to login...</span>
|
|
745
|
-
</div>
|
|
746
|
-
`
|
|
747
|
-
validationError(400):
|
|
748
|
-
|> jq: `{
|
|
749
|
-
success: false,
|
|
750
|
-
error: "Validation failed",
|
|
751
|
-
field: .errors[0].context,
|
|
752
|
-
message: .errors[0].message
|
|
753
|
-
}`
|
|
754
|
-
|> handlebars: `
|
|
755
|
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
|
756
|
-
<strong class="font-bold">Validation Error:</strong>
|
|
757
|
-
<span class="block sm:inline">{{message}}</span>
|
|
758
|
-
</div>
|
|
759
|
-
`
|
|
760
|
-
authError(409):
|
|
761
|
-
|> jq: `{
|
|
762
|
-
success: false,
|
|
763
|
-
error: "Registration failed",
|
|
764
|
-
message: .errors[0].message
|
|
765
|
-
}`
|
|
766
|
-
|> handlebars: `
|
|
767
|
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
|
768
|
-
<strong class="font-bold">Registration Failed:</strong>
|
|
769
|
-
<span class="block sm:inline">{{message}}</span>
|
|
770
|
-
</div>
|
|
771
|
-
`
|
|
772
|
-
|
|
773
|
-
GET /login-page
|
|
774
|
-
|> jq: `{
|
|
775
|
-
pageTitle: "Login - Todo App",
|
|
776
|
-
message: "Please log in to access your todos"
|
|
777
|
-
}`
|
|
778
|
-
|> handlebars: `
|
|
779
|
-
{{#*inline "content"}}
|
|
780
|
-
<body class="bg-gray-100 font-sans">
|
|
781
|
-
<div class="max-w-md mx-auto mt-20 p-6">
|
|
782
|
-
<div class="bg-white p-8 rounded-lg shadow-md">
|
|
783
|
-
<div class="text-center mb-8">
|
|
784
|
-
<h1 class="text-3xl font-bold text-gray-800 mb-2">Welcome Back</h1>
|
|
785
|
-
<p class="text-gray-600">{{message}}</p>
|
|
786
|
-
</div>
|
|
787
|
-
|
|
788
|
-
<div id="login-response" class="mb-4"></div>
|
|
789
|
-
|
|
790
|
-
<form hx-post="/login" hx-target="#login-response" hx-swap="innerHTML" class="space-y-4">
|
|
791
|
-
<div>
|
|
792
|
-
<label for="login" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
|
793
|
-
<input type="text" id="login" name="login" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your username">
|
|
794
|
-
</div>
|
|
795
|
-
<div>
|
|
796
|
-
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
|
797
|
-
<input type="password" id="password" name="password" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your password">
|
|
798
|
-
</div>
|
|
799
|
-
<button type="submit" class="w-full bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
|
800
|
-
Sign In
|
|
801
|
-
</button>
|
|
802
|
-
</form>
|
|
803
|
-
|
|
804
|
-
<div class="mt-6 text-center">
|
|
805
|
-
<p class="text-sm text-gray-600">
|
|
806
|
-
Don't have an account?
|
|
807
|
-
<a href="/register-page" class="text-blue-500 hover:text-blue-600 font-medium">Sign up</a>
|
|
808
|
-
</p>
|
|
809
|
-
</div>
|
|
810
|
-
|
|
811
|
-
<div class="mt-4 text-center">
|
|
812
|
-
<a href="/hello" class="text-sm text-gray-500 hover:text-gray-700">← Back to Home</a>
|
|
813
|
-
</div>
|
|
814
|
-
</div>
|
|
815
|
-
</div>
|
|
816
|
-
|
|
817
|
-
<script>
|
|
818
|
-
// Redirect to todos on successful login
|
|
819
|
-
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
820
|
-
if (event.detail.xhr.status === 200 && event.detail.target.id === 'login-response') {
|
|
821
|
-
try {
|
|
822
|
-
const response = JSON.parse(event.detail.xhr.responseText);
|
|
823
|
-
if (response.success) {
|
|
824
|
-
window.location.href = '/todos';
|
|
825
|
-
}
|
|
826
|
-
} catch (e) {
|
|
827
|
-
// If not JSON, might be an error message
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
});
|
|
831
|
-
</script>
|
|
832
|
-
<!-- content -->
|
|
833
|
-
{{/inline}}
|
|
834
|
-
{{> baseLayout}}
|
|
835
|
-
`
|
|
836
|
-
|
|
837
|
-
GET /register-page
|
|
838
|
-
|> jq: `{
|
|
839
|
-
pageTitle: "Register - Todo App",
|
|
840
|
-
message: "Create your account to get started"
|
|
841
|
-
}`
|
|
842
|
-
|> handlebars: `
|
|
843
|
-
{{#*inline "content"}}
|
|
844
|
-
<div class="max-w-md mx-auto mt-20 p-6">
|
|
845
|
-
<div class="bg-white p-8 rounded-lg shadow-md">
|
|
846
|
-
<div class="text-center mb-8">
|
|
847
|
-
<h1 class="text-3xl font-bold text-gray-800 mb-2">Create Account</h1>
|
|
848
|
-
<p class="text-gray-600">{{message}}</p>
|
|
849
|
-
</div>
|
|
850
|
-
|
|
851
|
-
<div id="register-response" class="mb-4"></div>
|
|
852
|
-
|
|
853
|
-
<form hx-post="/register" hx-target="#register-response" hx-swap="innerHTML" class="space-y-4">
|
|
854
|
-
<div>
|
|
855
|
-
<label for="login" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
|
856
|
-
<input type="text" id="login" name="login" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Choose a username (3-50 chars)">
|
|
857
|
-
</div>
|
|
858
|
-
<div>
|
|
859
|
-
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
|
860
|
-
<input type="email" id="email" name="email" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your email">
|
|
861
|
-
</div>
|
|
862
|
-
<div>
|
|
863
|
-
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
|
864
|
-
<input type="password" id="password" name="password" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Choose a password (8+ chars)">
|
|
865
|
-
</div>
|
|
866
|
-
<button type="submit" class="w-full bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
|
867
|
-
Create Account
|
|
868
|
-
</button>
|
|
869
|
-
</form>
|
|
870
|
-
|
|
871
|
-
<div class="mt-6 text-center">
|
|
872
|
-
<p class="text-sm text-gray-600">
|
|
873
|
-
Already have an account?
|
|
874
|
-
<a href="/login-page" class="text-blue-500 hover:text-blue-600 font-medium">Sign in</a>
|
|
875
|
-
</p>
|
|
876
|
-
</div>
|
|
877
|
-
|
|
878
|
-
<div class="mt-4 text-center">
|
|
879
|
-
<a href="/hello" class="text-sm text-gray-500 hover:text-gray-700">← Back to Home</a>
|
|
880
|
-
</div>
|
|
881
|
-
</div>
|
|
882
|
-
</div>
|
|
883
|
-
|
|
884
|
-
<script>
|
|
885
|
-
// Redirect to login on successful registration
|
|
886
|
-
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
887
|
-
if (event.detail.xhr.status === 200 && event.detail.target.id === 'register-response') {
|
|
888
|
-
try {
|
|
889
|
-
const response = JSON.parse(event.detail.xhr.responseText);
|
|
890
|
-
if (response.success) {
|
|
891
|
-
window.location.href = '/login-page';
|
|
892
|
-
}
|
|
893
|
-
} catch (e) {
|
|
894
|
-
// If not JSON, might be an error message
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
});
|
|
898
|
-
</script>
|
|
899
|
-
{{/inline}}
|
|
900
|
-
{{> baseLayout}}
|
|
901
|
-
`
|
|
902
|
-
|
|
903
|
-
handlebars loginRequiredLayout = `
|
|
904
|
-
{{#*inline "title"}}Login Required{{/inline}}
|
|
905
|
-
{{#*inline "content"}}
|
|
906
|
-
<div class="max-w-md mx-auto mt-20 p-6">
|
|
907
|
-
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
|
908
|
-
<div class="mb-6">
|
|
909
|
-
<svg class="mx-auto h-16 w-16 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
910
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
|
911
|
-
</svg>
|
|
912
|
-
</div>
|
|
913
|
-
<h1 class="text-2xl font-bold text-gray-800 mb-4">Authentication Required</h1>
|
|
914
|
-
<p class="text-gray-600 mb-6">You need to log in to access your todo list.</p>
|
|
915
|
-
<div class="space-y-3">
|
|
916
|
-
<a href="/login-page" class="block w-full bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Go to Login</a>
|
|
917
|
-
<a href="/hello" class="block w-full bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Go to Home</a>
|
|
918
|
-
</div>
|
|
919
|
-
</div>
|
|
920
|
-
</div>
|
|
921
|
-
{{/inline}}
|
|
922
|
-
{{> baseLayout}}
|
|
923
|
-
`
|
|
924
|
-
|
|
925
|
-
handlebars errorAlert = `
|
|
926
|
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 mt-4" role="alert">
|
|
927
|
-
<strong class="font-bold">{{> errorTitle}}:</strong>
|
|
928
|
-
<span class="block sm:inline">{{> errorMessage}}</span>
|
|
929
|
-
</div>
|
|
930
|
-
`
|
|
931
|
-
|
|
932
|
-
GET /todos
|
|
933
|
-
|> auth: "required"
|
|
934
|
-
|> result
|
|
935
|
-
ok(200):
|
|
936
|
-
|> jq: `. + { sqlParams: [.user.id] }`
|
|
937
|
-
|> pg: `SELECT id, title, completed, created_at, updated_at FROM todos WHERE user_id = $1 ORDER BY created_at DESC`
|
|
938
|
-
|> jq: `. + {
|
|
939
|
-
todos: .data.rows | map(. + {id: (.id | tostring)}),
|
|
940
|
-
pageTitle: "Todo List"
|
|
941
|
-
}`
|
|
942
|
-
|> handlebars: `
|
|
943
|
-
{{#*inline "title"}}{{pageTitle}}{{/inline}}
|
|
944
|
-
{{#*inline "pageTitle"}}{{pageTitle}} - {{user.login}}{{/inline}}
|
|
945
|
-
{{#*inline "pageContent"}}
|
|
946
|
-
<div class="bg-gray-50 p-6 rounded-lg mb-6">
|
|
947
|
-
<h3 class="text-lg font-semibold mb-4">Add New Todo</h3>
|
|
948
|
-
<div id="form-errors"></div>
|
|
949
|
-
<form hx-post="/todos/add" hx-target="#form-response" hx-swap="innerHTML" hx-on::response-error="document.getElementById('form-response').innerHTML = event.detail.xhr.responseText" class="space-y-4">
|
|
950
|
-
<div>
|
|
951
|
-
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title * (3-30 characters)</label>
|
|
952
|
-
<input type="text" id="title" name="title" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
953
|
-
</div>
|
|
954
|
-
<button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">Add Todo</button>
|
|
955
|
-
</form>
|
|
956
|
-
<div id="form-response"></div>
|
|
957
|
-
</div>
|
|
958
|
-
|
|
959
|
-
<ul id="todo-list" class="space-y-3">
|
|
960
|
-
{{#each todos}}
|
|
961
|
-
{{> todoItemPartial}}
|
|
962
|
-
{{else}}
|
|
963
|
-
<li class="text-center text-gray-500 italic py-10">
|
|
964
|
-
<p>No todos yet. Add your first todo above!</p>
|
|
965
|
-
</li>
|
|
966
|
-
{{/each}}
|
|
967
|
-
</ul>
|
|
968
|
-
{{/inline}}
|
|
969
|
-
{{> authLayout}}
|
|
970
|
-
`
|
|
971
|
-
authError(401):
|
|
972
|
-
|> handlebars: `{{>loginRequiredLayout}}`
|
|
973
|
-
|
|
974
|
-
POST /todos/add
|
|
975
|
-
|> auth: "required"
|
|
976
|
-
|> result
|
|
977
|
-
ok(200):
|
|
978
|
-
|> validate: `
|
|
979
|
-
title: string(3..30)
|
|
980
|
-
`
|
|
981
|
-
|> jq: `. + {
|
|
982
|
-
sqlParams: [.body.title, false, .user.id]
|
|
983
|
-
}`
|
|
984
|
-
|> pg: `INSERT INTO todos (title, completed, user_id) VALUES ($1, $2, $3) RETURNING *`
|
|
985
|
-
|> result
|
|
986
|
-
ok(201):
|
|
987
|
-
|> jq: `(.data.rows[0] | . + {id: (.id | tostring)})`
|
|
988
|
-
|> handlebars: `
|
|
989
|
-
<div hx-swap-oob="afterbegin:#todo-list">
|
|
990
|
-
{{>todoItemPartial}}
|
|
991
|
-
</div>
|
|
992
|
-
<input type="text" id="title" name="title" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" value="" hx-swap-oob="true">
|
|
993
|
-
`
|
|
994
|
-
validationError(400):
|
|
995
|
-
|> jq: `{
|
|
996
|
-
error: "Validation failed",
|
|
997
|
-
field: .errors[0].field,
|
|
998
|
-
rule: .errors[0].rule,
|
|
999
|
-
message: .errors[0].message
|
|
1000
|
-
}`
|
|
1001
|
-
|> handlebars: `
|
|
1002
|
-
{{#*inline "errorTitle"}}Validation Error{{/inline}}
|
|
1003
|
-
{{#*inline "errorMessage"}}{{message}}{{/inline}}
|
|
1004
|
-
{{> errorAlert}}
|
|
1005
|
-
`
|
|
1006
|
-
authError(401):
|
|
1007
|
-
|> handlebars: `
|
|
1008
|
-
{{<errorAlert}}
|
|
1009
|
-
{{$errorTitle}}Authentication Required{{/errorTitle}}
|
|
1010
|
-
{{$errorMessage}}Please log in to add todos.{{/errorMessage}}
|
|
1011
|
-
{{/errorAlert}}
|
|
1012
|
-
`
|
|
1013
|
-
|
|
1014
|
-
POST /todos/:id/toggle
|
|
1015
|
-
|> auth: "required"
|
|
1016
|
-
|> result
|
|
1017
|
-
ok(200):
|
|
1018
|
-
|> jq: `{ sqlParams: [.params.id, .user.id], todoId: .params.id }`
|
|
1019
|
-
|> pg: `SELECT * FROM todos WHERE id = $1 AND user_id = $2`
|
|
1020
|
-
|> jq: `{
|
|
1021
|
-
sqlParams: [(.data.rows[0].completed | not), .todoId],
|
|
1022
|
-
currentTodo: .data.rows[0]
|
|
1023
|
-
}`
|
|
1024
|
-
|> pg: `UPDATE todos SET completed = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`
|
|
1025
|
-
|> jq: `.data.rows[0]`
|
|
1026
|
-
|> handlebars: `{{> todoItemPartial}}`
|
|
1027
|
-
authError(401):
|
|
1028
|
-
|> handlebars: `
|
|
1029
|
-
{{#*inline "errorTitle"}}Authentication Required{{/inline}}
|
|
1030
|
-
{{#*inline "errorMessage"}}Please log in to modify todos.{{/inline}}
|
|
1031
|
-
{{> errorAlert}}
|
|
1032
|
-
`
|
|
1033
|
-
|
|
1034
|
-
DELETE /todos/:id
|
|
1035
|
-
|> auth: "required"
|
|
1036
|
-
|> result
|
|
1037
|
-
ok(200):
|
|
1038
|
-
|> jq: `{ sqlParams: [.params.id, .user.id] }`
|
|
1039
|
-
|> pg: `DELETE FROM todos WHERE id = $1 AND user_id = $2`
|
|
1040
|
-
|> handlebars: ``
|
|
1041
|
-
authError(401):
|
|
1042
|
-
|> handlebars: `
|
|
1043
|
-
{{#*inline "errorTitle"}}Authentication Required{{/inline}}
|
|
1044
|
-
{{#*inline "errorMessage"}}Please log in to delete todos.{{/inline}}
|
|
1045
|
-
{{> errorAlert}}
|
|
1046
|
-
`
|
|
1047
|
-
|
|
1048
|
-
handlebars todoItemPartial = `
|
|
1049
|
-
<li class="{{#if completed}}bg-green-50 border-l-4 border-green-400{{else}}bg-gray-50 border-l-4 border-blue-400{{/if}} p-4 rounded-lg flex justify-between items-start">
|
|
1050
|
-
<div class="flex-1">
|
|
1051
|
-
<div class="{{#if completed}}text-gray-500 line-through{{else}}text-gray-800 font-medium{{/if}}">{{title}}</div>
|
|
1052
|
-
<div class="text-sm text-gray-500 mt-1">Created: {{created_at}}</div>
|
|
1053
|
-
</div>
|
|
1054
|
-
<div class="flex space-x-2 ml-4">
|
|
1055
|
-
{{#if completed}}
|
|
1056
|
-
<button hx-post="/todos/{{id}}/toggle" hx-target="closest li" hx-swap="outerHTML" class="px-3 py-1 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">Mark Incomplete</button>
|
|
1057
|
-
{{else}}
|
|
1058
|
-
<button hx-post="/todos/{{id}}/toggle" hx-target="closest li" hx-swap="outerHTML" class="px-3 py-1 text-sm bg-green-500 hover:bg-green-600 text-white rounded transition-colors">Mark Complete</button>
|
|
1059
|
-
{{/if}}
|
|
1060
|
-
<button hx-delete="/todos/{{id}}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Are you sure you want to delete this todo?" class="px-3 py-1 text-sm bg-red-500 hover:bg-red-600 text-white rounded transition-colors">Delete</button>
|
|
1061
|
-
</div>
|
|
1062
|
-
</li>
|
|
1063
|
-
`
|
|
1064
|
-
|
|
1065
|
-
## Test routes for new resultName functionality
|
|
1066
|
-
|
|
1067
|
-
# Test explicit resultName functionality
|
|
1068
|
-
GET /test/named-query/:id
|
|
1069
|
-
|> jq: `{ resultName: "userProfile" }`
|
|
1070
|
-
|> pg: `SELECT 1 as id, 'Test User' as name, 'test@example.com' as email`
|
|
1071
|
-
|> jq: `{ user: .data.userProfile }`
|
|
1072
|
-
|
|
1073
|
-
# Test multiple named results
|
|
1074
|
-
GET /test/multiple/:id
|
|
1075
|
-
|> jq: `{ resultName: "userInfo" }`
|
|
1076
|
-
|> pg: `SELECT 1 as id, 'Test User' as name`
|
|
1077
|
-
|> jq: `{ resultName: "userTeams" }`
|
|
1078
|
-
|> pg: `SELECT 1 as id, 'Engineering' as name UNION SELECT 2 as id, 'Product' as name`
|
|
1079
|
-
|> jq: `{
|
|
1080
|
-
profile: .data.userInfo,
|
|
1081
|
-
teams: .data.userTeams
|
|
1082
|
-
}`
|
|
1083
|
-
|
|
1084
|
-
# Test auto-naming with variables (should now use variable name as result key)
|
|
1085
|
-
pg getUserQuery = `SELECT 1 as id, 'Auto Named User' as name`
|
|
1086
|
-
pg getTeamsQuery = `SELECT 1 as id, 'Auto Team' as name`
|
|
1087
|
-
|
|
1088
|
-
GET /test/auto-naming/:id
|
|
1089
|
-
|> pg: getUserQuery
|
|
1090
|
-
|> pg: getTeamsQuery
|
|
1091
|
-
|> jq: `{
|
|
1092
|
-
user: .data.getUserQuery,
|
|
1093
|
-
teams: .data.getTeamsQuery,
|
|
1094
|
-
hasUserData: (.data.getUserQuery != null),
|
|
1095
|
-
hasTeamsData: (.data.getTeamsQuery != null)
|
|
1096
|
-
}`
|
|
1097
|
-
|
|
1098
|
-
# Test that legacy behavior is preserved (no resultName, no variable)
|
|
1099
|
-
GET /test/legacy/:id
|
|
1100
|
-
|> pg: `SELECT 1 as id, 'Legacy User' as name`
|
|
1101
|
-
|
|
1102
|
-
# Test Fetch Middleware
|
|
1103
|
-
|
|
1104
|
-
## Basic GET request
|
|
1105
|
-
GET /test-fetch
|
|
1106
|
-
|> fetch: `https://api.github.com/zen`
|
|
1107
|
-
|
|
1108
|
-
## GET with fetchUrl override
|
|
1109
|
-
GET /test-fetch-override
|
|
1110
|
-
|> jq: `{ fetchUrl: "https://api.github.com/zen" }`
|
|
1111
|
-
|> fetch: `https://example.com`
|
|
1112
|
-
|
|
1113
|
-
## POST with body
|
|
1114
|
-
GET /test-fetch-post
|
|
1115
|
-
|> jq: `{
|
|
1116
|
-
fetchMethod: "POST",
|
|
1117
|
-
fetchBody: { name: "test", value: 123 },
|
|
1118
|
-
fetchHeaders: { "Content-Type": "application/json" }
|
|
1119
|
-
}`
|
|
1120
|
-
|> fetch: `https://api.github.com/zen`
|
|
1121
|
-
|
|
1122
|
-
## Named result
|
|
1123
|
-
GET /test-fetch-named
|
|
1124
|
-
|> jq: `{ resultName: "apiCall" }`
|
|
1125
|
-
|> fetch: `https://api.github.com/zen`
|
|
1126
|
-
|> jq: `{
|
|
1127
|
-
response: .data.apiCall.response,
|
|
1128
|
-
status: .data.apiCall.status,
|
|
1129
|
-
success: (.data.apiCall.status == 200)
|
|
1130
|
-
}`
|
|
1131
|
-
|
|
1132
|
-
## Test error handling
|
|
1133
|
-
GET /test-fetch-error
|
|
1134
|
-
|> fetch: `https://nonexistent-domain-12345.com`
|
|
1135
|
-
|
|
1136
|
-
## Test timeout (if httpbin is slow)
|
|
1137
|
-
GET /test-fetch-timeout
|
|
1138
|
-
|> jq: `{ fetchTimeout: 1 }`
|
|
1139
|
-
|> fetch: `https://httpbin.org/delay/5`
|