@zenera/faker 1.1.20 → 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 +32 -13
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +57 -1
- package/dist/command.js +83 -9
- package/dist/generate.d.ts +11 -1
- package/dist/generate.js +60 -12
- package/dist/logger.d.ts +62 -0
- package/dist/logger.js +273 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -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 +15 -1
- package/dist/prompt.js +40 -6
- package/dist/router.d.ts +8 -2
- package/dist/router.js +23 -5
- package/dist/server.d.ts +8 -0
- package/dist/server.js +346 -41
- package/dist/setup.d.ts +18 -0
- package/dist/setup.js +57 -15
- package/dist/spec.d.ts +9 -0
- package/dist/spec.js +45 -7
- package/package.json +3 -3
package/dist/server.js
CHANGED
|
@@ -1,8 +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";
|
|
5
|
-
import {
|
|
7
|
+
import { formatLogLine, formatRegenerated, formatRegenerating, formatRegenLimitHit, RegenerationLimiter, RequestHistory, saveRequestDump, } from "./logger.js";
|
|
8
|
+
import { indexPage } from "./page.js";
|
|
9
|
+
import { cutLoop, getSentToken, PaginationLimiter, pagingSeen, } from "./paging.js";
|
|
6
10
|
import { describeIssues, issues } from "./validate.js";
|
|
7
11
|
// ---------------------------------------------------------------------------
|
|
8
12
|
// The server
|
|
@@ -22,11 +26,22 @@ const REDACTED = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
|
22
26
|
const SECRETISH = /key|token|secret|password|credential/i;
|
|
23
27
|
const DEFAULT_MAX_BODY = 1024 * 1024;
|
|
24
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
|
+
};
|
|
25
40
|
return createServer((req, res) => {
|
|
26
|
-
handle(req, res,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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}`);
|
|
30
45
|
});
|
|
31
46
|
});
|
|
32
47
|
}
|
|
@@ -53,27 +68,115 @@ export async function listen(opts, host, port) {
|
|
|
53
68
|
// ---------------------------------------------------------------------------
|
|
54
69
|
async function handle(req, res, opts) {
|
|
55
70
|
const started = Date.now();
|
|
71
|
+
const timestamp = new Date(started);
|
|
72
|
+
const id = randomUUID().slice(0, 8);
|
|
56
73
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
57
74
|
const method = (req.method ?? 'GET').toLowerCase();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
};
|
|
61
122
|
if (url.pathname.startsWith('/__faker/')) {
|
|
62
123
|
introspect(url.pathname, res, opts);
|
|
63
|
-
|
|
124
|
+
await finish(200, { note: 'introspection' });
|
|
64
125
|
return;
|
|
65
126
|
}
|
|
66
|
-
const match = opts.router.match(method, url.pathname);
|
|
127
|
+
const match = opts.router.match(method, url.pathname, url.searchParams);
|
|
67
128
|
if (!match) {
|
|
68
|
-
|
|
129
|
+
// The document owns `/` only when it declares it. Otherwise the root is
|
|
130
|
+
// the contents page, which is what a browser pointed here came for.
|
|
131
|
+
if (url.pathname === '/' && (method === 'get' || method === 'head')) {
|
|
132
|
+
const end = prepareHtml(res, indexPage(opts.router.operations));
|
|
133
|
+
await finish(200, { note: 'index' });
|
|
134
|
+
end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// The path matched and the query did not, which no one guesses unaided
|
|
138
|
+
// — and it is not a 405 either, since the method is defined here.
|
|
139
|
+
const expects = opts.router.expects(method, url.pathname);
|
|
140
|
+
if (expects.length > 0) {
|
|
141
|
+
const bodyObj = {
|
|
142
|
+
error: `${req.method} ${url.pathname} is only defined with a query`,
|
|
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(' ')}`,
|
|
151
|
+
});
|
|
152
|
+
end();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const allowed = opts.router.allowed(url.pathname, url.searchParams);
|
|
69
156
|
if (allowed.length > 0) {
|
|
70
157
|
res.setHeader('allow', allowed.map((m) => m.toUpperCase()).join(', '));
|
|
71
|
-
|
|
72
|
-
|
|
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();
|
|
73
167
|
return;
|
|
74
168
|
}
|
|
75
|
-
|
|
76
|
-
|
|
169
|
+
const bodyObj = {
|
|
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',
|
|
178
|
+
});
|
|
179
|
+
end();
|
|
77
180
|
return;
|
|
78
181
|
}
|
|
79
182
|
const { operation, pathParams } = match;
|
|
@@ -84,20 +187,51 @@ async function handle(req, res, opts) {
|
|
|
84
187
|
}
|
|
85
188
|
catch (err) {
|
|
86
189
|
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
-
|
|
88
|
-
|
|
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();
|
|
89
204
|
return;
|
|
90
205
|
}
|
|
91
206
|
const problems = check(operation, opts.checks, pathParams, url.searchParams, body);
|
|
92
207
|
if (problems.length > 0) {
|
|
93
|
-
|
|
94
|
-
|
|
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();
|
|
95
223
|
return;
|
|
96
224
|
}
|
|
97
225
|
if (!operation.success.schema) {
|
|
98
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
|
+
});
|
|
99
234
|
res.end();
|
|
100
|
-
say(operation.success.status, 'no body declared');
|
|
101
235
|
return;
|
|
102
236
|
}
|
|
103
237
|
let generator;
|
|
@@ -107,15 +241,27 @@ async function handle(req, res, opts) {
|
|
|
107
241
|
catch (err) {
|
|
108
242
|
const status = err instanceof BuildFailed ? 501 : 500;
|
|
109
243
|
const detail = reason(err);
|
|
110
|
-
|
|
244
|
+
const bodyObj = {
|
|
111
245
|
error: `no generator for ${operation.operationId}`,
|
|
112
246
|
detail,
|
|
113
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}`,
|
|
114
259
|
});
|
|
115
|
-
|
|
260
|
+
end();
|
|
116
261
|
return;
|
|
117
262
|
}
|
|
118
263
|
res.setHeader('x-faker-cache', generator.cached ? 'hit' : 'miss');
|
|
264
|
+
let cacheStatus = generator.cached ? 'hit' : 'miss';
|
|
119
265
|
const input = {
|
|
120
266
|
operationId: operation.operationId,
|
|
121
267
|
method: operation.method,
|
|
@@ -126,33 +272,179 @@ async function handle(req, res, opts) {
|
|
|
126
272
|
body,
|
|
127
273
|
seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
|
|
128
274
|
};
|
|
129
|
-
|
|
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
|
+
}
|
|
130
289
|
if (!outcome.ok) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
|
137
408
|
}
|
|
138
|
-
const note =
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
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();
|
|
142
425
|
}
|
|
143
426
|
/**
|
|
144
427
|
* The generator on disk was written before the pagination rule existed, and a
|
|
145
428
|
* cache is not rebuilt just because the rule changed. A body offering back the
|
|
146
429
|
* token it was handed is therefore still possible, and it is the one bug here
|
|
147
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.
|
|
148
434
|
*/
|
|
149
|
-
function cut(operation, value, query) {
|
|
150
|
-
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);
|
|
151
437
|
if (!paging) {
|
|
152
|
-
return
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
const sent = getSentToken(query, body, paging);
|
|
441
|
+
if (limiter) {
|
|
442
|
+
return limiter.process(operation.key, streamKey, sent, value, paging);
|
|
153
443
|
}
|
|
154
|
-
|
|
155
|
-
|
|
444
|
+
if (sent !== undefined && cutLoop(value, paging, sent)) {
|
|
445
|
+
return { cut: true, reason: 'cut a looping page token' };
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
156
448
|
}
|
|
157
449
|
function check(operation, checks, pathParams, query, body) {
|
|
158
450
|
const compiled = checks.for(operation);
|
|
@@ -264,11 +556,24 @@ function introspect(pathname, res, opts) {
|
|
|
264
556
|
}
|
|
265
557
|
send(res, 404, { error: `no such endpoint: ${pathname}` });
|
|
266
558
|
}
|
|
267
|
-
function
|
|
559
|
+
function prepareSend(res, status, value) {
|
|
268
560
|
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
269
561
|
res.statusCode = status;
|
|
270
562
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
271
563
|
res.setHeader('content-length', Buffer.byteLength(text));
|
|
272
|
-
res.end(text);
|
|
564
|
+
return () => res.end(text);
|
|
565
|
+
}
|
|
566
|
+
function send(res, status, value) {
|
|
567
|
+
prepareSend(res, status, value)();
|
|
568
|
+
}
|
|
569
|
+
function prepareHtml(res, text) {
|
|
570
|
+
res.statusCode = 200;
|
|
571
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
572
|
+
res.setHeader('content-length', Buffer.byteLength(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)();
|
|
273
578
|
}
|
|
274
579
|
//# sourceMappingURL=server.js.map
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
import { KeyStore } from '@zenera/cli/lib';
|
|
1
2
|
import { type Model } from '@zenera/neo';
|
|
2
3
|
import { Box } from './box.ts';
|
|
3
4
|
import { Cache, type CacheOptions } from './cache.ts';
|
|
4
5
|
import { Router } from './router.ts';
|
|
5
6
|
import { SpecError } from './spec.ts';
|
|
6
7
|
import { Checks } from './validate.ts';
|
|
8
|
+
/** The variable a shell or a CI job names it with — a zen ref. */
|
|
9
|
+
export declare const MODEL_ENV = "ZENERA_FAKER_MODEL";
|
|
10
|
+
export type ModelSource = 'flag' | 'env' | 'store' | 'default';
|
|
11
|
+
export declare const SOURCE_LABELS: Record<ModelSource, string>;
|
|
12
|
+
export interface ModelChoice {
|
|
13
|
+
ref: string;
|
|
14
|
+
from: ModelSource;
|
|
15
|
+
}
|
|
16
|
+
export interface FakerFile {
|
|
17
|
+
version: 1;
|
|
18
|
+
model?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const readSettings: () => Promise<FakerFile>;
|
|
21
|
+
export declare function writeSettings(file: FakerFile): void;
|
|
22
|
+
/** What `open` would run, and where it came from, so a command can say so too. */
|
|
23
|
+
export declare function chooseModel(flag: string | undefined, keys: KeyStore): Promise<ModelChoice | undefined>;
|
|
7
24
|
export interface SetupOptions {
|
|
8
25
|
specs: readonly string[];
|
|
9
26
|
cwd: string;
|
|
@@ -26,6 +43,7 @@ export interface Setup {
|
|
|
26
43
|
cache: Cache;
|
|
27
44
|
box: Box;
|
|
28
45
|
model: Model;
|
|
46
|
+
choice: ModelChoice;
|
|
29
47
|
image: string;
|
|
30
48
|
root: string;
|
|
31
49
|
close(): Promise<void>;
|
package/dist/setup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { credentialError, ensureHome, ensurePodmanReady, envNames, invalidError, KeyStore, paths, PROVIDERS, } from '@zenera/cli/lib';
|
|
1
|
+
import { credentialError, ensureHome, ensurePodmanReady, envNames, home, invalidError, KeyStore, paths, PROVIDERS, readJson, writeJson, } from '@zenera/cli/lib';
|
|
2
2
|
import { createModel } from '@zenera/neo';
|
|
3
3
|
import { mkdirSync } from 'node:fs';
|
|
4
|
-
import { resolve } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { Box } from "./box.js";
|
|
6
6
|
import { Cache } from "./cache.js";
|
|
7
7
|
import { ensureImage } from "./image.js";
|
|
@@ -18,17 +18,58 @@ import { Checks } from "./validate.js";
|
|
|
18
18
|
// happened to be checked first.
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
/**
|
|
21
|
-
* The model each provider gets when none is named
|
|
21
|
+
* The model each provider gets when none is named — the same picks `zen meta`
|
|
22
|
+
* recommends (`RECOMMENDED` in `packages/cli/src/meta.ts`). Every ref names its
|
|
22
23
|
* provider: the shorthand reads the first segment as a *provider name*, so a
|
|
23
|
-
* bare `gemini-3.
|
|
24
|
+
* bare `gemini-3.8-flash` would be asked of OpenAI.
|
|
24
25
|
*/
|
|
25
26
|
const DEFAULT_MODEL = {
|
|
26
|
-
openai: 'openai:gpt-5.
|
|
27
|
-
anthropic: 'anthropic:claude-
|
|
28
|
-
google: 'google:gemini-3.
|
|
29
|
-
vertex: 'vertex:gemini-3.
|
|
30
|
-
openrouter: 'openrouter:
|
|
27
|
+
openai: 'openai:gpt-5.6-sol',
|
|
28
|
+
anthropic: 'anthropic:claude-opus-5',
|
|
29
|
+
google: 'google:gemini-3.8-flash',
|
|
30
|
+
vertex: 'vertex:gemini-3.8-flash',
|
|
31
|
+
openrouter: 'openrouter:anthropic/claude-opus-5',
|
|
31
32
|
};
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Which model, and why that one
|
|
35
|
+
//
|
|
36
|
+
// The same chain `zen meta` uses: a flag beats the environment, the environment
|
|
37
|
+
// beats what was stored, and with none of them set it is whatever the keys on
|
|
38
|
+
// hand can buy. Which model writes generators is a choice about a tool on this
|
|
39
|
+
// machine and not about a document, so it is stored beside the keyring rather
|
|
40
|
+
// than anywhere near a specification.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** The variable a shell or a CI job names it with — a zen ref. */
|
|
43
|
+
export const MODEL_ENV = 'ZENERA_FAKER_MODEL';
|
|
44
|
+
export const SOURCE_LABELS = {
|
|
45
|
+
flag: '--model',
|
|
46
|
+
env: MODEL_ENV,
|
|
47
|
+
store: 'zen faker model',
|
|
48
|
+
default: 'recommended',
|
|
49
|
+
};
|
|
50
|
+
const EMPTY = { version: 1 };
|
|
51
|
+
const settingsPath = () => join(home(), 'faker.json');
|
|
52
|
+
export const readSettings = () => readJson(settingsPath(), EMPTY);
|
|
53
|
+
export function writeSettings(file) {
|
|
54
|
+
ensureHome();
|
|
55
|
+
writeJson(settingsPath(), file);
|
|
56
|
+
}
|
|
57
|
+
/** What `open` would run, and where it came from, so a command can say so too. */
|
|
58
|
+
export async function chooseModel(flag, keys) {
|
|
59
|
+
const chain = [
|
|
60
|
+
['flag', flag],
|
|
61
|
+
['env', process.env[MODEL_ENV]],
|
|
62
|
+
['store', (await readSettings()).model],
|
|
63
|
+
['default', defaultRef(keys)],
|
|
64
|
+
];
|
|
65
|
+
for (const [from, ref] of chain) {
|
|
66
|
+
const named = ref?.trim();
|
|
67
|
+
if (named) {
|
|
68
|
+
return { ref: named, from };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
32
73
|
export async function open(opts) {
|
|
33
74
|
if (opts.specs.length === 0) {
|
|
34
75
|
throw invalidError('no specification given', 'name one or more openapi/swagger files');
|
|
@@ -37,7 +78,11 @@ export async function open(opts) {
|
|
|
37
78
|
ensureHome();
|
|
38
79
|
const keys = await KeyStore.open();
|
|
39
80
|
keys.materialize();
|
|
40
|
-
const
|
|
81
|
+
const choice = await chooseModel(opts.model, keys);
|
|
82
|
+
if (!choice) {
|
|
83
|
+
throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
|
|
84
|
+
}
|
|
85
|
+
const model = createModel(choice.ref);
|
|
41
86
|
const root = resolve(opts.cwd, opts.cache ?? paths.faker());
|
|
42
87
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
43
88
|
// Podman is asked about before the image is built, so a machine without a
|
|
@@ -62,7 +107,7 @@ export async function open(opts) {
|
|
|
62
107
|
ephemeral: opts.ephemeral,
|
|
63
108
|
...opts.events,
|
|
64
109
|
});
|
|
65
|
-
return { router, checks, cache, box, model, image, root, close: () => box.dispose() };
|
|
110
|
+
return { router, checks, cache, box, model, choice, image, root, close: () => box.dispose() };
|
|
66
111
|
}
|
|
67
112
|
/**
|
|
68
113
|
* Presence, not liveness. `zen init` probes because it is writing a project
|
|
@@ -72,10 +117,7 @@ export async function open(opts) {
|
|
|
72
117
|
function defaultRef(keys) {
|
|
73
118
|
const provider = PROVIDERS.find((p) => envNames(p).some((name) => process.env[name])) ??
|
|
74
119
|
PROVIDERS.find((p) => keys.active(p) !== undefined);
|
|
75
|
-
|
|
76
|
-
throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
|
|
77
|
-
}
|
|
78
|
-
return DEFAULT_MODEL[provider];
|
|
120
|
+
return provider ? DEFAULT_MODEL[provider] : undefined;
|
|
79
121
|
}
|
|
80
122
|
export { SpecError };
|
|
81
123
|
//# sourceMappingURL=setup.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';
|
|
@@ -23,6 +28,8 @@ export interface Operation {
|
|
|
23
28
|
summary?: string;
|
|
24
29
|
description?: string;
|
|
25
30
|
params: ParamSpec[];
|
|
31
|
+
/** query values the path key pinned down, see `splitQuery` */
|
|
32
|
+
fixed?: Record<string, string>;
|
|
26
33
|
requestBody?: {
|
|
27
34
|
required: boolean;
|
|
28
35
|
schema: Schema;
|
|
@@ -41,4 +48,6 @@ export declare class SpecError extends CliError {
|
|
|
41
48
|
}
|
|
42
49
|
export declare function loadSpecs(files: readonly string[]): Promise<Operation[]>;
|
|
43
50
|
export declare function loadSpec(file: string): Promise<Operation[]>;
|
|
51
|
+
/** The path as it has to be called — what a person needs to see. */
|
|
52
|
+
export declare const called: (op: Operation) => string;
|
|
44
53
|
//# sourceMappingURL=spec.d.ts.map
|