@zenera/faker 1.1.20 → 1.1.21

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 CHANGED
@@ -57,6 +57,12 @@ zen faker cache clear # throw it away
57
57
 
58
58
  `zen mock` is the same command under a shorter noun.
59
59
 
60
+ A path key with a query string on it — `/x/{id}?action=retry`, which VMware NSX
61
+ and others write when they need two `post`s under one path — is read for what
62
+ it means rather than taken verbatim: the path is the part before the `?`, and
63
+ each pair after it becomes a required query value that tells those operations
64
+ apart. Call one without it and the 404 says which queries the path answers to.
65
+
60
66
  ## How a body is produced
61
67
 
62
68
  The first time an operation is called, the faker asks a model to write a
@@ -117,15 +123,27 @@ because a body handed it X.
117
123
  zen faker serve <spec...> Serve them. Generators are written on demand.
118
124
  zen faker build <spec...> Write every generator now and exit.
119
125
  zen faker cache ls | clear What has been generated, or throw it away.
126
+ zen faker model [ref] Show or set the model it uses.
120
127
  ```
121
128
 
129
+ The model is looked for in this order: `--model`, `ZENERA_FAKER_MODEL`, what
130
+ `zen faker model <provider>:<id>` stored, then the best one your keys can buy.
131
+ The stored one lives beside the keyring in `~/.zenera/neo/faker.json`, so it is
132
+ remembered between runs and across projects; `zen faker model --clear` forgets
133
+ it.
134
+
122
135
  Useful options: `--port`, `--host` (reachable only from this machine by
123
136
  default), `--model`, `--seed` (same request, same answer), `--rebuild`,
124
137
  `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
125
138
  workspace), `--quiet`. `zen help faker` prints the full table.
126
139
 
