@zenera/faker 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,39 @@ model, no tokens.
67
67
  Generators run in a container with **no network**, on an image baked once with
68
68
  `faker`, `exrex`, `jsonschema` and `python-dateutil`.
69
69
 
70
+ ## Pages that end
71
+
72
+ A list endpoint is the one place a mock can hang a real client. Given
73
+ `?cursor=abc`, the honest-looking answer is a body that validates, echoes
74
+ nothing it shouldn't, and hands back `abc` again — so the client asks for the
75
+ same page forever.
76
+
77
+ The faker reads the document for this. Where an operation has a paging
78
+ parameter (`cursor`, `page`, `offset`, `page_token`, …) and a response property
79
+ that carries the next one (`next`, `next_cursor`, `has_more`, …), three things
80
+ happen, all in the operation's own names:
81
+
82
+ - the model is told to fabricate **three pages** in total, to build the token
83
+ out of the paging parameter rather than the seed, and to end the list — null,
84
+ absent, or `has_more: false` where the schema leaves no other room;
85
+ - the generator is then **walked**: the faker calls it with no cursor, follows
86
+ the token it gets back, and rejects the file if the token repeats, cycles, or
87
+ never runs out. The diagnostics say which, and the model gets another go;
88
+ - at request time a token identical to the one just sent is **cut** — nulled or
89
+ dropped, whichever the schema allows — and the request line says so. Nothing
90
+ is invented in its place; a generator written before this rule existed is
91
+ still on disk, and a cache is not rebuilt because a rule changed.
92
+
93
+ Only paginated operations are affected. Their cache keys changed once, so they
94
+ are written again on first use; everything else keeps the key it had.
95
+ `GET /__faker/routes` reports the shape that was recognised, per operation.
96
+
97
+ Plenty of documents describe the envelope and never write down the parameter
98
+ that reads it back. The first two steps cannot help there — nothing static can
99
+ see a parameter that is not declared — but the cut still applies: it takes the
100
+ paging parameter from the request itself, since a client only sends `?cursor=X`
101
+ because a body handed it X.
102
+
70
103
  ## Commands
71
104
 
72
105
  ```
package/dist/generate.js CHANGED
@@ -1,4 +1,5 @@
1
- import { echoIssues, probesFor } from "./probe.js";
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.
@@ -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 { Schema } from './schema.ts';
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, generator.cached ? 'hit' : 'miss');
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.8",
3
+ "version": "1.1.10",
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.8",
49
- "@zenera/neo": "^1.1.8"
48
+ "@zenera/cli": "^1.1.10",
49
+ "@zenera/neo": "^1.1.10"
50
50
  }
51
51
  }