@powerduck/request 0.1.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/dist/index.cjs +1 -0
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +1 -0
- package/dist/protocol-58EyJAsY.d.cts +372 -0
- package/dist/protocol-58EyJAsY.d.ts +372 -0
- package/dist/protocols/http/index.cjs +1 -0
- package/dist/protocols/http/index.d.cts +86 -0
- package/dist/protocols/http/index.d.ts +86 -0
- package/dist/protocols/http/index.js +1 -0
- package/dist/protocols/ws/index.cjs +1 -0
- package/dist/protocols/ws/index.d.cts +53 -0
- package/dist/protocols/ws/index.d.ts +53 -0
- package/dist/protocols/ws/index.js +1 -0
- package/package.json +83 -0
- package/readme.md +463 -0
package/readme.md
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# @powerduck/request
|
|
2
|
+
|
|
3
|
+
Turn an OpenAPI 3.2 document into real HTTP, SSE, and WebSocket traffic — then feed the
|
|
4
|
+
responses back into the document as inferred `response` fragments.
|
|
5
|
+
|
|
6
|
+
`@powerduck/request` runs on top of **postman-runtime**, so everything you already know from
|
|
7
|
+
Postman works here: pre-request scripts, test scripts, assertions, variable scopes, dynamic
|
|
8
|
+
variables. Nothing is reimplemented, nothing is stripped out.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @powerduck/request
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Node.js 18 or newer.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Writing OpenAPI by hand drifts from reality. Recording traffic gives you reality but not a
|
|
21
|
+
schema. This library closes the loop: you point it at an operation in your spec, it builds a
|
|
22
|
+
Postman collection under the hood, sends the request against a live server, infers a schema
|
|
23
|
+
from what actually came back, and merges that schema into your original document — without
|
|
24
|
+
clobbering the parts you wrote by hand.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import fs from "node:fs/promises";
|
|
32
|
+
import { createDebugger } from "@powerduck/request";
|
|
33
|
+
|
|
34
|
+
const spec = JSON.parse(await fs.readFile("./openapi.json", "utf8"));
|
|
35
|
+
|
|
36
|
+
const pk = createDebugger();
|
|
37
|
+
|
|
38
|
+
const result = await pk.send({
|
|
39
|
+
spec,
|
|
40
|
+
target: { operationId: "getUser" },
|
|
41
|
+
values: { path: { id: "1024" } },
|
|
42
|
+
serverUrl: "https://api.example.com/v1",
|
|
43
|
+
auth: { type: "bearer", token: process.env.API_TOKEN },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
console.log(result.response.status); // 200
|
|
47
|
+
console.log(result.responseFragment); // inferred OpenAPI response object
|
|
48
|
+
await fs.writeFile(
|
|
49
|
+
"./openapi.json",
|
|
50
|
+
JSON.stringify(result.patchedSpec, null, 2),
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`result.patchedSpec` is your original document with the inferred fragment merged in. If the
|
|
55
|
+
write-back was skipped — for example because a test assertion failed — `patchedSpec` is
|
|
56
|
+
`undefined` and `writeBackSkippedReason` tells you why.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Core concepts
|
|
61
|
+
|
|
62
|
+
### The debugger instance
|
|
63
|
+
|
|
64
|
+
`createDebugger(options)` returns an object with three methods:
|
|
65
|
+
|
|
66
|
+
| Method | Purpose |
|
|
67
|
+
| ------------------------------------- | ------------------------------------------------------------------------- |
|
|
68
|
+
| `send(request)` | Execute one operation and infer its response. |
|
|
69
|
+
| `sendMany(spec, requests, shared)` | Execute several operations, accumulating schema changes across the batch. |
|
|
70
|
+
| `toCollection(spec, target, options)` | Build a Postman collection and environment **without sending anything**. |
|
|
71
|
+
|
|
72
|
+
### Targeting an operation
|
|
73
|
+
|
|
74
|
+
Either by `operationId`, or by path plus method:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
target: { operationId: "getUser" }
|
|
78
|
+
target: { path: "/users/{id}", method: "get" }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Supplying values
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
values: {
|
|
85
|
+
path: { id: "1024" },
|
|
86
|
+
query: { include: ["profile", "roles"] },
|
|
87
|
+
header: { "X-Request-Id": "abc" },
|
|
88
|
+
body: { sku: "A-1", qty: 2 },
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Anything you omit is generated from the schema, honoring `default`, `example`, `examples`,
|
|
93
|
+
and `enum`. Generation stops descending at depth 2 and drops optional fields beyond that, so
|
|
94
|
+
deeply recursive schemas will not blow up.
|
|
95
|
+
|
|
96
|
+
### Server resolution
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
serverUrl: "http://127.0.0.1:4000/v1", // explicit override, wins over the spec
|
|
100
|
+
serverVariables: { host: "staging.example.com" }, // fills in `servers[].variables`
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Protocols
|
|
106
|
+
|
|
107
|
+
Protocol selection is automatic. Each registered adapter scores the operation; the highest
|
|
108
|
+
score wins, with plain HTTP as the score-1 fallback. `result.protocol` tells you what ran.
|
|
109
|
+
|
|
110
|
+
### HTTP
|
|
111
|
+
|
|
112
|
+
The default path. Nothing special required.
|
|
113
|
+
|
|
114
|
+
### Server-Sent Events
|
|
115
|
+
|
|
116
|
+
Triggered by a `text/event-stream` response content type. SSE shares the exact same runtime
|
|
117
|
+
execution path as regular HTTP — scripts and assertions still run — and events arrive
|
|
118
|
+
incrementally through `onEvent`.
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
const sse = await pk.send({
|
|
122
|
+
spec,
|
|
123
|
+
target: { operationId: "streamChat" },
|
|
124
|
+
values: { body: { model: "demo-model", stream: true, messages } },
|
|
125
|
+
serverUrl: BASE_URL,
|
|
126
|
+
maxEvents: 200,
|
|
127
|
+
maxStreamMs: 60_000,
|
|
128
|
+
onEvent: (event) => {
|
|
129
|
+
if (event.data.trim() === "[DONE]") return;
|
|
130
|
+
process.stdout.write(event.parsed?.choices?.[0]?.delta?.content ?? "");
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
console.log(sse.response.events.length);
|
|
135
|
+
console.log(sse.response.truncated); // true if maxEvents or maxStreamMs cut it short
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The parser handles named events (`event:`), event IDs (`id:`), `retry:`, comment lines, and
|
|
139
|
+
multi-line `data:` payloads. Non-JSON payloads such as `[DONE]` are left as raw strings in
|
|
140
|
+
`event.data`; `event.parsed` is simply `undefined` for them.
|
|
141
|
+
|
|
142
|
+
> **Do not pass `requester.maxResponseSize: 0` on a streaming request.** That option is a byte
|
|
143
|
+
> ceiling, and `0` means "zero bytes allowed" — it will terminate the stream on the first
|
|
144
|
+
> chunk and you will silently receive no events. Omit it, or pass a real limit.
|
|
145
|
+
|
|
146
|
+
### WebSocket
|
|
147
|
+
|
|
148
|
+
Triggered by `x-protocol: websocket` on the operation. Backed by the `ws` package on a
|
|
149
|
+
separate path from the runtime, so **Postman scripts do not apply to WebSocket sessions**. If
|
|
150
|
+
you need assertions there, drive `postman-sandbox` yourself.
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
const socket = await pk.send({
|
|
154
|
+
spec,
|
|
155
|
+
target: { operationId: "joinRoom" },
|
|
156
|
+
values: { path: { room: "general" }, query: { since: "0" } },
|
|
157
|
+
serverUrl: BASE_URL,
|
|
158
|
+
websocket: {
|
|
159
|
+
subprotocols: ["json.v1"],
|
|
160
|
+
headers: { "X-Client": "powerduck" },
|
|
161
|
+
send: [{ type: "subscribe", channel: "messages" }],
|
|
162
|
+
sendDelayMs: 200,
|
|
163
|
+
maxMessages: 25,
|
|
164
|
+
maxSessionMs: 20_000,
|
|
165
|
+
idleTimeoutMs: 8_000,
|
|
166
|
+
keepAlive: { intervalMs: 10_000, payload: "ping" },
|
|
167
|
+
closeCode: 1000,
|
|
168
|
+
closeReason: "done",
|
|
169
|
+
rejectUnauthorized: true,
|
|
170
|
+
},
|
|
171
|
+
onOpen: (info) => console.log(info.url, info.protocol),
|
|
172
|
+
onEvent: (e) => console.log(e.direction === "out" ? "-->" : "<--", e.data),
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Streaming protocols all normalize into the same `response.events[]` shape, which is what the
|
|
177
|
+
write-back layer inspects to build an `itemSchema`.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Scripts and assertions
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
await pk.send({
|
|
185
|
+
spec,
|
|
186
|
+
target: { operationId: "getUser" },
|
|
187
|
+
values: { path: { id: "1024" } },
|
|
188
|
+
serverUrl: BASE_URL,
|
|
189
|
+
variables: { tenantId: "acme" }, // environment scope
|
|
190
|
+
globals: { appVersion: "2.3.1" }, // global scope
|
|
191
|
+
|
|
192
|
+
scripts: {
|
|
193
|
+
collectionPreRequest: {
|
|
194
|
+
exec: [
|
|
195
|
+
"pm.request.headers.upsert({ key: 'X-Tenant', value: pm.environment.get('tenantId') });",
|
|
196
|
+
"pm.request.headers.upsert({ key: 'X-Trace-Id', value: pm.variables.replaceIn('{{$guid}}') });",
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
test: [
|
|
200
|
+
BUILTIN_CAPTURE_TEST,
|
|
201
|
+
{
|
|
202
|
+
id: "latency-budget",
|
|
203
|
+
exec: "pm.test('under 2s', () => pm.expect(pm.response.responseTime).to.be.below(2000));",
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
onConsole: (log) => console.log("[script]", log.level, ...log.messages),
|
|
209
|
+
onAssertion: (a) => console.log(a.passed ? "PASS" : "FAIL", a.name),
|
|
210
|
+
onResponseStart: (info) => console.log(info.status, info.contentType),
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Scripts declared in the spec via `x-postman-scripts` (at document level or operation level)
|
|
215
|
+
are picked up automatically and run before the ones you pass inline.
|
|
216
|
+
|
|
217
|
+
`BUILTIN_CAPTURE_TEST` is an exported script that records the response body for schema
|
|
218
|
+
inference. Include it whenever you supply your own `test` array and still want write-back.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Runner options
|
|
223
|
+
|
|
224
|
+
Every documented `runner.run()` option is forwarded verbatim. Merge precedence is:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
library defaults < convenience fields < your `runner` object
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
runner: {
|
|
232
|
+
iterationCount: 1,
|
|
233
|
+
stopOnError: false,
|
|
234
|
+
timeout: { request: 20_000, script: 10_000 },
|
|
235
|
+
delay: { item: 0, iteration: 0 },
|
|
236
|
+
fileResolver: fs,
|
|
237
|
+
requester: {
|
|
238
|
+
strictSSL: true,
|
|
239
|
+
followRedirects: true,
|
|
240
|
+
maxRedirects: 5,
|
|
241
|
+
maxResponseSize: 8 * 1024 * 1024,
|
|
242
|
+
timings: true,
|
|
243
|
+
verbose: true,
|
|
244
|
+
systemHeaders: { "User-Agent": "powerduck-request/0.1.0" },
|
|
245
|
+
network: { restrictedAddresses: { "169.254.169.254": true } },
|
|
246
|
+
agents: {
|
|
247
|
+
http: { agentClass: http.Agent, agentOptions: { keepAlive: true } },
|
|
248
|
+
https: new https.Agent({ keepAlive: true }),
|
|
249
|
+
},
|
|
250
|
+
authorizer: {
|
|
251
|
+
refreshOAuth2Token(id, callback) { callback(null, freshToken); },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
secretResolver({ secrets }, callback) { /* ... */ },
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Schema inference and write-back
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
const pk = createDebugger({
|
|
264
|
+
writeBack: {
|
|
265
|
+
strategy: "merge", // "merge" | "replace" | "off"
|
|
266
|
+
requirePassingTests: true, // skip write-back if any assertion failed
|
|
267
|
+
protectComponentRefs: true, // never merge into a `$ref` target
|
|
268
|
+
keepExistingDescription: true,
|
|
269
|
+
},
|
|
270
|
+
response: {
|
|
271
|
+
includeExamples: true,
|
|
272
|
+
maxExampleChars: 4000,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Rules worth knowing before you turn this loose on a real document:
|
|
278
|
+
|
|
279
|
+
- **`$ref` is never followed for writing.** If a response schema is a reference, the fragment
|
|
280
|
+
is produced but the reference is left untouched. Your shared components stay yours.
|
|
281
|
+
- **`required` is intersected across observations.** A field seen in one response but missing
|
|
282
|
+
from another drops out of `required` rather than producing a contradiction.
|
|
283
|
+
- **Examples use the OpenAPI 3.1+ array form** (`examples: [...]` inside a schema), which is
|
|
284
|
+
what 3.2 expects — not the deprecated singular `example`.
|
|
285
|
+
- **Descriptions you wrote are preserved** when `keepExistingDescription` is on.
|
|
286
|
+
- **A failed assertion blocks the merge** when `requirePassingTests` is on, so a broken
|
|
287
|
+
endpoint cannot poison your spec.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Batch replay
|
|
292
|
+
|
|
293
|
+
`sendMany` threads the evolving spec through the whole list, so later requests see schemas
|
|
294
|
+
learned from earlier ones.
|
|
295
|
+
|
|
296
|
+
```js
|
|
297
|
+
const batch = await pk.sendMany(
|
|
298
|
+
spec,
|
|
299
|
+
[
|
|
300
|
+
{ target: { operationId: "getUser" }, values: { path: { id: "1" } } },
|
|
301
|
+
{ target: { operationId: "getUser" }, values: { path: { id: "2" } } },
|
|
302
|
+
{
|
|
303
|
+
target: { operationId: "createOrder" },
|
|
304
|
+
values: { body: { sku: "A-1", qty: 2 } },
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
{ serverUrl: BASE_URL, auth: { type: "bearer", token: TOKEN } },
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
for (const entry of batch.results) {
|
|
311
|
+
if ("error" in entry) console.warn("failed:", entry.error);
|
|
312
|
+
else console.log("ok:", entry.request.method, entry.response.status);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await fs.writeFile(
|
|
316
|
+
"./openapi.patched.json",
|
|
317
|
+
JSON.stringify(batch.spec, null, 2),
|
|
318
|
+
);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
One failing request does not abort the batch — failures land in `results` as entries carrying
|
|
322
|
+
an `error` field.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Exporting to the Postman app
|
|
327
|
+
|
|
328
|
+
No network traffic, just artifacts:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
const exported = pk.toCollection(
|
|
332
|
+
spec,
|
|
333
|
+
{ operationId: "getUser" },
|
|
334
|
+
{
|
|
335
|
+
serverUrl: BASE_URL,
|
|
336
|
+
values: { path: { id: "{{userId}}" } },
|
|
337
|
+
variables: { userId: "1024", token: TOKEN },
|
|
338
|
+
auth: { type: "bearer", token: "{{token}}" },
|
|
339
|
+
scripts: { test: { exec: "pm.test('ok', () => pm.response.to.be.ok);" } },
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
console.log(exported.protocol, exported.streaming);
|
|
344
|
+
await fs.writeFile(
|
|
345
|
+
"collection.json",
|
|
346
|
+
JSON.stringify(exported.collection, null, 2),
|
|
347
|
+
);
|
|
348
|
+
await fs.writeFile(
|
|
349
|
+
"environment.json",
|
|
350
|
+
JSON.stringify(exported.environment, null, 2),
|
|
351
|
+
);
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Both files import directly into Postman.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## API reference
|
|
359
|
+
|
|
360
|
+
### `createDebugger(options?): Debugger`
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
interface DebuggerOptions {
|
|
364
|
+
writeBack?: {
|
|
365
|
+
strategy?: "merge" | "replace" | "off";
|
|
366
|
+
requirePassingTests?: boolean;
|
|
367
|
+
protectComponentRefs?: boolean;
|
|
368
|
+
keepExistingDescription?: boolean;
|
|
369
|
+
};
|
|
370
|
+
response?: {
|
|
371
|
+
includeExamples?: boolean;
|
|
372
|
+
maxExampleChars?: number;
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### `send(request): Promise<SendResult>`
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
interface SendResult {
|
|
381
|
+
protocol: "http" | "sse" | "websocket" | string;
|
|
382
|
+
request: {
|
|
383
|
+
method: string;
|
|
384
|
+
url: string;
|
|
385
|
+
headers: Record<string, string>;
|
|
386
|
+
body?: unknown;
|
|
387
|
+
};
|
|
388
|
+
response: {
|
|
389
|
+
status: number;
|
|
390
|
+
statusText: string;
|
|
391
|
+
headers: Record<string, string>;
|
|
392
|
+
body?: unknown;
|
|
393
|
+
events?: StreamEvent[];
|
|
394
|
+
truncated?: boolean;
|
|
395
|
+
timings: { durationMs: number; firstByteMs: number };
|
|
396
|
+
};
|
|
397
|
+
scripts?: { passed: boolean; assertions: Assertion[] };
|
|
398
|
+
replays: ReplayRecord[];
|
|
399
|
+
responseFragment: OpenAPIResponseObject;
|
|
400
|
+
patchedSpec?: OpenAPIDocument;
|
|
401
|
+
writeBackSkippedReason?: string;
|
|
402
|
+
error?: Error;
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Callbacks
|
|
407
|
+
|
|
408
|
+
| Callback | Fires when |
|
|
409
|
+
| ------------------------ | ----------------------------------------- |
|
|
410
|
+
| `onResponseStart(info)` | Response headers arrive, before the body. |
|
|
411
|
+
| `onEvent(event)` | Each SSE event or WebSocket frame. |
|
|
412
|
+
| `onOpen(info)` | WebSocket handshake completes. |
|
|
413
|
+
| `onConsole(log)` | A script calls `console.*`. |
|
|
414
|
+
| `onAssertion(assertion)` | Each `pm.test` resolves. |
|
|
415
|
+
|
|
416
|
+
### Exports
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
import {
|
|
420
|
+
createDebugger,
|
|
421
|
+
BUILTIN_CAPTURE_TEST,
|
|
422
|
+
registerAdapter,
|
|
423
|
+
ProtocolError,
|
|
424
|
+
SpecError,
|
|
425
|
+
} from "@powerduck/request";
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Extending with a custom protocol
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { registerAdapter } from "@powerduck/request";
|
|
434
|
+
|
|
435
|
+
registerAdapter({
|
|
436
|
+
name: "grpc",
|
|
437
|
+
score(operation) {
|
|
438
|
+
return operation["x-protocol"] === "grpc" ? 10 : 0;
|
|
439
|
+
},
|
|
440
|
+
async execute(context) {
|
|
441
|
+
// ...
|
|
442
|
+
return { status: 200, statusText: "OK", headers: {}, events: [], timings };
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Return `events[]` for anything stream-shaped and the inference layer will derive an
|
|
448
|
+
`itemSchema` for you with no additional work.
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Known limitations
|
|
453
|
+
|
|
454
|
+
- Aborting a stream at `maxEvents` aborts the run, which means **test scripts do not execute**
|
|
455
|
+
on a truncated stream. Sampling and assertions are mutually exclusive for SSE.
|
|
456
|
+
- WebSocket sessions bypass the Postman runtime entirely, so no script or assertion support.
|
|
457
|
+
- Referenced schemas (`$ref`) are read but never written.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## License
|
|
462
|
+
|
|
463
|
+
Apache
|