@zenera/faker 1.1.21 → 1.1.22
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 +12 -11
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +57 -1
- package/dist/command.js +11 -1
- package/dist/generate.d.ts +10 -0
- package/dist/generate.js +50 -3
- package/dist/logger.d.ts +62 -0
- package/dist/logger.js +273 -0
- package/dist/paging.d.ts +31 -3
- package/dist/paging.js +315 -6
- package/dist/probe.js +11 -0
- package/dist/prompt.d.ts +14 -0
- package/dist/prompt.js +38 -5
- package/dist/server.d.ts +8 -0
- package/dist/server.js +323 -46
- package/dist/spec.d.ts +5 -0
- package/dist/spec.js +7 -1
- package/package.json +3 -3
package/dist/server.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { createHash, randomInt } from 'node:crypto';
|
|
1
|
+
import { createHash, randomInt, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { createServer } from 'node:http';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { BuildFailed } from "./cache.js";
|
|
4
6
|
import { reason } from "./generate.js";
|
|
7
|
+
import { formatLogLine, formatRegenerated, formatRegenerating, formatRegenLimitHit, RegenerationLimiter, RequestHistory, saveRequestDump, } from "./logger.js";
|
|
5
8
|
import { indexPage } from "./page.js";
|
|
6
|
-
import { cutLoop, pagingSeen } from "./paging.js";
|
|
9
|
+
import { cutLoop, getSentToken, PaginationLimiter, pagingSeen, } from "./paging.js";
|
|
7
10
|
import { describeIssues, issues } from "./validate.js";
|
|
8
11
|
// ---------------------------------------------------------------------------
|
|
9
12
|
// The server
|
|
@@ -23,11 +26,22 @@ const REDACTED = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
|
23
26
|
const SECRETISH = /key|token|secret|password|credential/i;
|
|
24
27
|
const DEFAULT_MAX_BODY = 1024 * 1024;
|
|
25
28
|
export function build(opts) {
|
|
29
|
+
const history = opts.history ?? new RequestHistory();
|
|
30
|
+
const limiter = opts.limiter ?? new RegenerationLimiter(opts.regenLimit ?? 3);
|
|
31
|
+
const paginationLimiter = opts.paginationLimiter ?? new PaginationLimiter(opts.maxPages);
|
|
32
|
+
const requestsDir = opts.requestsDir ?? join(opts.box.root, 'requests');
|
|
33
|
+
const enriched = {
|
|
34
|
+
...opts,
|
|
35
|
+
history,
|
|
36
|
+
limiter,
|
|
37
|
+
paginationLimiter,
|
|
38
|
+
requestsDir,
|
|
39
|
+
};
|
|
26
40
|
return createServer((req, res) => {
|
|
27
|
-
handle(req, res,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
41
|
+
handle(req, res, enriched).catch((err) => {
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
send(res, 500, { error: message });
|
|
44
|
+
opts.onRequest?.(`${req.method ?? 'GET'} ${req.url ?? '/'} 500 [SERVER_INTERNAL_ERROR] ${message}`);
|
|
31
45
|
});
|
|
32
46
|
});
|
|
33
47
|
}
|
|
@@ -54,14 +68,60 @@ export async function listen(opts, host, port) {
|
|
|
54
68
|
// ---------------------------------------------------------------------------
|
|
55
69
|
async function handle(req, res, opts) {
|
|
56
70
|
const started = Date.now();
|
|
71
|
+
const timestamp = new Date(started);
|
|
72
|
+
const id = randomUUID().slice(0, 8);
|
|
57
73
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
58
74
|
const method = (req.method ?? 'GET').toLowerCase();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
const finish = async (status, meta) => {
|
|
76
|
+
const responseHeaders = Object.fromEntries(Object.entries(res.getHeaders()).map(([k, v]) => [k, String(v)]));
|
|
77
|
+
const dumpData = {
|
|
78
|
+
id,
|
|
79
|
+
timestamp,
|
|
80
|
+
durationMs: Date.now() - started,
|
|
81
|
+
method,
|
|
82
|
+
url: req.url ?? '/',
|
|
83
|
+
pathname: url.pathname,
|
|
84
|
+
search: url.search,
|
|
85
|
+
operationId: meta.operationId,
|
|
86
|
+
routePath: meta.routePath,
|
|
87
|
+
pathParams: meta.pathParams,
|
|
88
|
+
query: meta.query ?? Object.fromEntries(url.searchParams),
|
|
89
|
+
requestHeaders: safeHeaders(req),
|
|
90
|
+
requestBody: meta.requestBody,
|
|
91
|
+
status,
|
|
92
|
+
responseHeaders,
|
|
93
|
+
responseBody: meta.responseBody,
|
|
94
|
+
errorKind: meta.errorKind,
|
|
95
|
+
errorMessage: meta.errorMessage,
|
|
96
|
+
stderr: meta.stderr,
|
|
97
|
+
cacheStatus: meta.cacheStatus,
|
|
98
|
+
regenerated: meta.regenerated,
|
|
99
|
+
regenAttempts: meta.regenAttempts,
|
|
100
|
+
regenLimitHit: meta.regenLimitHit,
|
|
101
|
+
regenLimit: opts.limiter?.limit,
|
|
102
|
+
issues: meta.issues,
|
|
103
|
+
note: meta.note,
|
|
104
|
+
};
|
|
105
|
+
let dumpPath = '';
|
|
106
|
+
const shouldDump = Boolean(opts.requestsDir &&
|
|
107
|
+
url.pathname !== '/' &&
|
|
108
|
+
!url.pathname.startsWith('/__faker/') &&
|
|
109
|
+
meta.operationId);
|
|
110
|
+
if (shouldDump) {
|
|
111
|
+
try {
|
|
112
|
+
dumpPath = await saveRequestDump(opts.requestsDir, dumpData);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Ignore filesystem dump write failures so server remains operational
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (opts.onRequest) {
|
|
119
|
+
opts.onRequest(formatLogLine(dumpData, dumpPath));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
62
122
|
if (url.pathname.startsWith('/__faker/')) {
|
|
63
123
|
introspect(url.pathname, res, opts);
|
|
64
|
-
|
|
124
|
+
await finish(200, { note: 'introspection' });
|
|
65
125
|
return;
|
|
66
126
|
}
|
|
67
127
|
const match = opts.router.match(method, url.pathname, url.searchParams);
|
|
@@ -69,32 +129,54 @@ async function handle(req, res, opts) {
|
|
|
69
129
|
// The document owns `/` only when it declares it. Otherwise the root is
|
|
70
130
|
// the contents page, which is what a browser pointed here came for.
|
|
71
131
|
if (url.pathname === '/' && (method === 'get' || method === 'head')) {
|
|
72
|
-
|
|
73
|
-
|
|
132
|
+
const end = prepareHtml(res, indexPage(opts.router.operations));
|
|
133
|
+
await finish(200, { note: 'index' });
|
|
134
|
+
end();
|
|
74
135
|
return;
|
|
75
136
|
}
|
|
76
137
|
// The path matched and the query did not, which no one guesses unaided
|
|
77
138
|
// — and it is not a 405 either, since the method is defined here.
|
|
78
139
|
const expects = opts.router.expects(method, url.pathname);
|
|
79
140
|
if (expects.length > 0) {
|
|
80
|
-
|
|
141
|
+
const bodyObj = {
|
|
81
142
|
error: `${req.method} ${url.pathname} is only defined with a query`,
|
|
82
143
|
expects,
|
|
144
|
+
};
|
|
145
|
+
const end = prepareSend(res, 404, bodyObj);
|
|
146
|
+
await finish(404, {
|
|
147
|
+
errorKind: 'CLIENT_ERROR: NOT_FOUND',
|
|
148
|
+
errorMessage: `expects ${expects.join(' ')}`,
|
|
149
|
+
responseBody: bodyObj,
|
|
150
|
+
note: `expects ${expects.join(' ')}`,
|
|
83
151
|
});
|
|
84
|
-
|
|
152
|
+
end();
|
|
85
153
|
return;
|
|
86
154
|
}
|
|
87
155
|
const allowed = opts.router.allowed(url.pathname, url.searchParams);
|
|
88
156
|
if (allowed.length > 0) {
|
|
89
157
|
res.setHeader('allow', allowed.map((m) => m.toUpperCase()).join(', '));
|
|
90
|
-
|
|
91
|
-
|
|
158
|
+
const bodyObj = { error: `${req.method} is not defined for ${url.pathname}` };
|
|
159
|
+
const end = prepareSend(res, 405, bodyObj);
|
|
160
|
+
await finish(405, {
|
|
161
|
+
errorKind: 'CLIENT_ERROR: METHOD_NOT_ALLOWED',
|
|
162
|
+
errorMessage: 'no such method',
|
|
163
|
+
responseBody: bodyObj,
|
|
164
|
+
note: 'no such method',
|
|
165
|
+
});
|
|
166
|
+
end();
|
|
92
167
|
return;
|
|
93
168
|
}
|
|
94
|
-
|
|
169
|
+
const bodyObj = {
|
|
95
170
|
error: `no operation matches ${req.method} ${url.pathname}`,
|
|
171
|
+
};
|
|
172
|
+
const end = prepareSend(res, 404, bodyObj);
|
|
173
|
+
await finish(404, {
|
|
174
|
+
errorKind: 'CLIENT_ERROR: NOT_FOUND',
|
|
175
|
+
errorMessage: 'no route',
|
|
176
|
+
responseBody: bodyObj,
|
|
177
|
+
note: 'no route',
|
|
96
178
|
});
|
|
97
|
-
|
|
179
|
+
end();
|
|
98
180
|
return;
|
|
99
181
|
}
|
|
100
182
|
const { operation, pathParams } = match;
|
|
@@ -105,20 +187,51 @@ async function handle(req, res, opts) {
|
|
|
105
187
|
}
|
|
106
188
|
catch (err) {
|
|
107
189
|
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
-
|
|
109
|
-
|
|
190
|
+
const status = message.includes('too large') ? 413 : 400;
|
|
191
|
+
const errorKind = status === 413 ? 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' : 'CLIENT_ERROR: BAD_REQUEST';
|
|
192
|
+
const bodyObj = { error: message };
|
|
193
|
+
const end = prepareSend(res, status, bodyObj);
|
|
194
|
+
await finish(status, {
|
|
195
|
+
operationId: operation.operationId,
|
|
196
|
+
routePath: operation.path,
|
|
197
|
+
pathParams,
|
|
198
|
+
errorKind,
|
|
199
|
+
errorMessage: message,
|
|
200
|
+
responseBody: bodyObj,
|
|
201
|
+
note: status === 413 ? 'body too large' : 'bad body',
|
|
202
|
+
});
|
|
203
|
+
end();
|
|
110
204
|
return;
|
|
111
205
|
}
|
|
112
206
|
const problems = check(operation, opts.checks, pathParams, url.searchParams, body);
|
|
113
207
|
if (problems.length > 0) {
|
|
114
|
-
|
|
115
|
-
|
|
208
|
+
const desc = describeIssues(problems);
|
|
209
|
+
const bodyObj = { error: desc, issues: problems };
|
|
210
|
+
const end = prepareSend(res, 400, bodyObj);
|
|
211
|
+
await finish(400, {
|
|
212
|
+
operationId: operation.operationId,
|
|
213
|
+
routePath: operation.path,
|
|
214
|
+
pathParams,
|
|
215
|
+
requestBody: body,
|
|
216
|
+
errorKind: 'CLIENT_ERROR: VALIDATION_FAILED',
|
|
217
|
+
errorMessage: desc,
|
|
218
|
+
issues: problems,
|
|
219
|
+
responseBody: bodyObj,
|
|
220
|
+
note: desc,
|
|
221
|
+
});
|
|
222
|
+
end();
|
|
116
223
|
return;
|
|
117
224
|
}
|
|
118
225
|
if (!operation.success.schema) {
|
|
119
226
|
res.statusCode = operation.success.status;
|
|
227
|
+
await finish(operation.success.status, {
|
|
228
|
+
operationId: operation.operationId,
|
|
229
|
+
routePath: operation.path,
|
|
230
|
+
pathParams,
|
|
231
|
+
requestBody: body,
|
|
232
|
+
note: 'no body declared',
|
|
233
|
+
});
|
|
120
234
|
res.end();
|
|
121
|
-
say(operation.success.status, 'no body declared');
|
|
122
235
|
return;
|
|
123
236
|
}
|
|
124
237
|
let generator;
|
|
@@ -128,15 +241,27 @@ async function handle(req, res, opts) {
|
|
|
128
241
|
catch (err) {
|
|
129
242
|
const status = err instanceof BuildFailed ? 501 : 500;
|
|
130
243
|
const detail = reason(err);
|
|
131
|
-
|
|
244
|
+
const bodyObj = {
|
|
132
245
|
error: `no generator for ${operation.operationId}`,
|
|
133
246
|
detail,
|
|
134
247
|
diagnostics: err instanceof BuildFailed ? err.diagnostics : undefined,
|
|
248
|
+
};
|
|
249
|
+
const end = prepareSend(res, status, bodyObj);
|
|
250
|
+
await finish(status, {
|
|
251
|
+
operationId: operation.operationId,
|
|
252
|
+
routePath: operation.path,
|
|
253
|
+
pathParams,
|
|
254
|
+
requestBody: body,
|
|
255
|
+
errorKind: 'GENERATOR_BUILD_FAILED',
|
|
256
|
+
errorMessage: `no generator: ${detail}`,
|
|
257
|
+
responseBody: bodyObj,
|
|
258
|
+
note: `no generator: ${detail}`,
|
|
135
259
|
});
|
|
136
|
-
|
|
260
|
+
end();
|
|
137
261
|
return;
|
|
138
262
|
}
|
|
139
263
|
res.setHeader('x-faker-cache', generator.cached ? 'hit' : 'miss');
|
|
264
|
+
let cacheStatus = generator.cached ? 'hit' : 'miss';
|
|
140
265
|
const input = {
|
|
141
266
|
operationId: operation.operationId,
|
|
142
267
|
method: operation.method,
|
|
@@ -147,33 +272,179 @@ async function handle(req, res, opts) {
|
|
|
147
272
|
body,
|
|
148
273
|
seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
|
|
149
274
|
};
|
|
150
|
-
|
|
275
|
+
let outcome = await opts.box.run(operation.key, input);
|
|
276
|
+
const responseValidator = opts.checks.for(operation).response;
|
|
277
|
+
let schemaMismatch = false;
|
|
278
|
+
if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
|
|
279
|
+
schemaMismatch = true;
|
|
280
|
+
const desc = describeIssues(issues('', responseValidator.errors));
|
|
281
|
+
outcome = {
|
|
282
|
+
ok: false,
|
|
283
|
+
fault: `output does not match response schema — ${desc}`,
|
|
284
|
+
stderr: outcome.stderr,
|
|
285
|
+
durationMs: outcome.durationMs,
|
|
286
|
+
value: outcome.value,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
151
289
|
if (!outcome.ok) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
290
|
+
const urlKey = url.pathname;
|
|
291
|
+
const errorKind = outcome.fault?.includes('longer than')
|
|
292
|
+
? 'GENERATOR_TIMEOUT'
|
|
293
|
+
: schemaMismatch
|
|
294
|
+
? 'GENERATOR_SCHEMA_MISMATCH'
|
|
295
|
+
: 'GENERATOR_FAULT';
|
|
296
|
+
// Check regeneration limit for this URL
|
|
297
|
+
if (opts.limiter.isLimitHit(urlKey)) {
|
|
298
|
+
opts.onRequest?.(formatRegenLimitHit(urlKey, opts.limiter.limit, operation.operationId));
|
|
299
|
+
const bodyObj = {
|
|
300
|
+
error: `the generator for ${operation.operationId} ${outcome.fault}`,
|
|
301
|
+
stderr: outcome.stderr || undefined,
|
|
302
|
+
regenerationLimitHit: true,
|
|
303
|
+
regenerationLimit: opts.limiter.limit,
|
|
304
|
+
};
|
|
305
|
+
const end = prepareSend(res, 502, bodyObj);
|
|
306
|
+
await finish(502, {
|
|
307
|
+
operationId: operation.operationId,
|
|
308
|
+
routePath: operation.path,
|
|
309
|
+
pathParams,
|
|
310
|
+
requestBody: body,
|
|
311
|
+
errorKind,
|
|
312
|
+
errorMessage: `${outcome.fault} (regeneration limit reached)`,
|
|
313
|
+
stderr: outcome.stderr,
|
|
314
|
+
responseBody: bodyObj,
|
|
315
|
+
cacheStatus,
|
|
316
|
+
regenLimitHit: true,
|
|
317
|
+
note: 'generator faulted (limit hit)',
|
|
318
|
+
});
|
|
319
|
+
end();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const attempt = opts.limiter.increment(urlKey);
|
|
323
|
+
opts.onRequest?.(formatRegenerating(urlKey, attempt, opts.limiter.limit, operation.operationId, outcome.fault));
|
|
324
|
+
const regenStarted = Date.now();
|
|
325
|
+
let currentSource = generator.source;
|
|
326
|
+
if (!currentSource?.trim()) {
|
|
327
|
+
currentSource = (await opts.cache.getSource?.(operation.key)) ?? '';
|
|
328
|
+
}
|
|
329
|
+
if (!currentSource?.trim()) {
|
|
330
|
+
try {
|
|
331
|
+
currentSource = await readFile(opts.box.sourceOf(operation.key), 'utf8');
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
currentSource = '# generator';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const examples = opts.history.getSuccessful(operation.key, 2);
|
|
338
|
+
try {
|
|
339
|
+
await opts.cache.regenerate(operation, {
|
|
340
|
+
currentSource,
|
|
341
|
+
failingInput: input,
|
|
342
|
+
fault: outcome.fault ?? 'generator faulted',
|
|
343
|
+
stderr: outcome.stderr,
|
|
344
|
+
examples,
|
|
345
|
+
});
|
|
346
|
+
cacheStatus = 'regenerated';
|
|
347
|
+
res.setHeader('x-faker-cache', 'regenerated');
|
|
348
|
+
opts.onRequest?.(formatRegenerated(urlKey, attempt, Date.now() - regenStarted, operation.operationId));
|
|
349
|
+
// Re-run the request with the newly regenerated generator
|
|
350
|
+
outcome = await opts.box.run(operation.key, input);
|
|
351
|
+
if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
|
|
352
|
+
outcome = {
|
|
353
|
+
ok: false,
|
|
354
|
+
fault: 'output does not match response schema after regeneration',
|
|
355
|
+
stderr: outcome.stderr,
|
|
356
|
+
durationMs: outcome.durationMs,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (regenErr) {
|
|
361
|
+
const detail = reason(regenErr);
|
|
362
|
+
const bodyObj = {
|
|
363
|
+
error: `regeneration failed for ${operation.operationId}: ${detail}`,
|
|
364
|
+
stderr: outcome.stderr || undefined,
|
|
365
|
+
regenerated: false,
|
|
366
|
+
};
|
|
367
|
+
const end = prepareSend(res, 502, bodyObj);
|
|
368
|
+
await finish(502, {
|
|
369
|
+
operationId: operation.operationId,
|
|
370
|
+
routePath: operation.path,
|
|
371
|
+
pathParams,
|
|
372
|
+
requestBody: body,
|
|
373
|
+
errorKind: 'REGENERATION_FAILED',
|
|
374
|
+
errorMessage: `regeneration failed: ${detail}`,
|
|
375
|
+
stderr: outcome.stderr,
|
|
376
|
+
responseBody: bodyObj,
|
|
377
|
+
cacheStatus,
|
|
378
|
+
regenAttempts: attempt,
|
|
379
|
+
note: 'regeneration failed',
|
|
380
|
+
});
|
|
381
|
+
end();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (!outcome.ok) {
|
|
385
|
+
const bodyObj = {
|
|
386
|
+
error: `the regenerated generator for ${operation.operationId} ${outcome.fault}`,
|
|
387
|
+
stderr: outcome.stderr || undefined,
|
|
388
|
+
regenerated: true,
|
|
389
|
+
};
|
|
390
|
+
const end = prepareSend(res, 502, bodyObj);
|
|
391
|
+
await finish(502, {
|
|
392
|
+
operationId: operation.operationId,
|
|
393
|
+
routePath: operation.path,
|
|
394
|
+
pathParams,
|
|
395
|
+
requestBody: body,
|
|
396
|
+
errorKind,
|
|
397
|
+
errorMessage: `regenerated generator ${outcome.fault}`,
|
|
398
|
+
stderr: outcome.stderr,
|
|
399
|
+
responseBody: bodyObj,
|
|
400
|
+
cacheStatus,
|
|
401
|
+
regenerated: true,
|
|
402
|
+
regenAttempts: attempt,
|
|
403
|
+
note: 'regenerated generator faulted',
|
|
404
|
+
});
|
|
405
|
+
end();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
158
408
|
}
|
|
159
|
-
const note =
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
409
|
+
const note = cacheStatus;
|
|
410
|
+
const streamKey = `${operation.key}:${req.socket.remoteAddress || 'local'}`;
|
|
411
|
+
const cutResult = cut(operation, outcome.value, url.searchParams, body, opts.paginationLimiter, streamKey);
|
|
412
|
+
opts.history.recordSuccess(operation.key, input, outcome.value);
|
|
413
|
+
const end = prepareSend(res, operation.success.status, outcome.value);
|
|
414
|
+
await finish(operation.success.status, {
|
|
415
|
+
operationId: operation.operationId,
|
|
416
|
+
routePath: operation.path,
|
|
417
|
+
pathParams,
|
|
418
|
+
requestBody: body,
|
|
419
|
+
responseBody: outcome.value,
|
|
420
|
+
cacheStatus,
|
|
421
|
+
regenerated: cacheStatus === 'regenerated',
|
|
422
|
+
note: cutResult?.cut ? `${note} · ${cutResult.reason}` : note,
|
|
423
|
+
});
|
|
424
|
+
end();
|
|
163
425
|
}
|
|
164
426
|
/**
|
|
165
427
|
* The generator on disk was written before the pagination rule existed, and a
|
|
166
428
|
* cache is not rebuilt just because the rule changed. A body offering back the
|
|
167
429
|
* token it was handed is therefore still possible, and it is the one bug here
|
|
168
430
|
* that costs the client rather than the mock: it hangs.
|
|
431
|
+
*
|
|
432
|
+
* In addition, pagination is tracked and capped to prevent infinite loops (random
|
|
433
|
+
* 1-10 max pages by default) and repetitive cycling tokens.
|
|
169
434
|
*/
|
|
170
|
-
function cut(operation, value, query) {
|
|
171
|
-
const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query);
|
|
435
|
+
function cut(operation, value, query, body, limiter, streamKey = 'default') {
|
|
436
|
+
const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query, body);
|
|
172
437
|
if (!paging) {
|
|
173
|
-
return
|
|
438
|
+
return undefined;
|
|
174
439
|
}
|
|
175
|
-
const sent = query
|
|
176
|
-
|
|
440
|
+
const sent = getSentToken(query, body, paging);
|
|
441
|
+
if (limiter) {
|
|
442
|
+
return limiter.process(operation.key, streamKey, sent, value, paging);
|
|
443
|
+
}
|
|
444
|
+
if (sent !== undefined && cutLoop(value, paging, sent)) {
|
|
445
|
+
return { cut: true, reason: 'cut a looping page token' };
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
177
448
|
}
|
|
178
449
|
function check(operation, checks, pathParams, query, body) {
|
|
179
450
|
const compiled = checks.for(operation);
|
|
@@ -285,18 +556,24 @@ function introspect(pathname, res, opts) {
|
|
|
285
556
|
}
|
|
286
557
|
send(res, 404, { error: `no such endpoint: ${pathname}` });
|
|
287
558
|
}
|
|
288
|
-
function
|
|
559
|
+
function prepareSend(res, status, value) {
|
|
289
560
|
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
290
561
|
res.statusCode = status;
|
|
291
562
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
292
563
|
res.setHeader('content-length', Buffer.byteLength(text));
|
|
293
|
-
res.end(text);
|
|
564
|
+
return () => res.end(text);
|
|
294
565
|
}
|
|
295
|
-
|
|
296
|
-
|
|
566
|
+
function send(res, status, value) {
|
|
567
|
+
prepareSend(res, status, value)();
|
|
568
|
+
}
|
|
569
|
+
function prepareHtml(res, text) {
|
|
297
570
|
res.statusCode = 200;
|
|
298
571
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
299
572
|
res.setHeader('content-length', Buffer.byteLength(text));
|
|
300
|
-
res.end(text);
|
|
573
|
+
return () => res.end(text);
|
|
574
|
+
}
|
|
575
|
+
/** Node drops the body itself when the request was a HEAD. */
|
|
576
|
+
function html(res, text) {
|
|
577
|
+
prepareHtml(res, text)();
|
|
301
578
|
}
|
|
302
579
|
//# sourceMappingURL=server.js.map
|
package/dist/spec.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { CliError } from '@zenera/cli/lib';
|
|
2
2
|
import { type Paging } from './paging.ts';
|
|
3
3
|
import { type Schema } from './schema.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Version of generator prompt rules and environment contracts.
|
|
6
|
+
* Bumping this invalidates cached Python generators and triggers regeneration.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GENERATOR_RULES_VERSION = 2;
|
|
4
9
|
export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
|
|
5
10
|
export type Method = (typeof METHODS)[number];
|
|
6
11
|
export type ParamIn = 'path' | 'query' | 'header' | 'cookie';
|
package/dist/spec.js
CHANGED
|
@@ -3,6 +3,11 @@ import { CliError, EXIT } from '@zenera/cli/lib';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { pagingOf } from "./paging.js";
|
|
5
5
|
import { normalize } from "./schema.js";
|
|
6
|
+
/**
|
|
7
|
+
* Version of generator prompt rules and environment contracts.
|
|
8
|
+
* Bumping this invalidates cached Python generators and triggers regeneration.
|
|
9
|
+
*/
|
|
10
|
+
export const GENERATOR_RULES_VERSION = 2;
|
|
6
11
|
// ---------------------------------------------------------------------------
|
|
7
12
|
// Documents, flattened
|
|
8
13
|
//
|
|
@@ -137,7 +142,7 @@ function build(b) {
|
|
|
137
142
|
fixed: b.fixed,
|
|
138
143
|
requestBody: b.body,
|
|
139
144
|
success,
|
|
140
|
-
paging: pagingOf(b.params, success.schema),
|
|
145
|
+
paging: pagingOf(b.params, success.schema, b.body?.schema),
|
|
141
146
|
};
|
|
142
147
|
return { ...operation, key: keyOf(operation) };
|
|
143
148
|
}
|
|
@@ -150,6 +155,7 @@ function build(b) {
|
|
|
150
155
|
*/
|
|
151
156
|
function keyOf(op) {
|
|
152
157
|
const shape = {
|
|
158
|
+
version: GENERATOR_RULES_VERSION,
|
|
153
159
|
method: op.method,
|
|
154
160
|
path: op.path,
|
|
155
161
|
operationId: op.operationId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/faker",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.22",
|
|
4
4
|
"description": "Mock HTTP server for swagger/OpenAPI documents, with response bodies generated by a model.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openapi",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@apidevtools/swagger-parser": "^12.0.0",
|
|
46
46
|
"ajv": "^8.17.1",
|
|
47
47
|
"ajv-formats": "^3.0.1",
|
|
48
|
-
"@zenera/cli": "^1.1.
|
|
49
|
-
"@zenera/neo": "^1.1.
|
|
48
|
+
"@zenera/cli": "^1.1.22",
|
|
49
|
+
"@zenera/neo": "^1.1.22"
|
|
50
50
|
}
|
|
51
51
|
}
|