spiceflow 1.17.11 → 1.18.0
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/README.md +168 -3
- package/dist/client/errors.d.ts +2 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +3 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/fetch.d.ts +86 -0
- package/dist/client/fetch.d.ts.map +1 -0
- package/dist/client/fetch.js +143 -0
- package/dist/client/fetch.js.map +1 -0
- package/dist/client/index.d.ts +4 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +39 -151
- package/dist/client/index.js.map +1 -1
- package/dist/client/shared.d.ts +47 -0
- package/dist/client/shared.d.ts.map +1 -0
- package/dist/client/shared.js +314 -0
- package/dist/client/shared.js.map +1 -0
- package/dist/client/types.d.ts +3 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client.test.js +43 -0
- package/dist/client.test.js.map +1 -1
- package/dist/fetch-client.test.d.ts +2 -0
- package/dist/fetch-client.test.d.ts.map +1 -0
- package/dist/fetch-client.test.js +362 -0
- package/dist/fetch-client.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-client-transport.d.ts.map +1 -1
- package/dist/mcp-client-transport.js +5 -2
- package/dist/mcp-client-transport.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/spiceflow.d.ts +36 -14
- package/dist/spiceflow.d.ts.map +1 -1
- package/dist/spiceflow.js +49 -16
- package/dist/spiceflow.js.map +1 -1
- package/dist/spiceflow.test.js +205 -1
- package/dist/spiceflow.test.js.map +1 -1
- package/dist/stream.test.js +1 -1
- package/dist/stream.test.js.map +1 -1
- package/package.json +3 -3
- package/src/client/errors.ts +3 -0
- package/src/client/fetch.ts +447 -0
- package/src/client/index.ts +73 -192
- package/src/client/shared.ts +406 -0
- package/src/client/types.ts +3 -1
- package/src/client.test.ts +52 -0
- package/src/fetch-client.test.ts +411 -0
- package/src/index.ts +1 -1
- package/src/mcp-client-transport.ts +5 -2
- package/src/spiceflow.test.ts +315 -1
- package/src/spiceflow.ts +106 -32
- package/src/stream.test.ts +1 -1
package/src/spiceflow.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, describe, expect } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { bfs, cloneDeep, Spiceflow } from './spiceflow.ts'
|
|
3
|
+
import { bfs, cloneDeep, createSafePath, Spiceflow } from './spiceflow.ts'
|
|
4
4
|
import { z } from 'zod'
|
|
5
5
|
import { createSpiceflowClient } from './client/index.ts'
|
|
6
6
|
|
|
@@ -1257,6 +1257,320 @@ describe('safePath', () => {
|
|
|
1257
1257
|
expect(res.status).toBe(200)
|
|
1258
1258
|
expect(await res.json()).toBe('/target')
|
|
1259
1259
|
})
|
|
1260
|
+
|
|
1261
|
+
test('safePath appends query params', () => {
|
|
1262
|
+
const app = new Spiceflow()
|
|
1263
|
+
.get('/search', () => 'results', {
|
|
1264
|
+
query: z.object({ q: z.string(), page: z.coerce.number() }),
|
|
1265
|
+
})
|
|
1266
|
+
.get('/users/:id', ({ params }) => params.id, {
|
|
1267
|
+
query: z.object({ fields: z.string() }),
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
expect(app.safePath('/search', { q: 'hello', page: 1 })).toBe(
|
|
1271
|
+
'/search?q=hello&page=1',
|
|
1272
|
+
)
|
|
1273
|
+
expect(
|
|
1274
|
+
app.safePath('/users/:id', { id: '42', fields: 'name' }),
|
|
1275
|
+
).toBe('/users/42?fields=name')
|
|
1276
|
+
|
|
1277
|
+
// @ts-expect-error - invalid query key 'invalid' not in schema
|
|
1278
|
+
app.safePath('/search', { invalid: 'x' })
|
|
1279
|
+
|
|
1280
|
+
// @ts-expect-error - invalid query key 'nonexistent' not in schema
|
|
1281
|
+
app.safePath('/users/:id', { id: '1', nonexistent: 'x' })
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
test('safePath with query params and no path params', () => {
|
|
1285
|
+
const app = new Spiceflow().get('/items', () => 'items', {
|
|
1286
|
+
query: z.object({ sort: z.string(), limit: z.coerce.number() }),
|
|
1287
|
+
})
|
|
1288
|
+
|
|
1289
|
+
expect(
|
|
1290
|
+
app.safePath('/items', { sort: 'date', limit: 10 }),
|
|
1291
|
+
).toBe('/items?sort=date&limit=10')
|
|
1292
|
+
|
|
1293
|
+
// @ts-expect-error - wrong query key
|
|
1294
|
+
app.safePath('/items', { order: 'asc' })
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
test('safePath without query still works', () => {
|
|
1298
|
+
const app = new Spiceflow()
|
|
1299
|
+
.get('/simple', () => 'simple')
|
|
1300
|
+
.get('/with-query', () => 'q', {
|
|
1301
|
+
query: z.object({ x: z.string() }),
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1304
|
+
expect(app.safePath('/simple')).toBe('/simple')
|
|
1305
|
+
expect(app.safePath('/with-query')).toBe('/with-query')
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
test('safePath with .route and query', () => {
|
|
1309
|
+
const app = new Spiceflow().route({
|
|
1310
|
+
method: 'GET',
|
|
1311
|
+
path: '/api/search',
|
|
1312
|
+
query: z.object({ term: z.string() }),
|
|
1313
|
+
handler: () => 'search',
|
|
1314
|
+
})
|
|
1315
|
+
|
|
1316
|
+
expect(
|
|
1317
|
+
app.safePath('/api/search', { term: 'test' }),
|
|
1318
|
+
).toBe('/api/search?term=test')
|
|
1319
|
+
|
|
1320
|
+
// @ts-expect-error - invalid query key for .route-based route
|
|
1321
|
+
app.safePath('/api/search', { wrong: 'x' })
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
test('safePath skips undefined/null query values', () => {
|
|
1325
|
+
const app = new Spiceflow().get('/filter', () => 'filter', {
|
|
1326
|
+
query: z.object({ a: z.string(), b: z.string().optional() }),
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
expect(
|
|
1330
|
+
app.safePath('/filter', { a: 'yes', b: undefined }),
|
|
1331
|
+
).toBe('/filter?a=yes')
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
test('safePath query with basePath', () => {
|
|
1335
|
+
const app = new Spiceflow({ basePath: '/api' }).get(
|
|
1336
|
+
'/search',
|
|
1337
|
+
() => 'search',
|
|
1338
|
+
{
|
|
1339
|
+
query: z.object({ q: z.string() }),
|
|
1340
|
+
},
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
expect(
|
|
1344
|
+
app.safePath('/api/search', { q: 'hello' }),
|
|
1345
|
+
).toBe('/api/search?q=hello')
|
|
1346
|
+
|
|
1347
|
+
// @ts-expect-error - invalid query key with basePath
|
|
1348
|
+
app.safePath('/api/search', { wrong: 'x' })
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
test('safePath query with all HTTP method shorthands', () => {
|
|
1352
|
+
const app = new Spiceflow()
|
|
1353
|
+
.put('/put-q', () => 'put', {
|
|
1354
|
+
query: z.object({ x: z.string() }),
|
|
1355
|
+
})
|
|
1356
|
+
.patch('/patch-q', () => 'patch', {
|
|
1357
|
+
query: z.object({ y: z.coerce.number() }),
|
|
1358
|
+
})
|
|
1359
|
+
.delete('/del-q', () => 'del', {
|
|
1360
|
+
query: z.object({ confirm: z.boolean() }),
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
expect(app.safePath('/put-q', { x: 'val' })).toBe(
|
|
1364
|
+
'/put-q?x=val',
|
|
1365
|
+
)
|
|
1366
|
+
expect(app.safePath('/patch-q', { y: 5 })).toBe(
|
|
1367
|
+
'/patch-q?y=5',
|
|
1368
|
+
)
|
|
1369
|
+
expect(app.safePath('/del-q', { confirm: true })).toBe(
|
|
1370
|
+
'/del-q?confirm=true',
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
// @ts-expect-error - wrong query key on put
|
|
1374
|
+
app.safePath('/put-q', { wrong: 'x' })
|
|
1375
|
+
|
|
1376
|
+
// @ts-expect-error - wrong query key on patch
|
|
1377
|
+
app.safePath('/patch-q', { wrong: 1 })
|
|
1378
|
+
|
|
1379
|
+
// @ts-expect-error - wrong query key on delete
|
|
1380
|
+
app.safePath('/del-q', { wrong: true })
|
|
1381
|
+
})
|
|
1382
|
+
|
|
1383
|
+
test('safePath routes without query schema accept arbitrary query at runtime', () => {
|
|
1384
|
+
const app = new Spiceflow().get('/no-schema', () => 'ok')
|
|
1385
|
+
|
|
1386
|
+
expect(
|
|
1387
|
+
app.safePath('/no-schema', { anything: 'works' }),
|
|
1388
|
+
).toBe('/no-schema?anything=works')
|
|
1389
|
+
})
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
describe('createSafePath', () => {
|
|
1393
|
+
test('works with simple paths', () => {
|
|
1394
|
+
const app = new Spiceflow()
|
|
1395
|
+
.get('/users', () => 'users')
|
|
1396
|
+
.get('/posts', () => 'posts')
|
|
1397
|
+
|
|
1398
|
+
const safePath = createSafePath(app)
|
|
1399
|
+
expect(safePath('/users')).toBe('/users')
|
|
1400
|
+
expect(safePath('/posts')).toBe('/posts')
|
|
1401
|
+
// @ts-expect-error - invalid path
|
|
1402
|
+
safePath('/nonexistent')
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
test('works with path params', () => {
|
|
1406
|
+
const app = new Spiceflow()
|
|
1407
|
+
.get('/users/:id', ({ params }) => params.id)
|
|
1408
|
+
.get('/posts/:postId/comments/:commentId', ({ params }) => params)
|
|
1409
|
+
|
|
1410
|
+
const safePath = createSafePath(app)
|
|
1411
|
+
expect(safePath('/users/:id', { id: '123' })).toBe('/users/123')
|
|
1412
|
+
expect(
|
|
1413
|
+
safePath('/posts/:postId/comments/:commentId', {
|
|
1414
|
+
postId: 'abc',
|
|
1415
|
+
commentId: '456',
|
|
1416
|
+
}),
|
|
1417
|
+
).toBe('/posts/abc/comments/456')
|
|
1418
|
+
// @ts-expect-error - wrong path
|
|
1419
|
+
safePath('/wrong/:id', { id: '1' })
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
test('works with query params and rejects invalid keys', () => {
|
|
1423
|
+
const app = new Spiceflow()
|
|
1424
|
+
.get('/search', () => 'results', {
|
|
1425
|
+
query: z.object({ q: z.string(), page: z.coerce.number() }),
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
const safePath = createSafePath(app)
|
|
1429
|
+
expect(safePath('/search', { q: 'hello', page: 1 })).toBe(
|
|
1430
|
+
'/search?q=hello&page=1',
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
// @ts-expect-error - invalid query key
|
|
1434
|
+
safePath('/search', { invalid: 'x' })
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
test('works with both path and query params', () => {
|
|
1438
|
+
const app = new Spiceflow()
|
|
1439
|
+
.get('/users/:id', ({ params }) => params.id, {
|
|
1440
|
+
query: z.object({ fields: z.string() }),
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
const safePath = createSafePath(app)
|
|
1444
|
+
expect(
|
|
1445
|
+
safePath('/users/:id', { id: '42', fields: 'name' }),
|
|
1446
|
+
).toBe('/users/42?fields=name')
|
|
1447
|
+
|
|
1448
|
+
// @ts-expect-error - invalid query key with path params
|
|
1449
|
+
safePath('/users/:id', { id: '1', wrong: 'x' })
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
test('works with wildcard paths', () => {
|
|
1453
|
+
const app = new Spiceflow().get('/files/*', () => 'files')
|
|
1454
|
+
|
|
1455
|
+
const safePath = createSafePath(app)
|
|
1456
|
+
expect(safePath('/files/*', { '*': 'a/b.txt' })).toBe('/files/a/b.txt')
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
test('rejects invalid query keys across multiple routes', () => {
|
|
1460
|
+
const app = new Spiceflow()
|
|
1461
|
+
.get('/items', () => 'items', {
|
|
1462
|
+
query: z.object({ sort: z.string(), limit: z.coerce.number() }),
|
|
1463
|
+
})
|
|
1464
|
+
.post('/create', () => 'created', {
|
|
1465
|
+
query: z.object({ dryRun: z.boolean() }),
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
const safePath = createSafePath(app)
|
|
1469
|
+
|
|
1470
|
+
expect(safePath('/items', { sort: 'name', limit: 10 })).toBe(
|
|
1471
|
+
'/items?sort=name&limit=10',
|
|
1472
|
+
)
|
|
1473
|
+
expect(safePath('/create', { dryRun: true })).toBe(
|
|
1474
|
+
'/create?dryRun=true',
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
// @ts-expect-error - 'order' not in /items query schema
|
|
1478
|
+
safePath('/items', { order: 'asc' })
|
|
1479
|
+
|
|
1480
|
+
// @ts-expect-error - 'verbose' not in /create query schema
|
|
1481
|
+
safePath('/create', { verbose: true })
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1484
|
+
test('works with .route and rejects invalid query keys', () => {
|
|
1485
|
+
const app = new Spiceflow().route({
|
|
1486
|
+
method: 'GET',
|
|
1487
|
+
path: '/api/data',
|
|
1488
|
+
query: z.object({ format: z.string() }),
|
|
1489
|
+
handler: () => 'data',
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
const safePath = createSafePath(app)
|
|
1493
|
+
expect(safePath('/api/data', { format: 'json' })).toBe(
|
|
1494
|
+
'/api/data?format=json',
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
// @ts-expect-error - invalid query key on .route
|
|
1498
|
+
safePath('/api/data', { type: 'csv' })
|
|
1499
|
+
|
|
1500
|
+
// @ts-expect-error - invalid path
|
|
1501
|
+
safePath('/api/other')
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
test('works with basePath and rejects invalid query keys', () => {
|
|
1505
|
+
const app = new Spiceflow({ basePath: '/v2' }).get(
|
|
1506
|
+
'/users',
|
|
1507
|
+
() => 'users',
|
|
1508
|
+
{
|
|
1509
|
+
query: z.object({ active: z.boolean() }),
|
|
1510
|
+
},
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
const safePath = createSafePath(app)
|
|
1514
|
+
expect(safePath('/v2/users', { active: true })).toBe(
|
|
1515
|
+
'/v2/users?active=true',
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
// @ts-expect-error - invalid query key
|
|
1519
|
+
safePath('/v2/users', { status: 'active' })
|
|
1520
|
+
|
|
1521
|
+
// @ts-expect-error - path without basePath prefix
|
|
1522
|
+
safePath('/users')
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
test('without query schema allows arbitrary query', () => {
|
|
1526
|
+
const app = new Spiceflow().get('/free', () => 'ok')
|
|
1527
|
+
|
|
1528
|
+
const safePath = createSafePath(app)
|
|
1529
|
+
expect(
|
|
1530
|
+
safePath('/free', { any: 'value', works: 'here' }),
|
|
1531
|
+
).toBe('/free?any=value&works=here')
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
test('partial query params are accepted', () => {
|
|
1535
|
+
const app = new Spiceflow().get('/filter', () => 'filter', {
|
|
1536
|
+
query: z.object({ a: z.string(), b: z.string(), c: z.string() }),
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
const safePath = createSafePath(app)
|
|
1540
|
+
expect(safePath('/filter', { a: 'only-a' })).toBe(
|
|
1541
|
+
'/filter?a=only-a',
|
|
1542
|
+
)
|
|
1543
|
+
expect(safePath('/filter', { a: '1', c: '3' })).toBe(
|
|
1544
|
+
'/filter?a=1&c=3',
|
|
1545
|
+
)
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
test('mixed routes with and without query schemas', () => {
|
|
1549
|
+
const app = new Spiceflow()
|
|
1550
|
+
.get('/typed', () => 'typed', {
|
|
1551
|
+
query: z.object({ x: z.string() }),
|
|
1552
|
+
})
|
|
1553
|
+
.get('/untyped', () => 'untyped')
|
|
1554
|
+
.get('/also-typed/:id', ({ params }) => params.id, {
|
|
1555
|
+
query: z.object({ verbose: z.boolean() }),
|
|
1556
|
+
})
|
|
1557
|
+
|
|
1558
|
+
const safePath = createSafePath(app)
|
|
1559
|
+
|
|
1560
|
+
expect(safePath('/typed', { x: 'val' })).toBe('/typed?x=val')
|
|
1561
|
+
expect(safePath('/untyped', { anything: 'goes' })).toBe(
|
|
1562
|
+
'/untyped?anything=goes',
|
|
1563
|
+
)
|
|
1564
|
+
expect(
|
|
1565
|
+
safePath('/also-typed/:id', { id: '1', verbose: true }),
|
|
1566
|
+
).toBe('/also-typed/1?verbose=true')
|
|
1567
|
+
|
|
1568
|
+
// @ts-expect-error - wrong key on typed route
|
|
1569
|
+
safePath('/typed', { wrong: 'x' })
|
|
1570
|
+
|
|
1571
|
+
// @ts-expect-error - wrong key on also-typed route
|
|
1572
|
+
safePath('/also-typed/:id', { id: '1', wrong: true })
|
|
1573
|
+
})
|
|
1260
1574
|
})
|
|
1261
1575
|
|
|
1262
1576
|
test('composition with .use() works with state and onError - child app gets same state, errors caught by root', async () => {
|
package/src/spiceflow.ts
CHANGED
|
@@ -101,6 +101,7 @@ export class Spiceflow<
|
|
|
101
101
|
},
|
|
102
102
|
const out ClientRoutes extends RouteBase = {},
|
|
103
103
|
const out RoutePaths extends string = '',
|
|
104
|
+
const in out RouteQuerySchemas extends Record<string, unknown> = {},
|
|
104
105
|
> {
|
|
105
106
|
private id: number = globalIndex++
|
|
106
107
|
private router: MedleyRouter = new OriginalRouter()
|
|
@@ -116,6 +117,7 @@ export class Spiceflow<
|
|
|
116
117
|
Prefix: '' as BasePath,
|
|
117
118
|
ClientRoutes: {} as ClientRoutes,
|
|
118
119
|
RoutePaths: '' as RoutePaths,
|
|
120
|
+
RouteQuerySchemas: {} as RouteQuerySchemas,
|
|
119
121
|
Scoped: false as Scoped,
|
|
120
122
|
Singleton: {} as Singleton,
|
|
121
123
|
Definitions: {} as Definitions,
|
|
@@ -274,7 +276,8 @@ export class Spiceflow<
|
|
|
274
276
|
Definitions,
|
|
275
277
|
Metadata,
|
|
276
278
|
ClientRoutes,
|
|
277
|
-
RoutePaths
|
|
279
|
+
RoutePaths,
|
|
280
|
+
RouteQuerySchemas
|
|
278
281
|
> {
|
|
279
282
|
this.defaultState[name] = value
|
|
280
283
|
return this as any
|
|
@@ -350,7 +353,8 @@ export class Spiceflow<
|
|
|
350
353
|
}
|
|
351
354
|
}
|
|
352
355
|
>,
|
|
353
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
356
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
357
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
354
358
|
> {
|
|
355
359
|
this.add({ method: 'POST', path, handler: handler, hooks: hook })
|
|
356
360
|
|
|
@@ -400,7 +404,8 @@ export class Spiceflow<
|
|
|
400
404
|
}
|
|
401
405
|
}
|
|
402
406
|
>,
|
|
403
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
407
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
408
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
404
409
|
> {
|
|
405
410
|
this.add({ method: 'GET', path, handler: handler, hooks: hook })
|
|
406
411
|
return this as any
|
|
@@ -448,7 +453,8 @@ export class Spiceflow<
|
|
|
448
453
|
}
|
|
449
454
|
}
|
|
450
455
|
>,
|
|
451
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
456
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
457
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
452
458
|
> {
|
|
453
459
|
this.add({ method: 'PUT', path, handler: handler, hooks: hook })
|
|
454
460
|
|
|
@@ -501,7 +507,8 @@ export class Spiceflow<
|
|
|
501
507
|
}
|
|
502
508
|
}
|
|
503
509
|
>,
|
|
504
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
510
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
511
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
505
512
|
> {
|
|
506
513
|
// If options.request is defined, disallow for GET and HEAD (methods that don't support a body)
|
|
507
514
|
const methodsWithNoBody = ['GET', 'HEAD']
|
|
@@ -611,7 +618,8 @@ export class Spiceflow<
|
|
|
611
618
|
}
|
|
612
619
|
}
|
|
613
620
|
>,
|
|
614
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
621
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
622
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
615
623
|
> {
|
|
616
624
|
this.add({ method: 'PATCH', path, handler: handler, hooks: hook })
|
|
617
625
|
|
|
@@ -660,7 +668,8 @@ export class Spiceflow<
|
|
|
660
668
|
}
|
|
661
669
|
}
|
|
662
670
|
>,
|
|
663
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
671
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
672
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
664
673
|
> {
|
|
665
674
|
this.add({ method: 'DELETE', path, handler: handler, hooks: hook })
|
|
666
675
|
|
|
@@ -709,7 +718,8 @@ export class Spiceflow<
|
|
|
709
718
|
}
|
|
710
719
|
}
|
|
711
720
|
>,
|
|
712
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
721
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
722
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
713
723
|
> {
|
|
714
724
|
this.add({ method: 'OPTIONS', path, handler: handler, hooks: hook })
|
|
715
725
|
|
|
@@ -758,7 +768,8 @@ export class Spiceflow<
|
|
|
758
768
|
}
|
|
759
769
|
}
|
|
760
770
|
>,
|
|
761
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
771
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
772
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
762
773
|
> {
|
|
763
774
|
for (const method of METHODS) {
|
|
764
775
|
this.add({ method, path, handler: handler, hooks: hook })
|
|
@@ -809,7 +820,8 @@ export class Spiceflow<
|
|
|
809
820
|
}
|
|
810
821
|
}
|
|
811
822
|
>,
|
|
812
|
-
RoutePaths | JoinPath<BasePath, Path
|
|
823
|
+
RoutePaths | JoinPath<BasePath, Path>,
|
|
824
|
+
RouteQuerySchemas & Record<JoinPath<BasePath, Path>, Schema['query']>
|
|
813
825
|
> {
|
|
814
826
|
this.add({ method: 'HEAD', path, handler: handler, hooks: hook })
|
|
815
827
|
|
|
@@ -832,7 +844,8 @@ export class Spiceflow<
|
|
|
832
844
|
? ClientRoutes & NewSpiceflow['_types']['ClientRoutes']
|
|
833
845
|
: ClientRoutes &
|
|
834
846
|
CreateClient<BasePath, NewSpiceflow['_types']['ClientRoutes']>,
|
|
835
|
-
RoutePaths | NewSpiceflow['_types']['RoutePaths']
|
|
847
|
+
RoutePaths | NewSpiceflow['_types']['RoutePaths'],
|
|
848
|
+
RouteQuerySchemas & NewSpiceflow['_types']['RouteQuerySchemas']
|
|
836
849
|
>
|
|
837
850
|
use<const Schema extends RouteSchema>(
|
|
838
851
|
handler: MiddlewareHandler<Schema, Singleton>,
|
|
@@ -1399,31 +1412,92 @@ export class Spiceflow<
|
|
|
1399
1412
|
}
|
|
1400
1413
|
safePath<
|
|
1401
1414
|
const Path extends RoutePaths,
|
|
1402
|
-
const Params extends ExtractParamsFromPath<Path>,
|
|
1403
1415
|
>(
|
|
1404
1416
|
path: Path,
|
|
1405
|
-
...rest: [
|
|
1406
|
-
?
|
|
1407
|
-
|
|
1417
|
+
...rest: [ExtractParamsFromPath<Path>] extends [undefined]
|
|
1418
|
+
? Path extends keyof RouteQuerySchemas
|
|
1419
|
+
? unknown extends RouteQuerySchemas[Path]
|
|
1420
|
+
? [] | [allParams?: Record<string, string | number | boolean>]
|
|
1421
|
+
: [] | [allParams?: Partial<RouteQuerySchemas[Path]>]
|
|
1422
|
+
: [] | [allParams?: Record<string, string | number | boolean>]
|
|
1423
|
+
: Path extends keyof RouteQuerySchemas
|
|
1424
|
+
? unknown extends RouteQuerySchemas[Path]
|
|
1425
|
+
? [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
|
|
1426
|
+
: [allParams: MergeParamsAndQuery<ExtractParamsFromPath<Path>, RouteQuerySchemas[Path]>]
|
|
1427
|
+
: [allParams: ExtractParamsFromPath<Path>] | [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
|
|
1408
1428
|
): string {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1429
|
+
return buildSafePath(path, rest[0] as Record<string, any> | undefined)
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
type MergeParamsAndQuery<P, Q> = P extends Record<string, any>
|
|
1434
|
+
? { [K in keyof (P & Omit<Partial<Q>, keyof P>)]: (P & Omit<Partial<Q>, keyof P>)[K] }
|
|
1435
|
+
: Partial<Q>
|
|
1436
|
+
|
|
1437
|
+
function buildSafePath(path: string, allParams: Record<string, any> | undefined): string {
|
|
1438
|
+
let result = path
|
|
1439
|
+
if (!allParams || typeof allParams !== 'object') return result
|
|
1440
|
+
|
|
1441
|
+
const pathParamNames = new Set<string>()
|
|
1442
|
+
const paramMatches = path.matchAll(/:(\w+)/g)
|
|
1443
|
+
for (const m of paramMatches) {
|
|
1444
|
+
pathParamNames.add(m[1])
|
|
1445
|
+
}
|
|
1446
|
+
const hasWildcard = path.includes('*')
|
|
1447
|
+
if (hasWildcard) pathParamNames.add('*')
|
|
1448
|
+
|
|
1449
|
+
const searchParams = new URLSearchParams()
|
|
1450
|
+
for (const [key, value] of Object.entries(allParams)) {
|
|
1451
|
+
if (value === undefined || value === null) continue
|
|
1452
|
+
if (key === '*' && hasWildcard) {
|
|
1453
|
+
result = result.replace(/\*/, String(value))
|
|
1454
|
+
} else if (pathParamNames.has(key)) {
|
|
1455
|
+
result = result.replace(new RegExp(`:${key}`, 'g'), String(value))
|
|
1456
|
+
} else {
|
|
1457
|
+
searchParams.set(key, String(value))
|
|
1424
1458
|
}
|
|
1459
|
+
}
|
|
1425
1460
|
|
|
1426
|
-
|
|
1461
|
+
const qs = searchParams.toString()
|
|
1462
|
+
if (qs) result += '?' + qs
|
|
1463
|
+
return result
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Create a standalone type-safe path builder. Pass your app instance for automatic
|
|
1468
|
+
* type inference, or call with explicit type params. The app value is not used at runtime.
|
|
1469
|
+
*
|
|
1470
|
+
* ```ts
|
|
1471
|
+
* const app = new Spiceflow()
|
|
1472
|
+
* .get('/users/:id', handler, { query: z.object({ page: z.number() }) })
|
|
1473
|
+
*
|
|
1474
|
+
* const safePath = createSafePath(app)
|
|
1475
|
+
* safePath('/users/:id', { id: '123', page: 1 })
|
|
1476
|
+
* ```
|
|
1477
|
+
*/
|
|
1478
|
+
export function createSafePath<
|
|
1479
|
+
const Paths extends string,
|
|
1480
|
+
const QS extends Record<string, unknown>,
|
|
1481
|
+
>(
|
|
1482
|
+
_app?: { _types: { RoutePaths: Paths; RouteQuerySchemas: QS } },
|
|
1483
|
+
) {
|
|
1484
|
+
return <
|
|
1485
|
+
const Path extends Paths,
|
|
1486
|
+
>(
|
|
1487
|
+
path: Path,
|
|
1488
|
+
...rest: [ExtractParamsFromPath<Path>] extends [undefined]
|
|
1489
|
+
? Path extends keyof QS
|
|
1490
|
+
? unknown extends QS[Path]
|
|
1491
|
+
? [] | [allParams?: Record<string, string | number | boolean>]
|
|
1492
|
+
: [] | [allParams?: Partial<QS[Path]>]
|
|
1493
|
+
: [] | [allParams?: Record<string, string | number | boolean>]
|
|
1494
|
+
: Path extends keyof QS
|
|
1495
|
+
? unknown extends QS[Path]
|
|
1496
|
+
? [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
|
|
1497
|
+
: [allParams: MergeParamsAndQuery<ExtractParamsFromPath<Path>, QS[Path]>]
|
|
1498
|
+
: [allParams: ExtractParamsFromPath<Path>] | [allParams: ExtractParamsFromPath<Path> & Record<string, string | number | boolean>]
|
|
1499
|
+
): string => {
|
|
1500
|
+
return buildSafePath(path, rest[0] as Record<string, any> | undefined)
|
|
1427
1501
|
}
|
|
1428
1502
|
}
|
|
1429
1503
|
|
|
@@ -1489,7 +1563,7 @@ export function bfs(tree: AnySpiceflow) {
|
|
|
1489
1563
|
}
|
|
1490
1564
|
|
|
1491
1565
|
|
|
1492
|
-
export type AnySpiceflow = Spiceflow<any, any, any, any, any, any, any>
|
|
1566
|
+
export type AnySpiceflow = Spiceflow<any, any, any, any, any, any, any, any>
|
|
1493
1567
|
|
|
1494
1568
|
export function isZodSchema(value: unknown): value is ZodType {
|
|
1495
1569
|
return (
|
package/src/stream.test.ts
CHANGED
|
@@ -468,7 +468,7 @@ describe('Stream', () => {
|
|
|
468
468
|
let streamError: Error | undefined
|
|
469
469
|
|
|
470
470
|
try {
|
|
471
|
-
const stream = streamSSEResponse(response, (x) => x.data)
|
|
471
|
+
const stream = streamSSEResponse({ response, map: (x) => x.data })
|
|
472
472
|
for await (const value of stream) {
|
|
473
473
|
values.push(value)
|
|
474
474
|
}
|