@zenera/faker 1.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/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/box.d.ts +43 -0
- package/dist/box.js +158 -0
- package/dist/cache.d.ts +57 -0
- package/dist/cache.js +125 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +339 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +38 -0
- package/dist/generate.js +125 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +77 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +278 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +98 -0
- package/dist/router.d.ts +14 -0
- package/dist/router.js +83 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +221 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +256 -0
- package/dist/setup.d.ts +35 -0
- package/dist/setup.js +81 -0
- package/dist/spec.d.ts +41 -0
- package/dist/spec.js +257 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +51 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
import { bold, CliError, cyan, dim, EXIT, fail, green, invokedAs, json, note, ownedContainers, parse, paths, printBanner, red, removeContainers, split, table, usageError, write, writeAll, yellow, } from 'zenera-cli/lib';
|
|
6
|
+
import { GENERATORS } from "./box.js";
|
|
7
|
+
import { reason } from "./generate.js";
|
|
8
|
+
import { listen } from "./server.js";
|
|
9
|
+
import { open } from "./setup.js";
|
|
10
|
+
import { SpecError } from "./spec.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// zfake — a mock API from a specification
|
|
13
|
+
//
|
|
14
|
+
// A separate binary rather than a `zen` subcommand. `zen` runs agent projects
|
|
15
|
+
// and everything in it is shaped by that; this is a server, it stays up, and
|
|
16
|
+
// the only thing the two genuinely share is where credentials live. Putting it
|
|
17
|
+
// under `zen` would have meant one command that means two different things.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** What the user typed: `zfake`, `zen-fake`, `zen-faker` or `zenera-fake`. */
|
|
20
|
+
const NAME = invokedAs('zfake');
|
|
21
|
+
const USAGE = `${NAME} <serve|build|cache> [spec...] [options]`;
|
|
22
|
+
const BANNER = {
|
|
23
|
+
head: 'Zenera',
|
|
24
|
+
accent: 'Faker',
|
|
25
|
+
subtitle: 'Mock API Server',
|
|
26
|
+
};
|
|
27
|
+
const OPTIONS = {
|
|
28
|
+
port: { type: 'string' },
|
|
29
|
+
host: { type: 'string' },
|
|
30
|
+
model: { type: 'string' },
|
|
31
|
+
image: { type: 'string' },
|
|
32
|
+
cache: { type: 'string' },
|
|
33
|
+
attempts: { type: 'string' },
|
|
34
|
+
concurrency: { type: 'string' },
|
|
35
|
+
seed: { type: 'string' },
|
|
36
|
+
timeout: { type: 'string' },
|
|
37
|
+
'max-body': { type: 'string' },
|
|
38
|
+
rebuild: { type: 'boolean' },
|
|
39
|
+
'no-cache': { type: 'boolean' },
|
|
40
|
+
quiet: { type: 'boolean' },
|
|
41
|
+
json: { type: 'boolean' },
|
|
42
|
+
};
|
|
43
|
+
async function main(argv) {
|
|
44
|
+
const { name, after } = split(argv);
|
|
45
|
+
if (!name || name === 'help' || name === '--help' || name === '-h') {
|
|
46
|
+
usage();
|
|
47
|
+
return name ? EXIT.ok : EXIT.usage;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
switch (name) {
|
|
51
|
+
case 'serve':
|
|
52
|
+
return await serve(after);
|
|
53
|
+
case 'build':
|
|
54
|
+
return await warm(after);
|
|
55
|
+
case 'cache':
|
|
56
|
+
return await cache(after);
|
|
57
|
+
default:
|
|
58
|
+
throw usageError(`unknown command "${name}"`, USAGE);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof CliError) {
|
|
63
|
+
fail(err.message, err.hint);
|
|
64
|
+
return err.code;
|
|
65
|
+
}
|
|
66
|
+
if (err instanceof SpecError) {
|
|
67
|
+
fail(err.message, err.hint);
|
|
68
|
+
return EXIT.invalid;
|
|
69
|
+
}
|
|
70
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
71
|
+
return EXIT.failed;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// serve
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
async function serve(args) {
|
|
78
|
+
const { values, positionals } = parse(args, OPTIONS, `${NAME} serve <spec...>`);
|
|
79
|
+
const loud = !values.quiet && !values.json;
|
|
80
|
+
if (loud) {
|
|
81
|
+
printBanner(BANNER);
|
|
82
|
+
}
|
|
83
|
+
const setup = await start(values, positionals, 'serve');
|
|
84
|
+
if (loud) {
|
|
85
|
+
printSpecs(setup.router.operations);
|
|
86
|
+
}
|
|
87
|
+
const host = values.host ?? '127.0.0.1';
|
|
88
|
+
const listener = await listen({
|
|
89
|
+
router: setup.router,
|
|
90
|
+
cache: setup.cache,
|
|
91
|
+
checks: setup.checks,
|
|
92
|
+
box: setup.box,
|
|
93
|
+
seed: number(values.seed, 'seed'),
|
|
94
|
+
maxBody: number(values['max-body'], 'max-body'),
|
|
95
|
+
onRequest: values.quiet ? undefined : (line) => note(dim(line)),
|
|
96
|
+
}, host, number(values.port, 'port') ?? 8787);
|
|
97
|
+
if (!values.quiet) {
|
|
98
|
+
note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
|
|
99
|
+
dim(`${setup.router.operations.length} operations`));
|
|
100
|
+
if (host !== '127.0.0.1' && host !== 'localhost') {
|
|
101
|
+
note(yellow(`bound to ${host} — this mock is reachable from the network`));
|
|
102
|
+
}
|
|
103
|
+
note(dim(`generators: ${setup.root}/${GENERATORS}`));
|
|
104
|
+
}
|
|
105
|
+
// The address is the answer; the rest was narration.
|
|
106
|
+
write(`http://${host}:${listener.port}`);
|
|
107
|
+
await until(['SIGINT', 'SIGTERM']);
|
|
108
|
+
if (!values.quiet) {
|
|
109
|
+
note(dim('stopping'));
|
|
110
|
+
}
|
|
111
|
+
await listener.close();
|
|
112
|
+
await setup.close();
|
|
113
|
+
return EXIT.ok;
|
|
114
|
+
}
|
|
115
|
+
/** Resolves when one of the signals arrives, and stops listening for them. */
|
|
116
|
+
function until(signals) {
|
|
117
|
+
return new Promise((settle) => {
|
|
118
|
+
const done = () => {
|
|
119
|
+
for (const s of signals) {
|
|
120
|
+
process.off(s, done);
|
|
121
|
+
}
|
|
122
|
+
settle();
|
|
123
|
+
};
|
|
124
|
+
for (const s of signals) {
|
|
125
|
+
process.once(s, done);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// build — every generator, up front
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
async function warm(args) {
|
|
133
|
+
const { values, positionals } = parse(args, OPTIONS, `${NAME} build <spec...>`);
|
|
134
|
+
const loud = !values.quiet && !values.json;
|
|
135
|
+
if (loud) {
|
|
136
|
+
printBanner(BANNER);
|
|
137
|
+
}
|
|
138
|
+
const setup = await start(values, positionals, 'build');
|
|
139
|
+
if (loud) {
|
|
140
|
+
printSpecs(setup.router.operations);
|
|
141
|
+
}
|
|
142
|
+
const results = [];
|
|
143
|
+
try {
|
|
144
|
+
for (const operation of setup.router.operations) {
|
|
145
|
+
const id = `${operation.method.toUpperCase()} ${operation.path}`;
|
|
146
|
+
if (!operation.success.schema) {
|
|
147
|
+
results.push({ operation: id, status: 'skipped', detail: 'no response body' });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const generator = await setup.cache.ensure(operation);
|
|
152
|
+
results.push({ operation: id, status: generator.cached ? 'cached' : 'built' });
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
results.push({
|
|
156
|
+
operation: id,
|
|
157
|
+
status: 'failed',
|
|
158
|
+
detail: reason(err),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await setup.close();
|
|
165
|
+
}
|
|
166
|
+
if (values.json) {
|
|
167
|
+
json(results);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
writeAll(table(results.map((r) => [
|
|
171
|
+
` ${mark(r.status)}`,
|
|
172
|
+
r.operation,
|
|
173
|
+
dim(r.detail?.slice(0, 100) ?? ''),
|
|
174
|
+
])));
|
|
175
|
+
}
|
|
176
|
+
return results.some((r) => r.status === 'failed') ? EXIT.failed : EXIT.ok;
|
|
177
|
+
}
|
|
178
|
+
const mark = (status) => status === 'failed' ? red('failed') : status === 'skipped' ? dim('skipped') : green(status);
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// cache
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
async function cache(args) {
|
|
183
|
+
const { values, positionals } = parse(args, OPTIONS, `${NAME} cache <ls|clear>`);
|
|
184
|
+
const root = values.cache ?? paths.faker();
|
|
185
|
+
const sub = positionals[0] ?? 'ls';
|
|
186
|
+
if (sub === 'ls') {
|
|
187
|
+
const entries = await listGenerators(root);
|
|
188
|
+
if (values.json) {
|
|
189
|
+
json(entries);
|
|
190
|
+
return EXIT.ok;
|
|
191
|
+
}
|
|
192
|
+
if (entries.length === 0) {
|
|
193
|
+
note('nothing cached yet');
|
|
194
|
+
return EXIT.ok;
|
|
195
|
+
}
|
|
196
|
+
writeAll(table([
|
|
197
|
+
[bold('KEY'), bold('OPERATION'), bold('MODEL'), bold('TRIES')],
|
|
198
|
+
...entries.map((e) => [
|
|
199
|
+
e.key,
|
|
200
|
+
`${e.method?.toUpperCase() ?? '?'} ${e.path ?? ''}`,
|
|
201
|
+
dim(e.model ?? '—'),
|
|
202
|
+
dim(String(e.attempts ?? '—')),
|
|
203
|
+
]),
|
|
204
|
+
]));
|
|
205
|
+
return EXIT.ok;
|
|
206
|
+
}
|
|
207
|
+
if (sub === 'clear') {
|
|
208
|
+
rmSync(join(root, GENERATORS), { recursive: true, force: true });
|
|
209
|
+
// The container is named after its configuration, so a stale one would
|
|
210
|
+
// otherwise sit there stopped forever with nothing pointing at it.
|
|
211
|
+
// `zn-<key>-<digest>` is the shape, and this one's key is `faker`.
|
|
212
|
+
const mine = (await ownedContainers()).filter((c) => c.name.startsWith('zn-faker-'));
|
|
213
|
+
if (mine.length > 0) {
|
|
214
|
+
await removeContainers(mine.map((c) => c.name));
|
|
215
|
+
}
|
|
216
|
+
note(`${green('cleared')} ${dim(root)}`);
|
|
217
|
+
return EXIT.ok;
|
|
218
|
+
}
|
|
219
|
+
throw usageError(`unknown cache command "${sub}"`, `${NAME} cache <ls|clear>`);
|
|
220
|
+
}
|
|
221
|
+
async function listGenerators(root) {
|
|
222
|
+
let keys;
|
|
223
|
+
try {
|
|
224
|
+
keys = await readdir(join(root, GENERATORS));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const key of keys.sort()) {
|
|
231
|
+
try {
|
|
232
|
+
const meta = JSON.parse(await readFile(join(root, GENERATORS, key, 'meta.json'), 'utf8'));
|
|
233
|
+
out.push({ key, ...meta });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
out.push({ key });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
function summarize(operations) {
|
|
242
|
+
const by = new Map();
|
|
243
|
+
for (const op of operations) {
|
|
244
|
+
let stat = by.get(op.source);
|
|
245
|
+
if (!stat) {
|
|
246
|
+
stat = { source: op.source, paths: new Set(), methods: 0, functions: 0 };
|
|
247
|
+
by.set(op.source, stat);
|
|
248
|
+
}
|
|
249
|
+
stat.paths.add(op.path);
|
|
250
|
+
stat.methods += 1;
|
|
251
|
+
if (op.success.schema) {
|
|
252
|
+
stat.functions += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [...by.values()];
|
|
256
|
+
}
|
|
257
|
+
const HEADERS = ['PATHS', 'METHODS', 'FUNCTIONS'];
|
|
258
|
+
function printSpecs(operations) {
|
|
259
|
+
const stats = summarize(operations);
|
|
260
|
+
const rows = stats.map((s) => ({
|
|
261
|
+
name: relative(process.cwd(), s.source) || s.source,
|
|
262
|
+
cells: [s.paths.size, s.methods, s.functions],
|
|
263
|
+
}));
|
|
264
|
+
if (rows.length > 1) {
|
|
265
|
+
rows.push({
|
|
266
|
+
name: 'total',
|
|
267
|
+
cells: HEADERS.map((_, i) => rows.reduce((n, r) => n + r.cells[i], 0)),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// Numbers are padded before they are styled: a colour code has no width,
|
|
271
|
+
// and `table` cannot know that.
|
|
272
|
+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r.cells[i]).length)));
|
|
273
|
+
const lines = table([
|
|
274
|
+
[bold('SPEC'), ...HEADERS.map((h, i) => bold(h.padStart(widths[i])))],
|
|
275
|
+
...rows.map((r) => [
|
|
276
|
+
r.name === 'total' ? dim(r.name) : r.name,
|
|
277
|
+
...r.cells.map((c, i) => String(c).padStart(widths[i])),
|
|
278
|
+
]),
|
|
279
|
+
]);
|
|
280
|
+
note('');
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
note(` ${line}`);
|
|
283
|
+
}
|
|
284
|
+
note('');
|
|
285
|
+
}
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Shared
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
async function start(values, specs, what) {
|
|
290
|
+
const loud = !values.quiet && !values.json;
|
|
291
|
+
return open({
|
|
292
|
+
specs,
|
|
293
|
+
cwd: process.cwd(),
|
|
294
|
+
cache: values.cache,
|
|
295
|
+
model: values.model,
|
|
296
|
+
image: values.image,
|
|
297
|
+
attempts: number(values.attempts, 'attempts'),
|
|
298
|
+
concurrency: number(values.concurrency, 'concurrency'),
|
|
299
|
+
timeout: number(values.timeout, 'timeout'),
|
|
300
|
+
rebuild: values.rebuild,
|
|
301
|
+
ephemeral: values['no-cache'],
|
|
302
|
+
onImageBuild: loud
|
|
303
|
+
? (tag) => note(`${dim('building')} ${tag} ${dim('— once, then cached')}`)
|
|
304
|
+
: undefined,
|
|
305
|
+
events: {
|
|
306
|
+
onStart: loud
|
|
307
|
+
? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation.path}`)
|
|
308
|
+
: undefined,
|
|
309
|
+
onAttempt: loud
|
|
310
|
+
? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
|
|
311
|
+
: undefined,
|
|
312
|
+
onReady: loud && what === 'serve'
|
|
313
|
+
? ({ operation, cached }) => cached
|
|
314
|
+
? undefined
|
|
315
|
+
: note(` ${green('ready')} ${dim(operation.operationId)}`)
|
|
316
|
+
: undefined,
|
|
317
|
+
onFail: loud
|
|
318
|
+
? ({ operation, error }) => note(` ${red('gave up')} ${dim(operation.operationId)} ${reason(error)}`)
|
|
319
|
+
: undefined,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
function number(raw, what) {
|
|
324
|
+
if (raw === undefined) {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
const value = Number(raw);
|
|
328
|
+
if (!Number.isFinite(value)) {
|
|
329
|
+
throw usageError(`--${what} must be a number, got "${raw}"`);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function usage() {
|
|
334
|
+
printBanner(BANNER);
|
|
335
|
+
write(`${bold(NAME)} ${dim('— a mock API from an openapi/swagger document')}`);
|
|
336
|
+
write(`\n${bold('Usage')}\n ${USAGE}`);
|
|
337
|
+
write(`\n${bold('Commands')}`);
|
|
338
|
+
writeAll(table([
|
|
339
|
+
[' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
|
|
340
|
+
[' build <spec...>', dim('Write every generator now and exit.')],
|
|
341
|
+
[' cache ls|clear', dim('What has been generated, or throw it away.')],
|
|
342
|
+
]));
|
|
343
|
+
write(`\n${bold('Options')}`);
|
|
344
|
+
writeAll(table([
|
|
345
|
+
[' --port <n>', dim('Default 8787.')],
|
|
346
|
+
[' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
|
|
347
|
+
[' --model <ref>', dim('Which model writes the generators.')],
|
|
348
|
+
[' --image <ref>', dim('Skip the baked image and use this one.')],
|
|
349
|
+
[' --cache <dir>', dim('Where generators live. Default ~/.zenera/neo/faker.')],
|
|
350
|
+
[' --seed <n>', dim('Answer the same request the same way every time.')],
|
|
351
|
+
[' --attempts <n>', dim('Tries per generator before giving up. Default 3.')],
|
|
352
|
+
[' --concurrency <n>', dim('Generators written at once. Default 4.')],
|
|
353
|
+
[' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
|
|
354
|
+
[' --max-body <n>', dim('Largest request body accepted, in bytes.')],
|
|
355
|
+
[' --rebuild', dim('Ignore what is cached and write it again.')],
|
|
356
|
+
[' --no-cache', dim('Do not record what is written.')],
|
|
357
|
+
[' --quiet', dim('No narration.')],
|
|
358
|
+
[' --json', dim('Machine-readable output.')],
|
|
359
|
+
]));
|
|
360
|
+
write(`\n${dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`)}`);
|
|
361
|
+
}
|
|
362
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
363
|
+
//# sourceMappingURL=main.js.map
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
2
|
+
import type { Schema } from './schema.ts';
|
|
3
|
+
import type { Operation } from './spec.ts';
|
|
4
|
+
import type { Issue } from './validate.ts';
|
|
5
|
+
export declare function probesFor(operation: Operation): GeneratorInput[];
|
|
6
|
+
export declare function echoIssues(input: GeneratorInput, value: unknown, schema: Schema | undefined): Issue[];
|
|
7
|
+
//# sourceMappingURL=probe.d.ts.map
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Probes
|
|
3
|
+
//
|
|
4
|
+
// A generator is judged before it is trusted, and it is judged on inputs made
|
|
5
|
+
// up here rather than on traffic. That is not only about cost: a probe built
|
|
6
|
+
// from a request body would put whatever a caller sent into the next prompt,
|
|
7
|
+
// and a mock server is exactly the kind of thing people point at with real
|
|
8
|
+
// payloads by accident.
|
|
9
|
+
//
|
|
10
|
+
// Two probes, deliberately. One ordinary, one at the edges of whatever the
|
|
11
|
+
// schema allows — a generator that hard-codes the first probe's id passes once
|
|
12
|
+
// and fails the second, which is the mistake this catches.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export function probesFor(operation) {
|
|
15
|
+
return [0, 1].map((variant) => ({
|
|
16
|
+
operationId: operation.operationId,
|
|
17
|
+
method: operation.method,
|
|
18
|
+
path: operation.path,
|
|
19
|
+
pathParams: values(operation.params, 'path', variant),
|
|
20
|
+
query: values(operation.params, 'query', variant),
|
|
21
|
+
headers: {},
|
|
22
|
+
body: operation.requestBody
|
|
23
|
+
? sample(operation.requestBody.schema, variant, 0, operation.requestBody.schema)
|
|
24
|
+
: undefined,
|
|
25
|
+
seed: variant === 0 ? 1 : 2,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function values(params, where, variant) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const p of params) {
|
|
31
|
+
if (p.in !== where) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// An optional query parameter is present in one probe and absent in the
|
|
35
|
+
// other, so a generator cannot assume either.
|
|
36
|
+
if (!p.required && variant === 1) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
out[p.name] = sample(p.schema, variant, 0, p.schema);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
const NAMES = ['ada', 'grace', 'linus'];
|
|
44
|
+
/**
|
|
45
|
+
* A value the schema would accept. Not a faker — that job belongs to the
|
|
46
|
+
* generator; this only has to be *valid*, so that a probe failure is the
|
|
47
|
+
* generator's fault and never the probe's.
|
|
48
|
+
*/
|
|
49
|
+
function sample(schema, variant, depth, root) {
|
|
50
|
+
if (depth > 4) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// Normalisation hoists anything shared or recursive, so a top-level schema
|
|
54
|
+
// is quite often nothing but a pointer into `$defs`.
|
|
55
|
+
const target = follow(schema, root);
|
|
56
|
+
if (target !== schema) {
|
|
57
|
+
return sample(target, variant, depth + 1, root);
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
60
|
+
return schema.enum[variant % schema.enum.length];
|
|
61
|
+
}
|
|
62
|
+
if (schema.const !== undefined) {
|
|
63
|
+
return schema.const;
|
|
64
|
+
}
|
|
65
|
+
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
|
66
|
+
const list = schema[key];
|
|
67
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
68
|
+
return sample(list[0], variant, depth + 1, root);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
switch (typeOf(schema)) {
|
|
72
|
+
case 'integer':
|
|
73
|
+
return bounded(schema, variant === 0 ? 12324 : 7, true);
|
|
74
|
+
case 'number':
|
|
75
|
+
return bounded(schema, variant === 0 ? 42.5 : 1.5, false);
|
|
76
|
+
case 'boolean':
|
|
77
|
+
return variant === 0;
|
|
78
|
+
case 'null':
|
|
79
|
+
return null;
|
|
80
|
+
case 'array': {
|
|
81
|
+
const items = schema.items;
|
|
82
|
+
const one = typeof items === 'object' && items !== null
|
|
83
|
+
? sample(items, variant, depth + 1, root)
|
|
84
|
+
: 1;
|
|
85
|
+
return variant === 0 ? [one] : [one, one];
|
|
86
|
+
}
|
|
87
|
+
case 'object': {
|
|
88
|
+
const properties = (schema.properties ?? {});
|
|
89
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [name, sub] of Object.entries(properties)) {
|
|
92
|
+
if (variant === 1 && !required.includes(name)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out[name] = sample(sub, variant, depth + 1, root);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
return text(schema, variant);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** One hop through a local `#/$defs/...` pointer, or the schema itself. */
|
|
104
|
+
function follow(schema, root) {
|
|
105
|
+
const ref = schema.$ref;
|
|
106
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/$defs/')) {
|
|
107
|
+
return schema;
|
|
108
|
+
}
|
|
109
|
+
const defs = root.$defs;
|
|
110
|
+
if (typeof defs !== 'object' || defs === null) {
|
|
111
|
+
return schema;
|
|
112
|
+
}
|
|
113
|
+
const target = defs[ref.slice('#/$defs/'.length)];
|
|
114
|
+
return typeof target === 'object' && target !== null ? target : schema;
|
|
115
|
+
}
|
|
116
|
+
function typeOf(schema) {
|
|
117
|
+
const type = schema.type;
|
|
118
|
+
if (typeof type === 'string') {
|
|
119
|
+
return type;
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(type)) {
|
|
122
|
+
return type.find((t) => t !== 'null');
|
|
123
|
+
}
|
|
124
|
+
return schema.properties !== undefined
|
|
125
|
+
? 'object'
|
|
126
|
+
: schema.items !== undefined
|
|
127
|
+
? 'array'
|
|
128
|
+
: undefined;
|
|
129
|
+
}
|
|
130
|
+
function bounded(schema, wanted, integer) {
|
|
131
|
+
const min = num(schema.minimum) ?? num(schema.exclusiveMinimum);
|
|
132
|
+
const max = num(schema.maximum) ?? num(schema.exclusiveMaximum);
|
|
133
|
+
let value = wanted;
|
|
134
|
+
if (min !== undefined && value <= min) {
|
|
135
|
+
value = min + 1;
|
|
136
|
+
}
|
|
137
|
+
if (max !== undefined && value >= max) {
|
|
138
|
+
value = max - 1;
|
|
139
|
+
}
|
|
140
|
+
return integer ? Math.round(value) : value;
|
|
141
|
+
}
|
|
142
|
+
const num = (v) => (typeof v === 'number' ? v : undefined);
|
|
143
|
+
function text(schema, variant) {
|
|
144
|
+
switch (schema.format) {
|
|
145
|
+
case 'uuid':
|
|
146
|
+
return variant === 0
|
|
147
|
+
? '3f2504e0-4f89-11d3-9a0c-0305e82c3301'
|
|
148
|
+
: '9c858901-8a57-4791-81fe-4c455b099bc9';
|
|
149
|
+
case 'date':
|
|
150
|
+
return variant === 0 ? '2024-01-15' : '1999-12-31';
|
|
151
|
+
case 'date-time':
|
|
152
|
+
return variant === 0 ? '2024-01-15T09:30:00Z' : '1999-12-31T23:59:59Z';
|
|
153
|
+
case 'email':
|
|
154
|
+
return `${NAMES[variant % NAMES.length]}@example.com`;
|
|
155
|
+
case 'uri':
|
|
156
|
+
case 'url':
|
|
157
|
+
return 'https://example.com/thing';
|
|
158
|
+
default:
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
// A `pattern` cannot be satisfied by guessing, so it is left to the
|
|
162
|
+
// parameter check to tell us the probe was wrong rather than the generator.
|
|
163
|
+
const base = NAMES[variant % NAMES.length];
|
|
164
|
+
const min = num(schema.minLength) ?? 0;
|
|
165
|
+
return base.length >= min ? base : base.padEnd(min, 'x');
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// The echo rule
|
|
169
|
+
//
|
|
170
|
+
// `get_user_by_id(12324)` answering `{ user_id: 999 }` validates perfectly and
|
|
171
|
+
// is still wrong, so schema conformance is not the whole test. Where the
|
|
172
|
+
// response declares a property with a parameter's name, the parameter's value
|
|
173
|
+
// has to be the one that comes back.
|
|
174
|
+
//
|
|
175
|
+
// **Path parameters only.** A path segment identifies the resource, so a
|
|
176
|
+
// mismatch there is a broken mock. A query parameter is usually a control
|
|
177
|
+
// rather than content — `?source=realtime`, `?page_size=50`, `?cursor=…` — and
|
|
178
|
+
// real APIs collide those names with unrelated response properties all the
|
|
179
|
+
// time. Enforcing on them rejected working generators for a rule they were
|
|
180
|
+
// right to ignore. The prompt still asks for query echo where it is meaningful;
|
|
181
|
+
// it is simply not a reason to throw the file away.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
export function echoIssues(input, value, schema) {
|
|
184
|
+
if (!schema) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const declared = propertyNames(schema);
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const [name, expected] of Object.entries(input.pathParams)) {
|
|
190
|
+
if (!declared.has(name) || expected === undefined || expected === null) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!carries(value, name, expected)) {
|
|
194
|
+
out.push({
|
|
195
|
+
where: `/${name}`,
|
|
196
|
+
message: `must echo the path parameter ${JSON.stringify(expected)}, the response schema declares this property`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/** Every property name the schema mentions, at any depth. */
|
|
203
|
+
function propertyNames(schema) {
|
|
204
|
+
const out = new Set();
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const stack = [schema];
|
|
207
|
+
while (stack.length > 0) {
|
|
208
|
+
const node = stack.pop();
|
|
209
|
+
if (typeof node !== 'object' || node === null) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(node)) {
|
|
213
|
+
stack.push(...node);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (seen.has(node)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
seen.add(node);
|
|
220
|
+
const record = node;
|
|
221
|
+
const properties = record.properties;
|
|
222
|
+
if (typeof properties === 'object' && properties !== null) {
|
|
223
|
+
for (const [name, sub] of Object.entries(properties)) {
|
|
224
|
+
out.add(name);
|
|
225
|
+
stack.push(sub);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// `$defs` holds schemas under arbitrary names, so its *values* are the
|
|
229
|
+
// subschemas — pushing the map itself would walk one level and stop,
|
|
230
|
+
// which is where every hoisted recursive schema lives.
|
|
231
|
+
for (const key of ['$defs', 'patternProperties']) {
|
|
232
|
+
const map = record[key];
|
|
233
|
+
if (typeof map === 'object' && map !== null) {
|
|
234
|
+
stack.push(...Object.values(map));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const key of ['items', 'allOf', 'anyOf', 'oneOf', 'prefixItems', 'not']) {
|
|
238
|
+
stack.push(record[key]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
/** Whether `name` anywhere in the value holds something equal to `expected`. */
|
|
244
|
+
function carries(value, name, expected) {
|
|
245
|
+
const stack = [value];
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
while (stack.length > 0) {
|
|
248
|
+
const node = stack.pop();
|
|
249
|
+
if (typeof node !== 'object' || node === null) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (Array.isArray(node)) {
|
|
253
|
+
stack.push(...node);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (seen.has(node)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
seen.add(node);
|
|
260
|
+
const record = node;
|
|
261
|
+
if (name in record && same(record[name], expected)) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
stack.push(...Object.values(record));
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
/** A path segment is text on the wire; `"12324"` and `12324` are the same id. */
|
|
269
|
+
function same(got, expected) {
|
|
270
|
+
if (got === expected) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
if (got === null || got === undefined || typeof got === 'object') {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return String(got) === String(expected);
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=probe.js.map
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Operation } from './spec.ts';
|
|
2
|
+
export declare const SYSTEM: string;
|
|
3
|
+
export declare function brief(operation: Operation): string;
|
|
4
|
+
/**
|
|
5
|
+
* Repeated in the file the model writes, so it is worth spelling out: the
|
|
6
|
+
* schema it validates against must be the one it was shown, embedded, not
|
|
7
|
+
* loaded from anywhere.
|
|
8
|
+
*/
|
|
9
|
+
export declare function instruction(operation: Operation): string;
|
|
10
|
+
export declare function retry(diagnostics: readonly string[]): string;
|
|
11
|
+
//# sourceMappingURL=prompt.d.ts.map
|