@zenera/faker 1.1.9 → 1.1.11
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 +39 -4
- package/dist/cache.d.ts +16 -3
- package/dist/cache.js +54 -30
- package/dist/command.js +18 -24
- package/dist/generate.js +51 -1
- package/dist/paging.d.ts +51 -0
- package/dist/paging.js +209 -0
- package/dist/probe.d.ts +6 -1
- package/dist/probe.js +25 -41
- package/dist/prompt.js +55 -1
- package/dist/schema.d.ts +13 -0
- package/dist/schema.js +75 -0
- package/dist/server.js +19 -1
- package/dist/spec.d.ts +3 -0
- package/dist/spec.js +9 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -60,13 +60,48 @@ for. `GET /users/12324` answering with somebody else's id validates perfectly
|
|
|
60
60
|
and is still wrong.
|
|
61
61
|
|
|
62
62
|
If it fails, the diagnostics go back to the model and it tries again, up to
|
|
63
|
-
`--attempts`. If it passes, the
|
|
63
|
+
`--attempts`. If it passes, the generator is kept in this machine's shared cache
|
|
64
|
+
under `~/.zenera/neo/cache/faker-generator/`, keyed by the operation's shape, and
|
|
64
65
|
every later request is just `podman exec python3 gen.py in.json out.json` — no
|
|
65
|
-
model, no tokens.
|
|
66
|
+
model, no tokens. The store is the machine's, so the same document served from
|
|
67
|
+
another directory costs nothing the second time.
|
|
66
68
|
|
|
67
69
|
Generators run in a container with **no network**, on an image baked once with
|
|
68
70
|
`faker`, `exrex`, `jsonschema` and `python-dateutil`.
|
|
69
71
|
|
|
72
|
+
## Pages that end
|
|
73
|
+
|
|
74
|
+
A list endpoint is the one place a mock can hang a real client. Given
|
|
75
|
+
`?cursor=abc`, the honest-looking answer is a body that validates, echoes
|
|
76
|
+
nothing it shouldn't, and hands back `abc` again — so the client asks for the
|
|
77
|
+
same page forever.
|
|
78
|
+
|
|
79
|
+
The faker reads the document for this. Where an operation has a paging
|
|
80
|
+
parameter (`cursor`, `page`, `offset`, `page_token`, …) and a response property
|
|
81
|
+
that carries the next one (`next`, `next_cursor`, `has_more`, …), three things
|
|
82
|
+
happen, all in the operation's own names:
|
|
83
|
+
|
|
84
|
+
- the model is told to fabricate **three pages** in total, to build the token
|
|
85
|
+
out of the paging parameter rather than the seed, and to end the list — null,
|
|
86
|
+
absent, or `has_more: false` where the schema leaves no other room;
|
|
87
|
+
- the generator is then **walked**: the faker calls it with no cursor, follows
|
|
88
|
+
the token it gets back, and rejects the file if the token repeats, cycles, or
|
|
89
|
+
never runs out. The diagnostics say which, and the model gets another go;
|
|
90
|
+
- at request time a token identical to the one just sent is **cut** — nulled or
|
|
91
|
+
dropped, whichever the schema allows — and the request line says so. Nothing
|
|
92
|
+
is invented in its place; a generator written before this rule existed is
|
|
93
|
+
still cached, and a cache is not rebuilt because a rule changed.
|
|
94
|
+
|
|
95
|
+
Only paginated operations are affected. Their cache keys changed once, so they
|
|
96
|
+
are written again on first use; everything else keeps the key it had.
|
|
97
|
+
`GET /__faker/routes` reports the shape that was recognised, per operation.
|
|
98
|
+
|
|
99
|
+
Plenty of documents describe the envelope and never write down the parameter
|
|
100
|
+
that reads it back. The first two steps cannot help there — nothing static can
|
|
101
|
+
see a parameter that is not declared — but the cut still applies: it takes the
|
|
102
|
+
paging parameter from the request itself, since a client only sends `?cursor=X`
|
|
103
|
+
because a body handed it X.
|
|
104
|
+
|
|
70
105
|
## Commands
|
|
71
106
|
|
|
72
107
|
```
|
|
@@ -77,8 +112,8 @@ zen faker cache ls | clear What has been generated, or throw it away.
|
|
|
77
112
|
|
|
78
113
|
Useful options: `--port`, `--host` (reachable only from this machine by
|
|
79
114
|
default), `--model`, `--seed` (same request, same answer), `--rebuild`,
|
|
80
|
-
`--attempts`, `--concurrency`, `--timeout`, `--cache <dir
|
|
81
|
-
`zen help faker` prints the full table.
|
|
115
|
+
`--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
|
|
116
|
+
workspace), `--quiet`. `zen help faker` prints the full table.
|
|
82
117
|
|
|
83
118
|
`GET /__faker/routes` lists what is being served; `GET /__faker/health` is a
|
|
84
119
|
health check.
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import type { Model } from '@zenera/neo';
|
|
2
|
-
import {
|
|
2
|
+
import type { Box } from './box.ts';
|
|
3
3
|
import { BuildFailed } from './generate.ts';
|
|
4
4
|
import type { Operation } from './spec.ts';
|
|
5
5
|
import type { Checks } from './validate.ts';
|
|
6
|
+
export declare const FAKER_KIND = "faker-generator";
|
|
7
|
+
/** What is kept about a generator besides the code, for `zen faker cache ls`. */
|
|
8
|
+
export interface GeneratorMeta {
|
|
9
|
+
operationId?: string;
|
|
10
|
+
method?: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
source?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
attempts?: number;
|
|
15
|
+
createdAt?: string;
|
|
16
|
+
}
|
|
6
17
|
export interface Generator {
|
|
7
18
|
key: string;
|
|
8
19
|
source: string;
|
|
9
|
-
/** whether it came
|
|
20
|
+
/** whether it came out of the cache rather than out of a model */
|
|
10
21
|
cached: boolean;
|
|
11
22
|
}
|
|
12
23
|
export interface CacheEvent {
|
|
@@ -26,10 +37,12 @@ export interface CacheOptions {
|
|
|
26
37
|
* on all of them.
|
|
27
38
|
*/
|
|
28
39
|
concurrency?: number;
|
|
29
|
-
/** ignore what is
|
|
40
|
+
/** ignore what is cached and write fresh */
|
|
30
41
|
rebuild?: boolean;
|
|
31
42
|
/** run generators but keep nothing */
|
|
32
43
|
ephemeral?: boolean;
|
|
44
|
+
/** keep them somewhere other than the shared store */
|
|
45
|
+
cacheDir?: string;
|
|
33
46
|
onStart?: (e: CacheEvent) => void;
|
|
34
47
|
onAttempt?: (e: CacheEvent) => void;
|
|
35
48
|
onReady?: (e: CacheEvent & {
|
package/dist/cache.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { writeJson } from '@zenera/cli/lib';
|
|
5
|
-
import { GENERATORS } from "./box.js";
|
|
1
|
+
import { Cache as Store } from '@zenera/cli/lib';
|
|
6
2
|
import { build, BuildFailed } from "./generate.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The cache
|
|
5
|
+
//
|
|
6
|
+
// Two layers over one identity. `Operation.key` is a function of the
|
|
7
|
+
// operation's shape, so a spec edit produces a new key and the old artefact is
|
|
8
|
+
// simply never asked for again — there is nothing to invalidate, which is the
|
|
9
|
+
// part of a cache that is usually wrong.
|
|
10
|
+
//
|
|
11
|
+
// The generator itself lives in the machine's shared cache rather than beside
|
|
12
|
+
// the container's workspace, so the same spec served from two directories is
|
|
13
|
+
// written once. The box root is scratch: a hit is copied back into it, because
|
|
14
|
+
// the container can only run what is under its mount.
|
|
15
|
+
//
|
|
16
|
+
// The in-flight map is the other half and matters more than it looks: ten
|
|
17
|
+
// requests arriving together for an uncached operation must produce one build,
|
|
18
|
+
// not ten. A failed build is remembered too, for the same reason — an operation
|
|
19
|
+
// the model could not write for should not re-ask on every request.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const FAKER_KIND = 'faker-generator';
|
|
7
22
|
const DEFAULT_CONCURRENCY = 4;
|
|
8
23
|
export class Cache {
|
|
9
24
|
#opts;
|
|
25
|
+
#store;
|
|
10
26
|
#live = new Map();
|
|
11
27
|
#settled = new Set();
|
|
12
28
|
#slots;
|
|
@@ -14,6 +30,7 @@ export class Cache {
|
|
|
14
30
|
#running = 0;
|
|
15
31
|
constructor(opts) {
|
|
16
32
|
this.#opts = opts;
|
|
33
|
+
this.#store = new Store(FAKER_KIND, { dir: opts.cacheDir, mode: 0o600 });
|
|
17
34
|
this.#slots = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
18
35
|
}
|
|
19
36
|
/**
|
|
@@ -51,7 +68,7 @@ export class Cache {
|
|
|
51
68
|
const { box, model, checks, rebuild, ephemeral } = this.#opts;
|
|
52
69
|
// Read before queueing: a cache hit costs nothing and must not wait
|
|
53
70
|
// behind somebody else's model call.
|
|
54
|
-
const source = rebuild ? undefined : await read(box, operation.key);
|
|
71
|
+
const source = rebuild ? undefined : await this.#read(box, operation.key);
|
|
55
72
|
if (source !== undefined) {
|
|
56
73
|
this.#opts.onReady?.({ operation, cached: true, attempts: 0 });
|
|
57
74
|
return { key: operation.key, source, cached: true };
|
|
@@ -67,17 +84,18 @@ export class Cache {
|
|
|
67
84
|
onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
|
|
68
85
|
});
|
|
69
86
|
if (!ephemeral) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
this.#store.put(operation.key, {
|
|
88
|
+
source: built.source,
|
|
89
|
+
meta: {
|
|
90
|
+
operationId: operation.operationId,
|
|
91
|
+
method: operation.method,
|
|
92
|
+
path: operation.path,
|
|
93
|
+
source: operation.source,
|
|
94
|
+
model: model.id,
|
|
95
|
+
attempts: built.attempts,
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
81
99
|
}
|
|
82
100
|
this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
|
|
83
101
|
return { key: operation.key, source: built.source, cached: false };
|
|
@@ -91,6 +109,25 @@ export class Cache {
|
|
|
91
109
|
this.#leave();
|
|
92
110
|
}
|
|
93
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* A hit is written into the box before it is returned. The container runs
|
|
114
|
+
* files under its mount and nothing else, and the mount is scratch that any
|
|
115
|
+
* `cache clear` is free to delete.
|
|
116
|
+
*/
|
|
117
|
+
async #read(box, key) {
|
|
118
|
+
const found = this.#store.get(key);
|
|
119
|
+
if (!found?.source?.trim()) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await box.write(key, found.source);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// The mount went away. Writing it again is the model's job.
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return found.source;
|
|
130
|
+
}
|
|
94
131
|
#enter() {
|
|
95
132
|
if (this.#running < this.#slots) {
|
|
96
133
|
this.#running++;
|
|
@@ -108,18 +145,5 @@ export class Cache {
|
|
|
108
145
|
this.#running--;
|
|
109
146
|
}
|
|
110
147
|
}
|
|
111
|
-
async function read(box, key) {
|
|
112
|
-
const path = box.sourceOf(key);
|
|
113
|
-
if (!existsSync(path)) {
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
const source = await readFile(path, 'utf8');
|
|
118
|
-
return source.trim() ? source : undefined;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
148
|
export { BuildFailed };
|
|
125
149
|
//# sourceMappingURL=cache.js.map
|
package/dist/command.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
2
|
import { rmSync } from 'node:fs';
|
|
2
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import { join, relative, resolve } from 'node:path';
|
|
4
|
-
import { bold, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
|
|
5
4
|
import { GENERATORS } from "./box.js";
|
|
5
|
+
import { FAKER_KIND } from "./cache.js";
|
|
6
6
|
import { reason } from "./generate.js";
|
|
7
7
|
import { listen } from "./server.js";
|
|
8
8
|
import { open } from "./setup.js";
|
|
@@ -53,7 +53,7 @@ export const command = {
|
|
|
53
53
|
[' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
|
|
54
54
|
[' --model <ref>', dim('Which model writes the generators.')],
|
|
55
55
|
[' --image <ref>', dim('Skip the baked image and use this one.')],
|
|
56
|
-
[' --cache <dir>', dim('
|
|
56
|
+
[' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/faker.")],
|
|
57
57
|
[' --seed <n>', dim('Answer the same request the same way every time.')],
|
|
58
58
|
[' --attempts <n>', dim('Tries per generator before giving up. Default 3.')],
|
|
59
59
|
[' --concurrency <n>', dim('Generators written at once. Default 4.')],
|
|
@@ -190,7 +190,7 @@ async function cache(args, ctx) {
|
|
|
190
190
|
const root = values.cache ? resolve(ctx.cwd, values.cache) : paths.faker();
|
|
191
191
|
const sub = positionals[0] ?? 'ls';
|
|
192
192
|
if (sub === 'ls') {
|
|
193
|
-
const entries =
|
|
193
|
+
const entries = listGenerators();
|
|
194
194
|
if (ctx.json) {
|
|
195
195
|
json(entries);
|
|
196
196
|
return;
|
|
@@ -211,6 +211,9 @@ async function cache(args, ctx) {
|
|
|
211
211
|
return;
|
|
212
212
|
}
|
|
213
213
|
if (sub === 'clear') {
|
|
214
|
+
clearCache({ kind: FAKER_KIND });
|
|
215
|
+
// The workspace is scratch — whatever a hit was copied into it is
|
|
216
|
+
// written again from the cache, or by the model.
|
|
214
217
|
rmSync(join(root, GENERATORS), { recursive: true, force: true });
|
|
215
218
|
// The container is named after its configuration, so a stale one would
|
|
216
219
|
// otherwise sit there stopped forever with nothing pointing at it.
|
|
@@ -219,30 +222,21 @@ async function cache(args, ctx) {
|
|
|
219
222
|
if (mine.length > 0) {
|
|
220
223
|
await removeContainers(mine.map((c) => c.name));
|
|
221
224
|
}
|
|
222
|
-
note(`${green('cleared')} ${dim(
|
|
225
|
+
note(`${green('cleared')} ${dim(paths.cache())}`);
|
|
223
226
|
return;
|
|
224
227
|
}
|
|
225
228
|
throw usageError(`unknown cache command "${sub}"`, 'zen faker cache <ls|clear>');
|
|
226
229
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
const meta = JSON.parse(await readFile(join(root, GENERATORS, key, 'meta.json'), 'utf8'));
|
|
239
|
-
out.push({ key, ...meta });
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
out.push({ key });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return out;
|
|
230
|
+
/**
|
|
231
|
+
* The keys are the operation keys, written verbatim — which is why they can be
|
|
232
|
+
* listed at all. Anything the store cannot parse is left out rather than shown
|
|
233
|
+
* as a row with nothing in it.
|
|
234
|
+
*/
|
|
235
|
+
function listGenerators() {
|
|
236
|
+
const { rows } = cacheItems(FAKER_KIND);
|
|
237
|
+
return rows
|
|
238
|
+
.map((row) => ({ key: row.key, ...row.value?.meta }))
|
|
239
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
246
240
|
}
|
|
247
241
|
function summarize(operations) {
|
|
248
242
|
const by = new Map();
|
package/dist/generate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { tokenOf } from "./paging.js";
|
|
2
|
+
import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
|
|
2
3
|
import { instruction, retry, SYSTEM } from "./prompt.js";
|
|
3
4
|
import { describeIssues, issues } from "./validate.js";
|
|
4
5
|
export class BuildFailed extends Error {
|
|
@@ -106,8 +107,57 @@ async function judge(operation, probes, response, box) {
|
|
|
106
107
|
out.push(`- ${called}: ${describeIssues(echo)}.`);
|
|
107
108
|
}
|
|
108
109
|
}
|
|
110
|
+
// Only worth the container round trips once the file answers at all, and
|
|
111
|
+
// only for an operation that hands out a token somebody could follow.
|
|
112
|
+
if (out.length === 0 && operation.paging?.next) {
|
|
113
|
+
out.push(...(await walk(operation, operation.paging, response, box)));
|
|
114
|
+
}
|
|
109
115
|
return out;
|
|
110
116
|
}
|
|
117
|
+
/** How many pages a mock may offer before it is simply not terminating. */
|
|
118
|
+
const MAX_PAGES = 8;
|
|
119
|
+
/**
|
|
120
|
+
* Follows the operation's own cursor and reports the ways that walk fails to
|
|
121
|
+
* end. A schema cannot express this and neither can any one response: the bug
|
|
122
|
+
* is a relation between two of them.
|
|
123
|
+
*/
|
|
124
|
+
async function walk(operation, paging, response, box) {
|
|
125
|
+
let input = walkStart(operation, paging);
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
let sent;
|
|
128
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
129
|
+
const outcome = await box.run(operation.key, input);
|
|
130
|
+
const called = `page ${page} of ${operation.method.toUpperCase()} ${operation.path}`;
|
|
131
|
+
if (!outcome.ok) {
|
|
132
|
+
return [`- ${called}: the file ${outcome.fault}.`];
|
|
133
|
+
}
|
|
134
|
+
if (response && !response(outcome.value)) {
|
|
135
|
+
return [
|
|
136
|
+
`- ${called}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
const token = tokenOf(outcome.value, paging);
|
|
140
|
+
if (token === undefined) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
if (token === sent) {
|
|
144
|
+
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
|
+
];
|
|
147
|
+
}
|
|
148
|
+
if (seen.has(token)) {
|
|
149
|
+
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
|
+
];
|
|
152
|
+
}
|
|
153
|
+
seen.add(token);
|
|
154
|
+
sent = token;
|
|
155
|
+
input = nextPage(input, paging, token);
|
|
156
|
+
}
|
|
157
|
+
return [
|
|
158
|
+
`- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate three pages in total and set it to null on the last.`,
|
|
159
|
+
];
|
|
160
|
+
}
|
|
111
161
|
/**
|
|
112
162
|
* Models fence code even when told not to, and a stray ```python line is a
|
|
113
163
|
* syntax error rather than a bad answer — not worth a round trip.
|
package/dist/paging.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Schema } from './schema.ts';
|
|
2
|
+
import type { ParamSpec } from './spec.ts';
|
|
3
|
+
export type PagingStyle = 'cursor' | 'offset';
|
|
4
|
+
export interface Paging {
|
|
5
|
+
style: PagingStyle;
|
|
6
|
+
/** the query parameter that turns the page */
|
|
7
|
+
param: string;
|
|
8
|
+
/** the page-size parameter, when the operation takes one */
|
|
9
|
+
size?: string;
|
|
10
|
+
/** the response property carrying the token for the page after this one */
|
|
11
|
+
next?: string;
|
|
12
|
+
/** whether `next` may be set to null */
|
|
13
|
+
nextNullable?: boolean;
|
|
14
|
+
/** whether the object declaring `next` lists it as required */
|
|
15
|
+
nextRequired?: boolean;
|
|
16
|
+
/** a boolean response property — `has_more` and friends */
|
|
17
|
+
more?: string;
|
|
18
|
+
/** the array of things being paged over */
|
|
19
|
+
items?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The paging shape of an operation, or nothing when it does not page.
|
|
23
|
+
*
|
|
24
|
+
* A page-size parameter on its own is not pagination — plenty of endpoints cap
|
|
25
|
+
* a one-shot list — so a control that turns the page *and* a property that says
|
|
26
|
+
* where the next one is are both required.
|
|
27
|
+
*/
|
|
28
|
+
export declare function pagingOf(params: readonly ParamSpec[], schema: Schema | undefined): Paging | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The paging an actual request reveals, for a document that declared none.
|
|
31
|
+
*
|
|
32
|
+
* Plenty of specs describe the response envelope — `cursor`, `has_more` — and
|
|
33
|
+
* never write down the parameter that reads it back. A client only sends
|
|
34
|
+
* `?cursor=X` because a body handed it X, so the exchange is pagination on the
|
|
35
|
+
* evidence even when the document is silent, and silence is exactly the case
|
|
36
|
+
* nothing else here can catch.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pagingSeen(params: readonly ParamSpec[], schema: Schema | undefined, query: Iterable<[string, string]>): Paging | undefined;
|
|
39
|
+
/** The token a body offers for the next page, or nothing when it offers none. */
|
|
40
|
+
export declare function tokenOf(value: unknown, paging: Paging): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* A last line of defence, for the generator that is already on disk: a body
|
|
43
|
+
* offering the very token it was given is cut back to "no more pages".
|
|
44
|
+
*
|
|
45
|
+
* Deliberately timid. Nothing re-validates a generator's output on the way to
|
|
46
|
+
* the client, so writing `null` into a required, non-nullable property would
|
|
47
|
+
* trade a client that hangs for a mock that lies — and a hang is at least
|
|
48
|
+
* obvious. Where the schema leaves no room, this changes nothing and says so.
|
|
49
|
+
*/
|
|
50
|
+
export declare function cutLoop(value: unknown, paging: Paging, sent: string): boolean;
|
|
51
|
+
//# sourceMappingURL=paging.d.ts.map
|
package/dist/paging.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { properties } from "./schema.js";
|
|
2
|
+
const squash = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
3
|
+
const CURSOR_PARAMS = new Set([
|
|
4
|
+
'cursor',
|
|
5
|
+
'nextcursor',
|
|
6
|
+
'pagetoken',
|
|
7
|
+
'nextpagetoken',
|
|
8
|
+
'continuationtoken',
|
|
9
|
+
'nexttoken',
|
|
10
|
+
'pagecursor',
|
|
11
|
+
'after',
|
|
12
|
+
'marker',
|
|
13
|
+
'startkey',
|
|
14
|
+
]);
|
|
15
|
+
const OFFSET_PARAMS = new Set([
|
|
16
|
+
'offset',
|
|
17
|
+
'page',
|
|
18
|
+
'pagenumber',
|
|
19
|
+
'pageindex',
|
|
20
|
+
'start',
|
|
21
|
+
'startindex',
|
|
22
|
+
'skip',
|
|
23
|
+
]);
|
|
24
|
+
const SIZE_PARAMS = new Set([
|
|
25
|
+
'pagesize',
|
|
26
|
+
'perpage',
|
|
27
|
+
'limit',
|
|
28
|
+
'maxresults',
|
|
29
|
+
'maxitems',
|
|
30
|
+
'count',
|
|
31
|
+
'size',
|
|
32
|
+
]);
|
|
33
|
+
const NEXT_PROPS = new Set([
|
|
34
|
+
'nextcursor',
|
|
35
|
+
'nextpagetoken',
|
|
36
|
+
'nexttoken',
|
|
37
|
+
'nextoffset',
|
|
38
|
+
'nextpage',
|
|
39
|
+
'nextlink',
|
|
40
|
+
'nexturl',
|
|
41
|
+
'next',
|
|
42
|
+
'cursor',
|
|
43
|
+
'pagetoken',
|
|
44
|
+
'continuationtoken',
|
|
45
|
+
'marker',
|
|
46
|
+
]);
|
|
47
|
+
const MORE_PROPS = new Set([
|
|
48
|
+
'hasmore',
|
|
49
|
+
'hasnext',
|
|
50
|
+
'hasnextpage',
|
|
51
|
+
'more',
|
|
52
|
+
'islast',
|
|
53
|
+
'islastpage',
|
|
54
|
+
'istruncated',
|
|
55
|
+
'truncated',
|
|
56
|
+
]);
|
|
57
|
+
const ITEMS_PROPS = new Set([
|
|
58
|
+
'items',
|
|
59
|
+
'results',
|
|
60
|
+
'data',
|
|
61
|
+
'values',
|
|
62
|
+
'records',
|
|
63
|
+
'entries',
|
|
64
|
+
'objects',
|
|
65
|
+
'content',
|
|
66
|
+
'list',
|
|
67
|
+
'edges',
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* The paging shape of an operation, or nothing when it does not page.
|
|
71
|
+
*
|
|
72
|
+
* A page-size parameter on its own is not pagination — plenty of endpoints cap
|
|
73
|
+
* a one-shot list — so a control that turns the page *and* a property that says
|
|
74
|
+
* where the next one is are both required.
|
|
75
|
+
*/
|
|
76
|
+
export function pagingOf(params, schema) {
|
|
77
|
+
if (!schema) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const query = params.filter((p) => p.in === 'query');
|
|
81
|
+
const cursor = query.find((p) => CURSOR_PARAMS.has(squash(p.name)));
|
|
82
|
+
const offset = query.find((p) => OFFSET_PARAMS.has(squash(p.name)) && numeric(p.schema));
|
|
83
|
+
const param = cursor ?? offset;
|
|
84
|
+
if (!param) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const declared = properties(schema);
|
|
88
|
+
const next = declared.find((d) => NEXT_PROPS.has(squash(d.name)));
|
|
89
|
+
const more = declared.find((d) => MORE_PROPS.has(squash(d.name)) && boolish(d.schema));
|
|
90
|
+
if (!next && !more) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
style: cursor ? 'cursor' : 'offset',
|
|
95
|
+
param: param.name,
|
|
96
|
+
size: query.find((p) => SIZE_PARAMS.has(squash(p.name)))?.name,
|
|
97
|
+
next: next?.name,
|
|
98
|
+
nextNullable: next ? nullable(next.schema) : undefined,
|
|
99
|
+
nextRequired: next?.required,
|
|
100
|
+
more: more?.name,
|
|
101
|
+
items: itemsOf(declared),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The paging an actual request reveals, for a document that declared none.
|
|
106
|
+
*
|
|
107
|
+
* Plenty of specs describe the response envelope — `cursor`, `has_more` — and
|
|
108
|
+
* never write down the parameter that reads it back. A client only sends
|
|
109
|
+
* `?cursor=X` because a body handed it X, so the exchange is pagination on the
|
|
110
|
+
* evidence even when the document is silent, and silence is exactly the case
|
|
111
|
+
* nothing else here can catch.
|
|
112
|
+
*/
|
|
113
|
+
export function pagingSeen(params, schema, query) {
|
|
114
|
+
const declared = new Set(params.map((p) => p.name));
|
|
115
|
+
const extra = [];
|
|
116
|
+
for (const [name, value] of query) {
|
|
117
|
+
if (value !== '' && !declared.has(name)) {
|
|
118
|
+
declared.add(name);
|
|
119
|
+
extra.push({ name, in: 'query', required: false, schema: guess(value) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return extra.length === 0 ? undefined : pagingOf([...params, ...extra], schema);
|
|
123
|
+
}
|
|
124
|
+
/** The token a body offers for the next page, or nothing when it offers none. */
|
|
125
|
+
export function tokenOf(value, paging) {
|
|
126
|
+
if (!paging.next) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const holder = holderOf(value, paging.next);
|
|
130
|
+
const token = holder?.[paging.next];
|
|
131
|
+
if (token === null || token === undefined || token === '') {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return typeof token === 'object' ? JSON.stringify(token) : String(token);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A last line of defence, for the generator that is already on disk: a body
|
|
138
|
+
* offering the very token it was given is cut back to "no more pages".
|
|
139
|
+
*
|
|
140
|
+
* Deliberately timid. Nothing re-validates a generator's output on the way to
|
|
141
|
+
* the client, so writing `null` into a required, non-nullable property would
|
|
142
|
+
* trade a client that hangs for a mock that lies — and a hang is at least
|
|
143
|
+
* obvious. Where the schema leaves no room, this changes nothing and says so.
|
|
144
|
+
*/
|
|
145
|
+
export function cutLoop(value, paging, sent) {
|
|
146
|
+
if (!paging.next || tokenOf(value, paging) !== sent) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const holder = holderOf(value, paging.next);
|
|
150
|
+
if (!holder) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (paging.nextNullable) {
|
|
154
|
+
holder[paging.next] = null;
|
|
155
|
+
}
|
|
156
|
+
else if (!paging.nextRequired) {
|
|
157
|
+
delete holder[paging.next];
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const more = paging.more ? holderOf(value, paging.more) : undefined;
|
|
163
|
+
if (more && paging.more) {
|
|
164
|
+
more[paging.more] = false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
/** The nearest object carrying `name`; a real body nests its envelope. */
|
|
169
|
+
function holderOf(value, name) {
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
let level = [value];
|
|
172
|
+
while (level.length > 0) {
|
|
173
|
+
const next = [];
|
|
174
|
+
for (const node of level) {
|
|
175
|
+
if (typeof node !== 'object' || node === null || seen.has(node)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
seen.add(node);
|
|
179
|
+
if (Array.isArray(node)) {
|
|
180
|
+
next.push(...node);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const record = node;
|
|
184
|
+
if (name in record) {
|
|
185
|
+
return record;
|
|
186
|
+
}
|
|
187
|
+
next.push(...Object.values(record));
|
|
188
|
+
}
|
|
189
|
+
level = next;
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
function itemsOf(declared) {
|
|
194
|
+
const named = declared.find((d) => ITEMS_PROPS.has(squash(d.name)) && listish(d.schema));
|
|
195
|
+
return (named ?? declared.find((d) => listish(d.schema)))?.name;
|
|
196
|
+
}
|
|
197
|
+
const types = (schema) => {
|
|
198
|
+
const type = schema.type;
|
|
199
|
+
return typeof type === 'string' ? [type] : Array.isArray(type) ? type : [];
|
|
200
|
+
};
|
|
201
|
+
const numeric = (schema) => types(schema).some((t) => t === 'integer' || t === 'number');
|
|
202
|
+
const boolish = (schema) => types(schema).includes('boolean');
|
|
203
|
+
const listish = (schema) => types(schema).includes('array') || schema.items !== undefined;
|
|
204
|
+
const nullable = (schema) => types(schema).includes('null');
|
|
205
|
+
/** An undeclared parameter has only its value to be typed by. */
|
|
206
|
+
const guess = (value) => ({
|
|
207
|
+
type: /^-?\d+$/.test(value) ? 'integer' : 'string',
|
|
208
|
+
});
|
|
209
|
+
//# sourceMappingURL=paging.js.map
|
package/dist/probe.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { GeneratorInput } from './envelope.ts';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Paging } from './paging.ts';
|
|
3
|
+
import { type Schema } from './schema.ts';
|
|
3
4
|
import type { Operation } from './spec.ts';
|
|
4
5
|
import type { Issue } from './validate.ts';
|
|
5
6
|
export declare function probesFor(operation: Operation): GeneratorInput[];
|
|
7
|
+
/** The first page: an ordinary probe with the paging control taken back off. */
|
|
8
|
+
export declare function walkStart(operation: Operation, paging: Paging): GeneratorInput;
|
|
9
|
+
/** The same request again, asking for whatever the last answer pointed at. */
|
|
10
|
+
export declare function nextPage(previous: GeneratorInput, paging: Paging, token: string): GeneratorInput;
|
|
6
11
|
export declare function echoIssues(input: GeneratorInput, value: unknown, schema: Schema | undefined): Issue[];
|
|
7
12
|
//# sourceMappingURL=probe.d.ts.map
|
package/dist/probe.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { propertyNames } from "./schema.js";
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// Probes
|
|
3
4
|
//
|
|
@@ -165,6 +166,30 @@ function text(schema, variant) {
|
|
|
165
166
|
return base.length >= min ? base : base.padEnd(min, 'x');
|
|
166
167
|
}
|
|
167
168
|
// ---------------------------------------------------------------------------
|
|
169
|
+
// The walk
|
|
170
|
+
//
|
|
171
|
+
// The probes above are independent, which is the right shape for everything one
|
|
172
|
+
// response can be wrong about and the wrong shape for pagination: a cursor only
|
|
173
|
+
// means anything in the answer it arrived with, so the pages have to be asked
|
|
174
|
+
// for in order.
|
|
175
|
+
//
|
|
176
|
+
// The seed is held still across the whole walk on purpose. A generator that
|
|
177
|
+
// mints its token out of `seed` rather than out of the request is the exact
|
|
178
|
+
// mistake being looked for, and a still seed makes it a fixed point — visible
|
|
179
|
+
// on the second page here instead of on somebody's client.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
/** The first page: an ordinary probe with the paging control taken back off. */
|
|
182
|
+
export function walkStart(operation, paging) {
|
|
183
|
+
const input = probesFor(operation)[0];
|
|
184
|
+
const query = { ...input.query };
|
|
185
|
+
delete query[paging.param];
|
|
186
|
+
return { ...input, query };
|
|
187
|
+
}
|
|
188
|
+
/** The same request again, asking for whatever the last answer pointed at. */
|
|
189
|
+
export function nextPage(previous, paging, token) {
|
|
190
|
+
return { ...previous, query: { ...previous.query, [paging.param]: token } };
|
|
191
|
+
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
168
193
|
// The echo rule
|
|
169
194
|
//
|
|
170
195
|
// `get_user_by_id(12324)` answering `{ user_id: 999 }` validates perfectly and
|
|
@@ -199,47 +224,6 @@ export function echoIssues(input, value, schema) {
|
|
|
199
224
|
}
|
|
200
225
|
return out;
|
|
201
226
|
}
|
|
202
|
-
/** Every property name the schema mentions, at any depth. */
|
|
203
|
-
function propertyNames(schema) {
|
|
204
|
-
const out = new Set();
|
|
205
|
-
const seen = new Set();
|
|
206
|
-
const stack = [schema];
|
|
207
|
-
while (stack.length > 0) {
|
|
208
|
-
const node = stack.pop();
|
|
209
|
-
if (typeof node !== 'object' || node === null) {
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
if (Array.isArray(node)) {
|
|
213
|
-
stack.push(...node);
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
if (seen.has(node)) {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
seen.add(node);
|
|
220
|
-
const record = node;
|
|
221
|
-
const properties = record.properties;
|
|
222
|
-
if (typeof properties === 'object' && properties !== null) {
|
|
223
|
-
for (const [name, sub] of Object.entries(properties)) {
|
|
224
|
-
out.add(name);
|
|
225
|
-
stack.push(sub);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// `$defs` holds schemas under arbitrary names, so its *values* are the
|
|
229
|
-
// subschemas — pushing the map itself would walk one level and stop,
|
|
230
|
-
// which is where every hoisted recursive schema lives.
|
|
231
|
-
for (const key of ['$defs', 'patternProperties']) {
|
|
232
|
-
const map = record[key];
|
|
233
|
-
if (typeof map === 'object' && map !== null) {
|
|
234
|
-
stack.push(...Object.values(map));
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
for (const key of ['items', 'allOf', 'anyOf', 'oneOf', 'prefixItems', 'not']) {
|
|
238
|
-
stack.push(record[key]);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return out;
|
|
242
|
-
}
|
|
243
227
|
/** Whether `name` anywhere in the value holds something equal to `expected`. */
|
|
244
228
|
function carries(value, name, expected) {
|
|
245
229
|
const stack = [value];
|
package/dist/prompt.js
CHANGED
|
@@ -37,7 +37,9 @@ export const SYSTEM = [
|
|
|
37
37
|
' with `user_id=12324` answers with `user_id` 12324, not a random one. Do',
|
|
38
38
|
' this generically, by looking the names up at run time. Do the same for a',
|
|
39
39
|
' query parameter where it plainly describes the content rather than',
|
|
40
|
-
' controlling the call.',
|
|
40
|
+
' controlling the call. A paging control — a cursor, page, offset or',
|
|
41
|
+
' page-size parameter — is never content and must not be copied into the',
|
|
42
|
+
' body; see PAGINATION below where the operation has one.',
|
|
41
43
|
'3. Every required property must be present. Optional ones may be omitted',
|
|
42
44
|
' sometimes; that is what makes a mock useful.',
|
|
43
45
|
'4. Values must suit their names, not just their types. Use `faker` for anything',
|
|
@@ -71,10 +73,62 @@ export function brief(operation) {
|
|
|
71
73
|
if (operation.requestBody) {
|
|
72
74
|
lines.push('', 'REQUEST BODY SCHEMA (arrives as `body`)', json(operation.requestBody.schema));
|
|
73
75
|
}
|
|
76
|
+
if (operation.paging) {
|
|
77
|
+
lines.push('', ...pagination(operation.paging));
|
|
78
|
+
}
|
|
74
79
|
lines.push('', `RESPONSE SCHEMA (status ${operation.success.status})`);
|
|
75
80
|
lines.push(operation.success.schema ? json(operation.success.schema) : ' (no body)');
|
|
76
81
|
return lines.join('\n');
|
|
77
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Said only to the operations that page, and said in terms of their own
|
|
85
|
+
* property names. The rule that earns the paragraph is the third one: a body
|
|
86
|
+
* offering the token it was just given passes the schema, passes the echo rule,
|
|
87
|
+
* and hangs every client that walks the list.
|
|
88
|
+
*/
|
|
89
|
+
function pagination(paging) {
|
|
90
|
+
const advance = paging.style === 'cursor'
|
|
91
|
+
? 'the base64 of a small JSON object holding the next page index, such as {"p": 2}'
|
|
92
|
+
: "the offset of the next page — this page's offset plus its size";
|
|
93
|
+
const lines = [
|
|
94
|
+
'PAGINATION',
|
|
95
|
+
` This operation is paged. \`${paging.param}\` asks for a page;`,
|
|
96
|
+
' absent or empty means the first one.',
|
|
97
|
+
' - Fabricate three pages in total and no more.',
|
|
98
|
+
];
|
|
99
|
+
if (paging.next) {
|
|
100
|
+
lines.push(` - \`${paging.next}\` carries the token for the page after this one.`, ` Build it out of \`${paging.param}\`:`, ` ${advance}.`, ' - Never build it out of `seed`. Unpinned, the seed changes on every', ' request; pinned, it is a function of the query. A token made from it', ' either wanders or never changes.', ' - It must strictly advance. Answering with the token you were given is', ' the one failure that matters: a client following it loops forever.');
|
|
101
|
+
}
|
|
102
|
+
if (paging.more && !stuck(paging)) {
|
|
103
|
+
lines.push(` - \`${paging.more}\` is false on the last page and true before it.`);
|
|
104
|
+
}
|
|
105
|
+
if (paging.next) {
|
|
106
|
+
lines.push(...last(paging));
|
|
107
|
+
}
|
|
108
|
+
lines.push(' - A token you cannot read, or one past the end, is the last page,', ` ended the same way and with ${paging.items ? `\`${paging.items}\` empty` : 'nothing listed'}.`, ' Never an error, and never the first page again.');
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* How the last page says so. A token that is required and cannot be null has
|
|
113
|
+
* nowhere to put the ending, so the ending has to be said some other way —
|
|
114
|
+
* telling the model to null it anyway would only ask for an invalid body.
|
|
115
|
+
*/
|
|
116
|
+
const stuck = (paging) => !paging.nextNullable && paging.nextRequired === true;
|
|
117
|
+
function last(paging) {
|
|
118
|
+
if (paging.nextNullable) {
|
|
119
|
+
return [` - On the last page set \`${paging.next}\` to null.`];
|
|
120
|
+
}
|
|
121
|
+
if (!stuck(paging)) {
|
|
122
|
+
return [` - On the last page leave \`${paging.next}\` out.`];
|
|
123
|
+
}
|
|
124
|
+
const otherwise = [paging.more && `\`${paging.more}\` false`, paging.items && 'nothing listed']
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join(' and ');
|
|
127
|
+
return [
|
|
128
|
+
` - The schema requires \`${paging.next}\` on every page, so the last page`,
|
|
129
|
+
` ends the list the other way: ${otherwise || 'an empty page'}.`,
|
|
130
|
+
];
|
|
131
|
+
}
|
|
78
132
|
/**
|
|
79
133
|
* Repeated in the file the model writes, so it is worth spelling out: the
|
|
80
134
|
* schema it validates against must be the one it was shown, embedded, not
|
package/dist/schema.d.ts
CHANGED
|
@@ -5,4 +5,17 @@ export type Dialect = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
|
|
|
5
5
|
* than once into `$defs`. The result is acyclic and safe to stringify.
|
|
6
6
|
*/
|
|
7
7
|
export declare function normalize(root: unknown, dialect: Dialect): Schema;
|
|
8
|
+
export interface Declared {
|
|
9
|
+
name: string;
|
|
10
|
+
/** the property's own schema, with a `$defs` pointer already followed */
|
|
11
|
+
schema: Schema;
|
|
12
|
+
/** whether the object declaring it lists it in `required` */
|
|
13
|
+
required: boolean;
|
|
14
|
+
/** how many objects deep it sits; 0 is the top level */
|
|
15
|
+
depth: number;
|
|
16
|
+
}
|
|
17
|
+
/** Every property a schema declares, at any depth, nearest first. */
|
|
18
|
+
export declare function properties(root: Schema): Declared[];
|
|
19
|
+
/** Every property name a schema mentions, at any depth. */
|
|
20
|
+
export declare const propertyNames: (root: Schema) => Set<string>;
|
|
8
21
|
//# sourceMappingURL=schema.d.ts.map
|
package/dist/schema.js
CHANGED
|
@@ -218,4 +218,79 @@ function repeated(root) {
|
|
|
218
218
|
}
|
|
219
219
|
return twice;
|
|
220
220
|
}
|
|
221
|
+
/** Every property a schema declares, at any depth, nearest first. */
|
|
222
|
+
export function properties(root) {
|
|
223
|
+
const out = [];
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
let level = [root];
|
|
226
|
+
for (let depth = 0; level.length > 0; depth++) {
|
|
227
|
+
const next = [];
|
|
228
|
+
for (const raw of level) {
|
|
229
|
+
const node = resolve(raw, root);
|
|
230
|
+
if (node === undefined || seen.has(node)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
seen.add(node);
|
|
234
|
+
const required = new Set(Array.isArray(node.required)
|
|
235
|
+
? node.required.filter((n) => typeof n === 'string')
|
|
236
|
+
: []);
|
|
237
|
+
const props = node.properties;
|
|
238
|
+
if (isObject(props)) {
|
|
239
|
+
for (const [name, sub] of Object.entries(props)) {
|
|
240
|
+
const target = resolve(sub, root);
|
|
241
|
+
if (target === undefined) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
out.push({ name, schema: target, required: required.has(name), depth });
|
|
245
|
+
next.push(sub);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const key of ONE) {
|
|
249
|
+
next.push(node[key]);
|
|
250
|
+
}
|
|
251
|
+
for (const key of LIST) {
|
|
252
|
+
const value = node[key];
|
|
253
|
+
if (Array.isArray(value)) {
|
|
254
|
+
next.push(...value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const key of MAP) {
|
|
258
|
+
if (key === 'properties') {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const value = node[key];
|
|
262
|
+
if (isObject(value)) {
|
|
263
|
+
next.push(...Object.values(value));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const items = node.items;
|
|
267
|
+
next.push(...(Array.isArray(items) ? items : [items]));
|
|
268
|
+
}
|
|
269
|
+
level = next;
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
/** Every property name a schema mentions, at any depth. */
|
|
274
|
+
export const propertyNames = (root) => new Set(properties(root).map((p) => p.name));
|
|
275
|
+
/** A schema, with a local `#/$defs/...` pointer followed as far as it goes. */
|
|
276
|
+
function resolve(value, root) {
|
|
277
|
+
let at = value;
|
|
278
|
+
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
|
279
|
+
if (!isObject(at)) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
const ref = at.$ref;
|
|
283
|
+
if (typeof ref !== 'string' || !ref.startsWith(DEFS)) {
|
|
284
|
+
return at;
|
|
285
|
+
}
|
|
286
|
+
const defs = root.$defs;
|
|
287
|
+
if (!isObject(defs)) {
|
|
288
|
+
return at;
|
|
289
|
+
}
|
|
290
|
+
at = defs[ref.slice(DEFS.length)];
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const DEFS = '#/$defs/';
|
|
295
|
+
const MAX_HOPS = 8;
|
|
221
296
|
//# sourceMappingURL=schema.js.map
|
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 { cutLoop, pagingSeen } from "./paging.js";
|
|
5
6
|
import { describeIssues, issues } from "./validate.js";
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// The server
|
|
@@ -134,8 +135,24 @@ async function handle(req, res, opts) {
|
|
|
134
135
|
say(502, 'generator faulted');
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
138
|
+
const note = generator.cached ? 'hit' : 'miss';
|
|
139
|
+
const looped = cut(operation, outcome.value, url.searchParams);
|
|
137
140
|
send(res, operation.success.status, outcome.value);
|
|
138
|
-
say(operation.success.status,
|
|
141
|
+
say(operation.success.status, looped ? `${note} · cut a looping page token` : note);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The generator on disk was written before the pagination rule existed, and a
|
|
145
|
+
* cache is not rebuilt just because the rule changed. A body offering back the
|
|
146
|
+
* token it was handed is therefore still possible, and it is the one bug here
|
|
147
|
+
* that costs the client rather than the mock: it hangs.
|
|
148
|
+
*/
|
|
149
|
+
function cut(operation, value, query) {
|
|
150
|
+
const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query);
|
|
151
|
+
if (!paging) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const sent = query.get(paging.param);
|
|
155
|
+
return sent !== null && cutLoop(value, paging, sent);
|
|
139
156
|
}
|
|
140
157
|
function check(operation, checks, pathParams, query, body) {
|
|
141
158
|
const compiled = checks.for(operation);
|
|
@@ -235,6 +252,7 @@ function introspect(pathname, res, opts) {
|
|
|
235
252
|
operationId: o.operationId,
|
|
236
253
|
status: o.success.status,
|
|
237
254
|
body: Boolean(o.success.schema),
|
|
255
|
+
paging: o.paging,
|
|
238
256
|
key: o.key,
|
|
239
257
|
source: o.source,
|
|
240
258
|
})));
|
package/dist/spec.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '@zenera/cli/lib';
|
|
2
|
+
import { type Paging } from './paging.ts';
|
|
2
3
|
import { type Schema } from './schema.ts';
|
|
3
4
|
export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
|
|
4
5
|
export type Method = (typeof METHODS)[number];
|
|
@@ -31,6 +32,8 @@ export interface Operation {
|
|
|
31
32
|
status: number;
|
|
32
33
|
schema?: Schema;
|
|
33
34
|
};
|
|
35
|
+
/** how the operation turns pages, when it turns pages at all */
|
|
36
|
+
paging?: Paging;
|
|
34
37
|
}
|
|
35
38
|
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
36
39
|
export declare class SpecError extends CliError {
|
package/dist/spec.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
2
|
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { pagingOf } from "./paging.js";
|
|
4
5
|
import { normalize } from "./schema.js";
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Documents, flattened
|
|
@@ -103,6 +104,7 @@ function build(b) {
|
|
|
103
104
|
params: b.params,
|
|
104
105
|
requestBody: b.body,
|
|
105
106
|
success,
|
|
107
|
+
paging: pagingOf(b.params, success.schema),
|
|
106
108
|
};
|
|
107
109
|
return { ...operation, key: keyOf(operation) };
|
|
108
110
|
}
|
|
@@ -123,6 +125,12 @@ function keyOf(op) {
|
|
|
123
125
|
.map((p) => [p.in, p.name, p.required, canonical(p.schema)]),
|
|
124
126
|
body: op.requestBody ? [op.requestBody.required, canonical(op.requestBody.schema)] : null,
|
|
125
127
|
success: [op.success.status, op.success.schema ? canonical(op.success.schema) : null],
|
|
128
|
+
// Derived from the two above, so it adds nothing to the identity — it is
|
|
129
|
+
// here to *change* it, once, for the operations whose generator now has
|
|
130
|
+
// a pagination rule to obey. Absent rather than null when there is no
|
|
131
|
+
// paging, so that everything else hashes to exactly what it did before
|
|
132
|
+
// and no one else is asked to rebuild.
|
|
133
|
+
...(op.paging ? { paging: canonical(op.paging) } : {}),
|
|
126
134
|
};
|
|
127
135
|
return createHash('sha256').update(JSON.stringify(shape)).digest('hex').slice(0, 16);
|
|
128
136
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/faker",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
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.11",
|
|
49
|
+
"@zenera/neo": "^1.1.11"
|
|
50
50
|
}
|
|
51
51
|
}
|