@zenera/faker 1.1.19 → 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 +20 -2
- package/dist/command.js +72 -8
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +11 -10
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +2 -1
- package/dist/router.d.ts +8 -2
- package/dist/router.js +23 -5
- package/dist/server.js +31 -3
- package/dist/setup.d.ts +18 -0
- package/dist/setup.js +57 -15
- package/dist/spec.d.ts +4 -0
- package/dist/spec.js +38 -6
- package/package.json +3 -3
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
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/generate.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Model } from '@zenera/neo';
|
|
2
2
|
import type { Box } from './box.ts';
|
|
3
|
-
import type
|
|
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
|
|
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
|
|
94
|
+
const what = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
|
|
94
95
|
if (!outcome.ok) {
|
|
95
|
-
out.push(`- ${
|
|
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(`- ${
|
|
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(`- ${
|
|
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
|
|
131
|
+
const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
|
|
131
132
|
if (!outcome.ok) {
|
|
132
|
-
return [`- ${
|
|
133
|
+
return [`- ${what}: the file ${outcome.fault}.`];
|
|
133
134
|
}
|
|
134
135
|
if (response && !response(outcome.value)) {
|
|
135
136
|
return [
|
|
136
|
-
`- ${
|
|
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
|
-
`- ${
|
|
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
|
-
`- ${
|
|
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
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'} · ${documents.length} document${documents.length === 1 ? '' : 's'} · <a href="/__faker/routes">routes</a> · <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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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">×</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
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
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
@@ -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.
|
|
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.
|
|
49
|
-
"@zenera/neo": "^1.1.
|
|
48
|
+
"@zenera/cli": "^1.1.21",
|
|
49
|
+
"@zenera/neo": "^1.1.21"
|
|
50
50
|
}
|
|
51
51
|
}
|