@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/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, opts).catch((err) => {
28
- // Nothing below is expected to throw; if it does, the client still
29
- // gets an answer and the operator still gets the reason.
30
- send(res, 500, { error: err instanceof Error ? err.message : String(err) });
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
- // The search string is part of the request: without it a 400 caused by a
60
- // query parameter is unexplainable from the log.
61
- const say = (status, what) => opts.onRequest?.(`${req.method} ${url.pathname}${url.search} ${status} ${Date.now() - started}ms ${what}`);
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
- say(200, 'introspection');
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
- html(res, indexPage(opts.router.operations));
73
- say(200, 'index');
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
- send(res, 404, {
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
- say(404, `expects ${expects.join(' ')}`);
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
- send(res, 405, { error: `${req.method} is not defined for ${url.pathname}` });
91
- say(405, 'no such method');
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
- send(res, 404, {
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
- say(404, 'no route');
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
- send(res, message.includes('too large') ? 413 : 400, { error: message });
109
- say(400, 'bad body');
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
- send(res, 400, { error: describeIssues(problems), issues: problems });
115
- say(400, describeIssues(problems));
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
- send(res, status, {
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
- say(status, `no generator: ${detail}`);
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
- const outcome = await opts.box.run(operation.key, input);
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
- send(res, 502, {
153
- error: `the generator for ${operation.operationId} ${outcome.fault}`,
154
- stderr: outcome.stderr || undefined,
155
- });
156
- say(502, 'generator faulted');
157
- return;
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 = generator.cached ? 'hit' : 'miss';
160
- const looped = cut(operation, outcome.value, url.searchParams);
161
- send(res, operation.success.status, outcome.value);
162
- say(operation.success.status, looped ? `${note} · cut a looping page token` : note);
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 false;
438
+ return undefined;
174
439
  }
175
- const sent = query.get(paging.param);
176
- return sent !== null && cutLoop(value, paging, sent);
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 send(res, status, value) {
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
- /** Node drops the body itself when the request was a HEAD. */
296
- function html(res, text) {
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.21",
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.21",
49
- "@zenera/neo": "^1.1.21"
48
+ "@zenera/cli": "^1.1.22",
49
+ "@zenera/neo": "^1.1.22"
50
50
  }
51
51
  }