127
- `GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
128
- health check.
140
+ `GET /` is a contents page: every operation the mock is serving, grouped by the
141
+ document it came from, with its parameters and the shape of its answer. Each
142
+ one has a **Run** button, which opens a form — a box per parameter, required
143
+ ones marked, a body prefilled with something the mock will accept — and shows
144
+ the status, the time and the answer. `GET /__faker/routes` is the same thing as
145
+ JSON; `GET /__faker/health` is a health check. A document that declares `/`
146
+ itself keeps it.
129
147
 
130
148
  ## Credentials
131
149
 
package/dist/command.js CHANGED
@@ -1,12 +1,13 @@
1
- import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
1
+ import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, KeyStore, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
2
+ import { createModel } from '@zenera/neo';
2
3
  import { rmSync } from 'node:fs';
3
4
  import { join, relative, resolve } from 'node:path';
4
5
  import { GENERATORS } from "./box.js";
5
6
  import { FAKER_KIND } from "./cache.js";
6
7
  import { reason } from "./generate.js";
7
8
  import { listen } from "./server.js";
8
- import { open } from "./setup.js";
9
- import {} from "./spec.js";
9
+ import { chooseModel, MODEL_ENV, open, readSettings, SOURCE_LABELS, writeSettings, } from "./setup.js";
10
+ import { called } from "./spec.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // zen faker — a mock API from a specification
12
13
  //
@@ -18,7 +19,7 @@ import {} from "./spec.js";
18
19
  // resolve until a signal arrives, and the process lives as long as the answer
19
20
  // takes, which is what every other command already means.
20
21
  // ---------------------------------------------------------------------------
21
- const USAGE = 'zen faker <serve|build|cache> [spec...]';
22
+ const USAGE = 'zen faker <serve|build|cache|model> [spec...]';
22
23
  // `--json` is the frame's, lifted out of the arguments before they arrive; it
23
24
  // reaches us as `ctx.json` and must not be declared again or `strict` rejects it.
24
25
  const OPTIONS = {
@@ -34,6 +35,7 @@ const OPTIONS = {
34
35
  'max-body': { type: 'string' },
35
36
  rebuild: { type: 'boolean' },
36
37
  'no-cache': { type: 'boolean' },
38
+ clear: { type: 'boolean' },
37
39
  quiet: { type: 'boolean' },
38
40
  };
39
41
  export const command = {
@@ -45,13 +47,14 @@ export const command = {
45
47
  [' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
46
48
  [' build <spec...>', dim('Write every generator now and exit.')],
47
49
  [' cache ls|clear', dim('What has been generated, or throw it away.')],
50
+ [' model [ref]', dim('Show or set the model it uses. Remembered between runs.')],
48
51
  ]),
49
52
  '',
50
53
  'Options',
51
54
  ...table([
52
55
  [' --port <n>', dim('Default 8787.')],
53
56
  [' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
54
- [' --model <ref>', dim('Which model writes the generators.')],
57
+ [' --model <ref>', dim('Which model writes the generators, for this run only.')],
55
58
  [' --image <ref>', dim('Skip the baked image and use this one.')],
56
59
  [' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/faker.")],
57
60
  [' --seed <n>', dim('Answer the same request the same way every time.')],
@@ -61,9 +64,12 @@ export const command = {
61
64
  [' --max-body <n>', dim('Largest request body accepted, in bytes.')],
62
65
  [' --rebuild', dim('Ignore what is cached and write it again.')],
63
66
  [' --no-cache', dim('Do not record what is written.')],
67
+ [' --clear', dim('With `model`: forget the stored one.')],
64
68
  [' --quiet', dim('No narration.')],
65
69
  ]),
66
70
  '',
71
+ dim(`The model is looked for in this order: ${cyan('--model')}, ${cyan(MODEL_ENV)}, ` +
72
+ `${cyan('zen faker model')}, then the best one your keys can buy.`),
67
73
  dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
68
74
  ],
69
75
  async run(ctx) {
@@ -75,6 +81,8 @@ export const command = {
75
81
  return await warm(rest, ctx);
76
82
  case 'cache':
77
83
  return await cache(rest, ctx);
84
+ case 'model':
85
+ return await model(rest, ctx);
78
86
  default:
79
87
  throw usageError(name ? `unknown command "${name}"` : 'no command given', USAGE);
80
88
  }
@@ -144,7 +152,7 @@ async function warm(args, ctx) {
144
152
  const results = [];
145
153
  try {
146
154
  for (const operation of setup.router.operations) {
147
- const id = `${operation.method.toUpperCase()} ${operation.path}`;
155
+ const id = `${operation.method.toUpperCase()} ${called(operation)}`;
148
156
  if (!operation.success.schema) {
149
157
  results.push({ operation: id, status: 'skipped', detail: 'no response body' });
150
158
  continue;
@@ -238,6 +246,58 @@ function listGenerators() {
238
246
  .map((row) => ({ key: row.key, ...row.value?.meta }))
239
247
  .sort((a, b) => a.key.localeCompare(b.key));
240
248
  }
249
+ // ---------------------------------------------------------------------------
250
+ // model
251
+ //
252
+ // A stored model is remembered the way `zen meta model` remembers one: in the
253
+ // home directory, beside the keyring, because it is a choice about this tool on
254
+ // this machine. Shown rather than merely set, since the question when a run
255
+ // surprises you is not "which model" but "why that one".
256
+ // ---------------------------------------------------------------------------
257
+ const MODEL_USAGE = 'zen faker model [ref|--clear]';
258
+ async function model(args, ctx) {
259
+ const { values, positionals } = parse(args, OPTIONS, MODEL_USAGE);
260
+ const ref = positionals[0];
261
+ if (ref && values.clear) {
262
+ throw usageError('--clear takes no model', MODEL_USAGE);
263
+ }
264
+ // Before anything builds a model: vertex reads its project out of the
265
+ // environment, and the keyring is what puts it there.
266
+ const keys = await KeyStore.open();
267
+ keys.materialize();
268
+ if (ref || values.clear) {
269
+ const file = await readSettings();
270
+ if (ref) {
271
+ // Only that the ref parses: asking the provider costs a call, and
272
+ // the next run makes one anyway.
273
+ createModel(ref);
274
+ writeSettings({ ...file, model: ref });
275
+ }
276
+ else {
277
+ delete file.model;
278
+ writeSettings(file);
279
+ }
280
+ if (ctx.json) {
281
+ json({ model: ref });
282
+ return;
283
+ }
284
+ note(ref ? `${green('set')} ${ref}` : `${green('cleared')} ${dim('the stored model')}`);
285
+ }
286
+ const chosen = await chooseModel(values.model, keys);
287
+ if (ctx.json) {
288
+ json({ model: chosen?.ref, from: chosen?.from, stored: (await readSettings()).model });
289
+ return;
290
+ }
291
+ if (!chosen) {
292
+ note('no model, and no key to recommend one from');
293
+ note(dim('add a key: zen key add openai'));
294
+ return;
295
+ }
296
+ note(`${cyan(chosen.ref)} ${dim(`from ${SOURCE_LABELS[chosen.from]}`)}`);
297
+ if (chosen.from === 'default') {
298
+ note(dim(`set one: zen faker model <provider>:<id>`));
299
+ }
300
+ }
241
301
  function summarize(operations) {
242
302
  const by = new Map();
243
303
  for (const op of operations) {
@@ -288,7 +348,7 @@ function printSpecs(operations, cwd) {
288
348
  // ---------------------------------------------------------------------------
289
349
  async function start(values, specs, ctx, what) {
290
350
  const loud = !values.quiet && !ctx.json;
291
- return open({
351
+ const setup = await open({
292
352
  specs,
293
353
  cwd: ctx.cwd,
294
354
  cache: values.cache,
@@ -304,7 +364,7 @@ async function start(values, specs, ctx, what) {
304
364
  : undefined,
305
365
  events: {
306
366
  onStart: loud
307
- ? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation.path}`)
367
+ ? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${called(operation)}`)
308
368
  : undefined,
309
369
  onAttempt: loud
310
370
  ? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
@@ -319,6 +379,10 @@ async function start(values, specs, ctx, what) {
319
379
  : undefined,
320
380
  },
321
381
  });
382
+ if (loud) {
383
+ note(dim(`model ${setup.choice.ref} from ${SOURCE_LABELS[setup.choice.from]}`));
384
+ }
385
+ return setup;
322
386
  }
323
387
  function number(raw, what) {
324
388
  if (raw === undefined) {
@@ -1,6 +1,6 @@
1
1
  import type { Model } from '@zenera/neo';
2
2
  import type { Box } from './box.ts';
3
- import type { Operation } from './spec.ts';
3
+ import { type Operation } from './spec.ts';
4
4
  import { type Checks } from './validate.ts';
5
5
  export interface BuildOptions {
6
6
  model: Model;
package/dist/generate.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { tokenOf } from "./paging.js";
2
2
  import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
3
3
  import { instruction, retry, SYSTEM } from "./prompt.js";
4
+ import { called } from "./spec.js";
4
5
  import { describeIssues, issues } from "./validate.js";
5
6
  export class BuildFailed extends Error {
6
7
  operation;
7
8
  diagnostics;
8
9
  constructor(operation, diagnostics) {
9
- super(`could not write a generator for ${operation.method.toUpperCase()} ${operation.path}`);
10
+ super(`could not write a generator for ${operation.method.toUpperCase()} ${called(operation)}`);
10
11
  this.name = 'BuildFailed';
11
12
  this.operation = operation;
12
13
  this.diagnostics = diagnostics;
@@ -90,9 +91,9 @@ async function judge(operation, probes, response, box) {
90
91
  const out = [];
91
92
  for (const probe of probes) {
92
93
  const outcome = await box.run(operation.key, probe);
93
- const called = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
94
+ const what = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
94
95
  if (!outcome.ok) {
95
- out.push(`- ${called}: the file ${outcome.fault}.`);
96
+ out.push(`- ${what}: the file ${outcome.fault}.`);
96
97
  if (outcome.stderr) {
97
98
  out.push(` stderr: ${tail(outcome.stderr)}`);
98
99
  }
@@ -100,11 +101,11 @@ async function judge(operation, probes, response, box) {
100
101
  break;
101
102
  }
102
103
  if (response && !response(outcome.value)) {
103
- out.push(`- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
104
+ out.push(`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
104
105
  }
105
106
  const echo = echoIssues(probe, outcome.value, operation.success.schema);
106
107
  if (echo.length > 0) {
107
- out.push(`- ${called}: ${describeIssues(echo)}.`);
108
+ out.push(`- ${what}: ${describeIssues(echo)}.`);
108
109
  }
109
110
  }
110
111
  // Only worth the container round trips once the file answers at all, and
@@ -127,13 +128,13 @@ async function walk(operation, paging, response, box) {
127
128
  let sent;
128
129
  for (let page = 1; page <= MAX_PAGES; page++) {
129
130
  const outcome = await box.run(operation.key, input);
130
- const called = `page ${page} of ${operation.method.toUpperCase()} ${operation.path}`;
131
+ const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
131
132
  if (!outcome.ok) {
132
- return [`- ${called}: the file ${outcome.fault}.`];
133
+ return [`- ${what}: the file ${outcome.fault}.`];
133
134
  }
134
135
  if (response && !response(outcome.value)) {
135
136
  return [
136
- `- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
137
+ `- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
137
138
  ];
138
139
  }
139
140
  const token = tokenOf(outcome.value, paging);
@@ -142,12 +143,12 @@ async function walk(operation, paging, response, box) {
142
143
  }
143
144
  if (token === sent) {
144
145
  return [
145
- `- ${called}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after three pages.`,
146
+ `- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after three pages.`,
146
147
  ];
147
148
  }
148
149
  if (seen.has(token)) {
149
150
  return [
150
- `- ${called}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
151
+ `- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
151
152
  ];
152
153
  }
153
154
  seen.add(token);
package/dist/page.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type Operation } from './spec.ts';
2
+ export declare function indexPage(operations: readonly Operation[]): string;
3
+ //# sourceMappingURL=page.d.ts.map
package/dist/page.js ADDED
@@ -0,0 +1,499 @@
1
+ import { bannerArt } from '@zenera/cli/lib';
2
+ import { basename } from 'node:path';
3
+ import { called } from "./spec.js";
4
+ // ---------------------------------------------------------------------------
5
+ // The index page
6
+ //
7
+ // What a person does first with a mock is point a browser at it, and a 404 on
8
+ // `/` tells them nothing about what the thing is serving. So the root is a
9
+ // contents page: every operation, grouped by the document it came from, in the
10
+ // order the document declared it, and a button that calls it.
11
+ //
12
+ // It is one self-contained string — nothing is fetched over a network the mock
13
+ // may well be standing in for: the stylesheet, the run dialog's script and the
14
+ // operation descriptions it works from are all inline. The banner is the CLI's
15
+ // own art, so the page and the terminal agree about what this program is
16
+ // called.
17
+ // ---------------------------------------------------------------------------
18
+ const BRAND = { head: 'Zenera', accent: 'Faker', subtitle: 'Mock API Server' };
19
+ export function indexPage(operations) {
20
+ const documents = group(operations);
21
+ const title = `${BRAND.head} ${BRAND.accent}`;
22
+ const at = new Map(operations.map((operation, i) => [operation, i]));
23
+ // `<` is escaped so no description can close this script element early.
24
+ const descriptors = JSON.stringify(operations.map(callable)).replace(/</g, '\\u003c');
25
+ return `<!doctype html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1">
30
+ <title>${esc(title)}</title>
31
+ <style>${CSS}</style>
32
+ </head>
33
+ <body>
34
+ <header>
35
+ ${banner()}
36
+ <p class="caption">${esc(BRAND.subtitle)}</p>
37
+ <p class="meta">${operations.length} operation${operations.length === 1 ? '' : 's'} &middot; ${documents.length} document${documents.length === 1 ? '' : 's'} &middot; <a href="/__faker/routes">routes</a> &middot; <a href="/__faker/health">health</a></p>
38
+ </header>
39
+ <main>
40
+ ${documents.map((d) => document(d, at)).join('\n') || '<p class="empty">No operations are being served.</p>'}
41
+ </main>
42
+ ${DIALOG}
43
+ <script type="application/json" id="faker-ops">${descriptors}</script>
44
+ <script>${SCRIPT}</script>
45
+ </body>
46
+ </html>
47
+ `;
48
+ }
49
+ /** By source document, each keeping the order its document declared. */
50
+ function group(operations) {
51
+ const out = new Map();
52
+ for (const operation of operations) {
53
+ const list = out.get(operation.source) ?? [];
54
+ list.push(operation);
55
+ out.set(operation.source, list);
56
+ }
57
+ return [...out];
58
+ }
59
+ function document([source, operations], at) {
60
+ return `<section>
61
+ <h2>${esc(basename(source))} <span class="source">${esc(source)}</span></h2>
62
+ ${operations.map((operation) => entry(operation, at.get(operation) ?? 0)).join('\n')}
63
+ </section>`;
64
+ }
65
+ function entry(operation, index) {
66
+ const parts = [
67
+ `<div class="op">`,
68
+ `<div class="sig"><span class="verb ${operation.method}">${operation.method.toUpperCase()}</span><code class="path">${esc(called(operation))}</code><span class="id">${esc(operation.operationId)}</span><button type="button" class="try" data-op="${index}">Run</button></div>`,
69
+ ];
70
+ if (operation.summary) {
71
+ parts.push(`<p class="summary">${esc(operation.summary)}</p>`);
72
+ }
73
+ if (operation.description && operation.description !== operation.summary) {
74
+ parts.push(`<p class="about">${esc(operation.description)}</p>`);
75
+ }
76
+ parts.push(`<p class="answer">${esc(answer(operation))}</p>`);
77
+ if (operation.params.length > 0 || operation.requestBody) {
78
+ parts.push(`<div class="rows"><table class="params">${rows(operation).join('')}</table></div>`);
79
+ }
80
+ parts.push('</div>');
81
+ return parts.join('\n');
82
+ }
83
+ /** The one line that says what a call gets back. */
84
+ function answer(operation) {
85
+ const body = operation.success.schema ? typeOf(operation.success.schema) : 'no body';
86
+ const paged = operation.paging
87
+ ? ` · pages by ${operation.paging.param}${operation.paging.next ? ` → ${operation.paging.next}` : ''}`
88
+ : '';
89
+ return `→ ${operation.success.status} ${body}${paged}`;
90
+ }
91
+ function rows(operation) {
92
+ const out = operation.params.map((p) => `<tr><td class="name">${esc(p.name)}</td><td class="in">${esc(p.in)}</td><td class="type">${esc(typeOf(p.schema))}</td><td class="need">${p.required ? 'required' : ''}</td><td class="note">${esc(p.description ?? '')}</td></tr>`);
93
+ if (operation.requestBody) {
94
+ out.push(`<tr><td class="name">body</td><td class="in">body</td><td class="type">${esc(typeOf(operation.requestBody.schema))}</td><td class="need">${operation.requestBody.required ? 'required' : ''}</td><td class="note"></td></tr>`);
95
+ }
96
+ return out;
97
+ }
98
+ /** A schema in as many words as fit in a column: the shape, not the contract. */
99
+ function typeOf(schema, root = schema, depth = 0) {
100
+ const node = deref(schema, root);
101
+ const type = Array.isArray(node.type) ? node.type.join(' | ') : node.type;
102
+ if (type === 'array' && depth < 3) {
103
+ const items = node.items;
104
+ return `${isObject(items) ? typeOf(items, root, depth + 1) : 'any'}[]`;
105
+ }
106
+ if (Array.isArray(node.enum)) {
107
+ return brief(node.enum.map((v) => String(v)), ' | ');
108
+ }
109
+ if (isObject(node.properties)) {
110
+ return `object { ${brief(Object.keys(node.properties), ', ')} }`;
111
+ }
112
+ if (typeof type === 'string') {
113
+ return `${type}${typeof node.format === 'string' ? ` <${node.format}>` : ''}`;
114
+ }
115
+ for (const key of ['oneOf', 'anyOf', 'allOf']) {
116
+ if (Array.isArray(node[key])) {
117
+ return key;
118
+ }
119
+ }
120
+ return 'any';
121
+ }
122
+ /**
123
+ * Anything shared or recursive was hoisted into `$defs` under a made-up name,
124
+ * so the pointer says nothing and the shape it points at says everything.
125
+ */
126
+ function deref(schema, root, hops = 0) {
127
+ const ref = schema.$ref;
128
+ if (typeof ref !== 'string' || !ref.startsWith(DEFS) || hops >= 4) {
129
+ return schema;
130
+ }
131
+ const defs = root.$defs;
132
+ if (!isObject(defs)) {
133
+ return schema;
134
+ }
135
+ const target = defs[ref.slice(DEFS.length)];
136
+ return isObject(target) ? deref(target, root, hops + 1) : schema;
137
+ }
138
+ const DEFS = '#/$defs/';
139
+ function callable(operation) {
140
+ // Cookies are not the browser's to set from a script, so they are shown in
141
+ // the table and left out of the form.
142
+ const fields = operation.params
143
+ .filter((p) => p.in !== 'cookie')
144
+ .map((p) => {
145
+ const node = deref(p.schema, p.schema);
146
+ return {
147
+ name: p.name,
148
+ in: p.in,
149
+ required: p.required,
150
+ type: typeOf(p.schema),
151
+ choices: choicesOf(node),
152
+ description: p.description,
153
+ };
154
+ });
155
+ const body = operation.requestBody;
156
+ return {
157
+ method: operation.method,
158
+ path: operation.path,
159
+ shown: called(operation),
160
+ fields,
161
+ body: body
162
+ ? {
163
+ required: body.required,
164
+ sample: JSON.stringify(sample(body.schema, body.schema), null, 2),
165
+ }
166
+ : undefined,
167
+ };
168
+ }
169
+ function choicesOf(node) {
170
+ if (Array.isArray(node.enum)) {
171
+ return node.enum.map((v) => String(v));
172
+ }
173
+ return node.type === 'boolean' ? ['true', 'false'] : undefined;
174
+ }
175
+ /** Something shaped like the body, so the textarea is edited rather than written. */
176
+ function sample(schema, root, depth = 0) {
177
+ const node = deref(schema, root);
178
+ if (node.default !== undefined) {
179
+ return node.default;
180
+ }
181
+ if (Array.isArray(node.enum)) {
182
+ return node.enum[0];
183
+ }
184
+ const type = Array.isArray(node.type) ? node.type[0] : node.type;
185
+ if (type === 'array') {
186
+ return depth < 2 && isObject(node.items) ? [sample(node.items, root, depth + 1)] : [];
187
+ }
188
+ if (isObject(node.properties) && depth < 3) {
189
+ // Only what the document insists on. An optional branch filled in with
190
+ // blanks is a request the mock would reject before it answered.
191
+ const need = Array.isArray(node.required) ? node.required.map(String) : null;
192
+ const out = {};
193
+ for (const [name, sub] of Object.entries(node.properties)) {
194
+ if (isObject(sub) && (need === null || need.includes(name))) {
195
+ out[name] = sample(sub, root, depth + 1);
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+ if (Array.isArray(node.allOf) && depth < 3) {
201
+ // The branches of an `allOf` all apply at once, so they are merged.
202
+ return node.allOf
203
+ .filter(isObject)
204
+ .reduce((all, branch) => Object.assign(all, sample(branch, root, depth + 1)), {});
205
+ }
206
+ for (const key of ['oneOf', 'anyOf']) {
207
+ const branches = node[key];
208
+ if (Array.isArray(branches) && isObject(branches[0]) && depth < 3) {
209
+ return sample(branches[0], root, depth + 1);
210
+ }
211
+ }
212
+ if (type === 'integer' || type === 'number') {
213
+ return 0;
214
+ }
215
+ if (type === 'boolean') {
216
+ return false;
217
+ }
218
+ if (type === 'string') {
219
+ // A blank fails `format`, and the mock checks the request before it
220
+ // answers it, so a formatted string starts out as something legal.
221
+ return LIKE[String(node.format)] ?? '';
222
+ }
223
+ return type === 'object' ? {} : null;
224
+ }
225
+ const LIKE = {
226
+ email: 'someone@example.com',
227
+ hostname: 'example.com',
228
+ uri: 'https://example.com',
229
+ 'uri-reference': '/example',
230
+ uuid: '00000000-0000-4000-8000-000000000000',
231
+ date: '2024-01-01',
232
+ 'date-time': '2024-01-01T00:00:00Z',
233
+ time: '00:00:00',
234
+ duration: 'P1D',
235
+ ipv4: '127.0.0.1',
236
+ ipv6: '::1',
237
+ };
238
+ /** A list cut short: a contents page is not the document. */
239
+ const brief = (values, sep) => values.slice(0, MOST).join(sep) + (values.length > MOST ? `${sep}…` : '');
240
+ const MOST = 6;
241
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
242
+ /** The CLI's block face, split at the word so the accent keeps its colour. */
243
+ function banner() {
244
+ const { head, accent } = bannerArt(BRAND);
245
+ const rows = head.map((row, i) => `<span class="head">${paint(row)}</span> <span class="accent">${paint(accent[i] ?? '')}</span>`);
246
+ return `<pre class="banner" role="img" aria-label="${esc(`${BRAND.head} ${BRAND.accent}`)}">${rows.join('\n')}</pre>`;
247
+ }
248
+ /**
249
+ * Stroke and bevel, the terminal's two tones. Without the darker bevel the
250
+ * letter reads as a doubled outline rather than as a raised face.
251
+ */
252
+ const paint = (row) => row.replace(/[█▀▄]+|[^█▀▄ ]+/g, (run) => /[█▀▄]/.test(run) ? `<b>${run}</b>` : `<i>${run}</i>`);
253
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
254
+ const DIALOG = `<dialog id="run">
255
+ <div class="run-head"><h3 id="run-title"></h3><button type="button" id="run-close" aria-label="Close">&times;</button></div>
256
+ <form id="run-form">
257
+ <div id="run-fields"></div>
258
+ <div class="actions"><button type="submit" class="go">Run</button><button type="button" id="run-cancel" class="cancel">Cancel</button></div>
259
+ </form>
260
+ <pre id="run-out" hidden></pre>
261
+ </dialog>`;
262
+ // Values from the document reach the dialog as text nodes rather than markup:
263
+ // the page escapes what it prints, and this has to do the same by construction.
264
+ const SCRIPT = `
265
+ const specs = JSON.parse(document.getElementById('faker-ops').textContent);
266
+ const dialog = document.getElementById('run');
267
+ const title = document.getElementById('run-title');
268
+ const slots = document.getElementById('run-fields');
269
+ const form = document.getElementById('run-form');
270
+ const out = document.getElementById('run-out');
271
+ let current = null;
272
+
273
+ function box(field) {
274
+ if (field.choices) {
275
+ const select = document.createElement('select');
276
+ if (!field.required) {
277
+ select.append(new Option('', ''));
278
+ }
279
+ for (const choice of field.choices) {
280
+ select.append(new Option(choice, choice));
281
+ }
282
+ return select;
283
+ }
284
+ const input = document.createElement('input');
285
+ input.type = 'text';
286
+ input.placeholder = field.type;
287
+ return input;
288
+ }
289
+
290
+ function head(name, hint, required) {
291
+ const span = document.createElement('span');
292
+ span.className = 'label';
293
+ span.textContent = name;
294
+ const where = document.createElement('em');
295
+ where.textContent = hint;
296
+ span.append(' ', where);
297
+ if (required) {
298
+ const need = document.createElement('b');
299
+ need.textContent = 'required';
300
+ span.append(' ', need);
301
+ }
302
+ return span;
303
+ }
304
+
305
+ function row(field) {
306
+ const label = document.createElement('label');
307
+ label.className = 'field';
308
+ const input = box(field);
309
+ input.name = field.in + ':' + field.name;
310
+ input.required = field.required;
311
+ label.append(head(field.name, field.in + ' \u00b7 ' + field.type, field.required), input);
312
+ if (field.description) {
313
+ const note = document.createElement('small');
314
+ note.textContent = field.description;
315
+ label.append(note);
316
+ }
317
+ return label;
318
+ }
319
+
320
+ function bodyRow(body) {
321
+ const label = document.createElement('label');
322
+ label.className = 'field';
323
+ const area = document.createElement('textarea');
324
+ area.name = '@body';
325
+ area.value = body.sample;
326
+ area.required = body.required;
327
+ label.append(head('body', 'application/json', body.required), area);
328
+ return label;
329
+ }
330
+
331
+ function show(index) {
332
+ current = specs[index];
333
+ title.textContent = current.method.toUpperCase() + ' ' + current.shown;
334
+ slots.replaceChildren();
335
+ for (const field of current.fields) {
336
+ slots.append(row(field));
337
+ }
338
+ if (current.body) {
339
+ slots.append(bodyRow(current.body));
340
+ }
341
+ if (!current.fields.length && !current.body) {
342
+ const hint = document.createElement('p');
343
+ hint.className = 'hint';
344
+ hint.textContent = 'This call takes no arguments.';
345
+ slots.append(hint);
346
+ }
347
+ out.hidden = true;
348
+ out.textContent = '';
349
+ dialog.showModal();
350
+ }
351
+
352
+ form.addEventListener('submit', async (event) => {
353
+ event.preventDefault();
354
+ const spec = current;
355
+ const data = new FormData(form);
356
+ const path = spec.path.replace(/\\{(.*?)\\}/g, (whole, name) =>
357
+ encodeURIComponent(String(data.get('path:' + name) || '')),
358
+ );
359
+ const url = new URL(path, location.origin);
360
+ const headers = {};
361
+ for (const field of spec.fields) {
362
+ const value = data.get(field.in + ':' + field.name);
363
+ if (value === null || value === '') {
364
+ continue;
365
+ }
366
+ if (field.in === 'query') {
367
+ url.searchParams.set(field.name, value);
368
+ }
369
+ if (field.in === 'header') {
370
+ headers[field.name] = value;
371
+ }
372
+ }
373
+ let body;
374
+ const typed = spec.body ? String(data.get('@body') || '').trim() : '';
375
+ if (typed && spec.method !== 'get' && spec.method !== 'head') {
376
+ body = typed;
377
+ headers['content-type'] = 'application/json';
378
+ }
379
+
380
+ out.hidden = false;
381
+ out.dataset.ok = 'true';
382
+ out.textContent = 'running\u2026';
383
+ const started = performance.now();
384
+ try {
385
+ const res = await fetch(url, { method: spec.method.toUpperCase(), headers, body });
386
+ const answer = await res.text();
387
+ let shown = answer;
388
+ try {
389
+ shown = JSON.stringify(JSON.parse(answer), null, 2);
390
+ } catch {}
391
+ out.dataset.ok = String(res.ok);
392
+ out.textContent =
393
+ res.status + ' ' + res.statusText + ' \u00b7 ' +
394
+ Math.round(performance.now() - started) + 'ms \u00b7 ' +
395
+ spec.method.toUpperCase() + ' ' + url.pathname + url.search + '\\n\\n' + shown;
396
+ } catch (err) {
397
+ out.dataset.ok = 'false';
398
+ out.textContent = String(err);
399
+ }
400
+ });
401
+
402
+ for (const button of document.querySelectorAll('button.try')) {
403
+ button.addEventListener('click', () => show(Number(button.dataset.op)));
404
+ }
405
+ document.getElementById('run-close').addEventListener('click', () => dialog.close());
406
+ document.getElementById('run-cancel').addEventListener('click', () => dialog.close());
407
+ `;
408
+ // The two schemes are the terminal's two, and the accent is the same mint the
409
+ // `zen faker` banner is painted in on both.
410
+ const CSS = `
411
+ :root {
412
+ color-scheme: dark light;
413
+ --bg: #12131a; --fg: #e6e7ee; --dim: #8b8fa3; --line: #262838;
414
+ --card: #191b25; --accent: #87ffaf; --shade: #5fd787;
415
+ }
416
+ @media (prefers-color-scheme: light) {
417
+ :root { --bg: #fbfbfd; --fg: #1d1f2a; --dim: #6a6e80; --line: #e2e3ec;
418
+ --card: #ffffff; --accent: #14915a; --shade: #0f7548; }
419
+ }
420
+ * { box-sizing: border-box; }
421
+ body { margin: 0; padding: 2rem 0 4rem; background: var(--bg); color: var(--fg);
422
+ font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; }
423
+ header, main { width: 90%; margin: 0 auto; }
424
+ .banner { font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
425
+ margin: 0; overflow-x: auto; }
426
+ .banner b, .banner i { font: inherit; }
427
+ .banner .head b { color: var(--fg); }
428
+ .banner .head i { color: var(--dim); }
429
+ .banner .accent b { color: var(--accent); }
430
+ .banner .accent i { color: var(--shade); }
431
+ .caption { margin: .75rem 0 0; color: var(--dim); letter-spacing: .35em;
432
+ text-transform: uppercase; font-size: 11px; }
433
+ .meta { margin: .35rem 0 2.5rem; color: var(--dim); font-size: 13px; }
434
+ .meta a { color: var(--shade); text-decoration: none; }
435
+ .meta a:hover { text-decoration: underline; }
436
+ h2 { font-size: 14px; font-weight: 600; margin: 2.5rem 0 .75rem;
437
+ padding-bottom: .4rem; border-bottom: 1px solid var(--line); }
438
+ h2 .source { font-weight: 400; color: var(--dim); font-size: 12px; margin-left: .5rem;
439
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
440
+ .op { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
441
+ padding: .8rem 1rem; margin: .5rem 0; }
442
+ .sig { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
443
+ .verb { font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em;
444
+ padding: .3rem .45rem; border-radius: 4px; color: #0c0d12; background: var(--dim); }
445
+ .verb.get { background: #87ffaf; } .verb.post { background: #7fbcff; }
446
+ .verb.put { background: #ffd479; } .verb.patch { background: #d3a6ff; }
447
+ .verb.delete { background: #ff8f8f; }
448
+ .path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;
449
+ overflow-wrap: anywhere; }
450
+ .id { color: var(--dim); font-size: 12px; margin-left: auto;
451
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
452
+ .summary { margin: .5rem 0 0; }
453
+ .about { margin: .3rem 0 0; color: var(--dim); font-size: 13px; white-space: pre-wrap;
454
+ overflow-wrap: anywhere; }
455
+ .answer { margin: .5rem 0 0; color: var(--shade); font-size: 12px; overflow-wrap: anywhere;
456
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
457
+ .rows { overflow-x: auto; }
458
+ .params { margin: .6rem 0 0; border-collapse: collapse; width: 100%; font-size: 12px; }
459
+ .params td { padding: .2rem .5rem .2rem 0; vertical-align: top; overflow-wrap: break-word;
460
+ border-top: 1px solid var(--line); }
461
+ .params .name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
462
+ .params .in, .params .need { color: var(--dim); white-space: nowrap; }
463
+ .params .type { color: var(--dim); max-width: 18rem; overflow-wrap: anywhere; }
464
+ .params .note { color: var(--dim); width: 50%; }
465
+ .empty { color: var(--dim); }
466
+ .try { background: transparent; color: var(--shade); border: 1px solid var(--line);
467
+ border-radius: 6px; padding: .2rem .6rem; font: 600 11px/1.6 inherit; cursor: pointer; }
468
+ .try:hover { border-color: var(--accent); color: var(--accent); }
469
+ dialog { width: min(48rem, 92%); padding: 0; color: var(--fg); background: var(--card);
470
+ border: 1px solid var(--line); border-radius: 10px; }
471
+ dialog::backdrop { background: rgba(0, 0, 0, .55); }
472
+ .run-head { display: flex; align-items: center; gap: .6rem;
473
+ padding: .8rem 1rem; border-bottom: 1px solid var(--line); }
474
+ .run-head h3 { margin: 0; overflow-wrap: anywhere;
475
+ font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
476
+ #run-close { margin-left: auto; background: none; border: 0; color: var(--dim);
477
+ font-size: 18px; line-height: 1; cursor: pointer; }
478
+ #run-form { padding: 1rem; max-height: 55vh; overflow-y: auto; }
479
+ .field { display: block; margin: 0 0 .9rem; }
480
+ .field .label { font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
481
+ .field .label em { font-style: normal; font-weight: 400; color: var(--dim); font-size: 11px; }
482
+ .field .label b { color: var(--shade); font-size: 11px; }
483
+ .field small { display: block; margin-top: .2rem; color: var(--dim); font-size: 11px; }
484
+ .field input, .field select, .field textarea { display: block; width: 100%; margin-top: .3rem;
485
+ padding: .4rem .5rem; color: var(--fg); background: var(--bg); border: 1px solid var(--line);
486
+ border-radius: 6px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
487
+ .field textarea { min-height: 9rem; resize: vertical; }
488
+ .hint { color: var(--dim); font-size: 13px; margin: 0 0 .9rem; }
489
+ .actions { display: flex; gap: .5rem; }
490
+ .go { background: var(--accent); color: #0c0d12; border: 0; border-radius: 6px;
491
+ padding: .45rem 1.1rem; font: 600 13px/1.4 inherit; cursor: pointer; }
492
+ .cancel { background: transparent; color: var(--dim); border: 1px solid var(--line);
493
+ border-radius: 6px; padding: .45rem .9rem; font: 13px/1.4 inherit; cursor: pointer; }
494
+ #run-out { margin: 0; padding: .8rem 1rem; max-height: 40vh; overflow: auto;
495
+ border-top: 1px solid var(--line); background: var(--bg); white-space: pre-wrap;
496
+ overflow-wrap: anywhere; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
497
+ #run-out[data-ok="false"] { color: #ff8f8f; }
498
+ `;
499
+ //# sourceMappingURL=page.js.map
package/dist/prompt.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Operation } from './spec.ts';
1
+ import { type Operation } from './spec.ts';
2
2
  export declare const SYSTEM: string;
3
3
  export declare function brief(operation: Operation): string;
4
4
  /**
package/dist/prompt.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AVAILABLE } from "./image.js";
2
+ import { called } from "./spec.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // What the model is told
4
5
  //
@@ -53,7 +54,7 @@ export const SYSTEM = [
53
54
  ].join('\n');
54
55
  export function brief(operation) {
55
56
  const lines = [
56
- `OPERATION ${operation.method.toUpperCase()} ${operation.path}`,
57
+ `OPERATION ${operation.method.toUpperCase()} ${called(operation)}`,
57
58
  `operationId: ${operation.operationId}`,
58
59
  ];
59
60
  if (operation.summary) {
package/dist/router.d.ts CHANGED
@@ -7,8 +7,14 @@ export declare class Router {
7
7
  #private;
8
8
  readonly operations: readonly Operation[];
9
9
  constructor(operations: readonly Operation[]);
10
- match(method: string, pathname: string): Match | undefined;
10
+ match(method: string, pathname: string, search?: URLSearchParams): Match | undefined;
11
11
  /** Whether the path exists under some other method — a 405, not a 404. */
12
- allowed(pathname: string): Method[];
12
+ allowed(pathname: string, search?: URLSearchParams): Method[];
13
+ /**
14
+ * The query strings this method would have answered on this path. Empty
15
+ * unless the document pinned one, which is the only way a request can miss
16
+ * a route whose path it matched exactly.
17
+ */
18
+ expects(method: string, pathname: string): string[];
13
19
  }
14
20
  //# sourceMappingURL=router.d.ts.map
package/dist/router.js CHANGED
@@ -6,7 +6,8 @@ export class Router {
6
6
  this.operations = operations;
7
7
  const seen = new Map();
8
8
  for (const operation of operations) {
9
- const id = `${operation.method} ${operation.path}`;
9
+ const fixed = Object.entries(operation.fixed ?? {}).sort(([a], [b]) => a.localeCompare(b));
10
+ const id = `${operation.method} ${operation.path}${query(fixed)}`;
10
11
  const clash = seen.get(id);
11
12
  if (clash) {
12
13
  throw new SpecError(`${id} is declared twice`, `${clash.source} and ${operation.source} both define it`);
@@ -17,17 +18,21 @@ export class Router {
17
18
  list.push({
18
19
  operation,
19
20
  segments,
21
+ fixed,
20
22
  specificity: segments.filter((s) => s.name === undefined).length,
21
23
  });
22
24
  this.#routes.set(operation.method, list);
23
25
  }
24
26
  for (const list of this.#routes.values()) {
25
- list.sort((a, b) => b.specificity - a.specificity);
27
+ list.sort((a, b) => b.fixed.length - a.fixed.length || b.specificity - a.specificity);
26
28
  }
27
29
  }
28
- match(method, pathname) {
30
+ match(method, pathname, search) {
29
31
  const parts = split(pathname);
30
32
  for (const route of this.#routes.get(method.toLowerCase()) ?? []) {
33
+ if (!pins(route, search)) {
34
+ continue;
35
+ }
31
36
  const pathParams = apply(route.segments, parts);
32
37
  if (pathParams) {
33
38
  return { operation: route.operation, pathParams };
@@ -36,17 +41,30 @@ export class Router {
36
41
  return undefined;
37
42
  }
38
43
  /** Whether the path exists under some other method — a 405, not a 404. */
39
- allowed(pathname) {
44
+ allowed(pathname, search) {
40
45
  const parts = split(pathname);
41
46
  const out = [];
42
47
  for (const [method, list] of this.#routes) {
43
- if (list.some((r) => apply(r.segments, parts) !== undefined)) {
48
+ if (list.some((r) => pins(r, search) && apply(r.segments, parts) !== undefined)) {
44
49
  out.push(method);
45
50
  }
46
51
  }
47
52
  return out;
48
53
  }
54
+ /**
55
+ * The query strings this method would have answered on this path. Empty
56
+ * unless the document pinned one, which is the only way a request can miss
57
+ * a route whose path it matched exactly.
58
+ */
59
+ expects(method, pathname) {
60
+ const parts = split(pathname);
61
+ return (this.#routes.get(method.toLowerCase()) ?? [])
62
+ .filter((r) => r.fixed.length > 0 && apply(r.segments, parts) !== undefined)
63
+ .map((r) => query(r.fixed));
64
+ }
49
65
  }
66
+ const query = (fixed) => fixed.length === 0 ? '' : `?${new URLSearchParams(fixed).toString()}`;
67
+ const pins = (route, search) => route.fixed.every(([name, value]) => search?.get(name) === value);
50
68
  function compile(template) {
51
69
  return split(template).map((raw) => {
52
70
  const m = /^\{(.+)\}$/.exec(raw);
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { createHash, randomInt } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { BuildFailed } from "./cache.js";
4
4
  import { reason } from "./generate.js";
5
+ import { indexPage } from "./page.js";
5
6
  import { cutLoop, pagingSeen } from "./paging.js";
6
7
  import { describeIssues, issues } from "./validate.js";
7
8
  // ---------------------------------------------------------------------------
@@ -63,16 +64,36 @@ async function handle(req, res, opts) {
63
64
  say(200, 'introspection');
64
65
  return;
65
66
  }
66
- const match = opts.router.match(method, url.pathname);
67
+ const match = opts.router.match(method, url.pathname, url.searchParams);
67
68
  if (!match) {
68
- const allowed = opts.router.allowed(url.pathname);
69
+ // The document owns `/` only when it declares it. Otherwise the root is
70
+ // the contents page, which is what a browser pointed here came for.
71
+ if (url.pathname === '/' && (method === 'get' || method === 'head')) {
72
+ html(res, indexPage(opts.router.operations));
73
+ say(200, 'index');
74
+ return;
75
+ }
76
+ // The path matched and the query did not, which no one guesses unaided
77
+ // — and it is not a 405 either, since the method is defined here.
78
+ const expects = opts.router.expects(method, url.pathname);
79
+ if (expects.length > 0) {
80
+ send(res, 404, {
81
+ error: `${req.method} ${url.pathname} is only defined with a query`,
82
+ expects,
83
+ });
84
+ say(404, `expects ${expects.join(' ')}`);
85
+ return;
86
+ }
87
+ const allowed = opts.router.allowed(url.pathname, url.searchParams);
69
88
  if (allowed.length > 0) {
70
89
  res.setHeader('allow', allowed.map((m) => m.toUpperCase()).join(', '));
71
90
  send(res, 405, { error: `${req.method} is not defined for ${url.pathname}` });
72
91
  say(405, 'no such method');
73
92
  return;
74
93
  }
75
- send(res, 404, { error: `no operation matches ${req.method} ${url.pathname}` });
94
+ send(res, 404, {
95
+ error: `no operation matches ${req.method} ${url.pathname}`,
96
+ });
76
97
  say(404, 'no route');
77
98
  return;
78
99
  }
@@ -271,4 +292,11 @@ function send(res, status, value) {
271
292
  res.setHeader('content-length', Buffer.byteLength(text));
272
293
  res.end(text);
273
294
  }
295
+ /** Node drops the body itself when the request was a HEAD. */
296
+ function html(res, text) {
297
+ res.statusCode = 200;
298
+ res.setHeader('content-type', 'text/html; charset=utf-8');
299
+ res.setHeader('content-length', Buffer.byteLength(text));
300
+ res.end(text);
301
+ }
274
302
  //# 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. Every ref names its
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.5-flash` would be asked of OpenAI.
24
+ * bare `gemini-3.8-flash` would be asked of OpenAI.
24
25
  */
25
26
  const DEFAULT_MODEL = {
26
- openai: 'openai:gpt-5.4-mini',
27
- anthropic: 'anthropic:claude-sonnet-4-5',
28
- google: 'google:gemini-3.5-flash',
29
- vertex: 'vertex:gemini-3.5-flash',
30
- openrouter: 'openrouter:inclusionai/ling-3.0-flash-fin:free',
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 model = createModel(opts.model ?? defaultRef(keys));
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
- if (!provider) {
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
@@ -23,6 +23,8 @@ export interface Operation {
23
23
  summary?: string;
24
24
  description?: string;
25
25
  params: ParamSpec[];
26
+ /** query values the path key pinned down, see `splitQuery` */
27
+ fixed?: Record<string, string>;
26
28
  requestBody?: {
27
29
  required: boolean;
28
30
  schema: Schema;
@@ -41,4 +43,6 @@ export declare class SpecError extends CliError {
41
43
  }
42
44
  export declare function loadSpecs(files: readonly string[]): Promise<Operation[]>;
43
45
  export declare function loadSpec(file: string): Promise<Operation[]>;
46
+ /** The path as it has to be called — what a person needs to see. */
47
+ export declare const called: (op: Operation) => string;
44
48
  //# sourceMappingURL=spec.d.ts.map
package/dist/spec.js CHANGED
@@ -57,13 +57,14 @@ export async function loadSpec(file) {
57
57
  if (!isObject(op)) {
58
58
  continue;
59
59
  }
60
- const path = join(prefix, template);
61
- const merged = override(shared, params(op.parameters, dialect));
60
+ const { path, fixed } = splitQuery(join(prefix, template));
61
+ const merged = override(override(shared, params(op.parameters, dialect)), pinned(fixed));
62
62
  out.push(build({
63
63
  source: file,
64
64
  dialect,
65
65
  method,
66
66
  path,
67
+ fixed,
67
68
  op,
68
69
  params: merged,
69
70
  body: requestBody(op, dialect),
@@ -88,11 +89,42 @@ function join(prefix, template) {
88
89
  const base = prefix.replace(/\/+$/, '');
89
90
  return `${base}${template.startsWith('/') ? '' : '/'}${template}` || '/';
90
91
  }
92
+ /** The path as it has to be called — what a person needs to see. */
93
+ export const called = (op) => op.fixed ? `${op.path}?${new URLSearchParams(op.fixed).toString()}` : op.path;
94
+ /**
95
+ * A path template has no query string, but VMware, Oracle and others write one
96
+ * anyway — `/x/{id}?action=retry` — because they need two `post`s under one
97
+ * path and the object model gives them one. Taken verbatim the key matches
98
+ * nothing, since a request path never contains a `?`. So it is read for what
99
+ * it means: the path is the part before the `?`, and each pair after it is a
100
+ * query value this operation insists on.
101
+ */
102
+ function splitQuery(template) {
103
+ const cut = template.indexOf('?');
104
+ if (cut < 0) {
105
+ return { path: template };
106
+ }
107
+ const fixed = {};
108
+ for (const [name, value] of new URLSearchParams(template.slice(cut + 1))) {
109
+ fixed[name] = value;
110
+ }
111
+ const path = template.slice(0, cut) || '/';
112
+ return Object.keys(fixed).length > 0 ? { path, fixed } : { path };
113
+ }
114
+ /** A one-value enum, so the check, the page and the generator all see the rule. */
115
+ function pinned(fixed) {
116
+ return Object.entries(fixed ?? {}).map(([name, value]) => ({
117
+ name,
118
+ in: 'query',
119
+ required: true,
120
+ schema: { type: 'string', enum: [value] },
121
+ }));
122
+ }
91
123
  function build(b) {
92
124
  const success = successOf(b.op, b.dialect);
93
125
  const operationId = typeof b.op.operationId === 'string' && b.op.operationId
94
126
  ? b.op.operationId
95
- : synthesizeId(b.method, b.path);
127
+ : synthesizeId(b.method, b.path, b.fixed);
96
128
  const operation = {
97
129
  key: '',
98
130
  source: b.source,
@@ -102,6 +134,7 @@ function build(b) {
102
134
  summary: typeof b.op.summary === 'string' ? b.op.summary : undefined,
103
135
  description: typeof b.op.description === 'string' ? b.op.description : undefined,
104
136
  params: b.params,
137
+ fixed: b.fixed,
105
138
  requestBody: b.body,
106
139
  success,
107
140
  paging: pagingOf(b.params, success.schema),
@@ -146,9 +179,8 @@ function canonical(value) {
146
179
  }
147
180
  return value;
148
181
  }
149
- function synthesizeId(method, path) {
150
- const words = path
151
- .split('/')
182
+ function synthesizeId(method, path, fixed) {
183
+ const words = [...path.split('/'), ...Object.values(fixed ?? {})]
152
184
  .filter(Boolean)
153
185
  .map((s) => s.replace(/[{}]/g, '').replace(/[^A-Za-z0-9]+/g, '_'));
154
186
  return [method, ...words].join('_');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/faker",
3
- "version": "1.1.20",
3
+ "version": "1.1.21",
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.20",
49
- "@zenera/neo": "^1.1.20"
48
+ "@zenera/cli": "^1.1.21",
49
+ "@zenera/neo": "^1.1.21"
50
50
  }
51
51
  }