@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/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