opinionated-machine 6.3.0 → 6.4.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 +158 -24
- package/dist/lib/contracts/contractBuilders.d.ts +59 -92
- package/dist/lib/contracts/contractBuilders.js +94 -18
- package/dist/lib/contracts/contractBuilders.js.map +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.d.ts +1 -1
- package/dist/lib/dualmode/AbstractDualModeController.js +1 -1
- package/dist/lib/dualmode/dualModeContracts.d.ts +71 -9
- package/dist/lib/dualmode/dualModeContracts.js +12 -1
- package/dist/lib/dualmode/dualModeContracts.js.map +1 -1
- package/dist/lib/dualmode/index.d.ts +2 -1
- package/dist/lib/dualmode/index.js +1 -0
- package/dist/lib/dualmode/index.js.map +1 -1
- package/dist/lib/routes/fastifyRouteBuilder.d.ts +3 -3
- package/dist/lib/routes/fastifyRouteBuilder.js +92 -17
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
- package/dist/lib/routes/fastifyRouteTypes.d.ts +151 -11
- package/dist/lib/routes/fastifyRouteTypes.js.map +1 -1
- package/dist/lib/routes/fastifyRouteUtils.d.ts +47 -1
- package/dist/lib/routes/fastifyRouteUtils.js +118 -12
- package/dist/lib/routes/fastifyRouteUtils.js.map +1 -1
- package/dist/lib/routes/index.d.ts +2 -2
- package/dist/lib/routes/index.js +1 -1
- package/dist/lib/routes/index.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +1 -1
- package/dist/lib/sse/sseContracts.d.ts +3 -3
- package/dist/lib/testing/sseTestTypes.d.ts +1 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -398,7 +398,7 @@ await app.register(FastifySSEPlugin)
|
|
|
398
398
|
|
|
399
399
|
### Defining SSE Contracts
|
|
400
400
|
|
|
401
|
-
Use `buildContract` to define SSE routes. The contract type is automatically determined based on the presence of `
|
|
401
|
+
Use `buildContract` to define SSE routes. The contract type is automatically determined based on the presence of `requestBody` and `jsonResponse` fields. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
|
|
402
402
|
|
|
403
403
|
```ts
|
|
404
404
|
import { z } from 'zod'
|
|
@@ -429,14 +429,14 @@ export const notificationsContract = buildContract({
|
|
|
429
429
|
},
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
// POST-based SSE stream (e.g., AI chat completions) - has
|
|
432
|
+
// POST-based SSE stream (e.g., AI chat completions) - has requestBody = POST/PUT/PATCH
|
|
433
433
|
export const chatCompletionContract = buildContract({
|
|
434
434
|
method: 'POST',
|
|
435
435
|
pathResolver: () => '/api/chat/completions',
|
|
436
436
|
params: z.object({}),
|
|
437
437
|
query: z.object({}),
|
|
438
438
|
requestHeaders: z.object({}),
|
|
439
|
-
|
|
439
|
+
requestBody: z.object({
|
|
440
440
|
message: z.string(),
|
|
441
441
|
stream: z.literal(true),
|
|
442
442
|
}),
|
|
@@ -502,7 +502,7 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
|
|
|
502
502
|
handlers: this.handleStream,
|
|
503
503
|
options: {
|
|
504
504
|
onConnect: (conn) => this.onConnect(conn),
|
|
505
|
-
|
|
505
|
+
onClose: (conn, reason) => this.onClose(conn, reason),
|
|
506
506
|
},
|
|
507
507
|
},
|
|
508
508
|
}
|
|
@@ -541,10 +541,10 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
|
|
|
541
541
|
console.log('Client connected:', connection.id)
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
-
private
|
|
544
|
+
private onClose = (connection: SSEConnection, reason: SSECloseReason) => {
|
|
545
545
|
const userId = connection.context?.userId as string
|
|
546
546
|
this.notificationService.unsubscribe(userId)
|
|
547
|
-
console.log(
|
|
547
|
+
console.log(`Client disconnected (${reason}):`, connection.id)
|
|
548
548
|
}
|
|
549
549
|
}
|
|
550
550
|
```
|
|
@@ -736,7 +736,7 @@ public buildSSERoutes() {
|
|
|
736
736
|
}
|
|
737
737
|
},
|
|
738
738
|
onConnect: (conn) => console.log('Admin connected'),
|
|
739
|
-
|
|
739
|
+
onClose: (conn, reason) => console.log(`Admin disconnected (${reason})`),
|
|
740
740
|
// Handle client reconnection with Last-Event-ID
|
|
741
741
|
onReconnect: async (conn, lastEventId) => {
|
|
742
742
|
// Return events to replay, or handle manually
|
|
@@ -753,12 +753,64 @@ public buildSSERoutes() {
|
|
|
753
753
|
**Available route options:**
|
|
754
754
|
|
|
755
755
|
| Option | Description |
|
|
756
|
-
|
|
756
|
+
| -------- | ------------- |
|
|
757
757
|
| `preHandler` | Authentication/authorization hook that runs before SSE connection |
|
|
758
758
|
| `onConnect` | Called after client connects (SSE handshake complete) |
|
|
759
|
-
| `
|
|
759
|
+
| `onClose` | Called when connection closes (client disconnect, network failure, or server close). Receives `(connection, reason)` where reason is `'server'` or `'client'` |
|
|
760
760
|
| `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
|
|
761
761
|
| `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
|
|
762
|
+
| `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
|
|
763
|
+
| `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
|
|
764
|
+
|
|
765
|
+
**onClose reason parameter:**
|
|
766
|
+
- `'server'`: Server explicitly closed the connection (via `closeConnection()` or `success('disconnect')`)
|
|
767
|
+
- `'client'`: Client closed the connection (EventSource.close(), navigation, network failure)
|
|
768
|
+
|
|
769
|
+
```ts
|
|
770
|
+
options: {
|
|
771
|
+
onConnect: (conn) => console.log('Client connected'),
|
|
772
|
+
onClose: (conn, reason) => {
|
|
773
|
+
console.log(`Connection closed (${reason}):`, conn.id)
|
|
774
|
+
// reason is 'server' or 'client'
|
|
775
|
+
},
|
|
776
|
+
serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
|
|
777
|
+
heartbeatInterval: 30000, // Send heartbeat every 30 seconds
|
|
778
|
+
}
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### SSE Connection Methods
|
|
782
|
+
|
|
783
|
+
The `connection` object in SSE handlers provides several useful methods:
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
private handleStream = buildHandler(streamContract, {
|
|
787
|
+
sse: async (request, connection) => {
|
|
788
|
+
// Check if connection is still active
|
|
789
|
+
if (connection.isConnected()) {
|
|
790
|
+
await connection.send('status', { connected: true })
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Get raw writable stream for advanced use cases (e.g., pipeline)
|
|
794
|
+
const stream = connection.getStream()
|
|
795
|
+
|
|
796
|
+
// Stream messages from an async iterable with automatic validation
|
|
797
|
+
async function* generateMessages() {
|
|
798
|
+
yield { event: 'message' as const, data: { text: 'Hello' } }
|
|
799
|
+
yield { event: 'message' as const, data: { text: 'World' } }
|
|
800
|
+
}
|
|
801
|
+
await connection.sendStream(generateMessages())
|
|
802
|
+
|
|
803
|
+
return success('disconnect')
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
| Method | Description |
|
|
809
|
+
| -------- | ------------- |
|
|
810
|
+
| `send(event, data, options?)` | Send a typed event (validates against contract schema) |
|
|
811
|
+
| `isConnected()` | Check if the connection is still active |
|
|
812
|
+
| `getStream()` | Get the underlying `WritableStream` for advanced use cases |
|
|
813
|
+
| `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
|
|
762
814
|
|
|
763
815
|
### Graceful Shutdown
|
|
764
816
|
|
|
@@ -779,7 +831,7 @@ if (!sent) {
|
|
|
779
831
|
}
|
|
780
832
|
```
|
|
781
833
|
|
|
782
|
-
**Lifecycle hook errors** (`onConnect`, `onReconnect`, `
|
|
834
|
+
**Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
|
|
783
835
|
- All lifecycle hooks are wrapped in try/catch to prevent crashes
|
|
784
836
|
- If a `logger` is provided in route options, errors are logged with context
|
|
785
837
|
- If no logger is provided, errors are silently ignored
|
|
@@ -795,7 +847,7 @@ public buildSSERoutes() {
|
|
|
795
847
|
options: {
|
|
796
848
|
logger: this.logger, // pino-compatible logger
|
|
797
849
|
onConnect: (conn) => { /* may throw */ },
|
|
798
|
-
|
|
850
|
+
onClose: (conn, reason) => { /* may throw */ },
|
|
799
851
|
},
|
|
800
852
|
},
|
|
801
853
|
}
|
|
@@ -1142,7 +1194,7 @@ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
|
|
|
1142
1194
|
const server = await SSETestServer.create(async (app) => {
|
|
1143
1195
|
app.get('/api/events', async (request, reply) => {
|
|
1144
1196
|
reply.sse({ event: 'message', data: { hello: 'world' } })
|
|
1145
|
-
reply.
|
|
1197
|
+
reply.sse.close()
|
|
1146
1198
|
})
|
|
1147
1199
|
})
|
|
1148
1200
|
|
|
@@ -1360,21 +1412,21 @@ Dual-mode contracts define endpoints that can return **either** a complete JSON
|
|
|
1360
1412
|
- You're building OpenAI-style APIs where `stream: true` triggers SSE
|
|
1361
1413
|
- You need polling fallback for clients that don't support SSE
|
|
1362
1414
|
|
|
1363
|
-
To create a dual-mode contract, include a `
|
|
1364
|
-
- Has `
|
|
1365
|
-
- Has both `
|
|
1415
|
+
To create a dual-mode contract, include a `jsonResponse` schema in your `buildContract` call:
|
|
1416
|
+
- Has `jsonResponse` but no `requestBody` → GET dual-mode route
|
|
1417
|
+
- Has both `jsonResponse` and `requestBody` → POST/PUT/PATCH dual-mode route
|
|
1366
1418
|
|
|
1367
1419
|
```ts
|
|
1368
1420
|
import { z } from 'zod'
|
|
1369
1421
|
import { buildContract } from 'opinionated-machine'
|
|
1370
1422
|
|
|
1371
|
-
// GET dual-mode route (polling or streaming job status) - has
|
|
1423
|
+
// GET dual-mode route (polling or streaming job status) - has jsonResponse, no requestBody
|
|
1372
1424
|
export const jobStatusContract = buildContract({
|
|
1373
1425
|
pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
|
|
1374
1426
|
params: z.object({ jobId: z.string().uuid() }),
|
|
1375
1427
|
query: z.object({ verbose: z.string().optional() }),
|
|
1376
1428
|
requestHeaders: z.object({}),
|
|
1377
|
-
|
|
1429
|
+
jsonResponse: z.object({
|
|
1378
1430
|
status: z.enum(['pending', 'running', 'completed', 'failed']),
|
|
1379
1431
|
progress: z.number(),
|
|
1380
1432
|
result: z.string().optional(),
|
|
@@ -1385,15 +1437,15 @@ export const jobStatusContract = buildContract({
|
|
|
1385
1437
|
},
|
|
1386
1438
|
})
|
|
1387
1439
|
|
|
1388
|
-
// POST dual-mode route (OpenAI-style chat completion) - has both
|
|
1440
|
+
// POST dual-mode route (OpenAI-style chat completion) - has both jsonResponse and requestBody
|
|
1389
1441
|
export const chatCompletionContract = buildContract({
|
|
1390
1442
|
method: 'POST',
|
|
1391
1443
|
pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
|
|
1392
1444
|
params: z.object({ chatId: z.string().uuid() }),
|
|
1393
1445
|
query: z.object({}),
|
|
1394
1446
|
requestHeaders: z.object({ authorization: z.string() }),
|
|
1395
|
-
|
|
1396
|
-
|
|
1447
|
+
requestBody: z.object({ message: z.string() }),
|
|
1448
|
+
jsonResponse: z.object({
|
|
1397
1449
|
reply: z.string(),
|
|
1398
1450
|
usage: z.object({ tokens: z.number() }),
|
|
1399
1451
|
}),
|
|
@@ -1417,8 +1469,8 @@ export const rateLimitedContract = buildContract({
|
|
|
1417
1469
|
params: z.object({}),
|
|
1418
1470
|
query: z.object({}),
|
|
1419
1471
|
requestHeaders: z.object({}),
|
|
1420
|
-
|
|
1421
|
-
|
|
1472
|
+
requestBody: z.object({ data: z.string() }),
|
|
1473
|
+
jsonResponse: z.object({ result: z.string() }),
|
|
1422
1474
|
// Define expected response headers
|
|
1423
1475
|
responseHeaders: z.object({
|
|
1424
1476
|
'x-ratelimit-limit': z.string(),
|
|
@@ -1447,6 +1499,73 @@ handlers: {
|
|
|
1447
1499
|
|
|
1448
1500
|
If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
|
|
1449
1501
|
|
|
1502
|
+
### Multi-Format Responses (Verbose Mode)
|
|
1503
|
+
|
|
1504
|
+
For endpoints that need to return multiple response formats (JSON, plain text, CSV, etc.), use `multiFormatResponses` instead of `jsonResponse`. This enables content negotiation based on the `Accept` header:
|
|
1505
|
+
|
|
1506
|
+
```ts
|
|
1507
|
+
import { z } from 'zod'
|
|
1508
|
+
import { buildContract } from 'opinionated-machine'
|
|
1509
|
+
|
|
1510
|
+
// Multi-format export endpoint
|
|
1511
|
+
export const exportContract = buildContract({
|
|
1512
|
+
method: 'POST',
|
|
1513
|
+
pathResolver: () => '/api/export',
|
|
1514
|
+
params: z.object({}),
|
|
1515
|
+
query: z.object({}),
|
|
1516
|
+
requestHeaders: z.object({}),
|
|
1517
|
+
requestBody: z.object({
|
|
1518
|
+
data: z.array(z.object({ name: z.string(), value: z.number() })),
|
|
1519
|
+
}),
|
|
1520
|
+
// Define multiple response formats
|
|
1521
|
+
multiFormatResponses: {
|
|
1522
|
+
'application/json': z.object({
|
|
1523
|
+
items: z.array(z.object({ name: z.string(), value: z.number() })),
|
|
1524
|
+
count: z.number(),
|
|
1525
|
+
}),
|
|
1526
|
+
'text/plain': z.string(),
|
|
1527
|
+
'text/csv': z.string(),
|
|
1528
|
+
},
|
|
1529
|
+
events: {
|
|
1530
|
+
progress: z.object({ percent: z.number() }),
|
|
1531
|
+
done: z.object({ format: z.string() }),
|
|
1532
|
+
},
|
|
1533
|
+
})
|
|
1534
|
+
```
|
|
1535
|
+
|
|
1536
|
+
The handler structure changes to `sync` with per-format handlers:
|
|
1537
|
+
|
|
1538
|
+
```ts
|
|
1539
|
+
handlers: buildHandler(exportContract, {
|
|
1540
|
+
sync: {
|
|
1541
|
+
'application/json': (request) => ({
|
|
1542
|
+
items: request.body.data,
|
|
1543
|
+
count: request.body.data.length,
|
|
1544
|
+
}),
|
|
1545
|
+
'text/plain': (request) =>
|
|
1546
|
+
request.body.data.map((item) => `${item.name}: ${item.value}`).join('\n'),
|
|
1547
|
+
'text/csv': (request) =>
|
|
1548
|
+
`name,value\n${request.body.data.map((item) => `${item.name},${item.value}`).join('\n')}`,
|
|
1549
|
+
},
|
|
1550
|
+
sse: async (request, connection) => {
|
|
1551
|
+
// SSE streaming handler
|
|
1552
|
+
await connection.send('done', { totalItems: request.body.data.length })
|
|
1553
|
+
return success('disconnect')
|
|
1554
|
+
},
|
|
1555
|
+
})
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
**Contract styles comparison:**
|
|
1559
|
+
|
|
1560
|
+
| Style | Contract Field | Handler Key | Use Case |
|
|
1561
|
+
|-------|---------------|-------------|----------|
|
|
1562
|
+
| Simplified | `jsonResponse` | `json` | Single JSON format (recommended) |
|
|
1563
|
+
| Verbose | `multiFormatResponses` | `sync` | Multiple formats (JSON, text, CSV, etc.) |
|
|
1564
|
+
|
|
1565
|
+
TypeScript enforces the correct handler structure based on your contract:
|
|
1566
|
+
- `jsonResponse` contracts must use `json` handler
|
|
1567
|
+
- `multiFormatResponses` contracts must use `sync` handlers for all declared formats
|
|
1568
|
+
|
|
1450
1569
|
### Implementing Dual-Mode Controllers
|
|
1451
1570
|
|
|
1452
1571
|
Dual-mode controllers use `buildHandler` to define both JSON and SSE handlers:
|
|
@@ -1514,7 +1633,7 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1514
1633
|
},
|
|
1515
1634
|
// Optional: SSE lifecycle hooks
|
|
1516
1635
|
onConnect: (conn) => console.log('Client connected:', conn.id),
|
|
1517
|
-
|
|
1636
|
+
onClose: (conn, reason) => console.log(`Client disconnected (${reason}):`, conn.id),
|
|
1518
1637
|
},
|
|
1519
1638
|
},
|
|
1520
1639
|
}
|
|
@@ -1529,7 +1648,7 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
|
|
|
1529
1648
|
| `json` | `(request, reply) => Response` |
|
|
1530
1649
|
| `sse` | `(request, connection) => Either<Error, SSEHandlerResult>` |
|
|
1531
1650
|
|
|
1532
|
-
The `json` handler must return a value matching `
|
|
1651
|
+
The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `connection.send()` for type-safe event streaming.
|
|
1533
1652
|
|
|
1534
1653
|
### Registering Dual-Mode Controllers
|
|
1535
1654
|
|
|
@@ -1612,6 +1731,21 @@ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
|
|
|
1612
1731
|
curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
|
|
1613
1732
|
```
|
|
1614
1733
|
|
|
1734
|
+
**Subtype wildcards** are supported for flexible content negotiation:
|
|
1735
|
+
|
|
1736
|
+
```bash
|
|
1737
|
+
# Accept any text format (matches text/plain, text/csv, etc.)
|
|
1738
|
+
curl -H "Accept: text/*" ...
|
|
1739
|
+
|
|
1740
|
+
# Accept any application format (matches application/json, application/xml, etc.)
|
|
1741
|
+
curl -H "Accept: application/*" ...
|
|
1742
|
+
|
|
1743
|
+
# Combine with quality values
|
|
1744
|
+
curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
|
|
1745
|
+
```
|
|
1746
|
+
|
|
1747
|
+
The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
|
|
1748
|
+
|
|
1615
1749
|
### Testing Dual-Mode Controllers
|
|
1616
1750
|
|
|
1617
1751
|
Test both JSON and SSE modes:
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { z } from 'zod';
|
|
2
|
-
import type {
|
|
2
|
+
import type { MultiFormatResponses, PathResolver, SimplifiedDualModeContractDefinition, VerboseDualModeContractDefinition } from '../dualmode/dualModeContracts.ts';
|
|
3
3
|
import type { SSEContractDefinition, SSEPathResolver } from '../sse/sseContracts.ts';
|
|
4
4
|
import type { SSEEventSchemas } from '../sse/sseTypes.ts';
|
|
5
5
|
/**
|
|
6
6
|
* Configuration for building a GET SSE route.
|
|
7
|
-
* Forbids
|
|
7
|
+
* Forbids requestBody for GET variants.
|
|
8
8
|
*/
|
|
9
9
|
export type SSEGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Events extends SSEEventSchemas> = {
|
|
10
10
|
pathResolver: SSEPathResolver<z.infer<Params>>;
|
|
@@ -12,12 +12,13 @@ export type SSEGetContractConfig<Params extends z.ZodTypeAny, Query extends z.Zo
|
|
|
12
12
|
query: Query;
|
|
13
13
|
requestHeaders: RequestHeaders;
|
|
14
14
|
events: Events;
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
requestBody?: never;
|
|
16
|
+
jsonResponse?: never;
|
|
17
|
+
multiFormatResponses?: never;
|
|
17
18
|
};
|
|
18
19
|
/**
|
|
19
|
-
* Configuration for building a POST/PUT/PATCH SSE route with request
|
|
20
|
-
* Requires
|
|
20
|
+
* Configuration for building a POST/PUT/PATCH SSE route with request requestBody.
|
|
21
|
+
* Requires requestBody for payload variants.
|
|
21
22
|
*/
|
|
22
23
|
export type SSEPayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Events extends SSEEventSchemas> = {
|
|
23
24
|
method?: 'POST' | 'PUT' | 'PATCH';
|
|
@@ -25,20 +26,23 @@ export type SSEPayloadContractConfig<Params extends z.ZodTypeAny, Query extends
|
|
|
25
26
|
params: Params;
|
|
26
27
|
query: Query;
|
|
27
28
|
requestHeaders: RequestHeaders;
|
|
28
|
-
|
|
29
|
+
requestBody: Body;
|
|
29
30
|
events: Events;
|
|
30
|
-
|
|
31
|
+
jsonResponse?: never;
|
|
32
|
+
multiFormatResponses?: never;
|
|
31
33
|
};
|
|
32
34
|
/**
|
|
33
|
-
* Configuration for building a GET dual-mode route.
|
|
34
|
-
*
|
|
35
|
+
* Configuration for building a GET dual-mode route (simplified - single JSON format).
|
|
36
|
+
* Requires jsonResponse, forbids requestBody.
|
|
35
37
|
*/
|
|
36
|
-
export type DualModeGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny,
|
|
38
|
+
export type DualModeGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
|
|
37
39
|
pathResolver: PathResolver<z.infer<Params>>;
|
|
38
40
|
params: Params;
|
|
39
41
|
query: Query;
|
|
40
42
|
requestHeaders: RequestHeaders;
|
|
41
|
-
|
|
43
|
+
/** Single JSON response schema */
|
|
44
|
+
jsonResponse: JsonResponse;
|
|
45
|
+
multiFormatResponses?: never;
|
|
42
46
|
/**
|
|
43
47
|
* Schema for validating response headers (JSON mode only).
|
|
44
48
|
* Used to define and validate headers that the server will send in the response.
|
|
@@ -53,20 +57,22 @@ export type DualModeGetContractConfig<Params extends z.ZodTypeAny, Query extends
|
|
|
53
57
|
*/
|
|
54
58
|
responseHeaders?: ResponseHeaders;
|
|
55
59
|
events: Events;
|
|
56
|
-
|
|
60
|
+
requestBody?: never;
|
|
57
61
|
};
|
|
58
62
|
/**
|
|
59
|
-
* Configuration for building a POST/PUT/PATCH dual-mode route with request
|
|
60
|
-
*
|
|
63
|
+
* Configuration for building a POST/PUT/PATCH dual-mode route with request requestBody (simplified).
|
|
64
|
+
* Requires both requestBody and jsonResponse.
|
|
61
65
|
*/
|
|
62
|
-
export type DualModePayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny,
|
|
66
|
+
export type DualModePayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
|
|
63
67
|
method?: 'POST' | 'PUT' | 'PATCH';
|
|
64
68
|
pathResolver: PathResolver<z.infer<Params>>;
|
|
65
69
|
params: Params;
|
|
66
70
|
query: Query;
|
|
67
71
|
requestHeaders: RequestHeaders;
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
requestBody: Body;
|
|
73
|
+
/** Single JSON response schema */
|
|
74
|
+
jsonResponse: JsonResponse;
|
|
75
|
+
multiFormatResponses?: never;
|
|
70
76
|
/**
|
|
71
77
|
* Schema for validating response headers (JSON mode only).
|
|
72
78
|
* Used to define and validate headers that the server will send in the response.
|
|
@@ -83,80 +89,41 @@ export type DualModePayloadContractConfig<Params extends z.ZodTypeAny, Query ext
|
|
|
83
89
|
events: Events;
|
|
84
90
|
};
|
|
85
91
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* Automatically determines the contract type based on the presence of `body` and `syncResponse`:
|
|
89
|
-
*
|
|
90
|
-
* | syncResponse | body | Result |
|
|
91
|
-
* |--------------|------|--------|
|
|
92
|
-
* | ❌ absent | ❌ absent | SSE GET |
|
|
93
|
-
* | ❌ absent | ✅ present | SSE POST/PUT/PATCH |
|
|
94
|
-
* | ✅ present | ❌ absent | Dual-mode GET |
|
|
95
|
-
* | ✅ present | ✅ present | Dual-mode POST/PUT/PATCH |
|
|
96
|
-
*
|
|
97
|
-
* @example
|
|
98
|
-
* ```typescript
|
|
99
|
-
* // SSE GET - no body, no syncResponse
|
|
100
|
-
* const notificationsStream = buildContract({
|
|
101
|
-
* pathResolver: () => '/api/notifications/stream',
|
|
102
|
-
* params: z.object({}),
|
|
103
|
-
* query: z.object({ userId: z.string().optional() }),
|
|
104
|
-
* requestHeaders: z.object({}),
|
|
105
|
-
* events: {
|
|
106
|
-
* notification: z.object({ id: z.string(), message: z.string() }),
|
|
107
|
-
* },
|
|
108
|
-
* })
|
|
109
|
-
*
|
|
110
|
-
* // SSE POST - has body, no syncResponse
|
|
111
|
-
* const chatCompletionStream = buildContract({
|
|
112
|
-
* method: 'POST',
|
|
113
|
-
* pathResolver: () => '/api/chat/completions',
|
|
114
|
-
* params: z.object({}),
|
|
115
|
-
* query: z.object({}),
|
|
116
|
-
* requestHeaders: z.object({}),
|
|
117
|
-
* body: z.object({ message: z.string(), stream: z.literal(true) }),
|
|
118
|
-
* events: {
|
|
119
|
-
* chunk: z.object({ content: z.string() }),
|
|
120
|
-
* done: z.object({ totalTokens: z.number() }),
|
|
121
|
-
* },
|
|
122
|
-
* })
|
|
123
|
-
*
|
|
124
|
-
* // Dual-mode GET - has syncResponse, no body
|
|
125
|
-
* const jobStatus = buildContract({
|
|
126
|
-
* pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
|
|
127
|
-
* params: z.object({ jobId: z.string().uuid() }),
|
|
128
|
-
* query: z.object({}),
|
|
129
|
-
* requestHeaders: z.object({}),
|
|
130
|
-
* syncResponse: z.object({
|
|
131
|
-
* status: z.enum(['pending', 'running', 'completed']),
|
|
132
|
-
* progress: z.number(),
|
|
133
|
-
* }),
|
|
134
|
-
* events: {
|
|
135
|
-
* progress: z.object({ percent: z.number() }),
|
|
136
|
-
* done: z.object({ result: z.string() }),
|
|
137
|
-
* },
|
|
138
|
-
* })
|
|
139
|
-
*
|
|
140
|
-
* // Dual-mode POST - has both body and syncResponse
|
|
141
|
-
* const chatCompletion = buildContract({
|
|
142
|
-
* method: 'POST',
|
|
143
|
-
* pathResolver: () => '/api/chat/completions',
|
|
144
|
-
* params: z.object({}),
|
|
145
|
-
* query: z.object({}),
|
|
146
|
-
* requestHeaders: z.object({ authorization: z.string() }),
|
|
147
|
-
* body: z.object({ message: z.string() }),
|
|
148
|
-
* syncResponse: z.object({
|
|
149
|
-
* reply: z.string(),
|
|
150
|
-
* usage: z.object({ tokens: z.number() }),
|
|
151
|
-
* }),
|
|
152
|
-
* events: {
|
|
153
|
-
* chunk: z.object({ delta: z.string() }),
|
|
154
|
-
* done: z.object({ usage: z.object({ total: z.number() }) }),
|
|
155
|
-
* },
|
|
156
|
-
* })
|
|
157
|
-
* ```
|
|
92
|
+
* Configuration for building a GET dual-mode route with multi-format responses.
|
|
93
|
+
* Has multiFormatResponses, forbids requestBody and jsonResponse.
|
|
158
94
|
*/
|
|
159
|
-
export
|
|
160
|
-
|
|
95
|
+
export type MultiFormatGetContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
|
|
96
|
+
pathResolver: PathResolver<z.infer<Params>>;
|
|
97
|
+
params: Params;
|
|
98
|
+
query: Query;
|
|
99
|
+
requestHeaders: RequestHeaders;
|
|
100
|
+
jsonResponse?: never;
|
|
101
|
+
/** Multi-format response schemas */
|
|
102
|
+
multiFormatResponses: Formats;
|
|
103
|
+
responseHeaders?: ResponseHeaders;
|
|
104
|
+
events: Events;
|
|
105
|
+
requestBody?: never;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Configuration for building a POST/PUT/PATCH dual-mode route with multi-format responses.
|
|
109
|
+
* Has both requestBody and multiFormatResponses.
|
|
110
|
+
*/
|
|
111
|
+
export type MultiFormatPayloadContractConfig<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined> = {
|
|
112
|
+
method?: 'POST' | 'PUT' | 'PATCH';
|
|
113
|
+
pathResolver: PathResolver<z.infer<Params>>;
|
|
114
|
+
params: Params;
|
|
115
|
+
query: Query;
|
|
116
|
+
requestHeaders: RequestHeaders;
|
|
117
|
+
requestBody: Body;
|
|
118
|
+
jsonResponse?: never;
|
|
119
|
+
/** Multi-format response schemas */
|
|
120
|
+
multiFormatResponses: Formats;
|
|
121
|
+
responseHeaders?: ResponseHeaders;
|
|
122
|
+
events: Events;
|
|
123
|
+
};
|
|
124
|
+
export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: MultiFormatPayloadContractConfig<Params, Query, RequestHeaders, Body, Formats, Events, ResponseHeaders>): VerboseDualModeContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, Formats, Events, ResponseHeaders>;
|
|
125
|
+
export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Formats extends MultiFormatResponses, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: MultiFormatGetContractConfig<Params, Query, RequestHeaders, Formats, Events, ResponseHeaders>): VerboseDualModeContractDefinition<'GET', Params, Query, RequestHeaders, undefined, Formats, Events, ResponseHeaders>;
|
|
126
|
+
export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: DualModePayloadContractConfig<Params, Query, RequestHeaders, Body, JsonResponse, Events, ResponseHeaders>): SimplifiedDualModeContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, JsonResponse, Events, ResponseHeaders>;
|
|
127
|
+
export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, JsonResponse extends z.ZodTypeAny, Events extends SSEEventSchemas, ResponseHeaders extends z.ZodTypeAny | undefined = undefined>(config: DualModeGetContractConfig<Params, Query, RequestHeaders, JsonResponse, Events, ResponseHeaders>): SimplifiedDualModeContractDefinition<'GET', Params, Query, RequestHeaders, undefined, JsonResponse, Events, ResponseHeaders>;
|
|
161
128
|
export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Body extends z.ZodTypeAny, Events extends SSEEventSchemas>(config: SSEPayloadContractConfig<Params, Query, RequestHeaders, Body, Events>): SSEContractDefinition<'POST' | 'PUT' | 'PATCH', Params, Query, RequestHeaders, Body, Events>;
|
|
162
129
|
export declare function buildContract<Params extends z.ZodTypeAny, Query extends z.ZodTypeAny, RequestHeaders extends z.ZodTypeAny, Events extends SSEEventSchemas>(config: SSEGetContractConfig<Params, Query, RequestHeaders, Events>): SSEContractDefinition<'GET', Params, Query, RequestHeaders, undefined, Events>;
|
|
@@ -1,29 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified contract builder with overloads for SSE-only, simplified dual-mode, and verbose dual-mode contracts.
|
|
3
|
+
*
|
|
4
|
+
* Automatically determines the contract type based on the presence of `requestBody`, `jsonResponse`, and `multiFormatResponses`:
|
|
5
|
+
*
|
|
6
|
+
* | Response Config | requestBody | Result |
|
|
7
|
+
* |-----------------|------|--------|
|
|
8
|
+
* | none | ❌ | SSE GET |
|
|
9
|
+
* | none | ✅ | SSE POST/PUT/PATCH |
|
|
10
|
+
* | jsonResponse | ❌ | Simplified Dual-mode GET |
|
|
11
|
+
* | jsonResponse | ✅ | Simplified Dual-mode POST/PUT/PATCH |
|
|
12
|
+
* | multiFormatResponses | ❌ | Verbose Dual-mode GET |
|
|
13
|
+
* | multiFormatResponses | ✅ | Verbose Dual-mode POST/PUT/PATCH |
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* // SSE GET - no requestBody, no jsonResponse/multiFormatResponses
|
|
18
|
+
* const notificationsStream = buildContract({
|
|
19
|
+
* pathResolver: () => '/api/notifications/stream',
|
|
20
|
+
* params: z.object({}),
|
|
21
|
+
* query: z.object({ userId: z.string().optional() }),
|
|
22
|
+
* requestHeaders: z.object({}),
|
|
23
|
+
* events: { notification: z.object({ id: z.string(), message: z.string() }) },
|
|
24
|
+
* })
|
|
25
|
+
*
|
|
26
|
+
* // Simplified dual-mode POST (recommended) - single JSON format
|
|
27
|
+
* const chatCompletion = buildContract({
|
|
28
|
+
* method: 'POST',
|
|
29
|
+
* pathResolver: () => '/api/chat/completions',
|
|
30
|
+
* params: z.object({}),
|
|
31
|
+
* query: z.object({}),
|
|
32
|
+
* requestHeaders: z.object({}),
|
|
33
|
+
* requestBody: z.object({ message: z.string() }),
|
|
34
|
+
* jsonResponse: z.object({ reply: z.string(), usage: z.object({ tokens: z.number() }) }),
|
|
35
|
+
* events: { chunk: z.object({ delta: z.string() }), done: z.object({ usage: z.object({ total: z.number() }) }) },
|
|
36
|
+
* })
|
|
37
|
+
*
|
|
38
|
+
* // Verbose dual-mode POST - multiple format support
|
|
39
|
+
* const exportData = buildContract({
|
|
40
|
+
* method: 'POST',
|
|
41
|
+
* pathResolver: () => '/api/export',
|
|
42
|
+
* params: z.object({}),
|
|
43
|
+
* query: z.object({}),
|
|
44
|
+
* requestHeaders: z.object({}),
|
|
45
|
+
* requestBody: z.object({ format: z.string() }),
|
|
46
|
+
* multiFormatResponses: {
|
|
47
|
+
* 'application/json': z.object({ data: z.array(z.unknown()) }),
|
|
48
|
+
* 'text/csv': z.string(),
|
|
49
|
+
* 'text/plain': z.string(),
|
|
50
|
+
* },
|
|
51
|
+
* events: { progress: z.object({ percent: z.number() }), done: z.object({ rowCount: z.number() }) },
|
|
52
|
+
* })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
// Helper to build base contract fields
|
|
56
|
+
// biome-ignore lint/suspicious/noExplicitAny: Config union type
|
|
57
|
+
function buildBaseFields(config, hasBody) {
|
|
58
|
+
return {
|
|
59
|
+
pathResolver: config.pathResolver,
|
|
60
|
+
params: config.params,
|
|
61
|
+
query: config.query,
|
|
62
|
+
requestHeaders: config.requestHeaders,
|
|
63
|
+
requestBody: hasBody ? config.requestBody : undefined,
|
|
64
|
+
events: config.events,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Helper to determine method
|
|
68
|
+
function determineMethod(config, hasBody, defaultMethod) {
|
|
69
|
+
return hasBody ? (config.method ?? defaultMethod) : 'GET';
|
|
70
|
+
}
|
|
1
71
|
// Implementation
|
|
2
72
|
export function buildContract(config) {
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
|
|
73
|
+
const hasMultiFormat = 'multiFormatResponses' in config && config.multiFormatResponses !== undefined;
|
|
74
|
+
const hasJsonResponse = 'jsonResponse' in config && config.jsonResponse !== undefined;
|
|
75
|
+
const hasBody = 'requestBody' in config && config.requestBody !== undefined;
|
|
76
|
+
const base = buildBaseFields(config, hasBody);
|
|
77
|
+
if (hasMultiFormat) {
|
|
78
|
+
// Verbose multi-format contract
|
|
6
79
|
return {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
query: config.query,
|
|
11
|
-
requestHeaders: config.requestHeaders,
|
|
12
|
-
body: hasBody ? config.body : undefined,
|
|
13
|
-
syncResponse: config.syncResponse,
|
|
80
|
+
...base,
|
|
81
|
+
method: determineMethod(config, hasBody, 'POST'),
|
|
82
|
+
multiFormatResponses: config.multiFormatResponses,
|
|
14
83
|
responseHeaders: config.responseHeaders,
|
|
15
|
-
events: config.events,
|
|
16
84
|
isDualMode: true,
|
|
85
|
+
isVerbose: true,
|
|
17
86
|
};
|
|
18
87
|
}
|
|
88
|
+
if (hasJsonResponse) {
|
|
89
|
+
// Simplified single-JSON-format contract
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
method: determineMethod(config, hasBody, 'POST'),
|
|
93
|
+
jsonResponse: config.jsonResponse,
|
|
94
|
+
responseHeaders: config.responseHeaders,
|
|
95
|
+
isDualMode: true,
|
|
96
|
+
isSimplified: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// SSE-only contract
|
|
19
100
|
return {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
params: config.params,
|
|
23
|
-
query: config.query,
|
|
24
|
-
requestHeaders: config.requestHeaders,
|
|
25
|
-
body: hasBody ? config.body : undefined,
|
|
26
|
-
events: config.events,
|
|
101
|
+
...base,
|
|
102
|
+
method: determineMethod(config, hasBody, 'POST'),
|
|
27
103
|
isSSE: true,
|
|
28
104
|
};
|
|
29
105
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"contractBuilders.js","sourceRoot":"","sources":["../../../lib/contracts/contractBuilders.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"contractBuilders.js","sourceRoot":"","sources":["../../../lib/contracts/contractBuilders.ts"],"names":[],"mappings":"AAgLA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAEH,uCAAuC;AACvC,gEAAgE;AAChE,SAAS,eAAe,CAAC,MAAW,EAAE,OAAgB;IACpD,OAAO;QACL,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,cAAc,EAAE,MAAM,CAAC,cAAc;QACrC,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;QACrD,MAAM,EAAE,MAAM,CAAC,MAAM;KACtB,CAAA;AACH,CAAC;AAED,6BAA6B;AAC7B,SAAS,eAAe,CAAC,MAA2B,EAAE,OAAgB,EAAE,aAAqB;IAC3F,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,IAAI,aAAa,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;AAC3D,CAAC;AA2ID,iBAAiB;AACjB,MAAM,UAAU,aAAa,CAC3B,MAW4C;IAG5C,MAAM,cAAc,GAClB,sBAAsB,IAAI,MAAM,IAAI,MAAM,CAAC,oBAAoB,KAAK,SAAS,CAAA;IAC/E,MAAM,eAAe,GAAG,cAAc,IAAI,MAAM,IAAI,MAAM,CAAC,YAAY,KAAK,SAAS,CAAA;IACrF,MAAM,OAAO,GAAG,aAAa,IAAI,MAAM,IAAI,MAAM,CAAC,WAAW,KAAK,SAAS,CAAA;IAC3E,MAAM,IAAI,GAAG,eAAe,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAE7C,IAAI,cAAc,EAAE,CAAC;QACnB,gCAAgC;QAChC,OAAO;YACL,GAAG,IAAI;YACP,MAAM,EAAE,eAAe,CAAC,MAA6B,EAAE,OAAO,EAAE,MAAM,CAAC;YACvE,oBAAoB,EAAG,MAA4C,CAAC,oBAAoB;YACxF,eAAe,EAAG,MAAwC,CAAC,eAAe;YAC1E,UAAU,EAAE,IAAI;YAChB,SAAS,EAAE,IAAI;SAChB,CAAA;IACH,CAAC;IAED,IAAI,eAAe,EAAE,CAAC;QACpB,yCAAyC;QACzC,OAAO;YACL,GAAG,IAAI;YACP,MAAM,EAAE,eAAe,CAAC,MAA6B,EAAE,OAAO,EAAE,MAAM,CAAC;YACvE,YAAY,EAAG,MAAoC,CAAC,YAAY;YAChE,eAAe,EAAG,MAAwC,CAAC,eAAe;YAC1E,UAAU,EAAE,IAAI;YAChB,YAAY,EAAE,IAAI;SACnB,CAAA;IACH,CAAC;IAED,oBAAoB;IACpB,OAAO;QACL,GAAG,IAAI;QACP,MAAM,EAAE,eAAe,CAAC,MAA6B,EAAE,OAAO,EAAE,MAAM,CAAC;QACvE,KAAK,EAAE,IAAI;KACZ,CAAA;AACH,CAAC"}
|